Effetti in tempo reale per immagini e video

Mat Scales

Molte delle app più usate al momento ti consentono di applicare filtri ed effetti a immagini o video. Questo articolo illustra come implementare queste funzionalità sul web aperto.

La procedura è sostanzialmente la stessa per video e immagini, ma alla fine tratterò alcune considerazioni importanti relative ai video. Nell'articolo, puoi assumere che "immagine" significhi "immagine o singolo fotogramma di un video"

Le categorie base di manipolazione delle immagini più comuni sono 3:

  • Effetti di Pixel come contrasto, luminosità, temperatura, tonalità seppia, saturazione.
  • Effetti multi-pixel, chiamati filtri di convoluzione, come l'aumento della nitidezza, il rilevamento dei bordi e la sfocatura.
  • Distorsione dell'intera immagine, ad esempio ritaglio, inclinazione, allungamento, effetti obiettivo, increspature.

Tutti questi metodi richiedono di accedere ai dati effettivi dei pixel dell'immagine di origine e di creare una nuova immagine da questi dati. L'unica interfaccia per farlo è una tela.

La scelta davvero importante, quindi, è se eseguire l'elaborazione sulla CPU, con una tela 2D, o sulla GPU, con WebGL.

Diamo un'occhiata veloce alle differenze tra i due approcci.

Canvas 2D

Questa è di gran lunga l'opzione più semplice. 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);

Quindi ottieni un array di valori di pixel per l'intera tela.

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

A questo punto la variabile pixels è un Uint8ClampedArray con una lunghezza di width * height * 4. Ogni elemento dell'array è un byte e ogni quattro elementi dell'array rappresentano il colore di un pixel. Ciascuno dei quattro elementi rappresenta la quantità di rosso, verde, blu e alfa (trasparenza) in questo ordine. I pixel sono ordinati a partire dall'angolo in alto a sinistra e vanno 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, il che ti consente di applicare qualsiasi effetto possibile. Tuttavia, questo array è una copia dei dati pixel effettivi per il canvas. Per scrivere nuovamente la versione modificata, devi utilizzare il metodo putImageData per scriverla nell'angolo in alto a sinistra del canvas

context.putImageData(imageData, 0, 0);

WebGL

WebGL è un argomento ampio, certamente troppo grande per essere trattato in un solo articolo. Se vuoi saperne di più su WebGL, consulta le letture consigliate alla fine di questo articolo.

Tuttavia, di seguito è riportata una breve introduzione a ciò che è necessario fare in caso di manipolazione di una singola immagine.

Una delle cose più importanti da ricordare su WebGL è che non è un'API grafica 3D. Infatti, WebGL (e OpenGL) sono bravi in una sola cosa: disegnare triangoli. Nella tua applicazione devi descrivere ciò che 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 le relative ipotensi siano nella stessa posizione.

La procedura di base è:

  • Invia alla GPU i dati che descrivono i vertici (punti) dei triangoli.
  • Invia l'immagine di origine alla GPU come texture (immagine).
  • Crea un "vertex shader".
  • Crea un "fragment shader".
  • Imposta alcune variabili dello shader, chiamate "uniformi".
  • Esegui gli shader.

Vediamo nel dettaglio. Inizia allocando della memoria sulla scheda grafica, chiamata buffer di vertici. Memorizzi i dati che descrivono ogni punto di ogni triangolo. Puoi anche impostare alcune variabili, chiamate uniformi, che sono valori globali.

Uno shader vertex utilizza i dati del buffer vertex per calcolare dove sullo schermo disegnare i tre punti di ogni triangolo.

Ora la GPU sa quali pixel all'interno della tela devono essere disegnati. Lo shader frammento viene chiamato una volta per pixel e deve restituire il colore da disegnare sullo schermo. Lo shader fragment può leggere le informazioni da una o più texture per determinare il colore.

Quando leggi una texture in un frammento shader, specifichi la parte dell'immagine che vuoi 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 trama in base alle coordinate dei pixel, devi passare le dimensioni della trama in pixel come vettore uniforme per poter eseguire la conversione per ogni 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 sposta 4 byte alla volta, ma modifica solo tre valori, perché questa particolare trasformazione non modifica il valore alfa. Inoltre, ricorda che un array Uint8Clamped arrotonderà tutti i valori a numeri interi e li limiterà a valori compresi tra 0 e 255.

Shader di frammenti WebGL:

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

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

Alcuni di questi filtri prendono informazioni aggiuntive, come la luminanza media dell'intera immagine, ma questi sono dati che possono essere calcolati una volta per l'intera immagine.

Ad esempio, un modo per modificare il contrasto è spostare ogni pixel verso o lontano da un valore "grigio", rispettivamente per un contrasto inferiore o superiore. Per il valore grigio viene comunemente scelto un colore grigio la cui luminanza è la luminanza media di tutti i pixel dell'immagine.

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

Multi-pixel

Alcuni effetti utilizzano il colore dei pixel vicini per decidere il colore del pixel corrente.

Questo cambia leggermente il modo in cui svolgi le operazioni nella custodia canvas 2D, perché vuoi poter leggere i colori originale dell'immagine e nell'esempio precedente vengono aggiornati i pixel.

È abbastanza facile. Quando inizialmente crei l'oggetto dati immagine, puoi crearne una copia.

const originalPixels = new Uint8Array(imageData.data);

Per il caso WebGL non è necessario apportare modifiche, poiché lo shader non scrive nella texture di input.

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

I pesi possono essere rappresentati 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 |

Supponiamo di voler calcolare il colore di output del pixel in (23, 19). Prendi gli 8 pixel circostanti (23, 19) e il pixel stesso e moltiplica i valori del colore per ciascuno 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

Sommali insieme, quindi dividi il risultato per 8, ovvero la somma dei pesi. Puoi vedere come il risultato sarà un pixel che è per lo più originale, ma con i pixel vicini che si fondono.

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

Questa è l'idea di base, ma esistono guide che forniscono informazioni molto più dettagliate e elencano molti altri kernel utili.

Immagine intera

Alcune trasformazioni di immagini intere sono semplici. In un canvas 2D, il ritaglio e la scalabilità consentono di disegnare solo 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);

La rotazione e la riflessione sono disponibili direttamente nel contesto 2D. Prima di disegnare l'immagine nel canvas, 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, cosa più importante, molte trasformazioni 2D possono essere scritte come matrici 2x3 e applicate al canvas 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 offset a ciascuna coordinata di destinazione per calcolare la coordinata del pixel di origine. Ad esempio, per ottenere un effetto di sfocatura orizzontale, puoi compensare la coordinata x del pixel di origine con un valore basato sulla 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

Tutti gli altri elementi dell'articolo funzionano 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 attuale del video. Pertanto, se vuoi applicare un effetto a un video in riproduzione, devi utilizzare drawImage/texImage2D su ogni frame per acquisire un nuovo frame video ed elaborarlo in ogni frame dell'animazione del browser.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Quando lavori con i video, è particolarmente importante che l'elaborazione sia rapida. Con un'immagine statica, un utente potrebbe non notare un ritardo di 100 ms tra il clic su un pulsante e l'applicazione di un effetto. Tuttavia, se sono animate, ritardi di soli 16 ms possono causare scatti visibili.

Feedback