Muitos dos apps mais usados hoje em dia 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 assumir 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 do Pixel, como contraste, brilho, calor, tom sépia e saturação.
- Efeitos de vários pixels, chamados de filtros convolucionais, como nitidez, detecção de bordas e desfoque.
- Distorção de toda a imagem, como corte, inclinação, alongamento, efeitos de lente, ondulações.
Todas elas envolvem acessar os dados de pixel reais da imagem de origem e criar uma nova imagem com base neles. A única interface para fazer isso é uma tela.
A escolha realmente importante, então, é se o processamento será feito na CPU, com uma tela 2D, ou na GPU, com WebGL.
Vamos analisar rapidamente as diferenças entre as duas abordagens.
Tela 2D
Essa é, de longe, a mais simples das duas opções. Primeiro, desenhe a imagem no canvas.
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;
Nesse ponto, a variável pixels é uma Uint8ClampedArray com um tamanho 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 a partir do canto superior esquerdo, 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 com base nas coordenadas dele, 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 como quiser, o que permite aplicar qualquer efeito que você
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 no canto superior esquerdo
da tela.
context.putImageData(imageData, 0, 0);
WebGL
O WebGL é um assunto amplo, certamente grande demais para ser abordado em um único artigo. Se quiser saber mais sobre a WebGL, confira a leitura recomendada no final deste artigo.
No entanto, aqui está uma breve introdução ao que precisa ser feito no caso de manipulação de uma única imagem.
Uma das coisas mais importantes a lembrar sobre o WebGL é que ele não é uma API de gráficos 3D. Na verdade, o WebGL (e o OpenGL) é bom em exatamente uma coisa: desenhar triângulos. No 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 as hipotenusas fiquem no mesmo lugar.
O processo básico é:
- Envie 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 "vertex shader".
- Crie um "fragment shader".
- 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 gráfica, chamada de buffer de vértice. 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 sombreadores.
Um sombreador de vértice usa dados do buffer de vértice para calcular onde desenhar na tela os três pontos de cada triângulo.
Agora, a GPU sabe quais pixels na tela precisam ser desenhados. O sombreador de fragmento é chamado uma vez por pixel e precisa retornar a cor que será mostrada 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 fragmento, 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 vai arredondar todos os valores para números inteiros e fixar os valores entre 0 e 255.
Sombreador de fragmentos 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 recebem informações extras, como a luminância média de toda a imagem, mas esses são dados que podem ser calculados uma vez para toda a imagem.
Uma maneira de mudar o contraste, por exemplo, é mover cada pixel para mais perto ou mais longe de um valor "cinza", para um contraste menor ou maior, respectivamente. O valor de cinza geralmente é escolhido para ser uma cor cinza com luminância igual à luminância mediana de todos os pixels na 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 ler as cores originais da imagem, e o exemplo anterior estava atualizando os pixels no lugar.
Mas isso é fácil. Ao criar o objeto de dados de imagem, é possível 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 multipixel é chamada de filtro convolucional. Um filtro convolucional usa vários pixels da imagem de entrada para calcular a cor de cada pixel na imagem de entrada. 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 de 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 oito pixels ao redor de (23, 19), além do próprio pixel, 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 os valores e divida o resultado por 8, que é a soma dos pesos. O resultado será um pixel que é, em grande parte, o original, mas com os pixels próximos se misturando.
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 há guias disponíveis que entram em muito mais detalhes e listam muitos outros kernels úteis.
Imagem inteira
Algumas transformações de imagem inteira 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, 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);
Mas, de forma mais eficiente, 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 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
Todo o resto do artigo já funciona para vídeos se você usar um elemento video como a imagem
de origem.
Canvas 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ídeo, é muito 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, quando animados, atrasos de apenas 16 ms podem causar instabilidade visível.
Leituras recomendadas
- Princípios básicos do WebGL: um site que ensina sobre o WebGL.
- Kernel (processamento de imagens): página da Wikipédia que explica os filtros convolucionais
- Explicação visual dos kernels de imagem: descrições de alguns kernels com demonstrações interativas.
- Transformações: artigo da MDN sobre transformações de canvas 2D