Эффекты в реальном времени для изображений и видео

Mat Scales

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

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

Как получить данные пикселей для изображения

Существует три основных категории манипуляций с изображениями:

  • Пиксельные эффекты, такие как контрастность, яркость, теплота, тон сепии, насыщенность.
  • Многопиксельные эффекты, называемые фильтрами свертки, такие как повышение резкости, обнаружение краев, размытие.
  • Искажение всего изображения, например обрезка, перекос, растяжение, эффекты линз, рябь.

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

Таким образом, действительно важный выбор заключается в том, выполнять ли обработку на ЦП с помощью 2D-холста или на графическом процессоре с помощью WebGL.

Давайте кратко рассмотрим различия между этими двумя подходами.

2D холст

Это определенно самый простой из двух вариантов. Сначала вы рисуете изображение на холсте.

const source = document.getElementById('source-image');

// Create the canvas and get a context
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');

// Set the canvas to be the same size as the original image
canvas.width = source.naturalWidth;
canvas.height = source.naturalHeight;

// Draw the image onto the top-left corner of the canvas
context.drawImage(theOriginalImage, 0, 0);

Затем вы получаете массив значений пикселей для всего холста.

const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;

На этом этапе переменная pixels представляет собой Uint8ClampedArray с длиной width * height * 4 . Каждый элемент массива имеет размер одного байта, а каждые четыре элемента массива представляют цвет одного пикселя. Каждый из четырех элементов представляет количество красного, зеленого, синего и альфа (прозрачности) в указанном порядке. Пиксели упорядочены, начиная с верхнего левого угла, слева направо и сверху вниз.

pixels[0] = red value for pixel 0
pixels[1] = green value for pixel 0
pixels[2] = blue value for pixel 0
pixels[3] = alpha value for pixel 0
pixels[4] = red value for pixel 1
pixels[5] = green value for pixel 1
pixels[6] = blue value for pixel 1
pixels[7] = alpha value for pixel 1
pixels[8] = red value for pixel 2
pixels[9] = green value for pixel 2
pixels[10] = blue value for pixel 2
pixels[11] = alpha value for pixel 2
pixels[12] = red value for pixel 3
...

Чтобы найти индекс любого пикселя по его координатам, существует простая формула.

const index = (x + y * imageWidth) * 4;
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
const alpha = pixels[index + 3];

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

context.putImageData(imageData, 0, 0);

ВебГЛ

WebGL — это большая тема, слишком большая, чтобы описать ее в одной статье. Если вы хотите узнать больше о WebGL, прочтите рекомендуемую литературу в конце этой статьи.

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

Одна из самых важных вещей, которые следует помнить о WebGL, — это то, что это не API 3D-графики. На самом деле WebGL (и OpenGL) хорош только в одном — рисовании треугольников. В своем приложении вы должны описать в виде треугольников то, что вы на самом деле хотите нарисовать. В случае с 2D-изображением это очень просто, ведь прямоугольник — это два одинаковых прямоугольных треугольника, расположенных так, что их гипотенузы находятся в одном и том же месте.

Основной процесс:

  • Отправьте в графический процессор данные, описывающие вершины (точки) треугольников.
  • Отправьте исходное изображение в графический процессор в виде текстуры (изображения).
  • Создайте «вершинный шейдер».
  • Создайте «фрагментный шейдер».
  • Установите некоторые переменные шейдера, называемые «униформами».
  • Запустите шейдеры.

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

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

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

При чтении текстуры во фрагментном шейдере вы указываете, какую часть изображения вы хотите прочитать, используя две координаты с плавающей запятой между 0 (слева или снизу) и 1 (справа или сверху).

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

varying vec2 pixelCoords;

uniform vec2 textureSize;
uniform sampler2D textureSampler;

main() {
  vec2 textureCoords = pixelCoords / textureSize;
  vec4 textureColor = texture2D(textureSampler, textureCoords);
  gl_FragColor = textureColor;
 }
Pretty much every kind of 2D image manipulation that you might want to do can be done in the
fragment shader, and all of the other WebGL parts can be abstracted away. You can see [the
abstraction layer](https://github.com/GoogleChromeLabs/snapshot/blob/master/src/filters/image-shader.ts) (in
TypeScript) that is being in used in one of our sample applications if you'd like to see an example.

