אפקטים בזמן אמת לתמונות ולסרטונים

Mat Scales

רבות מהאפליקציות הפופולריות ביותר של היום מאפשרות להוסיף מסננים ואפקטים לתמונות או לסרטונים. במאמר הזה נסביר איך להטמיע את התכונות האלה באינטרנט הפתוח.

התהליך זהה בעיקרו לסרטונים ולתמונות, אבל נעבור על כמה שיקולים חשובים בנוגע לסרטונים בסוף. במהלך המאמר, אפשר להניח שהמונח 'תמונה' מתייחס ל 'תמונה או לפריים יחיד של סרטון'.

יש 3 קטגוריות בסיסיות של טיפול בתמונות:

  • אפקטים של Pixel כמו ניגודיות, בהירות, חום, גוון ספיה ורוויה.
  • אפקטים של כמה פיקסלים, שנקראים פילטרים של עיבוד נתונים, כמו חידוד, זיהוי קצוות וטשטוש.
  • עיוות של כל התמונה, כמו חיתוך, הטיה, מתיחה, אפקטים של עדשה, תנודות.

כל הפעולות האלה כוללות גישה לנתוני הפיקסלים בפועל של תמונת המקור, ולאחר מכן יצירה של תמונה חדשה ממנה. הממשק היחיד שאפשר לעשות זאת באמצעותו הוא קנבס.

לכן, ההחלטה החשובה באמת היא אם לבצע את העיבוד במעבד (CPU), באמצעות בד ציור דו-ממדי, או ב-GPU באמצעות WebGL.

נעבור על ההבדלים בין שתי הגישות.

קנבס דו-ממדי

זו בהחלט האפשרות הפשוטה ביותר מבין שתי האפשרויות. קודם כל מציירים את התמונה על הלוח.

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

לאחר מכן מקבלים מערך של ערכי פיקסלים לכל שטח הלוח.

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

בשלב הזה, המשתנה pixels הוא Uint8ClampedArray באורך width * height * 4. כל רכיב במערך הוא ביייט אחד, וכל ארבעה רכיבים במערך מייצגים את הצבע של פיקסל אחד. כל אחד מארבעת הרכיבים מייצג את הכמות של אדום, ירוק, כחול ואלפא (שקיפות) בסדר הזה. הפיקסלים מסודרים מהפינה הימנית העליונה, משמאל לימין ומלמעלה למטה.

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

כדי למצוא את האינדקס של פיקסל נתון מהקואורדינטות שלו, יש נוסחה פשוטה.

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

עכשיו אפשר לקרוא ולכתוב את הנתונים האלה בכל דרך שרוצים, וכך להחיל כל אפקט שאפשר לחשוב עליו. עם זאת, המערך הזה הוא עותק של נתוני הפיקסלים בפועל של הלוח. כדי לכתוב חזרה את הגרסה הערוכה, צריך להשתמש בשיטה putImageData כדי לכתוב אותה שוב בפינה הימנית העליונה של הלוח

context.putImageData(imageData, 0, 0);

WebGL

WebGL הוא נושא גדול, אבל הוא גדול מדי מכדי שיהיה צדק במאמר אחד. אם אתם מעוניינים במידע נוסף על WebGL, עיינו בהמלצות בנושא בסוף המאמר הזה.

עם זאת, הנה מבוא קצר מאוד לגבי מה שצריך לעשות במקרה של מניפולציה בתמונה אחת.

אחד הדברים החשובים ביותר לזכור לגבי WebGL הוא שהוא לא ממשק API של גרפיקה תלת-ממדית. למעשה, WebGL (ו-OpenGL) טוב בעניין אחד בלבד – שרטוט משולשים. באפליקציה שלכם עליכם לתאר את מה שאתם באמת רוצים לצייר במונחים של משולשים. במקרה של תמונה דו-ממדית, זה פשוט מאוד, כי מלבן מורכב משני משולשים דומים עם זווית ישרה, שמסודרים כך שהיתרים שלהם נמצאים באותו מקום.

התהליך הבסיסי הוא:

  • שולחים ל-GPU נתונים שמתארים את הקודקודים (הנקודות) של המשולשים.
  • שולחים את תמונת המקור ל-GPU כטקסטורה (תמונה).
  • יוצרים 'vertex shader'.
  • יוצרים 'fragment shader'.
  • מגדירים כמה משתני Shader שנקראים 'uniforms'.
  • מריצים את השכבות.

נרחיב על כך. מתחילים בהקצאת קצת זיכרון בכרטיס הגרפיקה שנקרא מאגר קודקודים. שומרים בו נתונים שמתארים כל נקודה בכל משולש. אפשר גם להגדיר משתנים מסוימים, שנקראים uniforms, שהם ערכים גלובליים בשני השיזרים.

תוכנת ההצללה של קודקוד משתמשת בנתונים ממאגר הנתונים הזמני של הקוד כדי לחשב את המיקום במסך כדי לשרטט את שלוש הנקודות של כל משולש.

עכשיו המעבד הגרפי יודע אילו פיקסלים בתוך הלוח צריך לצייר. פונקציית ה-fragment shader נקראת פעם לכל פיקסל, והיא צריכה להחזיר את הצבע שצריך לצייר במסך. כדי לקבוע את הצבע, ה-fragment shader יכול לקרוא מידע מטקסטורה אחת או יותר.

כשקוראים טקסטורה ב-fragment shader, מציינים איזה חלק מהתמונה רוצים לקרוא באמצעות שתי קואורדינטות של נקודה צפה (float) בין 0 (שמאל או תחתית) ל-1 (ימין או עליונה).

