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

קנה מידה של משטח

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

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

איך להגיע לנתוני הפיקסלים של תמונה

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

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

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

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

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

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

כשקוראים מרקם בהצללה של קטעים, מציינים איזה חלק בתמונה רוצים לקרוא באמצעות שתי קואורדינטות של נקודה צפה (floating-point) בין 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.

תוכנת ההצללה למקטע WebGL:

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

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

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

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

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

ריבוי פיקסלים

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

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

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

const originalPixels = new Uint8Array(imageData.data);

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

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

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

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

קנבס בדו-ממד:

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 אלפיות השנייה בלבד יכולים לגרום לחוסר נוחות גלוי לעין.

משוב