Многие популярные приложения позволяют применять к изображениям и видео фильтры и эффекты. В этой статье рассказывается, как реализовать эти функции в открытом интернете.
Процесс практически одинаков для видео и изображений, но в конце я расскажу о некоторых важных особенностях видео. В этой статье под словом "изображение" подразумевается как изображение, так и отдельный кадр видео.
Как получить данные о пикселях изображения
Существует три основные категории манипуляций с изображениями:
- Эффекты Pixel, такие как контрастность, яркость, теплота, сепия, насыщенность.
- Многопиксельные эффекты, называемые фильтрами свертки, такие как повышение резкости, обнаружение краев, размытие.
- Искажение всего изображения, например кадрирование, наклон, растягивание, эффекты линзы, рябь.
Все эти операции требуют доступа к пиксельным данным исходного изображения и создания на их основе нового изображения. Единственный интерфейс, позволяющий это сделать, – холст.
Поэтому действительно важный выбор заключается в том, выполнять ли обработку на ЦП с 2D-холстом или на графическом процессоре с WebGL.
Давайте кратко рассмотрим различия между этими двумя подходами.
2D-холст
Это, безусловно, самый простой из двух вариантов. Сначала вы рисуете изображение на холсте.
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 для 3D-графики. На самом деле, WebGL (и OpenGL) хорош только в одном - рисовании треугольников. В своем приложении вы должны описать, что вы на самом деле хотите нарисовать, используя треугольники. В случае с 2D-изображением это очень просто, потому что прямоугольник – это два одинаковых прямоугольных треугольника, расположенных так, что их гипотенузы находятся в одном и том же месте.
Вот как это сделать:
- Отправьте графическому процессору данные, описывающие вершины (точки) треугольников.
- Отправьте исходное изображение на графический процессор в виде текстуры (изображения).
- Создайте вершинный шейдер.
- Создайте фрагментный шейдер.
- Задайте переменные шейдера, называемые "униформами".
- Запустите шейдеры.
Давайте рассмотрим их подробнее. Начните с выделения памяти на видеокарте, которая называется буфером вершин. В нем хранятся данные, описывающие каждую точку каждого треугольника. Вы также можете задать некоторые переменные, называемые униформами, которые являются глобальными значениями для обоих шейдеров.
Вершинный шейдер использует данные из буфера вершин, чтобы рассчитать, где на экране нарисовать три точки каждого треугольника.
Теперь графический процессор знает, какие пиксели на холсте нужно нарисовать. Фрагментный шейдер вызывается один раз для каждого пикселя и должен возвращать цвет, который должен быть нарисован на экране. Фрагментный шейдер может считывать информацию из одной или нескольких текстур, чтобы определить цвет.
При чтении текстуры во фрагментном шейдере вы указываете, какую часть изображения хотите прочитать, используя две координаты с плавающей запятой от 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-часть выходного цвета.
Для некоторых из этих фильтров требуется дополнительная информация, например средняя яркость всего изображения, но это то, что можно рассчитать один раз для всего изображения.
Например, чтобы изменить контрастность, можно приблизить или отдалить каждый пиксель от некоторого значения серого. Обычно выбирается серый цвет, яркость которого является медианной яркостью всех пикселей изображения.
Вы можете рассчитать это значение один раз при загрузке изображения, а затем использовать его каждый раз, когда вам нужно настроить эффект изображения.
Мультипиксель
При применении некоторых эффектов цвет текущего пикселя определяется цветом соседних пикселей.
Это немного меняет то, как вы работаете с 2D-холстом, потому что вам нужно иметь возможность считывать исходные цвета изображения, а в предыдущем примере пиксели обновлялись на месте.
Это достаточно просто. При создании объекта данных изображения можно сделать копию данных.
const originalPixels = new Uint8Array(imageData.data);
В случае с WebGL вам не нужно вносить никаких изменений, поскольку шейдер не записывает данные во входную текстуру.
Наиболее распространенная категория многопиксельных эффектов называется фильтром свертки. Фильтр свертки использует несколько пикселей входного изображения, чтобы рассчитать цвет каждого пикселя в выходном изображении. Уровень влияния каждого входного пикселя на выходной называется весом.
Веса могут быть представлены матрицей, называемой ядром, с центральным значением, соответствующим текущему пикселю. Например, это ядро для размытия по Гауссу 3 x 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;
}
}
Это лишь основные сведения. Более подробную информацию и список других полезных ядер можно найти в руководствах.
Изображение целиком
Некоторые преобразования изображений просты. В 2D-холсте обрезка и масштабирование – это простой случай, когда на холсте рисуется только часть исходного изображения.
// 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);
Поворот и отражение доступны непосредственно в контексте 2D. Прежде чем рисовать изображение на холсте, измените различные преобразования.
// 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);
Но что ещё важнее, многие 2D-преобразования можно записать в виде матриц 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.
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
};
При работе с видео особенно важно, чтобы обработка выполнялась быстро. При работе с неподвижным изображением пользователь может не заметить задержку в 100 мс между нажатием кнопки и применением эффекта. Однако при анимации задержка всего в 16 мс может вызвать видимые рывки.
Рекомендуемые материалы
- Основы WebGL – сайт, посвященный WebGL.
- Ядро (обработка изображений) – статья в Википедии, в которой объясняется, что такое фильтры свертки.
- Визуальное объяснение ядер изображений – описания нескольких ядер с интерактивными демонстрациями.
- Преобразования. Статья MDN о преобразованиях 2D-холста.