Удаляем фон и замазываем лица прямо в браузере: ONNX Runtime, MediaPipe и грабли Service Worker
- среда, 3 июня 2026 г. в 00:00:14
TL;DR. Две нейросетевые задачи — удаление фона (ONNX Runtime Web + модель silueta) и замазывание лиц (MediaPipe Tasks Vision + BlazeFace) — запущены полностью на клиенте: ни один пиксель не уходит на сервер. Ниже — почему «в браузере», какие модели выбраны и почему, как тянуть 43-мегабайтную модель с прогресс-баром, почему лица лучше пикселизировать, чем размывать, и подробный разбор бага, на который я убил вечер: Service Worker, кэширующий .mjs/.wasm, ломает dynamic import() с ошибкой «Failed to fetch dynamically imported module».
Типовой сценарий «убрать фон с фото» или «замазать лицо на скриншоте» сегодня выглядит так: заходишь на сайт, загружаешь свою картинку на чужой сервер и надеешься, что её обработают и не оставят у себя. Для мема — нормально. Для фотографии ребёнка, скана документа, кадра из журналистского материала или медицинского снимка — это передача чувствительных данных третьей стороне с непрозрачной retention-политикой. И отозвать её уже нельзя.
При этом современный браузер давно умеет гонять инференс локально. WebAssembly даёт near-native скорость на CPU, готовые рантаймы (ONNX Runtime Web, MediaPipe Tasks, TensorFlow.js) снимают почти всю низкоуровневую возню, а модели среднего размера (десятки мегабайт) скачиваются один раз и кэшируются. То есть задачу «обработать фото, ничего никуда не отправляя» можно решить честно: файл просто не покидает вкладку.
Я собрал такой набор инструментов и по дороге наступил на несколько граблей. Это рабочие заметки, а не презентация продукта: код, решения, компромиссы. Живой стенд, на котором всё крутится, дам ссылкой в конце — проверить главный тезис можно за десять секунд, прямо сейчас расскажу как.
Главная проблема privacy-инструмента — ему не верят на слово, и правильно делают. Хорошая новость: клиентскую обработку легко проверить руками, в отличие от серверной, где остаётся только верить политике.
Откройте DevTools → вкладку Network, очистите её, прогоните картинку через инструмент и смотрите список запросов. При первом запуске вы увидите загрузку рантайма и модели (один раз, дальше из кэша). Чего вы не увидите — ни одного исходящего запроса с телом вашего файла: ни POST с multipart/form-data, ни base64 в payload. Обработка идёт в JS/WASM прямо во вкладке, результат рисуется на <canvas> и скачивается через URL.createObjectURL — сеть в этом не участвует вообще.
Это и есть проверяемость, которую серверный сервис дать не может в принципе.

