เอฟเฟ็กต์แบบเรียลไทม์สำหรับรูปภาพและวิดีโอ

เครื่องชั่งน้ำหนัก

แอปยอดนิยมจำนวนมากในปัจจุบันอนุญาตให้คุณใช้ฟิลเตอร์และเอฟเฟกต์กับรูปภาพหรือวิดีโอ บทความนี้แสดงวิธีนำฟีเจอร์เหล่านี้ไปใช้กับเว็บแบบเปิด

โดยทั่วไปแล้ว กระบวนการสำหรับวิดีโอและรูปภาพจะเหมือนกัน แต่เราจะพูดถึงสิ่งสำคัญที่ควรพิจารณาเกี่ยวกับวิดีโอในตอนท้าย ตลอดทั้งบทความ คุณจะสันนิษฐานได้ว่า "รูปภาพ" หมายถึง "รูปภาพหรือเฟรมเดียวของวิดีโอ"

วิธีดูข้อมูลพิกเซลของรูปภาพ

การปรับแต่งรูปภาพที่ใช้กันทั่วไปมี 3 หมวดหมู่พื้นฐานดังนี้

  • เอฟเฟกต์พิกเซล เช่น คอนทราสต์ ความสว่าง อบอุ่น โทนซีเปีย ความอิ่มตัว
  • เอฟเฟกต์หลายพิกเซลที่เรียกว่าฟิลเตอร์คอนโวลูชัน (Convolution) เช่น การทำให้คมชัด การตรวจจับขอบ การเบลอ
  • การบิดเบี้ยวทั้งภาพ เช่น การครอบตัด การบิด การยืด เอฟเฟ็กต์เลนส์ คลื่น

ทั้งหมดนี้รวมถึงการดูข้อมูลพิกเซลจริงของรูปภาพต้นฉบับ แล้วสร้างรูปภาพใหม่จากรูปภาพ และอินเทอร์เฟซเดียวสำหรับการดำเนินการดังกล่าวคือผืนผ้าใบ

ตัวเลือกที่สำคัญจริงๆ คือการประมวลผลบน CPU, Canvas 2 มิติ หรือ GPU โดยใช้ WebGL

เราจะมาดูความแตกต่างระหว่าง 2 วิธีนี้กัน

แคนวาส 2 มิติ

แน่นอนว่านี่เป็นวิธีที่ง่ายที่สุดจาก 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 เพื่อเขียนกลับไปไว้ที่มุมบนซ้ายของผืนผ้าใบ

context.putImageData(imageData, 0, 0);

WebGL

WebGL เป็นหัวข้อใหญ่ๆ ซึ่งถือว่าใหญ่เกินกว่าจะดำเนินการได้ในบทความเดียว หากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับ WebGL ลองดูการอ่านที่แนะนำที่ส่วนท้ายของบทความนี้

อย่างไรก็ตาม เราขอสรุปสั้นๆ ถึงสิ่งที่จำเป็นต้องทำในกรณีที่ต้องบิดเบือนรูปภาพเพียงรูปเดียว

สิ่งสำคัญที่สุดอย่างหนึ่งที่ควรจำเกี่ยวกับ WebGL คือไม่ใช่ API กราฟิก 3 มิติ จริงๆ แล้ว WebGL (และ OpenGL) มีความสามารถด้านการวาดรูปสามเหลี่ยม ในแอปพลิเคชัน คุณต้องอธิบายสิ่งที่จริงๆ แล้วต้องการวาดในรูปแบบสามเหลี่ยม ในกรณีของรูปภาพ 2 มิตินั้นง่ายมาก เพราะสี่เหลี่ยมผืนผ้าคือสามเหลี่ยมมุมฉาก 2 รูปที่คล้ายกัน และจัดเรียงเพื่อให้ด้านตรงข้ามมุมฉากอยู่ในที่เดียวกัน

ขั้นตอนพื้นฐานมีดังนี้

  • ส่งข้อมูลไปยัง GPU ที่อธิบายจุดยอด (จุด) ของรูปสามเหลี่ยม
  • ส่งรูปภาพต้นฉบับไปยัง GPU เป็นพื้นผิว (รูปภาพ)
  • สร้าง "ตัวปรับเฉดสีเวอร์เท็กซ์"
  • สร้าง "ตัวปรับแสงเงาส่วน"
  • ตั้งค่าตัวแปรตัวปรับแสงเงาบางส่วนซึ่งเรียกว่า "เครื่องแบบ"
  • เรียกใช้ตัวปรับแสงเงา

