Як я створюю редактор для фотографів, що автоматизує ретуш

💡 Усі статті, обговорення, новини про AI — в одному місці. Приєднуйтесь до AI спільноти!

Привіт, спільното! Я Ігор, full-stack розробник. Ідея Glow виникла після розмови з дружиною про час, який вона витрачає на ретуш фотографій. Я взявся автоматизувати частину цієї роботи, а в процесі отримав задачі, які вже мало нагадували звичайний вебзастосунок: кілька inference runtime, великі буфери зображень, узгодження прев’ю з експортом і нестабільну паралельну роботу з GPU.

У цій статті розберу три інженерні кейси: як прибрав дублювання обчислень між прев’ю та експортом, змінив передавання шарів між Rust і WebView та відмовився від оптимізації, яка спричиняла Vulkan device loss. Матеріал адресований розробникам десктопних і AI-застосунків, особливо тим, хто інтегрує локальний інференс у графічний інтерфейс.

Йтиметься про конкретні етапи розробки в серпні та вересні 2026 року. Числа наведено з внутрішніх звітів від 13 серпня і 5 вересня, а не з нового тестування поточної версії. Умови різних експериментів відрізняються; далі я вказую їх окремо. Це розповідь про інтеграцію й виконання моделей, а не про навчання власної нейромережі з нуля.

Рис. 1. Редактор як контекст задачі: обчислення виконує нативний рушій, а прев’ю складається у WebView. Зображення не є ілюстрацією вимірювального корпусу.

З чого складається конвеєр і де виникла складність

Оболонка застосунку побудована на Tauri 2, React і TypeScript. За інференс та обробку зображень відповідає Rust-крейт native-lab. У Windows-шляху поєднані ONNX Runtime із DirectML та ncnn із Vulkan. Команди Tauri з’єднують редактор із рушієм, а компонент LayerCanvas складає прев’ю за допомогою WebGL. Це не один виклик «покращити фотографію», після якого залишається лише показати JPEG.

Спочатку потрібні аналіз облич і маски ділянок зображення. Далі окремі стадії виконують корекцію шкіри, блиску, тону, зубів та очей. Важлива не лише робота моделей, а й підготовка їхніх входів, геометрія кропів, порядок каналів і повернення поправок у спільні координати. У пакеті, з яким проводилися описані експерименти, було 16 моделей. Кількість шарів на виході не дорівнює кількості моделей: частина з них забезпечує аналіз, а не окремий видимий ефект.

Файл + налаштування

→ декодування й орієнтація за EXIF

→ аналіз облич, шкіри та частин обличчя

→ послідовність локальних корекцій

→ шари поправок + метадані

├─ WebGL-композиція → інтерактивне прев’ю

└─ Rust-композиція + full-resolution стадії → файл

Схема спрощена: вона показує рух даних, а не повний граф викликів. Обробка фону має окремі правила, про які йтиметься нижче.

Під час пошарового проходу зберігається різниця між результатом стадії та її входом — дельта. Тому для зміни сили вже обчисленої корекції можна змішати готові поправки, а не щоразу запускати модель заново. Це зручно для інтерактивності, але створює зобов’язання: прев’ю і файл мають однаково трактувати ці дані. Інакше користувач налаштовує одну фотографію, а отримує іншу.

У реалізації є цілком приземлені умови цього узгодження. Внутрішні зображення рушія використовують BGR, а PNG-шари — RGB. Знак і амплітуда дельти кодуються окремо: для значення v з PNG поправка відновлюється як (v — 128) / 127 × scale. Отже, шар — це не просто «ще одна картинка». Без його масштабу, порядку каналів і правильної бази неможливо відтворити той самий результат. Архітектуру та ці правила фіксує карта коду проєкту.

Кейс 1. Прев’ю та експорт двічі рахували одну фотографію

Два робочі шляхи виявилися однією зайвою обробкою

