Пару недель назад я проводил проверку концепции новой функции, и в процессе мне нужно было выполнить некоторые операции компьютерного зрения. Я хотел использовать это как возможность опробовать библиотеку компьютерного зрения JavaScript, отличную от jsfeat, которая помогла мне создать музыкальный инструмент с управлением камерой два года назад. Лично мне нравится API jsfeat, но он уже мертв. Последнему коммиту в репозитории по состоянию на ноябрь 2022 года исполнилось почти пять лет, поэтому я хотел попробовать альтернативу с немного более активными участниками. Итак, входите в GammaCV!

Но давайте посмотрим на поставленную задачу. Допустим, у нас есть (очень) простое изображение:

Для функции, которую мы создаем, нам нужно оттенить оттенки серого, размыть и извлечь края из этого и подобных изображений. Начнем с подготовки стандартного рабочего процесса GammaCV. Во-первых, нам нужны входные данные:

import * as gm from "gammacv";

import img from "../assets/bitmap.js"; // our image in base64 format

// the width and height of the image
const WIDTH = 820;
const HEIGHT = 462;

const process = async () => {
  const input = await gm.imageTensorFromURL(img, "uint8", [HEIGHT, WIDTH, 4]);
};

process();

Что такое тензор? Это N-мерная (в данном случае — 3-мерная) структура данных, содержащая всю информацию об изображении. В этом случае довольно интуитивно понятно, что количество столбцов равно ширине изображения в пикселях, количество строк равно высоте и 4 значения для каждого пикселя — значение красного, зеленого, синего и альфа-канала.

Далее нам нужно определить операции, которые будут применяться к изображению. В официальной документации показано, как это можно сделать с помощью переназначения переменной, но я предпочитаю использовать настоящую конвейерную обработку с использованием функционального подхода и ramda (или rambda, более быстрая альтернатива ramda).

import * as gm from "gammacv";
import * as R from "rambda";

import img from "../assets/bitmap.js"; // our image in base64 format

// the width and height of the image
const WIDTH = 820;
const HEIGHT = 462;

const process = async () => {
  const input = await gm.imageTensorFromURL(img, "uint8", [HEIGHT, WIDTH, 4]);

  const operations = R.pipe(
    gm.grayscale,
    (previous) => gm.gaussianBlur(previous, 10, 3),
    gm.sobelOperator,
    (previous) => gm.cannyEdges(previous, 0.25, 0.5)
  )(input);
};

process();

Это начинает выглядеть хорошо, но это ничего не даст. API GammaCV требует нескольких дополнительных шагов для фактической обработки. Нам нужен выходной тензор, который получит результат. GammaCV также требует от нас создания и запуска сеанса для обработки изображения.

import * as gm from "gammacv";
import * as R from "rambda";

import img from "../assets/bitmap.js"; // our image in base64 format

// the width and height of the image
const WIDTH = 820;
const HEIGHT = 462;

const process = async () => {
  const input = await gm.imageTensorFromURL(img, "uint8", [HEIGHT, WIDTH, 4]);

  const operations = R.pipe(
    gm.grayscale,
    (previous) => gm.gaussianBlur(previous, 10, 3),
    gm.sobelOperator,
    (previous) => gm.cannyEdges(previous, 0.25, 0.5)
  )(input);

  const output = gm.tensorFrom(operations);

  const session = new gm.Session();

  session.init(operations);
  session.runOp(operations, 0, output);
};

process();

Итак, операции запущены. Регистрация объекта output даст нам непустой массив пикселей. Как мы можем увидеть результат? GammaCV также предоставляет простой способ создания холстов, нам просто нужно прикрепить их к DOM:

import * as gm from "gammacv";
import * as R from "rambda";

import img from "../assets/bitmap.js"; // our image in base64 format

// the width and height of the image
const WIDTH = 820;
const HEIGHT = 462;

const process = async () => {
  const input = await gm.imageTensorFromURL(img, "uint8", [HEIGHT, WIDTH, 4]);

  const operations = R.pipe(
    gm.grayscale,
    (previous) => gm.gaussianBlur(previous, 7, 3),
    gm.sobelOperator,
    (previous) => gm.cannyEdges(previous, 0.25, 0.5)
  )(input);

  const output = gm.tensorFrom(operations);

  const session = new gm.Session();

  session.init(operations);
  session.runOp(operations, 0, output);

  const inputCanvas = gm.canvasCreate(WIDTH, HEIGHT);
  const outputCanvas = gm.canvasCreate(WIDTH, HEIGHT);

  document.body.append(inputCanvas);
  document.body.append(outputCanvas);

  gm.canvasFromTensor(inputCanvas, input);
  gm.canvasFromTensor(outputCanvas, output);
};

process();

Та-да! Теперь мы можем проверить результат:

Хорошо, что здесь произошло? Это немного сложно отлаживать, так как мы выполняем пару операций в одном пакете. Давайте пока ограничимся извлечением изображения в градациях серого. Результат:

