تأثيرات في الوقت الفعلي للصور والفيديو

موازين سجادة

تتيح لك العديد من التطبيقات الأكثر رواجًا اليوم تطبيق الفلاتر والتأثيرات على الصور أو الفيديوهات. توضّح هذه المقالة كيفية تنفيذ هذه الميزات على الويب المفتوح.

هذه العملية هي نفسها في الأساس بالنسبة إلى مقاطع الفيديو والصور، لكنني سأتناول بعض الاعتبارات المهمة للفيديو في النهاية. يمكنك أن تفترض في جميع أنحاء المقالة أن "الصورة" تعني "صورة أو إطارًا واحدًا من فيديو"

كيفية الحصول على بيانات البكسل لإحدى الصور

هناك 3 فئات شائعة من طرق معالجة الصور:

  • تأثيرات Pixel، مثل التباين والسطوع والدفء ودرجة اللون البني الداكن وتشبّع اللون
  • تأثيرات متعددة البكسل، تُسمى فلاتر الالتفاف، مثل زيادة الحدة ورصد الحواف والتمويه.
  • تشوّه الصورة بالكامل، مثل الاقتصاص والانحراف والتمديد وتأثيرات العدسة والتموجات

ينطوي كل ذلك على الحصول على بيانات البكسل الفعلية للصورة المصدر ثم إنشاء صورة جديدة منها، ولا يكون للواجهة الوحيدة للقيام بذلك سوى لوحة الرسم.

والخيار المهم حقًا عندها هو إجراء المعالجة على وحدة المعالجة المركزية (CPU) باستخدام لوحة ثنائية الأبعاد، أو على وحدة معالجة الرسومات باستخدام 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 (وOpenGL) جيد في شيء واحد فقط، وهو رسم المثلّثات. في التطبيق يجب أن تصف ما تريد فعليًا رسمه من خلال المثلّثات. وفي حالة الصورة ثنائية الأبعاد، الأمر بسيط للغاية، لأن المستطيل هو مثلثان متشابهان قائمان الزاوية، ويتم ترتيبهما بحيث تكون وترهما في المكان نفسه.

العملية الأساسية هي:

  • أرسِل بيانات إلى وحدة معالجة الرسومات التي تصف رؤوس المثلّثات.
  • إرسال صورة المصدر إلى وحدة معالجة الرسومات كزخرفة (صورة).
  • إنشاء أداة تظليل الرأس
  • إنشاء "أداة تظليل الأجزاء"
  • يمكنك ضبط بعض متغيّرات أداة التظليل التي تُسمى "الأزياء الموحدة".
  • شغِّل أدوات التظليل.

لندخل في التفاصيل. ابدأ بتخصيص بعض الذاكرة على بطاقة الرسومات تسمى المخزن المؤقت للرأس. أنت تقوم بتخزين البيانات فيه التي تصف كل نقطة من كل مثلث. يمكنك أيضًا تعيين بعض المتغيرات، التي تسمى الأزياء الموحدة، التي تمثل قيمًا عمومية من خلال كلا برنامجي التظليل.

يستخدم مظلل الرأس البيانات من المخزن المؤقت للرأس لحساب مكان الشاشة لرسم النقاط الثلاث لكل مثلث.

الآن تعرف وحدة معالجة الرسومات وحدات البكسل التي يجب رسمها داخل اللوحة. يُطلق على أداة تظليل الأجزاء مرة واحدة لكل بكسل، ويحتاج إلى إعادة اللون الذي يجب رسمه على الشاشة. يمكن لأداة تظليل الجزء قراءة معلومات من زخارف واحدة أو أكثر لتحديد اللون.

عند قراءة زخرفة في أداة تظليل الأجزاء، يمكنك تحديد أي جزء من الصورة تريد قراءته باستخدام إحداثي نقطة عائمة بين 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، لن تحتاج إلى إجراء أي تغييرات، لأن أداة التظليل لا تكتب إلى زخرفة المدخلات.

يُطلق على الفئة الأكثر شيوعًا للتأثير المتعدد البكسل اسم فلتر الالتفاف. يستخدم عامل تصفية الالتفاف عدة وحدات بكسل من الصورة المدخلة لحساب لون كل بكسل في صورة الإدخال. يُطلق على مستوى التأثير الذي يحدثه كل بكسل إدخال على الإخراج اسم الوزن.

يمكن تمثيل القيم التقديرية بمصفوفة، تسمى kernel، بالقيمة المركزية المقابلة للبكسل الحالي. على سبيل المثال، هذه هي نواة تمويه غاوسي 3×3.

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

ولكن الأهم من ذلك هو أنه يمكن كتابة العديد من التحويلات ثنائية الأبعاد في شكل مصفوفات 2×3 وتطبيقها على اللوحة باستخدام 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],
);

تتضمن التأثيرات الأكثر تعقيدًا مثل تشوه العدسة أو التموجات تطبيق إزاحة على كل إحداثي وجهة لحساب إحداثي بكسل المصدر. على سبيل المثال، للحصول على تأثير موجة أفقية، يمكنك إزاحة إحداثي بكسل س المصدر بقيمة ما بناءً على الإحداثي ص.

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 كصورة مصدر.

لوحة رسم ثنائية الأبعاد:

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 ملي ثانية فقط إلى اهتزاز مرئي.

إضافة ملاحظات