На етапі, зафіксованому в серпневому звіті, прев’ю вже працювало через нативний рушій, а експорт — через окремий Python-процес, або sidecar. Кожен шлях міг дати результат, але вони не використовували спільно виконану роботу. Спочатку застосунок рахував шари для екрана, а під час збереження повторно обробляв той самий кадр.

Проблема не обмежувалася секундоміром. Прогрів sidecar підіймав другий процес і другу копію моделей, а після експорту взаємодія з фотографією могла запускати новий прохід. Перед тим як оптимізувати окремі оператори нейромереж, потрібно було відповісти на простіше питання: чому вже обчислений результат не придатний для наступної дії користувача?

Я перевів експорт на нативний шлях і повторне використання пошарового проходу. Якщо потрібного набору немає, експорт ініціює його побудову. Якщо набір уже готовий, compose_layers збирає результат із поправок і поточних ваг. Під час міграції довелося також провести до рушія вибір обличчя та сторони очей: швидкий експорт, який ігнорує частину налаштувань редактора, був би функціонально іншою операцією.

Що означає «зібрати результат із дельт»

Спрощено математика виглядає так. Нехай I₀ - вхідне зображення, Fᵢ - чергова стадія, а Iᵢ - її результат. Для послідовно виконаних стадій зберігаю Dᵢ - різницю між сусідніми станами:

I_i = F_i(I_(i-1))

D_i = I_i — I_(i-1)

R = I_0 + sum(w_i * D_i)

Якщо всі ваги wᵢ дорівнюють одиниці, сума різниць телескопічна: у точній арифметиці вона відновлює останній стан послідовності. Але це не гарантує побайтової тотожності реального файлу. Між математичною формулою та JPEG є квантування шарів, масштабування, округлення й кодування.

За проміжних ваг змішування вже не тотожне повторному запуску всього нелінійного конвеєра з іншою силою кожної стадії. Наступні моделі під час початкового проходу отримували конкретні попередні результати. Це компроміс заради швидкого керування готовими поправками, який потрібно перевіряти, а не приховувати за формулою. Окремо я порівнював композицію з повним рендером і за максимальних, і за змішаних ваг.

Чому очищення фону не вписалося в загальну схему

Спроба однаково підняти всі дельти до повної роздільності мала виняток — Clean Backdrop. Дрібна пилинка, видима в оригіналі, могла зникнути після зменшення зображення для прев’ю. Відповідно, у дельті малого зображення не було потрібної локальної поправки. Її лінійне збільшення не прибирало дефект у повнорозмірному файлі.

Тому фон довелося винести за межі звичайного змішування і перераховувати на повному кадрі в експортному шляху. Спільне використання результатів не означало «жодного інференсу за будь-якого експорту»: для деяких операцій потрібна додаткова повнорозмірна робота. Це важлива межа оптимізації.

Інженерний висновок тут не в тому, що весь конвеєр потрібно рахувати на максимальному розмірі. Потрібно розрізняти ефекти, для яких збільшена поправка достатня, та операції, які залежать від деталей, утрачених під час зменшення. Єдина схема кешування для обох випадків дала б швидкий, але неправильний результат.

Як перевіряв перехід і що показав замір

У звіті від 13 серпня зафіксовано порівняння на Windows із RTX 3070: дев’ять фотографій 4000 × 6000, однакові налаштування Fashion / Ultra, інструменти на 100 і повна роздільність в обох шляхах. Для восьми кадрів наведено час: 7,2–8,4 с у нативного рушія проти 18,4–28,6 с у sidecar. Для дев’ятого є перевірка зображення, але таймінг у таблиці не записано.

У цьому самому порівнянні PSNR між результатами становив приблизно 47–52 дБ. Окремий тест композиції проти повного рендера дав 56,31 та 57,69 дБ за максимальних ваг на двох кадрах і 50,82 дБ для одного кадру з вагами 40%. Я розділяю ці перевірки: перша порівнює два рушії, друга — два способи отримати результат у нативному шляху. Це різні експерименти.

