Конфиденциальность это очень важная часть в современном мире. Снимая видео на своей телефон в общественном месте, не всегда, люди, которые попадают в кадр, будут довольны этим и можно получить "по жопке" за это. Для избежания этого лица рамывают или пикселизируются.
Сегодня я хочу поделиться реализацией такого блюра/пикселизации видео (изображения) в вебе.
Дано:
браузер
видео
метаданные видео
массив с координатами лиц для каждого кадра видео (он подготовлен заранее, прогнан через алгоритм поиска лиц)
Знания 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
framesDurations
это sample_deltas
в разделе Box View -> Tree View-> moov -> trak ->mdia -> minf -> stbl -> stts
timeScale
это timescale
в разделе Box View -> Tree View-> moov -> trak ->mdia -> mdhd
Все эти метаданные нам нужны будут для правильного определения фрейма по времени видео.
Работа с видео
У нас есть видео, которые мы хотим проигрывать и блюрить. Но использовать просто 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 раз в секунду и мы получим просто слайд-шоу в результате отрисовки.
Но наше видео играет с частотой почти 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)
fransua
24.08.2021 08:57Возможно пикселизация будет быстрее, если рисовать на маленьком канвасе с маской и растягивать его через трансформ поверх основного канваса.
смешать цвета соседних пикселей
Блюр перед пикселизацией должен как раз смешать цвета
AC130
24.08.2021 13:30+1Снимая видео на своей телефон в общественном месте, не всегда, люди, которые попадают в кадр, будут довольны этим и можно получить "по жопке" за это. Для избежания этого лица рамывают или пикселизируются.
Надо делать не блюр или пикселизацию, надо просто и тупо закрашивать лица одноцветным прямоугольником. Оставлять даже небольшую часть информации о лице -- полумеры.
yantishko Автор
24.08.2021 16:25это выглядит слишком грубо, данная реализация выполняет свою функциональность на юридическом уровне (результирующее видео без слоев на Web. Web app часть как редактор используется).
petropavel
24.08.2021 18:27Это, конечно, правильно (хотя можно для красоты закрашивать изображением размытого абстрактного лица). Но это если закрашивать на сервере.
А если блюр делать в браузере, javascript-ом, то всё равно. Смысл, вешать суперзамок на дверь, если окна нараспашку?
Alexufo
24.08.2021 23:00А если блюр делать в браузере, javascript-ом, то всё равно. Смысл, вешать суперзамок на дверь, если окна нараспашку?
Это приватный редактор у кого и так есть доступ к исходникам видео.
yantishko Автор
25.08.2021 08:46Это решение стоит рассматривать как сервис-редактор, который позволяет загрузить видео, заблюрить объекты, просмотреть результат и затем выгрузить готовое видео с заблюренными объектами. Т.е не для общего доступа с просмотром результат
Alexufo
А почему у вас canvas?.getContext('2d'); У канваса не может не быть 2D контекста.
Блюр ооочень ресурсоемок, я бы его заменил на что-то другое, хотя визуально красиво.
Такс… а это блюр на css -ке сделан? хм. интересно, но все равно он тяжеловат.
Работать с канвой можно попробовать через Uint32Array, там пиксель будет представлен как элемент массива в 4 байта, а в канвасовом 1 пиксель один элемент, теоретически облегчается задачка для вычислений.
Но, вообще странно, ведь исходник с лицами не может не быть недоступен для решения этой задачи на канве)
Делать Antialiasing на краях маски самостоятельно. Только это опять доп ресурсы.
Или может это поможет
developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingEnabled
yantishko Автор
Спасибо за комментарий
canvas?.getContext('2d'); сделан так, потому что берется по ref и TS ругается на то, что он может быть null, хотя он в ComponentDidMount. Возможно я не до конца пока разобрался с TS + React.
Блюр ресурсоемок, но для 30FPS он достаточен на современных компах, на маломощных не тестировал, таких не нашлось:) Но на крайний случай, можно использовать Web Worker + OffscreenCanvas и это разгрузит основной поток
getImage возвращает данные в формате
Uint8ClampedArray
и если я правильно понимаю, это в 4 раза меньше размерностиUint32Array
будет, вычисления только усложнятся, да и перегонять из одного формата в другой 30 раз в секунду не лучшая идеяПо поводу исходника лиц - да, это больше как редактор предполагается для людей, которые имеют доступ к просмотру лиц, финальное видео генерируется на бэке и отдается с уже размытыми объектами, там не подсмотришь. А со знанием Front End легко можно слои поудалять из DOM и просмотреть)
imageSmoothingEnabled
не помог, по крайней мере просто выставление в true.Про Antialiasing пойду почитаю, спасибо.
Alexufo
Вычисления должны наоборот увеличиться, cудя по этой статье,
hacks.mozilla.org/2011/12/faster-canvas-pixel-manipulation-with-typed-arrays
суть в том что доступны побитовые операторы, массив короче в 4 раза, и для редактирования 1 пикселя нужно затратить меньше операций. Ну это только теория :-)
Мне встречались на SO замеры дающие +25% к производительности.
Если я правильно понимаю типизированные массивы, то это просто маска для просмотра области памяти, низкая. Попробуйте сделать console.log() с вызовом buffer вашего канваса, увидите что-то такое:
Если сделать общий буфер 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 там завезли.
А вообще, я предлагаю в наглую кропать канвас с фронта и жать на фронте.
Правда я не знаю как обеспечить синхронизацию между фреймом отрисовки и фреймом захвата. Можно ли как стопарить процесс отрисовки браузером?
yantishko Автор
Впринципе да, если нам нужно по каждому пикселю пройтись, то
Uint32Array
в теории будет быстрее работать, а т.к тут мы по pixelSize идем, то оно будет одинаковым.C SVG реализация тоже интересная, можно будет попробовать и сравнить результаты, хорошая тема для след статьи :)
backdrop-filter
тоже вариант, но т.к эксперементальная, в продакшене использовать чревато.На сервере ffmpeg делает такую же логику и генерирует видео результирующее - да.
wasm-ffmpeg - интересно, поизучаю.
Много полезного узнал, спасибо
Alexufo
Выходит, что есть дублирующий функционал? На сервер передаются координаты?
вообще можно через tenserflow сделать треки лиц а потом дать возможность подправлять эти треки. он через wasm работает тоже.
Да, можно еще не 2D контекст брать, а 3D, там тоже есть какие то фишки для антиалиазинга.
yantishko Автор
Входные данные для FE и BE это координаты объектов, да.
Оно так и делает, только на стороне BE, была идея сделать на tensorflow на стороне FE, но у нас есть самописный трекер на питоне. Ну и редактирование потом на FE, да
EEuuGG
SVG пробовал - очень медленно получется, фпс упал до 6
Alexufo
вполне возможно, надо спеку смотреть куда там откуда и что гоняется.
yantishko Автор
Может есть пример кода? хотелось бы тоже попробовать самому сравнить результат
EEuuGG
Уже нет, я ноут менял и гит локальный почищен. Но смысл в том, что канвас рисует быстрей, чем работать с svg объектами.
Можно попробовать погонять перфоманс изменения объета на миллион повторений, для интереса.