มาดูรายละเอียดกัน เริ่มด้วยการจัดสรรหน่วยความจำบางส่วนบนการ์ดแสดงผลที่เรียกว่าบัฟเฟอร์จุดยอดมุม จะเก็บข้อมูลที่อธิบายแต่ละจุดของรูปสามเหลี่ยมแต่ละรูป นอกจากนี้ คุณยังสามารถตั้งค่าตัวแปรบางอย่างที่เรียกว่าชุดเครื่องแบบ ซึ่งเป็นค่าส่วนกลางผ่านตัวให้เฉดสีทั้งสอง

ตัวปรับแสงเงาจุดยอดใช้ข้อมูลจากบัฟเฟอร์จุดยอดเพื่อคำนวณตำแหน่งบนหน้าจอเพื่อวาดจุด 3 จุดของแต่ละรูปสามเหลี่ยม

ขณะนี้ GPU ทราบแล้วว่าจะต้องวาดพิกเซลใดใน Canvas ตัวปรับแสงเงาบางส่วนเรียกว่า 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 ค่าเท่านั้น เนื่องจากการเปลี่ยนรูปแบบนี้จะไม่เปลี่ยนแปลงค่าอัลฟ่า โปรดทราบด้วยว่า Uint8ClampedArray จะปัดเศษค่าทั้งหมดเป็นจำนวนเต็ม และค่ายึดให้อยู่ระหว่าง 0 ถึง 255

ตัวปรับแสงเงาส่วน WebGL:

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

ในทำนองเดียวกัน ระบบจะคูณเฉพาะส่วน RGB ของสีเอาต์พุตสำหรับการเปลี่ยนรูปแบบนี้

ตัวกรองบางอย่างจะให้ข้อมูลเพิ่มเติม เช่น ความสว่างเฉลี่ยของทั้งรูปภาพ แต่สามารถคำนวณข้อมูลนี้เพียงครั้งเดียวสำหรับทั้งรูปภาพ

เช่น วิธีหนึ่งของการเปลี่ยนคอนทราสต์คือการเลื่อนแต่ละพิกเซลออกห่างจากค่า "สีเทา" บางส่วน เพื่อให้ได้คอนทราสต์ต่ำหรือสูงตามลำดับ ค่าสีเทามักจะเลือกเป็นสีเทาที่มีความสว่างตามค่ามัธยฐานของพิกเซลทั้งหมดในรูปภาพ

โดยสามารถคำนวณค่านี้ได้เพียงครั้งเดียวเมื่อโหลดรูปภาพแล้วนำไปใช้ทุกครั้งที่ต้องปรับเอฟเฟกต์รูปภาพ

มัลติพิกเซล

เอฟเฟกต์บางอย่างใช้สีของพิกเซลใกล้เคียงในการกำหนดสีของพิกเซลปัจจุบัน

ซึ่งจะเปลี่ยนวิธีการทำสิ่งต่างๆ ในเคสภาพพิมพ์แคนวาส 2 มิติเล็กน้อย เพราะคุณต้องการให้อ่านสีต้นฉบับของรูปภาพได้ และตัวอย่างก่อนหน้านี้คือการอัปเดตพิกเซลที่มีอยู่

ซึ่งถือว่าง่ายพอแล้ว เมื่อสร้างออบเจ็กต์ข้อมูลรูปภาพเป็นครั้งแรก คุณจะทำสำเนาข้อมูลได้

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

นี่เป็นแนวคิดเบื้องต้น แต่มีคำแนะนำต่างๆ ที่จะให้รายละเอียดเพิ่มเติม และแสดงเคอร์เนลที่มีประโยชน์อื่นๆ อีกมากมาย

รูปภาพทั้งหมด

การเปลี่ยนรูปแบบรูปภาพทั้งหมดนั้นทำได้ง่าย ในผืนผ้าใบแบบ 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 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 มิลลิวินาทีก็อาจทำให้รู้สึกกระตุกได้

ความคิดเห็น