Ці числа не доводять, що Rust сам по собі «втричі швидший за Python», або що будь-яка різниця вище певного PSNR невидима. Порівнювалися конкретні реалізації на невеликому корпусі. Разом із метриками у перевірці були окремі ділянки шкіри, рівного фону та високочастотних деталей. Практичний результат міграції — усунення другого основного проходу там, де можна повторно використати шари, і відмова від зайвого sidecar у поставці Windows.

Кейс 2. Модель завершила роботу, а інтерфейс ще отримував зображення

Зайві байти в локальному застосунку теж мають ціну

Наступний етап стосувався не моделей, а шляху від рушія до екрана. У вересневому звіті зафіксована зайва робота: редактор уже малював оригінал із photo.url, але нативний API додатково кодував базовий PNG. База передавалася в подіях і повторювалася у фінальній відповіді. Шари їхали через base64.

Я прибрав повторну передачу бази для настільного редактора та розділив повідомлення про готовність шару і доставку його байтів. Подія містить невеликий дескриптор з одноразовим UUID. За ним frontend отримує PNG через tauri::ipc::Response, перетворює відповідь на Blob URL і передає зображення компоненту відображення. Події більше не слугують контейнерами для великих base64-рядків.

Рушій → дескриптор шару + одноразовий token

Frontend → запит байтів за token

Бінарна відповідь → ArrayBuffer → Blob URL

Декодування PNG → текстура WebGL → композиція

Самого переходу на binary було недостатньо. Подія та фінальна відповідь можуть посилатися на один і той самий шар, а одноразовий токен не можна прочитати двічі. Тому вони використовують спільне отримання байтів. Фінальна відповідь містить повний список шарів: якщо окрема подія не дійшла до обробника, набір можна відновити, а не залишити фотографію частково обробленою.

Черга доставки отримала власний ліміт 256 МіБ і строк очікування 120 секунд. Вона відокремлена від кешу, з якого складається експорт. При переповненні повертається явна помилка; живий токен не витісняється тільки тому, що з’явився новий шар. Скасовані доставки звільняються, а вже отриманий Blob URL можна повторно використати без нового читання токена. Тобто оптимізація вимагала визначити не лише формат повідомлення, а й власника та строк життя даних.

Поруч прибрав ще дві зайві операції. Настільний редактор перестав чекати окреме завдання детекції перед ретушшю: метадані облич повертає основний прохід. Відкриття діалогу експорту також перестало скасовувати вже запущене прев’ю — експорт чекає його кешу. Для фонових фотографій не відправляються шари, які зараз ніхто не показуватиме. Ці зміни стосувалися конкретних споживачів API; мобільний порядок обробки в цьому експерименті збережено.

Мінус 77% даних — не означає плюс 77% швидкості

Для тестового кадру A попередній шлях давав приблизно 75,47 МБ base64 без урахування JSON, новий — 17,22 МБ PNG. За обсягом це приблизно на 77% менше. Для відкриття з backend-кешу порівняння становило 49,21 проти 17,22 МБ. Це розрахунок за реальними файлами, а не виміряна зміна затримки інтерфейсу.

Зменшення у 4,38 раза не можна приписувати лише відмові від base64. Прибрано також дублікати та передачу бази, яка вже була у frontend. І навіть усунення цих байтів не говорить, наскільки прискорився інференс. Щоб не змішувати різні ефекти, нативний прохід вимірювався окремо.

Умови цього заміру: i9-11900K, RTX 3070 8 ГіБ, Windows 11, кадри на 24 Мп, Natural / Ultra, усі ефекти 100%, skin defects = 0, workMax = 0. Перед кожним вимірюванням A в одному NativeEngine оброблявся B: моделі залишалися прогрітими, але результат аналізу A не брався з кешу. Callback не записував файли, не рахував хеші та не виконував IPC чи браузерне декодування.

Таблиця 1. Парні заміри нативного проходу, 5 вересня 2026 року. Час у мілісекундах.

Прохід A

Попередній API, мс

API без base, мс

1

6675,431

6614,322

2

7007,070

