แอปยอดนิยมหลายแอปในปัจจุบันช่วยให้คุณใช้ฟิลเตอร์และเอฟเฟกต์กับรูปภาพหรือวิดีโอได้ บทความนี้จะแสดงวิธีใช้ฟีเจอร์เหล่านี้ใน Open Web
โดยพื้นฐานแล้ว กระบวนการสำหรับวิดีโอและรูปภาพจะเหมือนกัน แต่เราจะพูดถึงข้อควรพิจารณาที่สำคัญบางประการเกี่ยวกับวิดีโอในตอนท้าย ตลอดทั้งบทความนี้ คุณสามารถถือว่า "รูปภาพ" หมายถึง "รูปภาพหรือเฟรมเดียวของวิดีโอ"
วิธีเข้าถึงข้อมูลพิกเซลของรูปภาพ
การปรับแต่งรูปภาพที่พบได้ทั่วไปมี 3 หมวดหมู่พื้นฐาน ดังนี้
- เอฟเฟกต์ Pixel เช่น คอนทราสต์ ความสว่าง ความอบอุ่น โทนซีเปีย ความอิ่มตัว
- เอฟเฟกต์แบบหลายพิกเซลที่เรียกว่าฟิลเตอร์คอนโวลูชัน เช่น การเพิ่มความคมชัด การตรวจจับขอบ การเบลอ
- การบิดเบือนทั้งรูปภาพ เช่น การครอบตัด การเอียง การยืด เอฟเฟกต์เลนส์ การกระเพื่อม
ทั้งหมดนี้เกี่ยวข้องกับการเข้าถึงข้อมูลพิกเซลจริงของรูปภาพต้นฉบับ แล้วสร้างรูปภาพใหม่จากข้อมูลดังกล่าว และอินเทอร์เฟซเดียวที่ทำเช่นนั้นได้คือ Canvas
ตัวเลือกที่สำคัญจริงๆ คือจะประมวลผลบน CPU ด้วย Canvas 2 มิติ หรือบน GPU ด้วย WebGL
มาดูความแตกต่างระหว่าง 2 แนวทางนี้กันแบบเร็วๆ
Canvas 2 มิติ
ตัวเลือกนี้เป็นตัวเลือกที่ง่ายที่สุดใน 2 ตัวเลือกอย่างแน่นอน ก่อนอื่นให้วาดรูปภาพบน Canvas
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);
จากนั้นคุณจะได้รับอาร์เรย์ของค่าพิกเซลสำหรับทั้ง Canvas
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
ณ จุดนี้ ตัวแปร pixels คือ Uint8ClampedArray ที่มีความยาว width * height * 4
องค์ประกอบอาร์เรย์แต่ละรายการมีขนาด 1 ไบต์ และองค์ประกอบทุกๆ 4 รายการในอาร์เรย์จะแสดงสีของพิกเซล 1 พิกเซล องค์ประกอบทั้ง 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 หากต้องการเขียนเวอร์ชันที่แก้ไขกลับ คุณต้องใช้เมธอด putImageData เพื่อเขียนกลับไปที่มุมซ้ายบนของ Canvas
context.putImageData(imageData, 0, 0);
WebGL
WebGL เป็นหัวข้อที่กว้างมาก และกว้างเกินกว่าที่จะอธิบายได้อย่างครอบคลุมในบทความเดียว หากต้องการดูข้อมูลเพิ่มเติมเกี่ยวกับ WebGL โปรดดูการอ่านที่แนะนำที่ส่วนท้ายของบทความนี้
อย่างไรก็ตาม ต่อไปนี้เป็นข้อมูลเบื้องต้นโดยสังเขปเกี่ยวกับสิ่งที่ต้องทำในกรณีที่จัดการรูปภาพเดียว
สิ่งสำคัญที่สุดอย่างหนึ่งที่ต้องจำเกี่ยวกับ WebGL คือ WebGL ไม่ใช่ API กราฟิก 3 มิติ อันที่จริงแล้ว WebGL (และ OpenGL) มีความสามารถที่โดดเด่นเพียงอย่างเดียว นั่นคือการวาดรูปสามเหลี่ยม ในแอปพลิเคชัน คุณต้องอธิบายสิ่งที่คุณต้องการวาดจริงๆ ในรูปแบบของสามเหลี่ยม ในกรณีของรูปภาพ 2 มิติ การดำเนินการนี้ทำได้ง่ายมาก เนื่องจากสี่เหลี่ยมผืนผ้าคือสามเหลี่ยมมุมฉากที่คล้ายกัน 2 รูป ซึ่งจัดเรียงให้ด้านตรงข้ามมุมฉากอยู่ในตำแหน่งเดียวกัน
กระบวนการพื้นฐานมีดังนี้
- ส่งข้อมูลไปยัง GPU ที่อธิบายจุดยอด (จุด) ของสามเหลี่ยม
- ส่งรูปภาพต้นฉบับไปยัง GPU เป็นพื้นผิว (รูปภาพ)
- สร้าง "Vertex Shader"
- สร้าง "Fragment Shader"
- ตั้งค่าตัวแปรเฉดสีบางตัวที่เรียกว่า "Uniform"
- เรียกใช้เฉดสี
มาดูรายละเอียดกัน เริ่มต้นด้วยการจัดสรรหน่วยความจำบางส่วนในการ์ดกราฟิกที่เรียกว่าบัฟเฟอร์จุดยอด คุณจัดเก็บข้อมูลที่อธิบายจุดแต่ละจุดของสามเหลี่ยมแต่ละรูปไว้ในบัฟเฟอร์ นอกจากนี้ คุณยังตั้งค่าตัวแปรบางตัวที่เรียกว่า Uniform ซึ่งเป็นค่าส่วนกลางผ่านทั้ง 2 ตัวปรับเฉดสีได้ด้วย
Vertex Shader ใช้ข้อมูลจาก Vertex Buffer เพื่อคำนวณตำแหน่งบนหน้าจอที่จะวาดจุด 3 จุดของสามเหลี่ยมแต่ละรูป
ตอนนี้ GPU จะรู้ว่าต้องวาดพิกเซลใดภายใน Canvas ระบบจะเรียกใช้ Fragment Shader 1 ครั้งต่อพิกเซล และต้องแสดงผลสีที่ควรวาดลงในหน้าจอ Fragment Shader สามารถอ่านข้อมูลจากพื้นผิวอย่างน้อย 1 รายการเพื่อกำหนดสี
เมื่ออ่านพื้นผิวใน Fragment Shader คุณจะระบุส่วนของรูปภาพที่ต้องการอ่านโดยใช้พิกัดจุดลอยตัว 2 พิกัดระหว่าง 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 ไบต์ แต่จะเปลี่ยนค่าเพียง 3 ค่าเท่านั้น เนื่องจาก การเปลี่ยนรูปแบบนี้จะไม่เปลี่ยนค่าอัลฟา นอกจากนี้ โปรดทราบว่า Uint8ClampedArray จะปัดเศษค่าทั้งหมดเป็นจำนวนเต็ม และจำกัดค่าให้อยู่ระหว่าง 0 ถึง 255
Fragment Shader ของ WebGL:
float brightness = 1.1;
gl_FragColor = textureColor;
gl_FragColor.rgb *= brightness;
ในทำนองเดียวกัน ระบบจะคูณเฉพาะส่วน RGB ของสีเอาต์พุตสำหรับการเปลี่ยนรูปแบบนี้เท่านั้น
ฟิลเตอร์บางตัวเหล่านี้ใช้ข้อมูลเพิ่มเติม เช่น ความสว่างเฉลี่ยของทั้งรูปภาพ แต่สิ่งเหล่านี้สามารถคำนวณได้ครั้งเดียวสำหรับทั้งรูปภาพ
ตัวอย่างเช่น วิธีหนึ่งในการเปลี่ยนคอนทราสต์คือการย้ายแต่ละพิกเซลเข้าหาหรือออกจากค่า "สีเทา" เพื่อให้ได้คอนทราสต์ที่ต่ำลงหรือสูงขึ้นตามลำดับ โดยทั่วไปแล้ว ค่าสีเทาจะเลือกเป็นสีเทาที่มีความสว่างเป็นค่ามัธยฐานของความสว่างของพิกเซลทั้งหมดในรูปภาพ
คุณสามารถคำนวณค่านี้ได้ 1 ครั้งเมื่อโหลดรูปภาพ แล้วใช้ค่านั้นทุกครั้งที่ต้องการปรับเอฟเฟกต์รูปภาพ
หลายพิกเซล
เอฟเฟกต์บางอย่างจะใช้สีของพิกเซลที่อยู่ติดกันเมื่อกำหนดสีของพิกเซลปัจจุบัน
ซึ่งจะเปลี่ยนวิธีดำเนินการในกรณีของ Canvas 2 มิติเล็กน้อย เนื่องจากคุณต้องการอ่านสีดั้งเดิมของรูปภาพ และตัวอย่างก่อนหน้านี้เป็นการอัปเดตพิกเซลในตำแหน่งเดิม
ซึ่งวิธีนี้ก็ง่ายพอสมควร เมื่อสร้างออบเจ็กต์ข้อมูลรูปภาพในตอนแรก คุณสามารถทำสำเนาข้อมูลได้
const originalPixels = new Uint8Array(imageData.data);
สำหรับกรณี WebGL คุณไม่จำเป็นต้องทำการเปลี่ยนแปลงใดๆ เนื่องจากตัวปรับเฉดสีไม่ได้เขียนลงในพื้นผิวอินพุต
หมวดหมู่ที่พบบ่อยที่สุดของเอฟเฟกต์แบบหลายพิกเซลเรียกว่าตัวกรองคอนโวลูชัน ฟิลเตอร์ Convolution ใช้พิกเซลหลายพิกเซลจากรูปภาพอินพุตเพื่อคำนวณสีของแต่ละพิกเซลในรูปภาพอินพุต ระดับอิทธิพลที่พิกเซลอินพุตแต่ละพิกเซลมีต่อเอาต์พุตเรียกว่าน้ำหนัก
น้ำหนักสามารถแสดงด้วยเมทริกซ์ที่เรียกว่าเคอร์เนล โดยมีค่ากลางที่สอดคล้องกับพิกเซลปัจจุบัน ตัวอย่างเช่น นี่คือเคอร์เนลสำหรับการเบลอแบบเกาส์เซียนขนาด 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;
}
}
นี่เป็นเพียงแนวคิดพื้นฐาน แต่ยังมีคู่มือที่จะให้รายละเอียดเพิ่มเติมและแสดงรายการเคอร์เนลอื่นๆ ที่มีประโยชน์อีกมากมาย
ทั้งรูปภาพ
การเปลี่ยนรูปแบบทั้งรูปภาพบางอย่างนั้นทำได้ง่ายๆ ใน Canvas แบบ 2 มิติ การครอบตัดและการปรับขนาดเป็นกรณีง่ายๆ ของการวาดเฉพาะบางส่วนของรูปภาพต้นฉบับลงใน 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);
การหมุนและการสะท้อนจะใช้ได้โดยตรงในบริบท 2 มิติ ก่อนที่จะวาดรูปภาพลงใน 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);
แต่ที่ทรงพลังยิ่งกว่านั้นคือการแปลง 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
- เคอร์เนล (การประมวลผลรูปภาพ): หน้า Wikipedia ที่อธิบายตัวกรองคอนโวลูชัน
- Image Kernels Explained Visually: คำอธิบายเคอร์เนลบางส่วนพร้อมเดโมแบบอินเทอร์แอกทีฟ
- การเปลี่ยนรูปแบบ: บทความ MDN เกี่ยวกับการเปลี่ยนรูปแบบ Canvas 2 มิติ