Итак, мы знаем, что произошло — событие, хотя мы думаем, что у нас есть текст на белом фоне, GammaCV интерпретирует его как текст на черном фоне, а разница между цветами слишком мала, чтобы обнаружить края. Почему это происходит? Давайте проверим первый пиксель исходного изображения. Чтобы получить данные из tensor, нам нужно использовать его метод get. А поскольку это трехмерный тензор, каждый вызов будет получать значение канала, и нам нужно указать три координаты для каждого канала:

console.log(
  input.get(0, 0, 0),
  input.get(0, 0, 1),
  input.get(0, 0, 2),
  input.get(0, 0, 3)
);
// 0 0 0 0

Это не очень увлекательно, не так ли? Тем не менее, он выполняет свою работу. Как оказалось, фон нашего изображения прозрачен, на что указывает ноль. В чем проблема? Он также интерпретируется как черный (что имеет смысл, если непрозрачность равна 0), а применение оттенков серого GammaCV (как и любая другая операция на самом деле) игнорирует непрозрачность, тем самым преобразуя, казалось бы, белые пиксели в черные, и, по сути, разрушая наш выход.

Как с этим справиться? Что ж, к сожалению, у GammaCV нет готового метода для решения такой ситуации, но у него есть отличный API для создания наших собственных пользовательских операций. Давайте попробуем!

Во-первых, нам нужно зарегистрировать операцию. Поскольку мы хотим использовать эту пользовательскую операцию в нашем конвейере, давайте завернем ее в функцию стрелки:

import * as gm from "gammacv";

const removeTransparency = () => gm.RegisterOperation("removeTransparency");

На этом шаге мы просто создаем экземпляр операции, из которого мы можем связать методы, которые будут определять ее нужным нам образом. Давайте продолжим с определением ввода:

import * as gm from "gammacv";

const removeTransparency = () => gm.RegisterOperation("removeTransparency")
  .Input("tSrc", "uint8");

Метод Input принимает два аргумента: первый — имя, по которому в дальнейшем будут извлекаться данные, а второй — тип данных — в нашем случае это uint8. Что касается имени, часть Src является произвольной и может быть тем, что лучше всего описывает данные, часть t является хорошей практикой для дальнейшего различения входных данных. Также стоит упомянуть, что операция может принимать несколько входных данных. Далее, давайте определим вывод:

import * as gm from "gammacv";

const removeTransparency = () => gm.RegisterOperation("removeTransparency")
  .Input("tSrc", "uint8")
  .Output("uint8");

Это довольно просто. Операция может иметь только одно возвращаемое значение, и это будет uint8. Нам также нужно определить форму возвращаемого значения, аналогичную уже определенным нами тензорам:

import * as gm from "gammacv";

const removeTransparency = () => gm.RegisterOperation("removeTransparency")
  .Input("tSrc", "uint8")
  .Output("uint8")
  .SetShapeFn(() => [HEIGHT, WIDTH, 4]);

В этом случае мы будем перебирать пиксели один за другим, обрабатывая каждый при необходимости, и возвращать тензор той же формы. В этом случае это будет [HEIGHT, WIDTH, 4], где 4 снова представляет количество каналов. Мы могли бы подумать о случае, когда мы хотели бы вычислить среднее значение всех пикселей в изображении; в этом случае мы хотели бы вернуть только один пиксель, поэтому это будет [1, 1, 4]. Если бы мы хотели иметь пользовательскую функцию оттенков серого, которая возвращала бы одно значение оттенков серого вместо каналов RGBA, возвращаемая форма была бы [HEIGHT, WIDTH, 1].

Далее, поскольку пользовательские операции используют шейдеры GLSL для обработки, нам нужно загрузить фрагмент, который является предопределенной функцией, которую мы сможем использовать в самом ядре шейдера. В этом случае мы собираемся использовать функцию pickValue (подробнее о ней позже):

import * as gm from "gammacv";

const removeTransparency = () => gm.RegisterOperation("removeTransparency")
  .Input("tSrc", "uint8")
  .Output("uint8")
  .SetShapeFn(() => [HEIGHT, WIDTH, 4])
  .LoadChunk("pickValue");

Что делает pickValue? Наша функция ввода GLSL будет получать координаты пикселя, pickValue позволяет получить его значения. Перейдем к самой функции GLSL. Поместим код в отдельный файл kernel.glsl:

vec4 operation(float y, float x) {
  vec4 data = pickValue_tSrc(y, x);
}

Здесь происходит несколько вещей, так что давайте остановимся на мгновение. Во-первых, vec4. Это вектор из четырех чисел одинарной точности с плавающей запятой. Почему четыре? Потому что вектор в этом случае представляет собой пиксель, представленный в формате RGBA, который состоит из четырех чисел. Функция operation получает два аргумента, y и x позиции текущего пикселя в изображении. Чтобы получить фактическое значение, нам нужно использовать pickValue. Обратите внимание, что у каждого входа есть своя функция для извлечения значения — мы не передаем входные данные функции, а GammaCV генерирует отдельную функцию для каждого входа. Подробнее о функции pickValue можно прочитать здесь.