6628,137

3

6622,835

6615,759

Медіана

6675,431

6615,759

Різниця медіан — приблизно 60 мс, або 0,9%. За трьох пар і помітного розкиду це не підстава заявляти велике прискорення нейромереж. У цьому тесті основний нативний розрахунок усе ще займав близько 6,6 секунди. Найчіткіше доведений результат зміни транспорту — менше переданих даних і відсутність дубльованої бази.

Кейс 3. Два GPU workers виявилися гіршими за один

Паралельно перевіряв варіант із двома ncnn workers. Ідея полягала в тому, щоб виконувати більше роботи одночасно. На перевіреній конфігурації runtime та драйвера експеримент завершувався Vulkan device loss. Після цього варіант із двома workers було знято, а послідовний контрольний прохід обробив п’ять фотографій із точним збігом PNG.

Я не роблю з цього висновок, що ncnn або Vulkan загалом не підтримують паралельної роботи. Експеримент показав проблему конкретної конфігурації та способу виконання, а не універсальну властивість бібліотеки. Точна першопричина збою в цьому звіті не встановлена. Підстави вмикати такий шлях для користувачів на той момент не було.

Ще один перевірений варіант — спрощення перетворення даних у CHW, тобто розкладку «канали, висота, ширина». Вимірюваного виграшу він не дав і в реалізацію не потрапив. Це менш видовищний результат, ніж новий швидкий шлях, але теж корисний: додаткову складність не варто залишати в коді лише тому, що оптимізація виглядає логічно.

У вересневому наборі змін я не підміняв моделі, не знижував роздільність і не вимикав ефекти заради кращого часу. Фіксований результат був частиною умов експерименту. Інакше довелося б оцінювати інший компроміс — швидкість проти якості, а не усунення зайвої роботи в тому самому обчисленні.

Як перевіряв, що оптимізація не змінила фотографію

Для переходу між рушіями побайтова тотожність не була результатом: там аналізувався розмір відхилень. Для зміни транспорту вимога була суворішою, адже змінювати самі корекції я не планував. На двох вихідних зображеннях 24 Мп у перевірці від 5 вересня всі 17 delta PNG збіглися з попереднім варіантом побайтно; збіглося й бітове представлення f32-коефіцієнтів scale. Це сумарна кількість перевірених шарів для двох зображень, не кількість моделей.

Чотири композиції PNG — для повних і змішаних ваг — та два експортні JPEG збіглися за SHA-256. Окремо перевірялися метадані: розміри після EXIF-орієнтації становили 4000 × 6000 і 6000 × 4000, рамки облич збіглися з окремим нативним детектором, метадані приходили один раз на кадр. Відсутність base у новому API також перевірялася явно.

Доставка тестувалася через справжні Tauri-команди, а не тільки прямим викликом Rust-функції. Вісім PNG одного кадру були отримані як ArrayBuffer і декодовані у WebView; їхні хеші збіглися з вихідним варіантом. Повторне читання одноразового токена відхилялося, а фоновий прохід не надсилав зайві preview events. Так перевірка охопила не лише обчислення, а й протокол передавання.

Сценарій A → B → A перевіряв повторне використання frontend-кешу. У діагностиці залишилися два завдання та два нативні проходи: повернення до A не створило третього. Водночас спроба перехопити лічильники invoke у smoke-скрипті не спрацювала через захищену властивість. Порожні лічильники не стали «доказом відсутності викликів» — висновок спирався на інші записи діагностики.

Усе це — регресійна перевірка конкретного набору змін. Два зображення не представляють усіх ракурсів, відтінків шкіри й умов освітлення, а однаковий SHA-256 не доводить художньої якості ретуші. Він відповідає на вужче питання: чи змінилися перевірені виходи після оптимізації. Для такого питання точний збіг значно корисніший за враження «наче виглядає так само».

Час інференсу, перший ефект і готовий екран — різні метрики

