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

Mat Scales

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

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

هناك 3 فئات أساسية شائعة من التلاعب بالصور:

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

وتتم العملية الأساسية على النحو التالي:

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

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

يستخدم برنامج تشفير قمة الرأس بيانات من مخزن قمة الرأس لاحتساب مكان رسم النقاط الثلاث لكل مثلث على الشاشة.

الآن، تعرف وحدة معالجة الرسومات وحدات البكسل التي يجب رسمها داخل اللوحة. يتمّ استدعاء برنامج Shader للّقطة مرّة واحدة لكلّ بكسل، ويجب أن يعرض اللون الذي يجب رسمه على الشاشة. يمكن أن يقرأ مخطِّط كثافة ملف glsl للشريحة معلومات من نسيج واحد أو أكثر لتحديد اللون.

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

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

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

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

لنفترض أنّك تريد احتساب لون الإخراج للبكسل في (23، 19). اجمع وحدات البكسل الثماني التي تحيط بالنقطة (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],
);

إنّ التأثيرات الأكثر تعقيدًا، مثل تشويه العدسة أو التموجات، تتطلّب تطبيق بعض الانحراف على كل إحداثي الوجهة لحساب إحداثي بكسل المصدر. على سبيل المثال، للحصول على تأثير موجة أفقي، يمكنك تعويض الإحداثي 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 كمصدر الصورة.

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

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

ملاحظات