### Which should I use?

For pretty much any professional quality image manipulation, you should use WebGL. There is no
getting away from the fact that this kind of work is the whole reason GPUs were invented. You can
process images an order of magnitude faster on the GPU, which is essential for any real-time
effects.

The way that graphics cards work means that every pixel can be calculated in it's own thread. Even
if you parallelize your code CPU-based code with `Worker`s, your GPU may have 100s of times as many
specialized cores as your CPU has general cores.

2D canvas is much simpler, so is great for prototyping and may be fine for one-off transformations.
However, there are plenty of abstractions around for WebGL that mean you can get the performance
boost without needing to learn the details.

Examples in this article are mostly for 2D canvas to make explanations easier, but the principles
should translate pretty easily to fragment shader code.

## Effect types

### Pixel effects

This is the simplest category to both understand and implement. All of these transformations take
the color value of a single pixel and pass it into a function that returns another color value.

There are many variations on these operations that are more or less complicated. Some will take into
account how the human brain processes visual information based on decades of research, and some will
be dead simple ideas that give an effect that's mostly reasonable.

For example, a brightness control can be implemented by simply taking the red, green and blue values
of the pixel and multiplying them by a brightness value. A brightness of 0 will make the image
entirely black. A value of 1 will leave the image unchanged. A value greater than 1 will make it
brighter.

For 2D canvas:

