تتيح لك العديد من التطبيقات الأكثر رواجًا حاليًا تطبيق الفلاتر والمؤثرات على الصور أو الفيديوهات. توضّح هذه المقالة كيفية تنفيذ هذه الميزات على الويب المفتوح.
تكون العملية متماثلة في الأساس للفيديوهات والصور، ولكن سأتناول في النهاية بعض النقاط المهمة المتعلّقة بالفيديو. في هذه المقالة، يمكنك افتراض أنّ "الصورة" تعني "صورة أو إطار واحد من فيديو".
كيفية الوصول إلى بيانات وحدات البكسل لصورة
هناك 3 فئات أساسية شائعة من التلاعب بالصور:
- تأثيرات Pixel، مثل التباين والسطوع والدفء ولون البيج وتشبع اللون
- تأثيرات متعددة البكسل، والتي تُعرف باسم فلاتر التفاف، مثل تحسين الحدّة واكتشاف الحواف والتمويه
- تشويه الصورة بالكامل، مثل الاقتصاص أو التواء أو التمديد أو تأثيرات العدسة أو التموجات
تتطلّب كل هذه الإجراءات الوصول إلى بيانات البكسل الفعلية للصورة المصدر ثم إنشاء صورة جديدة منها، والواجهة الوحيدة لإجراء ذلك هي اللوحة.
وبالتالي، فإنّ الخيار المهم حقًا هو ما إذا كنت تريد إجراء المعالجة على وحدة المعالجة المركزية باستخدام لوحة رسومات ثنائية الأبعاد، أو على وحدة معالجة الرسومات باستخدام 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 ملي ثانية فقط إلى حدوث اهتزاز ملحوظ.
مقالة نقترح قراءتها
- WebGL Fundamentals: موقع إلكتروني يقدّم دروسًا حول WebGL
- النواة (معالجة الصور): صفحة في Wikipedia تشرح فلاتر التفاعُل
- شرح نوى الصور بشكل مرئي: أوصاف لبعض النوى مع عروض توضيحية تفاعلية
- التحويلات: مقالة MDN حول عمليات تحويل اللوحة ثنائية الأبعاد