Фото Sigmund с Unsplash
Фото Sigmund с Unsplash

Конфиденциальность это очень важная часть в современном мире. Снимая видео на своей телефон в общественном месте, не всегда, люди, которые попадают в кадр, будут довольны этим и можно получить "по жопке" за это. Для избежания этого лица рамывают или пикселизируются.

Сегодня я хочу поделиться реализацией такого блюра/пикселизации видео (изображения) в вебе.

Дано:

  • браузер

  • видео

  • метаданные видео

  • массив с координатами лиц для каждого кадра видео (он подготовлен заранее, прогнан через алгоритм поиска лиц)

  • Знания JavaScript и Canvas и немножко CSS

Блюрить мы будем как лица отдельно, так и все изображение, кроме лиц.

Как это выглядит:

блюр снаружи
блюр снаружи
блюр внутри
блюр внутри

Итак, поехали.

Получение метаданных видео

Сначала нужно получить метаданные нашего видео. Для этого заходим на сайт https://gpac.github.io/mp4box.js/test/filereader.html, загружаем видео.

Открываем src/constants/video.ts и меняем параметры

export const VIDEO_METADATA_INFO = {
 framesCounts: [492],
 framesDurations: [1000],
 timeScale: 29970,
}

framesCount это sample_counts в разделе Box View -> Tree View-> moov -> trak ->mdia -> minf -> stbl -> stts

как найти framesCount параметр
как найти framesCount параметр

framesDurations это sample_deltas в разделе Box View -> Tree View-> moov -> trak ->mdia -> minf -> stbl -> stts

как найти framesDurations параметр
как найти framesDurations параметр

timeScale это timescale в разделе Box View -> Tree View-> moov -> trak ->mdia -> mdhd

как найти timeScale параметр
как найти timeScale параметр

Все эти метаданные нам нужны будут для правильного определения фрейма по времени видео.

Работа с видео

У нас есть видео, которые мы хотим проигрывать и блюрить. Но использовать просто video тег мы не можем, т.к мы не можем его редактировать на лету, да и впринципе что-то с ним сделать в браузере. Для этого можно рисовать это самое видео на canvas.

Алгоритм отрисовки довольно простой:

1. Создаем video элемент и передаем ссылку на видео

this.video = document.createElement('video');
this.video.crossOrigin = 'anonymous';
this.video.src = 'VIDEO_URL';

2. Создаем canvas элемент, делаем его по размеру видео, получаем контекст

<canvas ref={this.canvasRef} className="videoCanvas" />
const canvas = this.canvasRef.current;
canvas.width = width;
canvas.height = height;

this.videoContext = canvas?.getContext('2d');

3. Рисуем на canvas текущий фрейм видео

this.videoContext?.drawImage(this.video,0,0,width, height);*

* Метод drawImage принимает интерфейсы: HTMLOrSVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas, поэтому мы просто можем передать элемент видео, и текущее изображение фрейма само отрисуется.

Выполнив этот код, мы увидим черный прямоугольник. Это потому, что наше видео на данный момент находится в 0 таймлайне. Чтобы увидеть изображение при открытии видео, нужно сделать небольшой хак, при первой отрисовке установить текущее время видео на 0.0001.

if (!this.video.currentTime) {
 this.video.currentTime = 0.0001;
}

Теперь нужно решить другую задачу: как нам отрисовывать фреймы видео при проигрывании.

Можно попробовать подписаться на событие timeupdate, но результат вас огорчит. Он тригерится 4-5 раз в секунду и мы получим просто слайд-шоу в результате отрисовки.

частота вызова timeupdate
частота вызова timeupdate

Но наше видео играет с частотой почти 30 кадров в секунду. Да, немного не этого мы ожидали, давайте пробовать дальше.

Используя рекурсивный вызов requestAnimationFrame мы сможем гораздо чаще (60 раз в сек в лучшем случае) вызывать метод, который будет получать время на видео и определять фрэйм.

requestTimeUpdate = () => {
 if (this.isDestroyed) {
   return;
 }

 this.processFrame();
 this.timeUpdateRAFId = window.requestAnimationFrame(this.requestTimeUpdate);
};

