Efeitos em tempo real para imagens e vídeos

Mat Scales

Muitos dos apps mais usados atualmente permitem aplicar filtros e efeitos a imagens ou vídeos. Este artigo mostra como implementar esses recursos na Web aberta.

O processo é basicamente o mesmo para vídeos e imagens, mas vou abordar algumas considerações importantes sobre vídeos no final. Ao longo do artigo, você pode presumir que "imagem" significa "imagem ou um único frame de um vídeo".

Como acessar os dados de pixel de uma imagem

Há três categorias básicas de manipulação de imagens que são comuns:

  • Efeitos de pixel, como contraste, brilho, calor, tom sépia e saturação.
  • Efeitos de vários pixels, chamados filtros de convolução, como nitidez, detecção de bordas e desfoque.
  • Distorção de toda a imagem, como corte, distorção, alongamento, efeitos de lente, ondulações.

Tudo isso envolve acessar os dados de pixel reais da imagem de origem e, em seguida, criar uma nova imagem a partir dela. A única interface para fazer isso é uma tela.

Então, a escolha realmente importante é se o processamento será feito na CPU, com uma tela 2D, ou na GPU, com o WebGL.

Vamos conferir as diferenças entre as duas abordagens.

Tela 2D

Essa é, sem dúvida, a opção mais simples das duas. Primeiro, você desenha a imagem na tela.

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

Em seguida, você recebe uma matriz de valores de pixel para toda a tela.

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

Neste ponto, a variável pixels é um Uint8ClampedArray com um comprimento de width * height * 4. Cada elemento da matriz é um byte, e cada quatro elementos na matriz representam a cor de um pixel. Cada um dos quatro elementos representa a quantidade de vermelho, verde, azul e Alfa (transparência) nessa ordem. Os pixels são ordenados começando pelo canto superior esquerdo e indo da esquerda para a direita e de cima para baixo.

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
...

Para encontrar o índice de qualquer pixel a partir das suas coordenadas, há uma fórmula simples.

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];

Agora você pode ler e gravar esses dados da maneira que quiser, permitindo aplicar todos os efeitos que imaginar. No entanto, essa matriz é uma cópia dos dados de pixel reais da tela. Para gravar a versão editada, use o método putImageData para gravar de volta no canto superior esquerdo da tela.

context.putImageData(imageData, 0, 0);

WebGL

O WebGL é um tópico grande, certamente muito grande para ser abordado em um único artigo. Se quiser saber mais sobre a WebGL, confira a leitura recomendada no fim deste artigo.

No entanto, aqui está uma breve introdução do que precisa ser feito no caso de manipular uma imagem.

Uma das coisas mais importantes a lembrar sobre o WebGL é que ele não é uma API gráfica 3D. Na verdade, o WebGL (e o OpenGL) é bom em exatamente uma coisa: desenhar triângulos. No app, você precisa descrever o que realmente quer desenhar em termos de triângulos. No caso de uma imagem 2D, isso é muito simples, porque um retângulo é composto por dois triângulos retângulos semelhantes, dispostos de modo que as hipotenusas fiquem no mesmo lugar.

O processo básico é:

  • Enviar dados para a GPU que descreve os vértices (pontos) dos triângulos.
  • Enviar a imagem de origem para a GPU como uma textura (imagem).
  • Crie um "sombreador de vértice".
  • Crie um "sombreador de fragmento".
  • Defina algumas variáveis de sombreador, chamadas de "uniformes".
  • Execute os sombreadores.

Vamos entrar em detalhes. Comece alocando um pouco de memória na placa de vídeo, chamada de buffer de vértices. Você armazena dados que descrevem cada ponto de cada triângulo. Também é possível definir algumas variáveis, chamadas de uniformes, que são valores globais em ambos os shaders.

Um sombreador de vértice usa dados do buffer de vértice para calcular onde na tela desenhar os três pontos de cada triângulo.

Agora a GPU sabe quais pixels da tela precisam ser desenhados. O sombreador de fragmentos é chamado uma vez por pixel e precisa retornar a cor que será exibida na tela. O sombreador de fragmento pode ler informações de uma ou mais texturas para determinar a cor.

Ao ler uma textura em um sombreador de fragmentos, você especifica qual parte da imagem quer ler usando duas coordenadas de ponto flutuante entre 0 (esquerda ou inferior) e 1 (direita ou superior).

