Удаляем фон и замазываем лица прямо в браузере: ONNX Runtime, MediaPipe и грабли Service Worker. dynamic import.. dynamic import. mediapipe.. dynamic import. mediapipe. ONNX Runtime.. dynamic import. mediapipe. ONNX Runtime. privacy.. dynamic import. mediapipe. ONNX Runtime. privacy. Service Worker.. dynamic import. mediapipe. ONNX Runtime. privacy. Service Worker. WebAssembly.. dynamic import. mediapipe. ONNX Runtime. privacy. Service Worker. WebAssembly. замазывание лиц.. dynamic import. mediapipe. ONNX Runtime. privacy. Service Worker. WebAssembly. замазывание лиц. нейросети в браузере.. dynamic import. mediapipe. ONNX Runtime. privacy. Service Worker. WebAssembly. замазывание лиц. нейросети в браузере. обработка изображений на клиенте.. dynamic import. mediapipe. ONNX Runtime. privacy. Service Worker. WebAssembly. замазывание лиц. нейросети в браузере. обработка изображений на клиенте. удаление фона.

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) снимают почти всю низкоуровневую возню, а модели среднего размера (десятки мегабайт) скачиваются один раз и кэшируются. То есть задачу «обработать фото, ничего никуда не отправляя» можно решить честно: файл просто не покидает вкладку.

Я собрал такой набор инструментов и по дороге наступил на несколько граблей. Это рабочие заметки, а не презентация продукта: код, решения, компромиссы. Живой стенд, на котором всё крутится, дам ссылкой в конце — проверить главный тезис можно за десять секунд, прямо сейчас расскажу как.

«Не верь — проверь»: F12 → Network

Главная проблема privacy-инструмента — ему не верят на слово, и правильно делают. Хорошая новость: клиентскую обработку легко проверить руками, в отличие от серверной, где остаётся только верить политике.

Откройте DevTools → вкладку Network, очистите её, прогоните картинку через инструмент и смотрите список запросов. При первом запуске вы увидите загрузку рантайма и модели (один раз, дальше из кэша). Чего вы не увидите — ни одного исходящего запроса с телом вашего файла: ни POST с multipart/form-data, ни base64 в payload. Обработка идёт в JS/WASM прямо во вкладке, результат рисуется на <canvas> и скачивается через URL.createObjectURL — сеть в этом не участвует вообще.

Это и есть проверяемость, которую серверный сервис дать не может в принципе.

Часть 1. Удаление фона на ONNX Runtime Web

Удаляем фон и замазываем лица прямо в браузере: ONNX Runtime, MediaPipe и грабли Service Worker - 1

Здесь сразу всплывает скучный, но решающий вопрос — лицензия. Фронтир-качество для «вырезать объект» сейчас дают 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).

Загрузка рантайма: wasm локально, без CDN

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-инференса — приемлемо.

Часть 2. Замазывание лиц на MediaPipe Tasks Vision

Удаляем фон и замазываем лица прямо в браузере: ONNX Runtime, MediaPipe и грабли Service Worker - 2

Для детекции лиц нет смысла тащить ещё одну тяжёлую 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% — чтобы захватить подбородок и уши) и применяю эффект только в этих областях.

Почему по умолчанию пикселизация, а не blur

Это место, где «приватный инструмент» легко стать псевдо-приватным. Gaussian blur выглядит солидно, но математически он — свёртка с известным ядром, а значит обратим: деконволюцией (и тем более нейросетевым deblur’ом) лицо из размытия частично восстанавливается. Любой, кто видел, как из «заблюренного» автомобильного номера достают цифры, понимает проблему.

Пикселизация усреднением — другое дело: внутри блока информация необратимо схлопывается в одно значение, восстановить нечего. Поэтому по умолчанию стоит pixelate (необратимо), а blur — опционально, для тех, кому важнее эстетика, чем стойкость. Именно по этой причине пикселизацию используют в публикациях, где анонимность критична.

if (effect === 'blur') {
  ctx.filter = 'blur(' + radius + 'px)';   // обратимо — на свой риск
  // ... перерисовка только области лица
} else {
  // pixelate: усреднение по блокам N×N — необратимо
}

Мелочь, но именно из таких мелочей складывается разница между «выглядит приватно» и «является приватным».

Часть 3 (ради неё всё и писалось). Service Worker ломает dynamic import

Теперь главная грабля. Сайт — 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-чека.

Фикс: network-only для модулей и WASM

Решение — не пускать .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. Мы лишь убрали лишний (и ломающий) слой, а нативное кэширование осталось.

Бонус: инвалидация 96 МБ вендоров

Сопутствующая проблема — версионирование кэша. Вендоров суммарно под сотню мегабайт; если при релизе имя кэша не меняется, у части пользователей останется старый закэшированный набор. Решение простое — имя кэша содержит версию темы, которая подставляется на сервере:

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 на других движках.

Автор: kingofligh

Источник