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

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

Комментарии (1)


  1. Arjgsh
    02.06.2026 15:29

    У вас тут черны шрифт на черном фоне
    У вас тут черны шрифт на черном фоне

    А сайт классный, кинул в закладки