Se você quiser ler a textura com base em coordenadas de pixel, transmita o tamanho da textura em pixels como um vetor uniforme para fazer a conversão de cada pixel.

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;
}

O loop move 4 bytes por vez, mas muda apenas três valores, porque essa transformação específica não muda o valor alfa. Lembre-se também de que um Uint8ClampedArray arredonda todos os valores para números inteiros e limita os valores entre 0 e 255.

Fragment shader do WebGL:

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

Da mesma forma, apenas a parte RGB da cor de saída é multiplicada para essa transformação específica.

Alguns desses filtros usam informações extras, como a luminância média de toda a imagem, mas essas são coisas que podem ser calculadas uma vez para toda a imagem.

Uma maneira de mudar o contraste, por exemplo, é mover cada pixel em direção ou para longe de algum valor "cinza", para contrastes mais baixos ou mais altos, respectivamente. O valor cinza é geralmente escolhido para ser uma cor cinza com luminance média de todos os pixels da imagem.

É possível calcular esse valor uma vez quando a imagem é carregada e usá-lo sempre que precisar ajustar o efeito da imagem.

Multipixel

Alguns efeitos usam a cor dos pixels vizinhos para decidir a cor do pixel atual.

Isso muda um pouco a forma como você faz as coisas no caso da tela 2D, porque você quer poder ler as cores original da imagem, e o exemplo anterior foi atualizar os pixels no lugar.

Isso é bem fácil. Ao criar o objeto de dados de imagem pela primeira vez, você pode fazer uma cópia dos dados.

const originalPixels = new Uint8Array(imageData.data);

No caso do WebGL, não é necessário fazer mudanças, já que o sombreador não grava na textura de entrada.

A categoria mais comum de efeito de vários pixels é chamada de filtro de convolução. Um filtro de convolução usa vários pixels da imagem de entrada para calcular a cor de cada pixel na imagem. O nível de influência que cada pixel de entrada tem na saída é chamado de peso.

Os pesos podem ser representados por uma matriz, chamada de kernel, com o valor central correspondente ao pixel atual. Por exemplo, este é o kernel para um desfoque gaussiano 3x3.

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

Digamos que você queira calcular a cor de saída do pixel em (23, 19). Pegue os 8 pixels ao redor (23, 19) e o pixel em si, e multiplique os valores de cor de cada um deles pelo peso correspondente.

    (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

Some todos eles e divida o resultado por 8, que é a soma dos pesos. Você pode ver como o resultado será um pixel que é basicamente o original, mas com os pixels próximos sangrando.

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;
  }
}

Isso dá a ideia básica, mas há guias disponíveis que vão entrar em mais detalhes e listar muitos outros kernels úteis.

Imagem inteira

Algumas transformações de imagem inteiras são simples. Em uma tela 2D, o corte e o dimensionamento são um caso simples de desenhar apenas parte da imagem de origem na tela.

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

Rotação e reflexão estão diretamente disponíveis no contexto 2D. Antes de desenhar a imagem na tela, mude as várias transformações.

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

No entanto, o mais eficiente é que muitas transformações 2D podem ser escritas como matrizes 2x3 e aplicadas à tela com setTransform(). Este exemplo usa uma matriz que combina uma rotação e uma translação.

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

Efeitos mais complicados, como distorção de lente ou ondulações, envolvem a aplicação de algum deslocamento a cada coordenada de destino para calcular a coordenada de pixel de origem. Por exemplo, para ter um efeito de onda horizontal, você pode deslocar a coordenada x do pixel de origem por algum valor com base na coordenada 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];
  }
}

Vídeo

O restante do artigo já funciona para vídeos se você usar um elemento video como imagem de origem.

Tela 2D:

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

WebGL:

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

No entanto, isso só vai usar o frame de vídeo atual. Portanto, se você quiser aplicar um efeito a um vídeo em reprodução, use drawImage/texImage2D em cada frame para capturar um novo frame de vídeo e processá-lo em cada frame de animação do navegador.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Ao trabalhar com vídeos, é particularmente importante que o processamento seja rápido. Com uma imagem estática, o usuário pode não notar um atraso de 100 ms entre o clique em um botão e a aplicação de um efeito. No entanto, com animações animadas, atrasos de apenas 16 ms podem causar instabilidade visível.

Feedback