Блюр объектов в реальном времени на видео с помощью canvas

Фото 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 элемент, делаем его по размеру видео, получаем контекст


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,
);

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

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

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

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

Магия!

image-loader.svg

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

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 не предлагать, слишком накладная либка.

© Habrahabr.ru