אם רוצים לקרוא את המרקם על סמך קואורדינטות של פיקסלים, צריך להעביר את גודל המרקם בפיקסלים כווקטור אחיד כדי לבצע את ההמרה של כל פיקסל.

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

שימו לב שהמחזור מעביר 4 בייטים בכל פעם, אבל משנה רק שלושה ערכים – הסיבה לכך היא שהטרנספורמציה הספציפית הזו לא משנה את ערך האלפא. חשוב לזכור גם ש-Uint8ClampedArray יעגל את כל הערכים למספרים שלמים, ויעצור את הערכים בין 0 ל-255.

תוכנת הצללה (shader) של פרגמנט ב-WebGL:

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

באופן דומה, רק החלק RGB של צבע הפלט מוכפל בטרנספורמציה הספציפית הזו.

חלק מהמסננים האלה נעזרים במידע נוסף, כמו הבהירות הממוצעת של כל התמונה, אבל מדובר בדברים שאפשר לחשב פעם אחת לכל התמונה.

לדוגמה, אחת הדרכים לשינוי הניגודיות היא להזיז כל פיקסל לכיוון ערך 'אפור' מסוים או הרחק ממנו, כדי לקבל ניגודיות נמוכה או גבוהה יותר, בהתאמה. בדרך כלל בוחרים את ערך האפור כצבע אפור שמידת הבהירות שלו היא חציון הבהירות של כל הפיקסלים בתמונה.

אפשר לחשב את הערך הזה פעם אחת כשהתמונה נטענת, ואז להשתמש בו בכל פעם שצריך לשנות את אפקט התמונה.

חיישן רב-פיקסלי

באפקטים מסוימים, המערכת משתמשת בצבע של הפיקסלים הסמוכים כדי לקבוע את הצבע של הפיקסל הנוכחי.

הדבר משנה מעט את האופן שבו מבצעים את הפעולות במקרה של קנבס דו-מימדי, כי רוצים להיות מסוגלים לקרוא את הצבעים המקוריים של התמונה, והדוגמה הקודמת עדכנה את הפיקסלים במקום.

אבל זה מספיק קל. כשיוצרים את אובייקט נתוני התמונה בפעם הראשונה, אפשר ליצור עותק של הנתונים.

const originalPixels = new Uint8Array(imageData.data);

במקרה של WebGL, אין צורך לבצע שינויים, כי ה-shader לא כותב בטקסטורת הקלט.

הקטגוריה הנפוצה ביותר של אפקטים מרובים של פיקסלים נקראת מסנן עיבוד. מסנן עיבוד נתונים משתמש בכמה פיקסלים מתמונת הקלט כדי לחשב את הצבע של כל פיקסל בתמונה. רמת ההשפעה של כל פיקסל קלט על הפלט נקראת משקל.

אפשר לייצג את המשקלים באמצעות מטריקס שנקרא ליבה, כאשר הערך המרכזי תואם לפסיק הנוכחי. לדוגמה, זוהי הליבה של טשטוש גופרתי 3x3.

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

נניח שאתם רוצים לחשב את צבע הפלט של הפיקסל בשעה (23, 19). לוקחים את 8 הפיקסלים שמקיפים את הנקודה (23, 19) ואת הפיקסל עצמו, ומכפילים את ערכי הצבע של כל אחד מהם במשקל המתאים.

    (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

מסכמים את כולם ומחלקים את התוצאה ב-8, שהיא סכום המשקלים. אפשר לראות איך התוצאה תהיה פיקסל שרובו המקורי, אבל עם פיקסלים בסביבה שמשתלבים בו.

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

זה נותן את הרעיון הבסיסי, אבל יש מדריכים שם שיפרטו יותר לעומק, ויכללו רשימה של עוד ליבות שימושיות נוספות.

התמונה כולה

חלק מהטרנספורמציות של תמונה שלמה הן פשוטות. בקנבס דו-מימדי, חיתוך ושינוי קנה מידה הם פשוט עניין של ציור של חלק מתמונה המקור על הקנבס.

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

סיבוב והשתקפות זמינים ישירות בהקשר הדו-ממדי. לפני שמשייכים את התמונה ללוח הציור, משנים את הטרנספורמציות השונות.

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

אבל אפשר גם לכתוב טרנספורמציות 2D רבות כמטריצות 2x3 ולהחיל אותן על הלוח באמצעות setTransform(). בדוגמה הזו נעשה שימוש במטריצה שמשלבת סיבוב ותרגום.

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

אפקטים מורכבים יותר, כמו עיוות של עדשה או תנודות, דורשים החלת סטייה כלשהי על כל קואורדינטת יעד כדי לחשב את קואורדינטת הפיקסל של המקור. לדוגמה, כדי ליצור אפקט גל אופקי, אפשר לקזז את קואורדינטות ה-x של המקור לפי ערך מסוים על סמך קואורדינטת ה-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 בתור תמונת המקור, כל שאר הדברים במאמר כבר מתאימים לסרטונים.

קנבס 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>,
);

עם זאת, המערכת תשתמש רק בפריים הנוכחי של הסרטון. לכן, אם רוצים להחיל אפקט על סרטון שפועל, צריך להשתמש ב-drawImage/texImage2D בכל פריים כדי לקבל פריים חדש של הסרטון ולעבד אותו בכל פריים של האנימציה בדפדפן.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

בעבודה עם סרטונים, חשוב במיוחד שהעיבוד יהיה מהיר. בתמונה סטטית, יכול להיות שהמשתמש לא יבחין בהשהיה של 100 אלפיות השנייה בין הלחיצה על הלחצן לבין החלת האפקט. עם זאת, במקרה של אנימציה, עיכובים של 16 אלפיות השנייה בלבד יכולים לגרום לקוצרנות גלויה.

משוב