Здесь сразу всплывает скучный, но решающий вопрос — лицензия. Фронтир-качество для «вырезать объект» сейчас дают RMBG 1.4 / 2.0 от BRIA AI. Они заметно лучше на сложных краях (волосы, мех), но идут под non-commercial лицензией. Для проекта, который должен оставаться свободным для любого использования, это блокер: нельзя обещать «бесплатно для всех», таща внутри компонент с ограничением на коммерцию.
Поэтому выбрана silueta — производная от U²-Net, под Apache 2.0, около 43 МБ. Качество — честный «general use»: хорошо на портретах, продуктах на простом фоне, объектах с чёткими краями; слабее на торчащих волосках, стекле и пушистых животных. Это ожидаемый потолок модели в 43 МБ, и я предпочитаю назвать ограничение вслух, чем притворяться, что его нет.
Рантайм — onnxruntime-web от Microsoft (тоже Apache 2.0, ~10 МБ wasm+js).
ORT по умолчанию пытается тянуть свои .wasm с unpkg/CDN. Это работает, но противоречит privacy-сценарию — не хочу, чтобы при запуске инструмента что-то уходило на сторонний CDN. Поэтому явно указываю локальный путь:
window.ort.env.wasm.wasmPaths = ORT_BASE; // локальная папка с .wasm window.ort.env.wasm.numThreads = 1; // single-threaded — см. ниже
Про numThreads = 1 — это вынужденно. Многопоточный WASM-бэкенд требует заголовков COOP/COEP (Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy), иначе SharedArrayBuffer недоступен и ORT молча сваливается в один поток. На моём хостинге (Apache 2.4 + mod_fcgid) выставить COOP/COEP корректно для всех ресурсов оказалось геморройно, поэтому я осознанно остановился на single-thread: медленнее, но предсказуемо и работает везде, включая мобильные WebView.
InferenceSession.create(url) умеет сам скачать модель по URL, но без обратной связи — а 43 МБ на медленном канале без прогресс-бара выглядят как зависание. Поэтому качаю руками через fetch + reader потока, считая байты:
const resp = await fetch(SILUETA_BASE + 'silueta.onnx'); const total = parseInt(resp.headers.get('content-length') || '0', 10); const reader = resp.body.getReader(); let loaded = 0; const chunks = []; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.byteLength; if (total) onProgress(loaded / total); // 0..1 → рисуем проценты } const modelBuffer = concat(chunks); // Uint8Array → ArrayBuffer const session = await ort.InferenceSession.create(modelBuffer, { executionProviders: ['wasm'], graphOptimizationLevel: 'all', });
Дальше модель лежит в HTTP-кэше браузера, и повторные запуски сети уже не требуют.
silueta ждёт фиксированный вход 320×320. Картинку привожу к этому размеру (letterbox, чтобы не плющить пропорции), нормализую в planar-RGB float32-тензор [1, 3, 320, 320]:
const tensorData = new Float32Array(3 * n); for (let i = 0; i < n; i++) { tensorData[0 * n + i] = (r - mean[0]) / std[0]; // R-плоскость tensorData[1 * n + i] = (g - mean[1]) / std[1]; // G tensorData[2 * n + i] = (b - mean[2]) / std[2]; // B } const input = new ort.Tensor('float32', tensorData, [1, 3, 320, 320]); const output = await session.run({ [inputName]: input });
На выходе — одноканальная маска в [0,1], которую растягиваю обратно до исходного размера и применяю как альфа-канал. Время на одну картинку: 2–5 с на десктопе, 5–15 с на телефоне. Для локального CPU-инференса — приемлемо.

Для детекции лиц нет смысла тащить ещё одну тяжёлую ONNX-модель — есть BlazeFace short-range, заточенный ровно под это и крошечный: модель ~230 КБ (.tflite) + рантайм MediaPipe Tasks Vision (~3 МБ WASM + ~600 КБ ESM-бандл). Поставляется как ES-модуль, который браузер грузит обычным dynamic import() с любого URL:
const mp = await import(/* webpackIgnore: true */ BUNDLE_URL); // vision_bundle.mjs const vision = await mp.FilesetResolver.forVisionTasks(WASM_BASE); const detector = await mp.FaceDetector.createFromOptions(vision, { baseOptions: { modelAssetPath: MODEL_URL }, // blaze_face_short_range.tflite runningMode: 'IMAGE', });
Дальше детектор отдаёт bounding-box'ы лиц в координатах исходного изображения, я их немного расширяю (ползунок expand, по умолчанию 20% — чтобы захватить подбородок и уши) и применяю эффект только в этих областях.
Это место, где «приватный инструмент» легко стать псевдо-приватным. Gaussian blur выглядит солидно, но математически он — свёртка с известным ядром, а значит обратим: деконволюцией (и тем более нейросетевым deblur'ом) лицо из размытия частично восстанавливается. Любой, кто видел, как из «заблюренного» автомобильного номера достают цифры, понимает проблему.
Пикселизация усреднением — другое дело: внутри блока информация необратимо схлопывается в одно значение, восстановить нечего. Поэтому по умолчанию стоит pixelate (необратимо), а blur — опционально, для тех, кому важнее эстетика, чем стойкость. Именно по этой причине пикселизацию используют в публикациях, где анонимность критична.
if (effect === 'blur') { ctx.filter = 'blur(' + radius + 'px)'; // обратимо — на свой риск // ... перерисовка только области лица } else { // pixelate: усреднение по блокам N×N — необратимо }
Мелочь, но именно из таких мелочей складывается разница между «выглядит приватно» и «является приватным».
Теперь главная грабля. Сайт — PWA, у него есть Service Worker, который кэширует ассеты для офлайна и ускорения. Тяжёлые вендор-файлы (модели, рантаймы) — идеальные кандидаты на cache-first: они immutable, лежат в версионированной папке.
И вот тут случилось интересное: face-blur стабильно работал на любом устройстве без зарегистрированного SW, но ломался ровно после того, как SW успевал закэшировать vision_bundle.mjs. В консоли:
Failed to fetch dynamically imported module: .../vision_bundle.mjs
Файл на месте, отдаётся с кодом 200, открывается в браузере напрямую. Но import() его отклоняет.
В ряде Chromium-движков (в том числе Android WebView) Response, положенный в Cache Storage через cache.put(), теряет или искажает Content-Type при последующем cache.match(). Для обычной статики (картинки, шрифты, даже классический .js, подключённый тегом) это незаметно. Но при dynamic import() модуля браузер делает строгую проверку MIME: если ответ приходит не с text/javascript (или application/javascript), модуль-скрипт отклоняется — спецификация требует это поведение. То же самое с .wasm, который ожидает application/wasm для WebAssembly.instantiateStreaming, и с .tflite.
То есть SW, пытаясь помочь, превращал валидный модуль в «битый» с точки зрения строгого MIME-чека.
Решение — не пускать .mjs/.wasm/.tflite через Cache API вообще. В fetch-обработчике SW добавляется ранний bypass:
function shouldBypass(url) { // ... другие исключения (сам sw.js, манифест, cross-origin) ... /* ES-модули (.mjs), WASM-бинарники (.wasm) и tflite-модели НЕЛЬЗЯ гонять через Cache API: в ряде Chromium-движков (в т.ч. WebView на Android) Response, положенный в кэш через cache.put(), теряет/искажает Content-Type. Для обычной статики это незаметно, но браузер при dynamic import() module-script'а делает СТРОГУЮ проверку MIME — и если из кэша прилетает .mjs без text/javascript, выполнение отклоняется с «Failed to fetch dynamically imported module». */ if (/\.mjs(\?|$)/.test(url)) return true; // network-only if (/\.wasm(\?|$)/.test(url)) return true; if (/\.tflite(\?|$)/.test(url)) return true; return false; } // в обработчике fetch: if (shouldBypass(url.pathname + url.search)) return; // отдаём браузеру как есть
Ключевой момент: это не стоит скорости второго визита. Сервер отдаёт эти файлы с Expires/Cache-Control: max-age на год вперёд, так что повторную загрузку берёт на себя обычный HTTP-кэш браузера — просто не Cache Storage. Мы лишь убрали лишний (и ломающий) слой, а нативное кэширование осталось.
Сопутствующая проблема — версионирование кэша. Вендоров суммарно под сотню мегабайт; если при релизе имя кэша не меняется, у части пользователей останется старый закэшированный набор. Решение простое — имя кэша содержит версию темы, которая подставляется на сервере:
const VERSION = '__PV_VERSION__'; // подставляется при рендере const CACHE_VENDOR = 'pv-vendor-' + VERSION; // при activate сносим всё, что начинается с 'pv-' и НЕ заканчивается текущей версией: if (name.startsWith('pv-') && !name.endsWith('-' + VERSION)) { await caches.delete(name); }
Плюс кнопка «сбросить кэш» в диагностике, шлющая в SW сообщение PV_CLEAR_CACHE — на случай, когда нужно вычистить вендоров принудительно, не дожидаясь смены версии.
Раз уж статья про privacy-инструменты, обязан быть точным, иначе грош цена всему остальному.
Обещание здесь строго ограничено файлами: ваши изображения не уходят на сервер — это и есть проверяемый тезис (см. раздел про Network). Что при этом происходит: на сайте стоит обычная аналитика посещений (счётчик визитов и страниц). Она считает заходы, а не содержимое ваших файлов, и описана на странице приватности.
Я специально не пишу «никакого трекинга» — это было бы неправдой при наличии счётчика, а privacy-инструмент, который преувеличивает, хуже того, который честно очерчивает границы. Приватны файлы. Факт визита — нет. Смешивать эти два уровня и продавать второе под видом первого — ровно та манипуляция, против которой весь проект и затевался.
Клиентский ML для таких задач уже жизнеспособен на обычных устройствах: 2–5 с на десктопе, 5–15 с на телефоне для сегментации 320×320 — терпимо, и это без GPU, на чистом WASM single-thread.
Размер модели ↔ качество — честный компромисс. 43 МБ дают «general use»; за фронтир придётся либо на сервер, либо на non-commercial лицензию. Apache-2.0-компоненты сохраняют проект свободным — для меня это весомее пары процентов качества на сложных краях.
Многопоточный WASM упирается в COOP/COEP — на shared-хостинге проще сразу планировать single-thread, чем потом ловить тихий фолбэк.
Service Worker и dynamic import() дружат не всегда. Если грузите ESM/WASM через import() — держите .mjs/.wasm подальше от Cache API и пускайте network-only; HTTP-кэш сделает остальное. Этот баг тихий: всё работает, пока SW не прогреется, и поэтому ловится не сразу.
Пикселизация ≠ blur. Если задача — анонимность, а не эстетика, усреднение необратимо, а размытие — нет.
Стенд, где всё это работает (и где можно открыть Network и проверить тезис про файлы), — photovoid.com. Буду рад замечаниям по делу: где ещё клиентский инференс выстрелит, какие модели под Apache/MIT стоит посмотреть, и не ловил ли кто похожих эффектов с Cache API на других движках.