Рисование эпициклами
Рисование изображения эпициклами: от контура к ряду Фурье
Введение
В этом проекте растровый рисунок превращается в замкнутую траекторию, а затем в сумму вращающихся векторов. Начало каждого следующего вектора находится на конце предыдущего. Последний конец цепочки играет роль пера и постепенно воспроизводит рисунок. Окружности, по которым движутся концы векторов относительно своих начал, называют эпициклами.
Основа метода – комплексный ряд Фурье. Любую достаточно регулярную периодическую траекторию можно описывать гармониками с разными радиусами, начальными углами и целочисленными частотами. Известен конечный набор точек, поэтому коэффициенты получают дискретным преобразованием Фурье (ДПФ). Геометрическую интерпретацию вращающихся векторов объясняет G. Sanderson, 2019; комплексное преобразование изложено в S. W. Smith, гл. 31.
Основная практическая сложность возникает перед преобразованием: пиксели нужно превратить в упорядоченный обход, согласовать отдельные части рисунка и выбрать параметр движения. ДПФ не умеет самостоятельно находить линии или определять порядок их прохождения. Чем больше сохранённых гармоник, тем лучше приближение подготовленной траектории и тем насыщеннее изображение цепочки.
Метод подходит для учебных демонстраций комплексных чисел и спектрального анализа, анимации логотипов и надписей, а также компактного представления плоских кривых. Применение коэффициентов Фурье к описанию и распознаванию формы имеет самостоятельную историю: см. Zahn и Roskies, 1972. В их работе используется другой дескриптор кривой; здесь преобразуются непосредственно координаты её точек.
После выполнения работы вы сможете объяснить связь амплитуды с длиной отрезка, частоты с числом оборотов, фазы с начальным углом, а также подобрать число гармоник по измеряемой ошибке.
Постановка задачи
Вход: одно статическое изображение PNG, JPEG или BMP. Предпочтителен PNG: чёрные линии на белом фоне, белые линии на тёмном фоне либо объект на прозрачном фоне. Цветной логотип также принимается: непрозрачное изображение переводится в градации серого, а при прозрачности можно выделять силуэт по альфа-каналу. Палитра исходника после выделения маски не сохраняется.
В комплекте уже находится engee-poster.png пример изображения с логотипом и названием Engee. Для нового изображения загрузите файл через браузер файлов Engee в папку проекта и укажите имя в маске. Поле пути выбирает загруженный файл; отдельного виджета отправки файла в маске нет.
Результат: циклическая GIF-анимация одного полного обхода с выбранным FPS и паузой на готовом рисунке. В папке output сохраняются также бинарная маска, иллюстрации этапов, последний кадр, коэффициенты в CSV и отчёт с фактической ошибкой. Основные параметры собраны в таблице 1.
Таблица 1 – Параметры эксперимента
|
Группа |
Параметры и смысл |
|
Изображение |
Путь; авто/тёмный/светлый/альфа; автоматический или ручной порог; размер обработки; граница или середина штриха |
|
Дискретизация |
N = 512, 1024, 2048 или 4096 точек, равномерно распределённых по длине маршрута |
|
Приближение |
Режим «По точности»: процент от 1 до 100; режим «По числу отрезков»: K от 1 до 300. Учитывается только выбранный режим |
|
Цвета |
Фон; вращающиеся отрезки; нарисованный контур; исходная траектория на фоне; окружности. Значения задаются строками #RRGGBB |
|
Анимация |
FPS от 5 до 50; длительность рисования от 2 до 30 с; пауза от 0 до 3 с; показ окружностей |
Для сохранения разумного времени расчёта ограничены размер файла (10 МиБ), число декодированных пикселей (12 миллионов), сторона обрабатываемой маски (800 пикселей), сложность обхода (6000 вершин и 40 участков), число отсчётов (4096), число отрезков (300) и число кадров GIF (900). При превышении сложности предлагается уменьшить изображение; при запросе слишком высокой точности число отрезков ограничивается и выводится реально достигнутый процент.
Важные математические операции показаны непосредственно в ячейках. Обход пиксельной границы, скелетизация, служебная визуализация и запись GIF вынесены в epicycles_functions.jl. Параметры оформлены нативными масками кодовых ячеек Engee. После изменения маски выполните её и все последующие ячейки; для полного пересчёта используйте «Выполнить всё».
using Images, FileIO, FFTW, Plots, Statistics, Markdown
# @markdown Подключение функций урока и подготовка папки результатов.
include(joinpath(@__DIR__, "epicycles_functions.jl"))
lesson_dir = @__DIR__
output_dir = joinpath(lesson_dir, "output")
mkpath(output_dir)
gr(format=:png)
nothing
Обработка входного изображения
Допущения и получение бинарной маски
Рисунок должен иметь контрастный объект и относительно однородный фон. Для автоматического определения тёмного или светлого объекта предполагается, что край изображения преимущественно принадлежит фону. Если объект касается большей части края, выберите режим вручную.
На первом шаге формируется бинарная маска: значение true соответствует объекту, false – фону. При наличии заметной прозрачности режим «Авто» использует альфа-канал с порогом 0,5. Это удобно для приложенного логотипа: цвет его заливки не мешает извлечению силуэта.
Для непрозрачного изображения используется яркость. Автоматический порог находится методом Оцу: выбирается разделение гистограммы, максимизирующее межклассовую дисперсию. См. Otsu, 1979. Ручной порог задаётся от 0 до 1. Небольшие изолированные группы, содержащие менее 10 пикселей, удаляются как шум.
# @markdown **Загрузка и выделение объекта**
путь_изображения = "engee-poster.png" # @param {type:"string",placeholder:"input_logo.png"}
выделение_объекта = "Авто" # @param ["Авто","Тёмный","Светлый","Альфа"]
авто_порог = true # @param {type:"boolean"}
порог = 0.5 # @param {type:"slider",min:0.0,max:1.0,step:0.01}
размер_обработки = 608 # @param {type:"slider",min:128,max:800,step:16}
тип_контура = "Граница" # @param ["Граница","Середина штриха"]
nothing
В следующей ячейке режим маски переводится в параметры функции. Относительный путь разрешается относительно папки проекта. Изображение уменьшается только при превышении выбранной стороны; маленький исходник не увеличивается. Координаты и ошибки далее измеряются в пикселях обработанного изображения.
mode = Dict("Авто"=>:auto, "Тёмный"=>:dark, "Светлый"=>:light, "Альфа"=>:alpha)[выделение_объекта]
input_path = isabspath(путь_изображения) ? путь_изображения : joinpath(lesson_dir, путь_изображения)
mask, image_meta = EpicycleLesson.load_mask(input_path;
foreground=mode, threshold=авто_порог ? nothing : порог,
max_dimension=размер_обработки)
image_meta
Упорядоченные контуры и соединение участков
Режим «Граница» обходит рёбра пиксельных ячеек, которые соседствуют с фоном. Сохраняются внешние границы и границы отверстий. У толстого обведённого штриха могут получиться два контура – это ожидаемый результат обхода его краёв.
Режим «Середина штриха» сначала утончает маску алгоритмом Zhang–Suen, 1984, затем прослеживает цепочки пикселей скелета. Он полезен для рукописных надписей и линейных рисунков. Для предоставленного логотипа выбран обход границы. Очень короткие участки длиной менее 12 пикселей исключаются.
Один конец цепочки эпициклов непрерывно рисует один маршрут. Поэтому отдельные участки соединяются мостами. Начинаем с самого длинного контура, затем выбираем ближайшую пару вершин уже построенного маршрута и одного из оставшихся участков. Вставляем переход, полный обход нового участка и возврат по тому же переходу. Открытый штрих обходится к обоим концам с возвращением в точку входа. Повторяем до включения всех участков.
Это жадный способ соединения: он даёт короткие локальные переходы, но не гарантирует глобально кратчайший маршрут. Мосты проходят дважды и остаются частью целевой траектории. Они будут видны в готовом рисунке; обрезать их означало бы менять поставленную задачу соединения.
using Plots, Markdown
paths, closed = тип_контура == "Граница" ? EpicycleLesson.boundary_paths(mask) : EpicycleLesson.centerline_paths(mask)
keep = [i for i in eachindex(paths) if length(paths[i]) >= 2 && EpicycleLesson.path_length(paths[i],closed[i]) >= 12]
paths, closed = paths[keep], closed[keep]
isempty(paths) && error("После выделения не осталось значимых контуров. Измените порог или размер обработки.")
center = complex(size(mask,2)/2, -size(mask,1)/2)
paths = [p .- center for p in paths]
route, bridges = EpicycleLesson.join_paths(paths,closed)
geometry_figure = EpicycleLesson.geometry_plot(mask,paths,closed,bridges)
savefig(geometry_figure,joinpath(output_dir,"01_contours.png")); display(geometry_figure)
Markdown.parse("*Рисунок 1 – Бинарная маска, выделенные контуры и соединительные мосты.*")
На рисунке 1 чёрным показана маска, синим – найденные границы, пунктиром – мосты. Для приложенного PNG автоматическое выделение даёт 12 контуров и 11 мостов. Если вместо узнаваемого силуэта выделился фон, поменяйте режим объекта или порог до перехода к преобразованию Фурье.
Параметризация по длине и равномерные отсчёты
Пиксельные вершины нельзя просто передать в ДПФ в произвольном порядке. Сначала они образуют непрерывный маршрут, а затем на маршруте выбираются равноудалённые по пройденной длине отсчёты. Это устраняет зависимость скорости пера от плотности исходных вершин.
Пусть – последовательные вершины замкнутого маршрута, – накопленная длина, – полная длина, а – число отсчётов. Определение длины и положения отсчётов приведено в (1):
Для точка находится линейной интерполяцией по ребру (2):
В конце маршрута первая вершина повторяется для описания последнего ребра. В массиве повторная конечная точка отсутствует: параметр периода лежит на полуинтервале . Она появится при визуализации замыкания.
# @markdown **Число отсчётов целевого маршрута**
число_отсчётов = 2048 # @param [512,1024,2048,4096] {type:"raw"}
nothing
Плоская точка записывается комплексным числом . Пиксельная вертикальная координата меняет знак, чтобы ось была направлена вверх. Перенос на половину ширины и высоты центрирует изображение для удобства графика. Постоянная составляющая ДПФ всё равно определяется средним положением пера на маршруте.
Число задаёт подробность представления целевого маршрута. Оно не задаёт число кадров GIF. Для небольшого логотипа достаточно начать с 2048 точек; повышение имеет смысл, когда в маршруте есть мелкие детали.
z = EpicycleLesson.resample_path(route, число_отсчётов)
N = length(z)
(отсчётов=N, участков=length(paths), мостов=length(bridges),
длина_маршрута_px=round(EpicycleLesson.path_length(route,false);digits=2))
Дискретное преобразование Фурье
Анализ и восстановление комплексной траектории
Для непрерывной периодической траектории комплексный ряд Фурье записывают в виде (3). Здесь параметр – доля полного обхода, а частота измеряется в оборотах за один период:
В нашем расчёте используются дискретных точек. Прямое преобразование (4) вычисляет коэффициенты, а обратная сумма (5) восстанавливает значения в узлах:
Знаки экспонент и нормировка должны соответствовать друг другу. В Julia fft вычисляет сумму без деления на , поэтому в ячейке явно используется fft(z) / N. Функция ifft уже содержит деление на ; следовательно, для выбранной здесь нормировки обратный расчёт имеет вид ifft(C) * N. Определения проверяются по документации AbstractFFTs; комплексная форма разобрана у Smith, гл. 31.
FFT и ДПФ: ДПФ – само математическое преобразование (4), FFT – быстрый способ его вычисления. Прямое суммирование требует порядка операций, FFT – порядка . Здесь применяется пакет FFTW.
Массив Julia индексируется с единицы: C[1] соответствует . Остальные элементы нужно сопоставить со знаковыми частотами. При чётном используем порядок . Отрицательная частота означает вращение по часовой стрелке, положительная – против часовой стрелки. Для частоты Найквиста принята отрицательная запись, как в fftfreq.
Сигнал комплексный, поэтому его коэффициенты в общем случае не обладают сопряжённой симметрией спектра вещественного сигнала. Сохранять только положительные частоты или искусственно объединять их попарно нельзя.
using FFTW, Plots, Markdown
C = fft(z) / N
frequencies = [q <= (N-1)÷2 ? q : q-N for q in 0:N-1]
offset = C[1]
spectrum_figure = EpicycleLesson.spectrum_plot(C,frequencies)
savefig(spectrum_figure,joinpath(output_dir,"02_spectrum.png")); display(spectrum_figure)
Markdown.parse("*Рисунок 2 – Спектр комплексной траектории и гармоники, упорядоченные по амплитуде.*")
Рисунок 2 показывает, какие частоты участвуют в описании маршрута. Постоянная составляющая исключена из графика: она задаёт начало цепочки и не является вращающимся отрезком. Её значение равно среднему , которое может отличаться от геометрического центра изображения.
Число отрезков и измеряемая точность
Пусть содержит индексы сохранённых непостоянных гармоник. Усечённая траектория определяется (6):
Мы сортируем гармоники по убыванию . Такой выбор сохраняет наибольшую энергию и минимизирует среднеквадратическую ошибку в исходных дискретных узлах при фиксированном . Частоты при этом остаются своими: порядок в цепочке меняет положение её промежуточных звеньев, но сумма и конец пера от него не зависят.
Введём RMS-ошибку , масштаб маршрута и показатель точности по (7):
Например, 99% означает RMS-ошибку не более 1% от . Это показатель точности координат подготовленного маршрута, включая мосты. Он не измеряет долю совпавших пикселей исходника и не ограничивает максимальную локальную ошибку.
Ортогональность гармоник и равенство Парсеваля дают (8); изложение принципа см. Smith, гл. 10. Здесь нормировка уже приведена к комплексным коэффициентам (4):
Поэтому для запрошенного процента достаточно найти минимальное , при котором хвост спектра удовлетворяет условию (9):
Режим «По числу отрезков» напрямую задаёт ; процент тогда только вычисляется. Смещение в число отрезков не входит. Если для заданного процента понадобилось более 300 отрезков, берутся 300 и показывается достигнутая точность. Запрос 100% без такого ограничения потребовал бы до отрезков и восстановил бы узловые точки с машинной точностью.
# @markdown **Выбор гармоник**
режим_точности = "По точности" # @param ["По точности","По числу отрезков"]
точность_процентов = 99.5 # @param {type:"slider",min:1.0,max:100.0,step:0.1}
число_отрезков = 89 # @param {type:"slider",min:1,max:300,step:1}
nothing
Следующая ячейка вызывает короткую процедуру отбора по (9), затем выполняет независимый обратный расчёт в узлах. В отчёте выводятся фактический процент, RMS и максимальная ошибка. Последняя полезна для оценки углов и мест соединения: средняя ошибка может быть небольшой даже при заметном локальном отклонении.
using FFTW, Statistics
selection = EpicycleLesson.select_harmonics(C; accuracy_percent=точность_процентов,
n_segments=режим_точности == "По числу отрезков" ? число_отрезков : nothing)
selected = selection.indices
C_kept = zeros(ComplexF64,N); C_kept[1] = offset; C_kept[selected] = C[selected]
node_errors = abs.(ifft(C_kept)*N .- z)
rms_error = sqrt(mean(abs2,node_errors))
scale = sqrt(mean(abs2,z .- offset))
actual_accuracy = clamp(100*(1-rms_error/scale),0,100)
model = (samples=z, offset=offset, coefficients=C[selected], frequencies=frequencies[selected],
accuracy=actual_accuracy, rms_error=rms_error, max_error=maximum(node_errors), capped=selection.capped)
(отрезков=length(selected), нужно_для_запроса=selection.needed, предел_сработал=selection.capped,
точность_процентов=actual_accuracy, RMS_px=rms_error, максимум_px=model.max_error)
Проверка восстановленного маршрута
Сравним целевой маршрут и усечённую сумму (6) на рисунке 3. Для отображения сумма интерполируется на более плотной сетке из 8192 точек. При малом числе гармоник углы сглаживаются; возле поворотов и мостов могут появляться выбросы. Повышение точности приближает выбранный маршрут, но не устраняет ошибки его выделения из изображения.
Число отсчётов контура и число сохранённых гармоник решают разные задачи: первое описывает исходные данные, второе ограничивает сложность их спектрального представления.
using Plots, Markdown
reconstructed = EpicycleLesson.reconstruct(model)
comparison_figure = EpicycleLesson.comparison_plot(route,reconstructed,model)
savefig(comparison_figure,joinpath(output_dir,"03_reconstruction.png")); display(comparison_figure)
Markdown.parse("*Рисунок 3 – Целевой маршрут с мостами и его восстановление выбранными гармониками.*")
Рисование эпициклов
Вращающиеся векторы и цепочка частичных сумм
Формула Эйлера превращает каждую гармонику в вращающийся вектор. Если – выбранный коэффициент, – его частота, то радиус, начальная фаза и вектор определяются (10):
Начало цепочки находится в . Остальные узлы – частичные суммы (11):
Окружность имеет центр и радиус . Сам центр движется вместе с предыдущими звеньями; длина каждого звена остаётся постоянной. cis(θ) в Julia вычисляет комплексное число , а cumsum строит частичные суммы.
За полный рисунок отрезок с делает три оборота против часовой стрелки, с – пять по часовой стрелке. При длительности рисования секунд физическая угловая скорость равна радиан в секунду.
Цвета и просмотр одной фазы
Задайте пять цветов шестью HEX-цифрами, например 101827. Символ # перед цветом добавляется кодом. Цвет «контур» применяется к следу пера; цвет «траектория на фоне» – к подготовленному маршруту, включая мосты. При замене тёмного фона на светлый подберите контрастные цвета линий. Скрытие окружностей влияет на оформление; все выбранные отрезки продолжают участвовать в сумме.
# @markdown **Цвета рисунка, HEX-код**
цвет_фона = "f3f3f3" # @param {type:"string"}
цвет_отрезков = "87d1f1" # @param {type:"string"}
цвет_контура = "1b84b8" # @param {type:"string"}
цвет_траектории_на_фоне = "e5e5e5" # @param {type:"string"}
цвет_окружностей = "cccccc" # @param {type:"string"}
style = (background="#"*цвет_фона, segments="#"*цвет_отрезков, contour="#"*цвет_контура,
reference="#"*цвет_траектории_на_фоне, circles="#"*цвет_окружностей)
nothing
# @markdown **Фаза предварительного просмотра и параметры GIF**
фаза = 0.35 # @param {type:"slider",min:0.0,max:1.0,step:0.01}
показать_окружности = true # @param {type:"boolean"}
fps = 25 # @param {type:"slider",min:5,max:50,step:1}
длительность_секунд = 12.0 # @param {type:"slider",min:2.0,max:30.0,step:0.5}
пауза_секунд = 1.0 # @param {type:"slider",min:0.0,max:3.0,step:0.5}
nothing
В ячейке ниже буквально реализованы (10) и (11). Массив nodes содержит начало цепочки и концы всех отрезков; последний элемент задаёт положение пера. Рисунок 4 позволяет рассмотреть выбранную фазу перед созданием всей анимации.
using Plots, Markdown
vectors = model.coefficients .* cis.(2π .* model.frequencies .* фаза)
nodes = vcat(offset, offset .+ cumsum(vectors))
limits = EpicycleLesson.canvas_limits(model,route,reconstructed)
phase_figure = EpicycleLesson.frame_plot(model,route,reconstructed,фаза,style,limits;
show_circles=показать_окружности)
savefig(phase_figure,joinpath(output_dir,"04_epicycles.png")); display(phase_figure)
Markdown.parse("*Рисунок 4 – Цепочка эпициклов и след пера в выбранной фазе.*")
Дискретизация времени и запись GIF
Пусть – FPS, – запрошенная длительность рисования. Формируем кадров и включаем оба конца периода по (12):
На первом кадре перо находится в начале маршрута, на последнем рисунок замыкается. Затем добавляются повторные кадры для паузы. Эпициклы рисуются для текущего , а след берётся из плотной интерполяции ряда до этой фазы. Благодаря этому контур не превращается в грубую ломаную при небольшом FPS.
Запись выполняется средствами Plots.Animation, frame и gif, описанными в документации Plots. Для кадров выбран графический backend GR. Оценка работы отрисовки – порядка при фиксированном числе точек каждой окружности; большой увеличивает время и визуальную плотность цепочки.
GIF хранит задержки кадров с шагом 0,01 с. Поэтому произвольный заданный FPS может быть приближён чередованием соседних задержек. Значение 25 FPS даёт ровно 0,04 с на кадр и выбрано по умолчанию. Браузерный проигрыватель также может ограничивать скорость воспроизведения.
Гармоники с большим модулем частоты вращаются быстро. При редкой временной выборке их движение может выглядеть скачкообразным или иметь видимое обратное направление из-за временного наложения частот. Для плавности увеличивайте длительность рисования или FPS. Это свойство наблюдения движения; вычисляемые положения пера при этом следуют формуле (6).
При стандартных параметрах создаются 300 кадров рисования и 25 кадров паузы: всего 325 кадров, 13 с воспроизведения. Фаза предварительного просмотра на полный обход в GIF не влияет. Следующая ячейка сохраняет отчёт, коэффициенты и анимацию в папку output.
using Plots, Markdown
EpicycleLesson.save_results(model,mask,image_meta,output_dir)
gif_result = EpicycleLesson.render_gif(model,route,joinpath(output_dir,"epicycles.gif");
style, fps, duration=длительность_секунд, hold_seconds=пауза_секунд,
show_circles=показать_окружности)
display(gif_result.animation)
Markdown.parse("*Рисунок 5 – GIF: $(gif_result.frames) кадров; задано $(fps) FPS; файл output/epicycles.gif.*")
Заключение
В работе построена последовательность: растровое изображение → бинарная маска → упорядоченные контуры → маршрут с мостами → равномерные отсчёты → ДПФ → отбор гармоник → цепочка эпициклов → GIF. Быстрое преобразование используется после подготовки геометрии. Поиск маршрута и его параметризация непосредственно влияют на получаемый спектр.
Изученные основы: комплексное представление плоских координат, формула Эйлера, прямое и обратное ДПФ, амплитуда и фаза, знаковые частоты, постоянная составляющая, ортогональность гармоник и равенство Парсеваля, нормированная RMS-ошибка, параметризация по длине и раздельная дискретизация пространства и времени.
Повторное использование. Загрузите новый PNG/JPEG/BMP рядом со скриптом, поменяйте путь в маске и проверьте рисунок 1. Затем подберите число отсчётов и точность, проверьте рисунок 3, задайте цвета и FPS и выполните запись GIF. Служебный файл .jl должен оставаться рядом со скриптом. Для многократного использования функций без учебного текста можно подключать модуль EpicycleLesson из обычного Julia-скрипта.
Эксперименты для закрепления.
- Для приложенного логотипа сравните 90%, 95% и 99%: запишите число отрезков, RMS и максимальную ошибку. Объясните, почему рост числа отрезков около 100% становится дорогим.
- Зафиксируйте число отрезков и увеличьте N. Сравните ошибки и проверьте, насколько изменилась сама целевая траектория.
- Сравните «Граница» и «Середина штриха» на толстой рукописной букве. Поясните разницу между границей области и линией пера.
- Измените длительность и FPS при фиксированном спектре. Оцените плавность вращения и форму уже восстановленного следа.
Дальнейшее развитие: выбирать мосты с учётом общей длины маршрута; вводить режим подъёма пера между участками; работать непосредственно с SVG; сглаживать растровую границу до ДПФ; сравнивать отбор по амплитуде с сохранением низких частот; экспортировать MP4 для более гибкого управления частотой кадров.
Эпициклы наглядно показывают, как сложное движение строится из простых гармоник. Для задач анализа формы те же коэффициенты могут служить признаками, а для генерации траекторий – удобным периодическим представлением координат.
Список источников
Литература и теория
- Smith S. W. The Scientist and Engineer’s Guide to Digital Signal Processing. California Technical Publishing, 1997. Глава 31: The Complex Fourier Transform (PDF); глава 10: Parseval’s Relation; глава 12: How the FFT Works. Комплексное ДПФ, нормировки, сохранение энергии и быстрые алгоритмы.
- Zahn C. T., Roskies R. Z. Fourier Descriptors for Plane Closed Curves. IEEE Transactions on Computers, C-21(3), 269–281, 1972. DOI: 10.1109/TC.1972.5008949; текст статьи (PDF). Применение спектральных дескрипторов к плоским замкнутым кривым.
- Otsu N. A Threshold Selection Method from Gray-Level Histograms. IEEE Transactions on Systems, Man, and Cybernetics, 9(1), 62–66, 1979. DOI: 10.1109/TSMC.1979.4310076. Автоматический выбор порога по гистограмме.
- Zhang T. Y., Suen C. Y. A Fast Parallel Algorithm for Thinning Digital Patterns. Communications of the ACM, 27(3), 236–239, 1984. DOI: 10.1145/357994.358023. Скелетизация бинарных штрихов.
- Sanderson G. (3Blue1Brown). But what is a Fourier series? From heat flow to drawing with circles, 2019. Авторская иллюстрированная лекция. Геометрическая интерпретация ряда Фурье.
Документация реализации
- Engee: маски кодовых ячеек; разметка текста.
- AbstractFFTs.jl: определения fft, ifft и fftfreq.
- Plots.jl: создание анимаций.




