جلوه‌های هم‌زمان برای تصاویر و ویدیوها

Mat Scales

بسیاری از پرطرفدارترین برنامه‌های امروزی به شما امکان می‌دهند فیلترها و جلوه‌هایی را روی تصاویر یا ویدیوها اعمال کنید. این مقاله نحوه پیاده‌سازی این ویژگی‌ها را در وب باز نشان می‌دهد.

این فرایند اساساً برای ویدیوها و تصاویر یکسان است، اما در پایان به برخی ملاحظات مهم درباره ویدیوها خواهم پرداخت. در سراسر این مقاله می‌توانید فرض کنید که «تصویر» به‌معنای «تصویر یا یک فریم از ویدیو» است

نحوه دسترسی به داده‌های پیکسل برای تصویر

‫۳ دسته اصلی برای دستکاری تصویر وجود دارد که رایج هستند:

  • جلوه‌های Pixel مثل کنتراست، روشنایی، گرما، رنگ سپیا، اشباع رنگ.
  • جلوه‌های چندپیکسلی، که فیلترهای پیچشی نامیده می‌شوند، مانند تیز کردن، تشخیص لبه، محو کردن.
  • اعوجاج کل تصویر، مانند برش، کج کردن، کشیدن، جلوه‌های لنز، موج‌دار کردن.

همه این موارد شامل دسترسی به داده‌های پیکسل واقعی تصویر منبع و سپس ایجاد یک تصویر جدید از آن است، و تنها رابط برای انجام این کار بوم است.

بنابراین، انتخاب واقعاً مهم این است که پردازش را روی 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 به‌خاطر داشته باشید این است که WebGL میانای برنامه‌سازی کاربردی گرافیک سه‌بعدی نیست. درواقع، WebGL (و OpenGL) دقیقاً در یک چیز خوب است - کشیدن مثلث. در برنامه‌تان باید آنچه را که واقعاً می‌خواهید ترسیم کنید براساس مثلث‌ها توصیف کنید. درمورد تصویر دوبعدی، این کار بسیار ساده است، زیرا مستطیل از دو مثلث قائم‌الزاویه مشابه تشکیل شده است که به‌گونه‌ای قرار گرفته‌اند که وترهای آن‌ها در یک مکان قرار دارند.

فرایند اصلی عبارت است از:

  • داده‌هایی را که رئوس (نقاط) مثلث‌ها را توصیف می‌کند به GPU ارسال کنید.
  • تصویر منبع را به‌عنوان بافت (تصویر) به GPU ارسال کنید.
  • یک «سایه‌زن رأس» ایجاد کنید.
  • یک «سایه‌زن قطعه» ایجاد کنید.
  • برخی‌از متغیرهای سایه‌زن را که «یونیفرم» نامیده می‌شوند تنظیم کنید.
  • سایه‌زن‌ها را اجرا کنید.

بیایید به جزئیات بپردازیم. با اختصاص دادن مقداری حافظه در کارت گرافیک به نام میان‌گیر رأس شروع کنید. داده‌هایی را در آن ذخیره می‌کنید که هر نقطه از هر مثلث را توصیف می‌کند. همچنین می‌توانید برخی متغیرها را تنظیم کنید، که به آن‌ها یونیفرم می‌گویند، که مقادیر سراسری در هر دو سایه‌زن هستند.

سایه‌زن رأس از داده‌های میان‌گیر رأس استفاده می‌کند تا محاسبه کند سه نقطه هر مثلث در کجای صفحه‌نمایش باید ترسیم شود.

اکنون GPU می‌داند کدام پیکسل‌ها در بوم باید ترسیم شوند. سایه‌زن قطعه یک‌بار برای هر پیکسل فراخوانی می‌شود و باید رنگی را که باید روی صفحه کشیده شود برگرداند. سایه‌زن قطعه می‌تواند اطلاعات را از یک یا چند بافت بخواند تا رنگ را تعیین کند.

هنگام خواندن بافت در سایه‌زن قطعه، بااستفاده از دو مختصات ممیز شناور بین ۰ (چپ یا پایین) و ۱ (راست یا بالا) مشخص می‌کنید که کدام بخش از تصویر را می‌خواهید بخوانید.

اگر می‌خواهید بافت را براساس مختصات پیکسلی بخوانید، باید اندازه بافت را به‌صورت پیکسل به‌عنوان بردار یکنواخت ارسال کنید تا بتوانید تبدیل را برای هر پیکسل انجام دهید.

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

