Effetti in tempo reale per immagini e video

Bilance per tappeti

Molte delle app più popolari al giorno d'oggi ti consentono di applicare filtri ed effetti a immagini o video. Questo articolo mostra come implementare queste funzionalità sul web aperto.

La procedura è sostanzialmente la stessa per i video e le immagini, ma alla fine mi occuperò di alcune importanti considerazioni sui video. In questo articolo puoi presumere che "immagine" significhi "un'immagine o un singolo fotogramma di un video"

Come ottenere i dati dei pixel per un'immagine

Esistono tre categorie di base di manipolazione delle immagini comuni:

  • Effetti di pixel come contrasto, luminosità, calore, tonalità seppia, saturazione.
  • Effetti multi-pixel, denominati filtri di convoluzione, come nitidezza, rilevamento dei bordi e sfocatura.
  • Distorsione dell'intera immagine, ad esempio ritaglio, inclinazione, allungamento, effetti dell'obiettivo, increspature.

Tutto questo prevede il recupero dei dati effettivi in pixel dell'immagine di origine per poi creare una nuova immagine a partire da questi dati. L'unica interfaccia per farlo è una canvas.

La scelta più importante, quindi, è se eseguire l'elaborazione sulla CPU, con un canvas 2D, o sulla GPU, con WebGL.

Diamo una rapida occhiata alle differenze tra i due approcci.

Tela 2D

Questa è sicuramente la più semplice delle due opzioni. Per prima cosa, disegna l'immagine sulla 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);

Si ottiene quindi un array di valori di pixel per l'intero canvas.

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

A questo punto, la variabile pixels è una Uint8ClampedArray con una lunghezza di width * height * 4. Ogni elemento dell'array è di un byte e ogni quattro elementi nell'array rappresenta il colore di un pixel. Ciascuno dei quattro elementi rappresenta la quantità di rosso, verde, blu e alfa (trasparenza) in quest'ordine. I pixel sono ordinati a partire dall'angolo in alto a sinistra, da sinistra a destra e dall'alto verso il basso.

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

Per trovare l'indice di un determinato pixel a partire dalle sue coordinate, esiste una semplice formula.

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

Ora puoi leggere e scrivere questi dati come preferisci, in modo da applicare tutti gli effetti che ti vengono in mente. Tuttavia, questo array è una copia dei dati effettivi sui pixel per il canvas. Per scrivere la versione modificata, devi utilizzare il metodo putImageData; riscrivila nell'angolo in alto a sinistra del canvas

context.putImageData(imageData, 0, 0);

WebGL

WebGL è un argomento importante, di sicuro troppo importante per renderlo giustizia in un solo articolo. Se vuoi saperne di più su WebGL, consulta la lettura consigliata alla fine di questo articolo.

Tuttavia, ecco una breve introduzione alle operazioni da eseguire nel caso di manipolazione di una singola immagine.

Una delle cose più importanti da ricordare di WebGL è che non è un'API grafica 3D. Infatti WebGL (e OpenGL) è molto utile per fare esattamente tutto: disegnare triangoli. Nella tua applicazione devi descrivere cosa vuoi effettivamente disegnare in termini di triangoli. Nel caso di un'immagine 2D, questo è molto semplice, perché un rettangolo è costituito da due triangoli rettangoli simili, disposti in modo che i rispettivi ipoteni si trovino nello stesso punto.

La procedura di base è:

  • Invia alla GPU i dati che descrivono i vertici (punti) dei triangoli.
  • Invia l'immagine di origine alla GPU sotto forma di texture (immagine).
  • Crea un "vertex shabbyr".
  • Crea uno strumento di "shadowing dei frammenti".
  • Imposta alcune variabili shabbyr, chiamate "uniformi".
  • Esegui gli Shader.

Veniamo nel dettaglio. Inizia assegnando un po' di memoria sulla scheda grafica, detta buffer del vertice. Qui memorizzi i dati che descrivono ogni punto di ogni triangolo. Puoi anche impostare alcune variabili, chiamate uniformi, che sono valori globali tramite entrambi i cursori.

Uno Shader di vertice utilizza i dati del buffer del vertice per calcolare dove sullo schermo tracciare i tre punti di ogni triangolo.

Ora la GPU sa quali pixel all'interno del canvas devono essere disegnati. Lo strumento di ombreggiatura dei frammenti viene chiamato una volta per pixel e deve restituire il colore da disegnare sullo schermo. Lo strumento di ombreggiatura dei frammenti può leggere le informazioni di una o più texture per determinarne il colore.

Durante la lettura di una texture in uno strumento di identificazione dei frammenti, specifica la parte dell'immagine da leggere utilizzando due coordinate in virgola mobile comprese tra 0 (a sinistra o in basso) e 1 (a destra o in alto).

