בהרבה מהאפליקציות הפופולריות היום אפשר להוסיף פילטרים ואפקטים לתמונות או לסרטונים. במאמר הזה מוסבר איך להטמיע את התכונות האלה באינטרנט הפתוח.
התהליך דומה לסרטונים ולתמונות, אבל בסוף אדבר על כמה דברים חשובים שצריך לקחת בחשבון כשמדובר בסרטונים. בכל המאמר, המונח "תמונה" מתייחס לתמונה או לפריים בודד בסרטון.
איך לגשת לנתוני הפיקסלים של תמונה
יש 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];
עכשיו אתם יכולים לקרוא ולכתוב את הנתונים האלה איך שאתם רוצים, ולהחיל כל אפקט שאתם יכולים לחשוב עליו. עם זאת, המערך הזה הוא עותק של נתוני הפיקסלים בפועל של ה-canvas. כדי לכתוב את הגרסה הערוכה בחזרה, צריך להשתמש ב-method putImageData כדי לכתוב אותה בחזרה בפינה השמאלית העליונה של ה-canvas.
context.putImageData(imageData, 0, 0);
WebGL
WebGL הוא נושא רחב, רחב מדי בשביל מאמר אחד. מידע נוסף על WebGL זמין בחומר הקריאה המומלץ בסוף המאמר הזה.
עם זאת, הנה הסבר קצר מאוד על מה שצריך לעשות במקרה של שינוי תמונה אחת.
אחד הדברים הכי חשובים שצריך לזכור לגבי WebGL הוא שזה לא API של גרפיקה תלת-ממדית. למעשה, WebGL (וגם OpenGL) טובים בדיוק בדבר אחד – ציור משולשים. באפליקציה צריך לתאר את מה שבאמת רוצים לצייר במונחים של משולשים. במקרה של תמונה דו-ממדית, זה פשוט מאוד, כי מלבן הוא שני משולשים ישרי זווית דומים, שמסודרים כך שהיתרים שלהם נמצאים באותו מקום.
התהליך הבסיסי הוא:
- שליחת נתונים ל-GPU שמתארים את הקודקודים (הנקודות) של המשולשים.
- שולחים את תמונת המקור ל-GPU כטקסטורה (תמונה).
- יוצרים 'הצללת קודקוד'.
- יוצרים 'fragment shader'.
- מגדירים כמה משתני הצללה, שנקראים uniforms.
- מפעילים את ההצללות.
בואו נרחיב על זה. מתחילים בהקצאת זיכרון בכרטיס המסך שנקרא מאגר קודקודים. אתם שומרים בו נתונים שמתארים כל נקודה בכל משולש. אפשר גם להגדיר משתנים מסוימים, שנקראים uniforms, שהם ערכים גלובליים בשני ה-shaders.
ה-vertex shader משתמש בנתונים מה-vertex buffer כדי לחשב איפה במסך צריך לצייר את שלוש הנקודות של כל משולש.
עכשיו ה-GPU יודע אילו פיקסלים בתוך אזור הציור צריך לצייר. ה-fragment shader נקרא פעם אחת לכל פיקסל, וצריך להחזיר את הצבע שצריך לצייר במסך. ה-fragment shader יכול לקרוא מידע ממרקם אחד או יותר כדי לקבוע את הצבע.
כשקוראים טקסטורה ב-fragment shader, מציינים איזה חלק בתמונה רוצים לקרוא באמצעות שתי קואורדינטות של נקודה צפה (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 בצבע הפלט מוכפל בטרנספורמציה הספציפית הזו.
חלק מהמסננים האלה מקבלים מידע נוסף, כמו הבהירות הממוצעת של כל התמונה, אבל אלה דברים שאפשר לחשב פעם אחת לכל התמונה.
לדוגמה, דרך אחת לשנות את הניגודיות היא להזיז כל פיקסל לכיוון ערך "אפור" מסוים או להרחיק אותו ממנו, כדי להקטין או להגדיל את הניגודיות, בהתאמה. הערך האפור נבחר בדרך כלל להיות צבע אפור שהבהירות שלו היא הבהירות החציונית של כל הפיקסלים בתמונה.
אפשר לחשב את הערך הזה פעם אחת כשהתמונה נטענת, ואז להשתמש בו בכל פעם שצריך לשנות את אפקט התמונה.
Multi-pixel
בחלק מהאפקטים, הצבע של הפיקסל הנוכחי נקבע לפי הצבע של הפיקסלים הסמוכים.
זה משנה קצת את אופן הפעולה במקרה של רכיב canvas דו-ממדי, כי אתם רוצים לקרוא את הצבעים המקוריים של התמונה, ובדוגמה הקודמת הפיקסלים עודכנו במקום.
אבל זה די פשוט. כשיוצרים את אובייקט נתוני התמונה, אפשר ליצור עותק של הנתונים.
const originalPixels = new Uint8Array(imageData.data);
במקרה של WebGL, לא צריך לבצע שינויים כי ה-shader לא כותב לטקסטורת הקלט.
הקטגוריה הנפוצה ביותר של אפקט מרובה פיקסלים נקראת מסנן קונבולוציה. מסנן קונבולוציה משתמש בכמה פיקסלים מתמונת הקלט כדי לחשב את הצבע של כל פיקסל בתמונת הקלט. רמת ההשפעה של כל פיקסל קלט על הפלט נקראת משקל.
אפשר לייצג את המשקלים באמצעות מטריצה שנקראת גרעין (kernel), שבה הערך המרכזי תואם לפיקסל הנוכחי. לדוגמה, זהו הגרעין לטשטוש גאוסיאני בגודל 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);
אפשר לסובב ולהפוך את התמונה ישירות בהקשר הדו-ממדי. לפני שאתם מציירים את התמונה ב-canvas, שנו את הטרנספורמציות השונות.
// 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 ולהחיל אותן על ה-canvas באמצעות 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 בתור תמונת המקור.
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>,
);
עם זאת, הפעולה הזו תשתמש רק בפריים הנוכחי של הסרטון. לכן, אם רוצים להחיל אפקט על סרטון שמופעל, צריך להשתמש ב-drawImage/texImage2D בכל פריים כדי לקבל פריים חדש של הסרטון ולעבד אותו בכל פריים של אנימציה בדפדפן.
const draw = () => {
requestAnimationFrame(draw);
context.drawImage(video, 0, 0);
// ...image processing goes here
};
כשעובדים עם וידאו, חשוב במיוחד שהעיבוד יהיה מהיר. בתמונה סטטית, יכול להיות שהמשתמש לא ישים לב להשהיה של 100 אלפיות השנייה בין הלחיצה על הכפתור לבין החלת האפקט. עם זאת, כשמדובר באנימציה, עיכובים של 16 אלפיות השנייה בלבד יכולים לגרום לתנועות קופצניות.
הצעות לקריאה
- WebGL Fundamentals: אתר שמלמד על WebGL
- קרנל (עיבוד תמונה): דף בוויקיפדיה שמסביר על מסנני קונבולוציה
- Image Kernels Explained Visually: תיאורים של כמה גרעינים עם הדגמות אינטראקטיביות.
- טרנספורמציות: מאמר של MDN על טרנספורמציות של רכיבי canvas דו-ממדיים