```js
const brightness = 1.1; // Make the image 10% brighter
for (let i = 0; i < imageData.data.length; i += 4) {
  imageData.data[i] = imageData.data[i] * brightness;
  imageData.data[i + 1] = imageData.data[i + 1] * brightness;
  imageData.data[i + 2] = imageData.data[i + 2] * brightness;
}

Обратите внимание, что цикл перемещает по 4 байта за раз, но меняет только три значения — это потому, что это конкретное преобразование не меняет значение альфа. Также помните, что Uint8ClampedArray округляет все значения до целых чисел и ограничивает значения в диапазоне от 0 до 255.

Фрагментный шейдер WebGL:

    float brightness = 1.1;
    gl_FragColor = textureColor;
    gl_FragColor.rgb *= brightness;

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

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

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

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

Мультипиксельный

Некоторые эффекты используют цвет соседних пикселей при определении цвета текущего пикселя.

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

Однако это достаточно легко. Когда вы изначально создаете объект данных изображения, вы можете сделать копию данных.

const originalPixels = new Uint8Array(imageData.data);

В случае с WebGL вам не нужно вносить никаких изменений, поскольку шейдер не записывает входную текстуру.

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

Веса могут быть представлены матрицей, называемой ядром, с центральным значением, соответствующим текущему пикселю. Например, это ядро ​​для размытия по Гауссу 3х3.

    | 0  1  0 |
    | 1  4  1 |
    | 0  1  0 |

Допустим, вы хотите вычислить выходной цвет пикселя в точке (23, 19). Возьмите 8 пикселей, окружающих (23, 19), а также сам пиксель, и умножьте значения цвета для каждого из них на соответствующий вес.

    (22, 18) x 0    (23, 18) x 1    (24, 18) x 0
    (22, 19) x 1    (23, 19) x 4    (24, 19) x 1
    (22, 20) x 0    (23, 20) x 1    (24, 20) x 0

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

const kernel = [
  [0, 1, 0],
  [1, 4, 1],
  [0, 1, 0],
];

for (let y = 0; y < imageHeight; y++) {
  for (let x = 0; x < imageWidth; x++) {
    let redTotal = 0;
    let greenTotal = 0;
    let blueTotal = 0;
    let weightTotal = 0;
    for (let i = -1; i <= 1; i++) {
      for (let j = -1; j <= 1; j++) {
        // Filter out pixels that are off the edge of the image
        if (
          x + i > 0 &&
          x + i < imageWidth &&
          y + j > 0 &&
          y + j < imageHeight
        ) {
          const index = (x + i + (y + j) * imageWidth) * 4;
          const weight = kernel[i + 1][j + 1];
          redTotal += weight * originalPixels[index];
          greenTotal += weight * originalPixels[index + 1];
          blueTotal += weight * originalPixels[index + 2];
          weightTotal += weight;
        }
      }
    }

    const outputIndex = (x + y * imageWidth) * 4;
    imageData.data[outputIndex] = redTotal / weightTotal;
    imageData.data[outputIndex + 1] = greenTotal / weightTotal;
    imageData.data[outputIndex + 2] = blueTotal / weightTotal;
  }
}

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

Все изображение

Некоторые преобразования всего изображения просты. В 2D-холсте обрезка и масштабирование — это простой случай рисования на холсте только части исходного изображения.

// Set the canvas to be a little smaller than the original image
canvas.width = source.naturalWidth - 100;
canvas.height = source.naturalHeight - 100;

// Draw only part of the image onto the canvas
const sx = 50; // The left-most part of the source image to copy
const sy = 50; // The top-most part of the source image to copy
const sw = source.naturalWidth - 100; // How wide the rectangle to copy is
const sh = source.naturalHeight - 100; // How tall the rectangle to copy is

const dx = 0; // The left-most part of the canvas to draw over
const dy = 0; // The top-most part of the canvas to draw over
const dw = canvas.width; // How wide the rectangle to draw over is
const dh = canvas.height; // How tall the rectangle to draw over is

context.drawImage(theOriginalImage, sx, sy, sw, sh, dx, dy, dw, dh);

Вращение и отражение доступны непосредственно в 2D-контексте. Прежде чем нарисовать изображение на холсте, измените различные преобразования.

// Move the canvas so that the center of the canvas is on the Y-axis
context.translate(-canvas.width / 2, 0);

// An X scale of -1 reflects the canvas in the Y-axis
context.scale(-1, 1);

// Rotate the canvas by 90°
context.rotate(Math.PI / 2);

Но что более важно, многие 2D-преобразования можно записать в виде матриц 2x3 и применить к холсту с помощью setTransform() . В этом примере используется матрица, сочетающая вращение и перемещение.

const matrix = [
  Math.cos(rot) * x1,
  -Math.sin(rot) * x2,
  tx,
  Math.sin(rot) * y1,
  Math.cos(rot) * y2,
  ty,
];

context.setTransform(
  matrix[0],
  matrix[1],
  matrix[2],
  matrix[3],
  matrix[4],
  matrix[5],
);

Более сложные эффекты, такие как искажение линзы или рябь, включают в себя применение некоторого смещения к каждой координате назначения для расчета координаты исходного пикселя. Например, чтобы получить эффект горизонтальной волны, вы можете сместить координату x исходного пикселя на некоторое значение, основанное на координате y.

for (let y = 0; y < canvas.height; y++) {
  const xOffset = 20 * Math.sin((y * Math.PI) / 20);
  for (let x = 0; x < canvas.width; x++) {
    // Clamp the source x between 0 and width
    const sx = Math.min(Math.max(0, x + xOffset), canvas.width);

    const destIndex = (y * canvas.width + x) * 4;
    const sourceIndex = (y * canvas.width + sx) * 4;

    imageData.data[destIndex] = originalPixels.data[sourceIndex];
    imageData.data[destIndex + 1] = originalPixels.data[sourceIndex + 1];
    imageData.data[destIndex + 2] = originalPixels.data[sourceIndex + 2];
  }
}

видео

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

Холст 2D:

context.drawImage(<strong>video</strong>, 0, 0);

ВебГЛ:

gl.texImage2D(
  gl.TEXTURE_2D,
  0,
  gl.RGBA,
  gl.RGBA,
  gl.UNSIGNED_BYTE,
  <strong>video</strong>,
);

Однако при этом будет использоваться только текущий видеокадр. Поэтому, если вы хотите применить эффект к воспроизводимому видео, вам нужно использовать drawImage / texImage2D для каждого кадра, чтобы захватывать новый видеокадр и обрабатывать его в каждом кадре анимации браузера.

const draw = () => {
  requestAnimationFrame(draw);

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

При работе с видео особенно важно, чтобы обработка выполнялась быстро. При использовании неподвижного изображения пользователь может не заметить задержку в 100 мс между нажатием кнопки и применением эффекта. Однако при анимации задержки всего в 16 мс могут вызвать видимые рывки.

Обратная связь