processFrame() {
 const { time } = this.state;

 if (!time) {
   this.drawToCanvas();
 }

 this.setState({ time: this.video.currentTime });

 this.setState({
   frame: videoUtils.getTimestampIndex(VIDEO_METADATA_INFO, time),
 });

 this.drawToCanvas();
}

Теперь у нас готов "плеер" на канвасе. Осталось научиться блюрить объекты.

Какие опции у нас есть по блюру:

1. Гауссовский блюр внутри объектов

2. Гауссовский блюр вокруг объекта

3. Пикселизация внутри объектов

4. Пикселизация вокруг объекта

Гауссовский блюр

Для реализации этого кейса, нам нужно поверх канваса с отрисованным кадром видео еще 2 канваса поверх него. Один для того, чтоб заблюрить весь холст, 2-й чтоб из заблюреного холста вырезать нужные объекты по координатам.

слои с канвасами для блюра
слои с канвасами для блюра

Заблюрить весь слой достаточно просто. Для этого нужно применить фильтр blur с заданной интенсивностью, в моей реализации это 30. Яркость здесь применяется для того, чтоб усилить эффект блюра, так как для ярких изображений все равно будет видно слишком сильно, что под блюром. Затем остается только отрисовать наш кадр видео на этом холсте.

const brightnessMax = blurIntensityMax + defaultBlurIntensity;
this.tmpContext.filter = `blur(${blurIntensity}px) brightness(${brightnessMax - blurIntensity}%)`;

this.tmpContext.drawImage(imageSource, 0, 0);

Теперь нужно вырезать наши объекты по координатам. У нас есть данные о всех объектах на каждом кадре (с координатами x, у, высотой и шириной, типом блюра), поэтому достаточно просто пройтись по всем этим объектам на кадре и сделать немножко магии.

Формируем данные об области, где находится объект.

const displayRect = {
 x: occurrence.x,
 y: occurrence.y,
 w: occurrence.w,
 h: occurrence.h,
};

Это получается квадрат, но нам же нужен эллипс…Хорошо, хорошо, сейчас все будет. 

Применив немного школьных знаний, мы получаем радиусы по высоте и ширине и находим центр эллипса. Рисуем эллипсы и ограничиваем их области рисования с помощью clip.

Ну и последний шаг. В зависимости от того, как мы будем блюрить ( внутри объекта или снаружи), в методе drawImage будут браться разные слои. Если нам нужен блюр вне объекта, мы берем изображение с видео, если внутри, мы берем с заблюренного холста.

this.displayContext.save();

// draw ellipse
this.displayContext.beginPath();
const radiusX = displayRect.w / 2;
const radiusY = displayRect.h / 2;
const centerX = displayRect.x + radiusX;
const centerY = displayRect.y + radiusY;
const rotation = Math.PI;
const startAngle = 0;
const endAngle = rotation * 2;

this.displayContext.ellipse(
 centerX,
 centerY,
 radiusX,
 radiusY,
 rotation,
 startAngle,
 endAngle,
);
this.displayContext.closePath();
this.displayContext.clip();

this.displayContext.drawImage(
 occurrence.isBlurOut ? imageSource : this.tmpCanvas,
 drawSourceRect.x, drawSourceRect.y, drawSourceRect.w, drawSourceRect.h,
 displayRect.x, displayRect.y, displayRect.w, displayRect.h,
);

this.displayContext.restore();

Вы могли заметить, что объекты зачем то сортируются по полю isBlurOut.

Object.values(occurrencesByFrame).sort((a: any, b: any) => b.isBlurOut - a.isBlurOut)

Это для случая, когда у нас на кадре присутствуют объекты с внешним блюром, и объекты с внутренним и они пересекаются в какой-то области, и чтобы объект с внутренним блюром был поверх изображения, нам нужно отрисовать сначала все объекты с внешним, а затем поверх отрисовать с внутренним.

перекрытие объектов
перекрытие объектов

Пикселизация

Для пикселизации будет немного сложнее уже. Рассмотрим сначала кейс с пикселизацией внутри объекта. У нас опять же есть 2 слоя поверх канваса с отрисованным видео. Но тут есть небольшой хак еще.

На слое, где мы блюрили весь холст, теперь мы уменьшаем наше изображение в 12.5 раз и рисуем его. В формуле downsizeRatio присутствует blurIntensity, для того, чтоб мы могли уменьшать или увеличивать размер пикселей.