توجه داشته باشید که حلقه هربار ۴ بایت حرکت می‌کند، اما فقط سه مقدار را تغییر می‌دهد - دلیلش این است که این تبدیل خاص مقدار آلفا را تغییر نمی‌دهد. همچنین به‌خاطر داشته باشید که Uint8ClampedArray همه مقادیر را به عدد صحیح گرد می‌کند و مقادیر را بین ۰ تا ۲۵۵ محدود می‌کند.

سایه‌زن قطعه WebGL:

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

به‌طور مشابه، فقط بخش RGB رنگ خروجی برای این تبدیل خاص ضرب می‌شود.

برخی‌از این فیلترها اطلاعات بیشتری می‌گیرند، مثلاً میانگین درخشندگی کل تصویر، اما این‌ها چیزهایی هستند که می‌توانند یک‌بار برای کل تصویر محاسبه شوند.

برای مثال، یکی از راه‌های تغییر کنتراست می‌تواند این باشد که هر پیکسل را به‌سمت مقداری «خاکستری» یا دور از آن حرکت دهید تا به‌ترتیب کنتراست کمتر یا بیشتر شود. مقدار خاکستری معمولاً به‌عنوان رنگ خاکستری‌ای انتخاب می‌شود که درخشندگی آن میانه درخشندگی همه پیکسل‌های تصویر است.

می‌توانید این مقدار را یک‌بار هنگام بار شدن تصویر محاسبه کنید و سپس هربار که لازم است جلوه تصویر را تنظیم کنید از آن استفاده کنید.

چندپیکسلی

برخی‌از جلوه‌ها هنگام تصمیم‌گیری درباره رنگ پیکسل فعلی، از رنگ پیکسل‌های مجاور استفاده می‌کنند.

این امر نحوه انجام کارها را در مورد بوم دوبعدی کمی تغییر می‌دهد زیرا می‌خواهید بتوانید رنگ‌های اصلی تصویر را بخوانید و مثال قبلی پیکسل‌ها را در محل به‌روزرسانی می‌کرد.

اما این کار به‌اندازه کافی آسان است. وقتی در ابتدا شیء داده‌های تصویرتان را ایجاد می‌کنید، می‌توانید نسخه‌ای از داده‌ها تهیه کنید.

const originalPixels = new Uint8Array(imageData.data);

برای مورد WebGL نیازی به ایجاد تغییر ندارید، زیرا سایه‌زن در بافت ورودی نمی‌نویسد.

رایج‌ترین دسته جلوه چندپیکسلی فیلتر پیچش نامیده می‌شود. فیلتر پیچشی از چندین پیکسل تصویر ورودی برای محاسبه رنگ هر پیکسل در تصویر ورودی استفاده می‌کند. سطح تأثیری که هر پیکسل ورودی بر خروجی دارد وزن نامیده می‌شود.

وزن‌ها را می‌توان با ماتریسی به‌نام هسته نشان داد که مقدار مرکزی آن با پیکسل فعلی مطابقت دارد. برای مثال، این هسته برای محو کردن گاوسی ۳×۳ است.

    | 0  1  0 |
    | 1  4  1 |
    | 0  1  0 |

بنابراین فرض کنید می‌خواهید رنگ خروجی پیکسل را در (۲۳، ۱۹) محاسبه کنید. ‫۸ پیکسل اطراف (۲۳، ۱۹) و همچنین خود پیکسل را درنظر بگیرید و مقادیر رنگ هرکدام را در وزن مربوطه ضرب کنید.

    (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

همه آن‌ها را باهم جمع کنید و سپس نتیجه را بر ۸ تقسیم کنید که مجموع وزن‌ها است. می‌توانید ببینید که چگونه نتیجه پیکسلی خواهد بود که بیشتر آن اصلی است، اما پیکسل‌های مجاور در آن نفوذ کرده‌اند.

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

اما به‌طور مؤثرتر، بسیاری‌از تبدیل‌های دوبعدی را می‌توان به‌صورت ماتریس‌های ۲×۳ نوشت و با 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
};

هنگام کار با ویدیو، سرعت پردازش اهمیت ویژه‌ای پیدا می‌کند. با یک تصویر ثابت، ممکن است کاربر متوجه تأخیر ۱۰۰ میلی‌ثانیه‌ای بین کلیک کردن روی دکمه و اعمال شدن جلوه نشود. اما وقتی پویانمایی می‌شود، تأخیرهای ۱۶ میلی‌ثانیه‌ای می‌تواند باعث لرزش قابل‌مشاهده شود.

بازخورد