Se vuoi leggere la texture in base alle coordinate in pixel, devi trasferire la dimensione della texture in pixel come un vettore uniforme, in modo da poter effettuare la conversione per ciascun 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;
}

Tieni presente che il loop si sposta di 4 byte alla volta, ma cambia solo tre valori; questo perché questa particolare trasformazione non modifica il valore alfa. Ricorda anche che Uint8ClampedArray arrotonda tutti i valori ai numeri interi e fissa i valori in modo che siano compresi tra 0 e 255.

Shader frammenti WebGL:

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

Allo stesso modo, solo la parte RGB del colore di output viene moltiplicata per questa particolare trasformazione.

Alcuni di questi filtri richiedono informazioni aggiuntive, come la luminanza media dell'intera immagine, ma si tratta di elementi che possono essere calcolati una volta per l'intera immagine.

Un modo per modificare il contrasto, ad esempio, può essere quello di avvicinare o allontanare ogni pixel un valore "grigio" per aumentare o diminuire il contrasto rispettivamente. Il valore di grigio viene generalmente scelto per un colore grigio la cui luminanza corrisponde a quella mediana di tutti i pixel dell'immagine.

Puoi calcolare questo valore una volta quando l'immagine è stata caricata e utilizzarlo ogni volta che devi regolare l'effetto dell'immagine.

Multipixel

Per decidere il colore del pixel corrente, alcuni effetti utilizzano il colore dei pixel vicini.

Questo modifica leggermente il modo in cui vengono eseguite le operazioni nel caso della tela 2D, perché vuoi poter leggere i colori originale dell'immagine e nell'esempio precedente si aggiornava la posizione dei pixel.

È abbastanza facile, però. Quando inizialmente crei l'oggetto dati immagine, puoi creare una copia dei dati.

const originalPixels = new Uint8Array(imageData.data);

Nel caso di WebGL, non è necessario apportare modifiche, poiché lo Shader non scrive nella texture di input.

La categoria più comune di effetti multipixel è chiamata filtro di convoluzione. Un filtro di convoluzione utilizza diversi pixel dell'immagine di input per calcolare il colore di ciascun pixel nell'immagine di input. Il livello di influenza che ogni pixel di input ha sull'output è chiamato peso.

Le ponderazioni possono essere rappresentate da una matrice, chiamata kernel, con il valore centrale corrispondente al pixel corrente. Ad esempio, questo è il kernel per una sfocatura gaussiana 3x3.

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

Quindi supponiamo di voler calcolare il colore di output del pixel in (23, 19). Prendi gli 8 pixel circostanti (23, 19) così come il pixel stesso e moltiplica i valori del colore per ognuno di essi per il peso corrispondente.

    (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

Somma tutte le ponderazioni, quindi dividi il risultato per 8, ovvero la somma delle ponderazioni. Puoi vedere che il risultato sarà un pixel composto per lo più dall'originale, ma con i pixel vicini sbiancanti.

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

Questo ti dà l'idea di base, ma sono disponibili delle guide che analizzano molto più in dettaglio ed elencano molti altri kernel utili.

Immagine intera

Alcune trasformazioni di un'intera immagine sono semplici. In un canvas 2D, il ritaglio e il ridimensionamento sono operazioni semplici di disegnare una parte dell'immagine di origine sul canvas.

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

Rotazione e riflesso sono disponibili direttamente nel contesto 2D. Prima di disegnare l'immagine nella tela, modifica le varie trasformazioni.

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

Ma con maggiore efficacia, molte trasformazioni 2D possono essere scritte come matrici 2x3 e applicate alla tela con setTransform(). Questo esempio utilizza una matrice che combina una rotazione e una traslazione.

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

Gli effetti più complicati, come la distorsione dell'obiettivo o le onde, prevedono l'applicazione di un po' di offset a ciascuna coordinata di destinazione per calcolare la coordinata dei pixel di origine. Ad esempio, per avere un effetto d'onda orizzontale, puoi spostare la coordinata x del pixel di origine di un valore in base alla coordinata 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

Tutto il resto dell'articolo funziona già per i video se utilizzi un elemento video come immagine di origine.

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

Tuttavia, verrà utilizzato solo il fotogramma video corrente. Pertanto, se vuoi applicare un effetto a un video in riproduzione, devi utilizzare drawImage/texImage2D su ogni frame per ottenere un nuovo fotogramma video ed elaborarlo in ogni frame dell'animazione del browser.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Quando si lavora con i video, è particolarmente importante che l'elaborazione sia veloce. Con un'immagine statica, l'utente potrebbe non notare un ritardo di 100 ms tra il clic su un pulsante e l'applicazione di un effetto. Quando vengono animati, tuttavia, ritardi di soli 16 ms possono causare discontinuità visibili.

Feedback