Після перевірки нативного проходу я запустив локальну Windows-збірку 1.2.5 у справжньому WebView2. Фотографії A та B імпортувалися через редактор, вікно було видимим. Моделі вже пройшли фонове завантаження. Увімкнена діагностика відмічала запуск запиту, отримання байтів, декодування, завантаження текстур і малювання.

Таблиця 2. Два проходи у справжньому редакторі, 5 вересня 2026 року. Час у секундах.

Подія

Кадр A, с

Кадр B, с

Перша корекція: відправлення draw

2,946

2,230

Відповідь native-команди

8,994

8,443

Усі шари: paint opportunity

9,199

8,679

Відлік починався із запуску preview effect після підготовки розмірів, а не з фізичного кліку користувача. Перша подія draw означала відправлення малювання першої корекції, не завершення всього набору. Paint opportunity оцінювалася через два requestAnimationFrame; це не GPU fence і не вимірювання моменту фізичного показу пікселів монітором.

Тому число 2,946 с не можна перетворити на твердження «повна ретуш за три секунди». До оцінки готовності всіх увімкнених шарів у цьому проході минуло 9,199 с. Стабільного повністю готового результату за п’ять секунд у цій збірці не досягнуто. Крім того, для старого інтерфейсу не було зіставного наскрізного заміру, отже відсоток E2E-прискорення я не заявляю.

Мене також цікавило відкриття готового набору з backend-кешу. Через Tauri отримання, хешування та декодування у WebView в описаній перевірці зайняли 379 мс. Але цей інтервал не включав завантаження текстур і малювання LayerCanvas. Його теж не можна підписати як «час до готового екрана».

Для подальших вимірювань я розділяю холодний запуск, прогрітий інференс, повернення до кешованого кадру, перший видимий ефект і готовність усіх увімкнених корекцій. Це різні сценарії з різною роботою всередині. Якщо звести їх до одного числа, легко продемонструвати прогрес там, де змінилися лише умови тесту.

Що я змінив би у своєму підході на старті

Раніше визначив би спільний результат для прев’ю й експорту. Не обов’язково однаковий кінцевий файл: екрану потрібні шари й інтерактивність, експорту — повна роздільність. Але дорогий аналіз та обчислені поправки не повинні зникати на межі двох користувацьких дій. Винятки на кшталт очищення фону потрібно описувати явно, а не приховувати всередині загального кешу.

Заклав би діагностику всього шляху даних раніше. Час моделі — лише частина затримки. У моєму випадку окремого розгляду потребували дублікати бази, упаковка шарів, IPC, декодування та відображення. Оптимізація транспорту прибрала багато байтів, але майже не змінила виміряний нативний прохід. Без розділених метрик ці два результати легко було б переплутати.

До кожного експерименту одразу прив’язував би критерій приймання. Для зміни транспорту — тотожність шарів, коефіцієнтів і файлів. Для заміни рушія — порівняння результатів із допустимими й перевіреними відхиленнями. Для паралельного виконання — не лише час, а й стабільність. Невдала оптимізація теж має залишати після себе висновок, щоб за місяць не перевіряти ту саму ідею заново.

Головний результат цієї роботи для мене — не універсальний рецепт «перепишіть на Rust» чи «додайте більше GPU workers». Найкориснішими виявилися конкретні зміни: не рахувати основний прохід двічі, не передавати дані, які вже є в отримувача, і не називати результат успішним, доки не перевірені його пікселі та умови вимірювання. Саме на цих межах локальний AI-конвеєр перетворюється з набору моделей на передбачуваний застосунок.

А у ваших графічних або AI-застосунках що виявлялося дорожчим, ніж очікували: сам інференс, підготовка даних чи шлях результату до екрана? Цікаві випадки, коли профілювання змусило оптимізувати зовсім не той компонент, з якого ви починали.

👍ПодобаєтьсяСподобалось1
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

гляньте до DirectX shared handles на вінді або до передачі сирих байтів, так можна не компресити-декомпресити 2 рази, з пнг-шки в текстури

Так а де сам редактор можна подивитись ?