const halfPerimeter = this.height + this.width;

this.downsizeRatio = (halfPerimeter / PERIMETER_DOWNSIZE_MULTIPLIER)
 * (blurIntensity / 100);

this.tmpContext.drawImage(
 imageSource,
 0, 0, this.width, this.height,
 0, 0, this.width / this.downsizeRatio, this.height / this.downsizeRatio,
);

Получаем такой результат

уменьшенное изображение
уменьшенное изображение

Теперь, когда мы пройдемся по всем объектам на кадре, мы получим такой результат, потому что мы рисуем эллипсы в реальном размере, но картинку берем с маленького изображения в левом верхнем углу. Получается мы его растягиваем и получаем пиксельное изображение.

получаем запикселенное изображение
получаем запикселенное изображение

Магия!

Осталось рассмотреть пикселизацию всего кадра. Это достаточно трудозатратная операция и парой строк кода не отделаешься.

const ctx = imageSource.getContext('2d');

const imgData = ctx && ctx.getImageData(0, 0,  this.width, this.height).data;

if (!imgData) return;

const perimeter = ( this.width + this.height) * 2;
let pixelSize = Math.floor((perimeter / PERIMETER_DOWNSIZE_MULTIPLIER)
 * (blurIntensity / blurIntensityMax));

for (let row = 0; row < this.height; row += pixelSize) {
 for (let col = 0; col < this.width; col += pixelSize) {
   let pixel = (col + ( row * this.width )) * 4;

   this.tmpContext.fillStyle = `rgba(${imgData[pixel]},${imgData[pixel + 1]},${imgData[pixel + 2]},${imgData[pixel + 3]})`;
   this.tmpContext.fillRect(col, row, pixelSize, pixelSize);
 }
}

Нужно получить imageData с канваса с отрисованным видео, это будет здоровенный массив с описанием пикселей изображения. Каждые 4 элемента в этом массиве описывают ​​RGBA каждого пикселя.

const imgData = ctx && ctx.getImageData(0, 0,  this.width, this.height).data;
массив с описанием изображения
массив с описанием изображения

Вот почему пикселизация всего кадра очень трудоемкая операция.

Но не все так плохо, нам не по всему этому массиву надо пробегаться. В зависимости от pixelSize (размер пикселя, или другими словами, сколько пикселей мы хотим объединить в один квадрат), во столько раз меньше у нас будет обход этого массива.

Пробегаясь по каждому пикселю, кратному pixelSize, мы просчитываем его позицию в массиве с RGBA представлением изображения по формуле:

let pixel = (col + ( row * this.width )) * 4;

А затем применяем наше изменение на канвас, рисуя этот пиксель размером pixelSize и его цветом:

this.tmpContext.fillStyle = `rgba(${imgData[pixel]},${imgData[pixel + 1]},${imgData[pixel + 2]},${imgData[pixel + 3]})`;
this.tmpContext.fillRect(col, row, pixelSize, pixelSize);

Помните, мы затемняли изображение с помощью filter для гауссовского блюра? Так вот, забудьте про такую реализацию :)

this.tmpContext.filter = `brightness(${brightnessMax - blurIntensity}%)`;

При пикселизации такая реализация отрабатывает оооочень долго, для одного кадра изменяется в секундах, можете проверить, нажав на кнопку Pixelate with canvas filter в демо.

Но мы же не будем на этом сдаваться, у нас же в дано было немножко знаний по CSS, поэтому самое время их применить.

this.tmpCanvas.style.filter = `brightness(${brightnessMax - blurIntensity}%)`;

Все, применили :)

Проверить результат можно в демке, нажав на кнопку Pixelate with css filter. Он дейстивельно удивит вас.

Заключение

Спасибо, что прочли статью до конца. Полную реализацию примера можно найти на GitHub и посмотреть онлайн демо тут.

Нет ничего невозможного, вопрос лишь времени.