Получив значение RGBA пикселя, мы можем приступить к удалению прозрачности. Нам нужно принять цвет фона (также можно передать цвет фона в качестве аргумента операции — если вы хотите узнать, как это сделать, дайте мне знать в комментариях!). Для нашего варианта использования предположим, что он будет белым. Нам нужно значение для каждого из каналов RGBA:

vec4 operation(float y, float x) {
  vec4 data = pickValue_tSrc(y, x);

  return vec4(
    ?,
    ?,
    ?,
    1.0
  );
}

Канал alpha уже имеет значение 1.0 — это потому, что мы хотим полностью убрать непрозрачность, и каждый канал в результирующих пикселях должен иметь значение из замкнутого интервала от 0.0 до 1.0. А как насчет каналов RGB? Чтобы полностью удалить прозрачность, нам нужно смешать исходный цвет с белым пропорционально исходной непрозрачности. Итак, для каждого канала результат должен быть:

const channelValue = 
  (1.0 - pixelOpacity) * 1.0 + pixelOpacity * originalValue;

Что тут происходит? Мы берем исходное значение канала и умножаем его на исходную непрозрачность пикселя. Если непрозрачность не 100% (1.0), мы заполняем недостающее значение максимальным значением канала (то есть — белым, если учесть все каналы). Теперь мы можем добавить эту логику в нашу функцию, но нам нужно знать еще одну вещь — как получить доступ к значениям канала из объекта data? Это довольно просто — это data.r, data.g, data.b и data.a для RGBA соответственно. Теперь мы готовы собрать все это вместе и завершить ядро ​​GLSL:

vec4 operation(float y, float x) {
  vec4 data = pickValue_tSrc(y, x);

  return vec4(
    (1.0 - data.a) * 1.0 + data.a * data.r,
    (1.0 - data.a) * 1.0 + data.a * data.g,
    (1.0 - data.a) * 1.0 + data.a * data.b,
    1.0
  );
}

Отсюда мы можем перейти к завершению нашей пользовательской операции:

import * as gm from "gammacv";
import kernel from "./kernel.glsl";

const removeTransparency = () => gm.RegisterOperation("removeTransparency")
  .Input("tSrc", "uint8")
  .Output("uint8")
  .SetShapeFn(() => [HEIGHT, WIDTH, 4])
  .LoadChunk("pickValue")
  .GLSLKernel(kernel);

Если установка нашего проекта не поддерживает .glsl файлов, и мы не можем добавить их, ядро ​​можно указать в виде строки:

import * as gm from "gammacv";

const removeTransparency = () => gm.RegisterOperation("removeTransparency")
  .Input("tSrc", "uint8")
  .Output("uint8")
  .SetShapeFn(() => [HEIGHT, WIDTH, 4])
  .LoadChunk("pickValue")
  .GLSLKernel(`
    vec4 operation(float y, float x) {
      vec4 data = pickValue_tSrc(y, x);
    
      return vec4(
        (1.0 - data.a) * 1.0 + data.a * data.r,
        (1.0 - data.a) * 1.0 + data.a * data.g,
        (1.0 - data.a) * 1.0 + data.a * data.b,
        1.0
      );
    }
  `);

Нам не хватает только еще одной вещи для завершения пользовательской операции — нам нужно предоставить данные!

import * as gm from "gammacv";
import kernel from "./kernel.glsl";

const removeTransparency = (previous) =>
  new gm.RegisterOperation("removeTransparency")
    .Input("tSrc", "uint8")
    .Output("uint8")
    .SetShapeFn(() => [HEIGHT, WIDTH, 4])
    .LoadChunk("pickValue")
    .GLSLKernel(kernel)
    .Compile({ tSrc: previous });

Теперь мы можем проверить, правильно ли работает операция. А пока давайте проверим, что происходит, когда мы — как и раньше — пытаемся применить оттенки серого, но на этот раз на эффекте нашей операции. Вот как будет выглядеть наш рабочий конвейер в этом случае:

  const operations = R.pipe(
    (previous) => removeTransparency(previous),
    gm.grayscale
  )(input);

И вот эффект:

Просто чтобы проверить, как смешиваются цвета, давайте проверим, как это будет выглядеть без операции grayscale:

Это похоже на исходное изображение, именно то, что мы хотели. Теперь давайте применим все операции и проверим, является ли конечный результат удовлетворительным. Вот наш последний конвейер operations:

const operations = R.pipe(
  (previous) => removeTransparency(previous),
  gm.grayscale,
  (previous) => gm.gaussianBlur(previous, 7, 3),
  gm.sobelOperator,
  (previous) => gm.cannyEdges(previous, 0.25, 0.75)
)(input);

И вот эффект:

И это именно то, чего мы хотели добиться. Полный код и рабочую демонстрацию можно найти в песочнице кода.

Хотите больше компьютерного зрения в браузере? Ознакомьтесь с моими предыдущими историями на эту тему ​​и обязательно подпишитесь на меня — все еще впереди!

Я наставляю разработчиков программного обеспечения. Напишите мне на MentorCruise для долгосрочного наставничества или на CodeMentor для индивидуальных сессий.