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