Efeitos em tempo real para imagens e vídeos

Balanças esteira

Muitos dos apps mais usados atualmente permitem que você aplique 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 abordaremos 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 conseguir os dados de pixel de uma imagem

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

  • Efeitos de pixel, como contraste, brilho, calor, tom de 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 completa da imagem, como corte, inclinação, alongamento, efeitos de lente e ondulações.

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

Portanto, a escolha realmente importante é fazer o processamento na CPU, com uma tela 2D, ou na GPU, com WebGL.

Vamos dar uma olhada nas diferenças entre as duas abordagens.

Tela 2D

Essa é definitivamente a mais simples das duas opções. Primeiro, 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);

Então 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 comprimento de width * height * 4. Cada elemento da matriz tem um byte, e cada quatro elementos na matriz representa 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 do canto superior esquerdo, trabalhando 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 fornecido a partir de suas coordenadas, existe 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 como quiser, o que permite aplicar os efeitos que imaginar. No entanto, essa matriz é uma cópia dos dados de pixels reais para a tela. Para gravar a versão editada de volta, você precisa usar o método putImageData para gravar isso no canto superior esquerdo da tela.

context.putImageData(imageData, 0, 0);

WebGL

WebGL é um assunto importante, certamente grande demais para ser justificado em um único artigo. Se você quiser saber mais sobre o WebGL, confira a leitura recomendada no final deste artigo.

No entanto, esta é uma introdução muito breve sobre o que precisa ser feito no caso de manipulação de uma única imagem.

Um dos pontos mais importantes a ser lembrado sobre o WebGL é que ele não é uma API de gráficos 3D. Na verdade, o WebGL (e o OpenGL) tem exatamente uma função: desenhar triângulos. No seu aplicativo, 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, organizados de modo que suas hipotenusas estejam no mesmo lugar.

O processo básico é:

  • Envia dados para a GPU que descreve os vértices (pontos) dos triângulos.
  • Envie 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 "uniforms".
  • Execute os sombreadores.

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

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

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

Ao ler uma textura em um sombreador de fragmento, especifique qual parte da imagem você quer ler usando duas coordenadas de ponto flutuante entre 0 (esquerda ou baixo) e 1 (direita ou cima).

Se você quiser ler a textura com base nas coordenadas de pixel, transmita o tamanho da textura em pixels como um vetor uniforme para que você possa fazer a conversão para 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;
}

Observe que o loop move 4 bytes de uma vez, mas muda apenas três valores. Isso ocorre porque essa transformação específica não muda o valor alfa. Lembre-se também de que um Uint8ClampedArray arredondará todos os valores para números inteiros e os de fixação para que fiquem entre 0 e 255.

Sombreador de fragmento WebGL:

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

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

Alguns desses filtros pegam informações extras, como a luminância média de toda a imagem, mas esses são itens que podem ser calculados uma vez para a imagem inteira.

Uma maneira de mudar o contraste, por exemplo, pode ser mover cada pixel para ou para um valor "cinza" para um contraste menor ou maior, respectivamente. O valor cinza é normalmente escolhido para ser uma cor cinza em que a luminância é a mediana de todos os pixels na imagem.

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

Multipixel

Alguns efeitos usam a cor de pixels vizinhos ao decidir a cor do pixel atual.

Isso muda um pouco a maneira como você faz as coisas na tela 2D, porque você quer ler as cores originais da imagem, e o exemplo anterior foi atualizando os pixels no local.

No entanto, isso é fácil. Quando você cria inicialmente o objeto de dados de imagem, é possível fazer uma cópia dos dados.

const originalPixels = new Uint8Array(imageData.data);

Não é necessário fazer mudanças no caso do WebGL, porque o sombreador não faz gravações 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 nessa 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), bem como o próprio pixel, e multiplique os valores de cor para 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. O resultado será um pixel quase original do original, mas com sangramento de pixels próximos.

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á uma ideia básica, mas existem guias por aí que vão dar mais detalhes e listar vários outros kernels úteis.

Imagem inteira

Algumas transformações de imagens 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);

A rotação e a reflexão estão disponíveis diretamente no contexto 2D. Antes de desenhar a imagem na tela, altere 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 eficaz é que muitas transformações 2D podem ser escritas como matrizes 2x3 e aplicadas à tela com setTransform(). Neste exemplo, usamos 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 um deslocamento em cada coordenada de destino para calcular a coordenada de pixel de origem. Por exemplo, para ter um efeito de onda horizontal, é possível deslocar a coordenada do pixel x de origem em 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

Todo o restante no artigo já funciona para vídeo se você usar um elemento video como a 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 usará apenas 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, é muito importante que o processamento seja rápido. Com uma imagem estática, um 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, quando animado, atrasos de apenas 16 ms podem causar instabilidade visível.

Feedback