جلوه های بلادرنگ برای تصاویر و ویدیوها

Mat Scales

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

فرآیند اساساً برای ویدیوها و تصاویر یکسان است، اما در پایان به برخی از ملاحظات ویدیویی مهم خواهم پرداخت. در طول مقاله می توانید فرض کنید که "تصویر" به معنای "تصویر یا یک فریم از یک ویدئو" است.

چگونه به داده های پیکسلی یک تصویر دست پیدا کنیم

3 دسته اصلی دستکاری تصویر وجود دارد که رایج هستند:

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

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

بنابراین، انتخاب واقعاً مهم این است که پردازش را روی CPU، با بوم دوبعدی، یا روی GPU، با WebGL انجام دهیم.

بیایید نگاهی گذرا به تفاوت های این دو رویکرد بیندازیم.

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

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

فرآیند اصلی این است:

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

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

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

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

هنگام خواندن یک بافت در سایه‌زن قطعه، مشخص می‌کنید که کدام قسمت از تصویر را می‌خواهید با استفاده از دو مختصات ممیز شناور بین 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 رنگ خروجی برای این تبدیل خاص ضرب می شود.

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

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

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

چند پیکسلی

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

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

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

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

این ایده اولیه را ارائه می دهد، اما راهنماهایی وجود دارد که به جزئیات بسیار بیشتری می پردازند و بسیاری از هسته های مفید دیگر را فهرست می کنند.

تصویر کامل

برخی از تغییرات کل تصویر ساده هستند. در یک بوم دوبعدی، برش و مقیاس‌بندی یک مورد ساده است که فقط بخشی از تصویر منبع را روی بوم می‌کشیم.

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

اما قوی تر، بسیاری از تبدیل های دو بعدی را می توان به صورت ماتریس های 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 به عنوان تصویر منبع استفاده کنید، هر چیز دیگری در مقاله قبلاً برای ویدیو کار می کند.

بوم 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 میلی‌ثانیه می‌تواند باعث تند شدن قابل مشاهده شود.

بازخورد