P.S. Если кто подскажет, как при пикселизации смешать цвета соседних пикселей, чтоб было не так "грубо", буду очень благодарен. OpenCV.js не предлагать, слишком накладная либка.

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


  1. Alexufo
    23.08.2021 21:45
    -2

    А почему у вас canvas?.getContext('2d'); У канваса не может не быть 2D контекста.
    Блюр ооочень ресурсоемок, я бы его заменил на что-то другое, хотя визуально красиво.
    Такс… а это блюр на css -ке сделан? хм. интересно, но все равно он тяжеловат.

    Работать с канвой можно попробовать через Uint32Array, там пиксель будет представлен как элемент массива в 4 байта, а в канвасовом 1 пиксель один элемент, теоретически облегчается задачка для вычислений.

    Но, вообще странно, ведь исходник с лицами не может не быть недоступен для решения этой задачи на канве)

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

    Делать Antialiasing на краях маски самостоятельно. Только это опять доп ресурсы.
    Или может это поможет
    developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingEnabled


    1. yantishko Автор
      24.08.2021 10:38

      Спасибо за комментарий

      canvas?.getContext('2d'); сделан так, потому что берется по ref и TS ругается на то, что он может быть null, хотя он в ComponentDidMount. Возможно я не до конца пока разобрался с TS + React.

      Блюр ресурсоемок, но для 30FPS он достаточен на современных компах, на маломощных не тестировал, таких не нашлось:) Но на крайний случай, можно использовать Web Worker + OffscreenCanvas и это разгрузит основной поток

      getImage возвращает данные в формате Uint8ClampedArray и если я правильно понимаю, это в 4 раза меньше размерности Uint32Array будет, вычисления только усложнятся, да и перегонять из одного формата в другой 30 раз в секунду не лучшая идея

      По поводу исходника лиц - да, это больше как редактор предполагается для людей, которые имеют доступ к просмотру лиц, финальное видео генерируется на бэке и отдается с уже размытыми объектами, там не подсмотришь. А со знанием Front End легко можно слои поудалять из DOM и просмотреть)

      imageSmoothingEnabled не помог, по крайней мере просто выставление в true.

      Про Antialiasing пойду почитаю, спасибо.


      1. Alexufo
        24.08.2021 12:43
        +1

        Вычисления должны наоборот увеличиться, cудя по этой статье,
        hacks.mozilla.org/2011/12/faster-canvas-pixel-manipulation-with-typed-arrays
        суть в том что доступны побитовые операторы, массив короче в 4 раза, и для редактирования 1 пикселя нужно затратить меньше операций. Ну это только теория :-)
        Мне встречались на SO замеры дающие +25% к производительности.

        Если я правильно понимаю типизированные массивы, то это просто маска для просмотра области памяти, низкая. Попробуйте сделать console.log() с вызовом buffer вашего канваса, увидите что-то такое:
        image

        Если сделать общий буфер new ArrayBuffer() для Uint8ClampedArray и Uint32Array то пересчета не происходит, просто возвращается разная структура. Но я не уверен, что переноса чисел из одной области памяти в другую при использовании уже существующего Uint8ClampedArray как Uint32Array не будет происходить, потому что мне как-то все равно приходилось создавать общий буфер как в примере из статьи выше
        jsfiddle.net/andrewjbaker/GhwUC
        может быть я не понимаю чего и при таких реализациях нет операций копирования, не знаю, а если есть… надо делать замеры.

        По поводу исходника лиц — да, это больше как редактор предполагается для людей, которые имеют доступ к просмотру лиц, финальное видео генерируется на бэке и отдается с уже размытыми объектами, там не подсмотришь.

        И кстати, вы про SVG не забыли? Можно же там маски накладывать, блюрть, и там будет антиалиазинг.
        developer.mozilla.org/en-US/docs/Web/SVG/Element/feGaussianBlur
        developer.mozilla.org/en-US/docs/Web/SVG/Element/mask

        И ведь можно делать блюр без канвы, создаете div тяните его с border-radius по нужной форме и это
        developer.mozilla.org/ru/docs/Web/CSS/backdrop-filter

        Но в таком случае как рендерить на сервере? Там стоит дублирующий функционал только для ffmpeg?
        Можно попробовать конвертить через wasm-ffmpeg на клиенте.

        А можно и на сервере, на ноде, wasm api там завезли.
        А вообще, я предлагаю в наглую кропать канвас с фронта и жать на фронте.
        Правда я не знаю как обеспечить синхронизацию между фреймом отрисовки и фреймом захвата. Можно ли как стопарить процесс отрисовки браузером?


        1. yantishko Автор
          24.08.2021 16:23
          +1

          Впринципе да, если нам нужно по каждому пикселю пройтись, то Uint32Array в теории будет быстрее работать, а т.к тут мы по pixelSize идем, то оно будет одинаковым.

          C SVG реализация тоже интересная, можно будет попробовать и сравнить результаты, хорошая тема для след статьи :)

          backdrop-filter тоже вариант, но т.к эксперементальная, в продакшене использовать чревато.

          На сервере ffmpeg делает такую же логику и генерирует видео результирующее - да.

          wasm-ffmpeg - интересно, поизучаю.

          Много полезного узнал, спасибо


          1. Alexufo
            24.08.2021 17:11

            На сервере ffmpeg делает такую же логику и генерирует видео результирующее — да.

            Выходит, что есть дублирующий функционал? На сервер передаются координаты?
            вообще можно через tenserflow сделать треки лиц а потом дать возможность подправлять эти треки. он через wasm работает тоже.

            Да, можно еще не 2D контекст брать, а 3D, там тоже есть какие то фишки для антиалиазинга.


            1. yantishko Автор
              25.08.2021 08:48
              +1

              Входные данные для FE и BE это координаты объектов, да.

              вообще можно через tenserflow сделать треки лиц а потом дать возможность подправлять эти треки. он через wasm работает тоже.

              Оно так и делает, только на стороне BE, была идея сделать на tensorflow на стороне FE, но у нас есть самописный трекер на питоне. Ну и редактирование потом на FE, да


          1. EEuuGG
            03.09.2021 13:28

            SVG пробовал - очень медленно получется, фпс упал до 6


            1. Alexufo
              03.09.2021 18:57

              вполне возможно, надо спеку смотреть куда там откуда и что гоняется.


            1. yantishko Автор
              06.09.2021 10:23

              Может есть пример кода? хотелось бы тоже попробовать самому сравнить результат


              1. EEuuGG
                06.09.2021 11:15

                Уже нет, я ноут менял и гит локальный почищен. Но смысл в том, что канвас рисует быстрей, чем работать с svg объектами.

                Можно попробовать погонять перфоманс изменения объета на миллион повторений, для интереса.


  1. fransua
    24.08.2021 08:57

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

    смешать цвета соседних пикселей

    Блюр перед пикселизацией должен как раз смешать цвета


    1. yantishko Автор
      24.08.2021 10:41

      хорошая идея, попробую, спасибо


  1. AC130
    24.08.2021 13:30
    +1

    Снимая видео на своей телефон в общественном месте, не всегда, люди, которые попадают в кадр, будут довольны этим и можно получить "по жопке" за это. Для избежания этого лица рамывают или пикселизируются.

    Надо делать не блюр или пикселизацию, надо просто и тупо закрашивать лица одноцветным прямоугольником. Оставлять даже небольшую часть информации о лице -- полумеры.


    1. yantishko Автор
      24.08.2021 16:25

      это выглядит слишком грубо, данная реализация выполняет свою функциональность на юридическом уровне (результирующее видео без слоев на Web. Web app часть как редактор используется).


    1. petropavel
      24.08.2021 18:27

      Это, конечно, правильно (хотя можно для красоты закрашивать изображением размытого абстрактного лица). Но это если закрашивать на сервере.

      А если блюр делать в браузере, javascript-ом, то всё равно. Смысл, вешать суперзамок на дверь, если окна нараспашку?


      1. Alexufo
        24.08.2021 23:00

        А если блюр делать в браузере, javascript-ом, то всё равно. Смысл, вешать суперзамок на дверь, если окна нараспашку?

        Это приватный редактор у кого и так есть доступ к исходникам видео.


      1. yantishko Автор
        25.08.2021 08:46

        Это решение стоит рассматривать как сервис-редактор, который позволяет загрузить видео, заблюрить объекты, просмотреть результат и затем выгрузить готовое видео с заблюренными объектами. Т.е не для общего доступа с просмотром результат


  1. v-aksenov
    26.08.2021 10:55

    Очень интересно было как такие штуки делаются. Благодаря статье стало понятнее. Автору спасибо! :)


    1. yantishko Автор
      26.08.2021 10:56

      рад был помочь :)