Всем привет, сегодня разработаем приложение которое определяет средний цвет изображения в отдельном потоке и покажем превью изображения (полезно при создании форм загрузки изображения).

Это новая серия статьей, которая по большей части предназначена для новичков. Я не уверен, что такой материал может быть интересен, но решил попробовать. Если ок пойдет, поснимаю видосиков, для тех кому лучше поглащать информацию визуально.

Зачем?


Какой-то острой необходимости в этом нет, но определение цветов изображение зачастую используется для:

  • Поиска по цвету
  • Определение заднего фона изображения (если оно не занимает весь экран, что бы хоть как-то сочеталось с остатком экрана)
  • Цветные миниатюры для оптимизации загрузки страницы (показывать цветную палитру вместо обжатого изображения)

Мы будем использовать:

  • Typescript
  • React наряду с Create React App — почему бы и нет? Мы быстро создадим рабочее окружения и сможем билдить наш проект
  • HTML Drag and Drop API — для перетаскивания изображения с рабочего стола в браузер
  • Web workers и Greenlet — для вынесения сложных вычислений в отдельный поток
  • classnames
  • File API
  • Data URLs

Подготовка


Прежде чем начать писать код, давайте разберемся с зависимостями. Я подозреваю, что у вас есть Node,js и NPM/NPX, поэтому давайте сразу перейдем к созданию пустого React приложения и установке зависимостей:

npx create-react-app average-color-app --template typescript

Мы получим проект с такой структурой:



Для запуска проект можно использовать:

npm start


Все изменения будут автоматически обновлять страницу в браузере.

Далее установим Greenlet:

npm install greenlet

О нем мы поговорим немного позже.

Drag and Drop


Конечно, можно найти удобную библиотеку для работы с Drag and Drop, но в нашем случае это будет излишнее. Drag and Drop API очень прост в использовании и для нашей задачи «ловли» изображения хватает с головой.

Для начала удалим все лишнее и сделаем заготовку нашей «дроп зоны»:

App.tsx

import React from "react";
import "./App.css";

function App() {
  function onDrop() {}

  function onDragOver() {}
 
  function onDragEnter() {}

  function onDragLeave() {}

  return (
    <div className="App">
      <div
        className="drop-zone"
        onDrop={onDrop}
        onDragEnter={onDragEnter}
        onDragLeave={onDragLeave}
      ></div>
    </div>
  );
}

export default App;

При желании можно выделить дроп зону в отдельный компонент, для простоты оставим так.
Из интересного, стоит обратить внимание на onDrop, onDragEnter, onDragLeave.

  • onDrop — слушатель события дроп, когда пользователь отпустит мышку над этой областью, «бросит» перетаскиваемый объект.
  • onDragEnter — когда пользователь перетаскивает объект в область drag and drop
  • onDragLeave — пользователь увел мышку

Рабочим для нас является onDrop, именно с помощью него мы будем получать изображение с компьютера. Но onDragEnter и onDragLeave нужны нам для улучшения UX, что бы пользователь понимал, что происходит.

Немного CSS для дроп зоны:

App.css

.drop-zone {
  height: 100vh;
  box-sizing: border-box; // Для того, что бы бордер не увеличивал размеры и не появлялся скрол.
}

.drop-zone-over {
  border: black 10px dashed;
}

UI/UX у нас очень простой, главное показать бордер когда пользователь тащит изображение над дроп зоной. Немного модифицируем наш JS:
/// ...

function onDragEnter(e: React.DragEvent<HTMLDivElement>) {
    e.preventDefault();
    e.stopPropagation();

    setIsOver(true);
  }

  function onDragLeave(e: React.DragEvent<HTMLDivElement>) {
    e.preventDefault();
    e.stopPropagation();

    setIsOver(false);
  }

  return (
    <div className="App">
      <div
        className={classnames("drop-zone", { "drop-zone-over": isOver })}
        onDrop={onDrop}
        onDragEnter={onDragEnter}
        onDragLeave={onDragLeave}
      ></div>
    </div>
  );

/// ...

По ходу написания, понял, что будет не лишний показать использование пакета classnames. Зачастую упрощает работу с классами в JSX.

Для его установки:

npm install classnames @types/classnames

В куске кода выше, мы создали переменную локального стейта и написали обработку событий over и leave. К сожалению получается немного мусорно из-за e.preventDefault(), но без этого никак иначе браузер просто откроет файл. А e.stopPropagation() позволяет нам убедиться, что событие не выйдет за рамки дроп зоны.

Eсли, isOver равен true, то к элементу дроп зоны добавляется класс который, отображает бордер:



Превью изображения


Для того, что бы отобразить превью, нам необходимо обработать событие onDrop получив ссылку (Data URL) на изображение.

FileReader поможет нам в этом:

// ...
  const [fileData, setFileData] = useState<string | ArrayBuffer | null>();
  const [isLoading, setIsLoading] = useState(false);

  function onDrop(e: React.DragEvent<HTMLDivElement>) {
    e.preventDefault();
    e.stopPropagation();

    setIsLoading(true);

    let reader = new FileReader();
    reader.onloadend = () => {
      setFileData(reader.result);
    };

    reader.readAsDataURL(e.dataTransfer.files[0]);

    setIsOver(false);
  }

  function onDragOver(e: React.DragEvent<HTMLDivElement>) {
    e.preventDefault();
    e.stopPropagation();
  }
// ...

Точно так же как и в других методах, нам нужно прописывать preventDefault и stopPropagation. Так же для работы Drag and Drop необходим обработчик onDragOver. Мы его использовать никак не будем, но он просто должен быть.

FileReader являются частью File API с его помощью мы можем читать файлы. В обработчики Drag and Drop попадают перетаскиваемые файлы и с помощью reader.readAsDataURL мы можем получить ссылку, которую подставим в src изображения. Для сохранения ссылки мы используем локальный стейт компонента.

Это позволяет нам отрендерить изображения следующим образом:

// ...
{fileData ? <img alt="Preview" src={fileData.toString()}></img> : null}
// ...


Для того, что бы все смотрелось красиво, добавим немного CSS для превью:
img {
  display: block;
  width: 500px;
  margin: auto;
  margin-top: 10%;
  box-shadow: 1px 1px 20px 10px grey;

  pointer-events: none;
}

Нечего сложного, просто задаем ширину изображению, что бы оно было стандартных размеров и его можно было отцентрировать за счет margin. pointer-events: none используя для того, что бы оно было прозрачным для мышки. Это позволит нам избежать случаев когда, пользователь хочет повторно закинуть изображение и бросает его на загруженное изображение которое не является дроп зоной.



Чтение изображения


Теперь нам необходимо получить пиксели изображения, что бы мы могли выделить средний цвет изображения. Для этого нам понадобится Canvas. Я уверен, что можно как-то попытаться и Blob распарсить, но Canvas нам позволяет сделать это проще. Основная суть подхода в том, что мы отрисовываем изображения на Canvas и с помощью getImageData получаем данные самого изображения в удобном формате. getImageData принимает аргументы координат, с которых взять данные изображения. Нам нужно все изображения, по этому мы указываем ширину и высоту изображения начиная от 0, 0.

Функция получения размера изображения:

function getImageSize(image: HTMLImageElement) {
  const height = (canvas.height =
    image.naturalHeight || image.offsetHeight || image.height);
  const width = (canvas.width =
    image.naturalWidth || image.offsetWidth || image.width);

  return {
    height,
    width,
  };
}

Скормить изображение Canvas, можно с помощью элемента Image. К счастью у нас есть превью, которое мы можем использовать. Для этого необходимо будет сделать Ref на элемент изображения.

//...  

const imageRef = useRef<HTMLImageElement>(null);
const [bgColor, setBgColor] = useState("rgba(255, 255, 255, 255)");

// ...
  useEffect(() => {
    if (imageRef.current) {
      const image = imageRef.current;
      const { height, width } = getImageSize(image);

      ctx!.drawImage(image, 0, 0);

      getAverageColor(ctx!.getImageData(0, 0, width, height).data).then(
        (res) => {
          setBgColor(res);
          setIsLoading(false);
        }
      );
    }
  }, [imageRef, fileData]);
// ...

 <img ref={imageRef} alt="Preview" src={fileData.toString()}></img>

// ...

Такой вот финт ушами, мы ожидаем появления ref на элемент и загрузку изображения за счет fileData.

 ctx!.drawImage(image, 0, 0);

Данная строчка отвечает за отрисовку изображения в «виртуальном» Canvas, объявленного за пределами компонента:

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

Затем используя getImageData, получаем массив данных изображения представляющий собой Uint8ClampedArray.

ctx!.getImageData(0, 0, width, height).data

Значения в котором «clamped» в диапазоне 0-255. Как вы наверняка знаете, в этом диапозоне лежат значения rgb цвета.

rgba(255, 0, 0, 0.3) /* красный с прозрачностью */

Только прозрачность в данном случае будет выражаться не в 0-1, а 0-255.

Получаем цвет изображения


Дело осталось за малыми, а именно получить средний цвет изображения.

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

Функция getAverageColor является тем самым «отдельным потоком» который мы создаем с помощью greenlet:

const getAverageColor = greenlet(async (imageData: Uint8ClampedArray) => {
  const len = imageData.length;
  const pixelsCount = len / 4;
  const arraySum: number[] = [0, 0, 0, 0];

  for (let i = 0; i < len; i += 4) {
    arraySum[0] += imageData[i];
    arraySum[1] += imageData[i + 1];
    arraySum[2] += imageData[i + 2];
    arraySum[3] += imageData[i + 3];
  }

  return `rgba(${[
    ~~(arraySum[0] / pixelsCount),
    ~~(arraySum[1] / pixelsCount),
    ~~(arraySum[2] / pixelsCount),
    ~~(arraySum[3] / pixelsCount),
  ].join(",")})`;
});

Использование greenlet максимально простое. Мы просто передаем туда асинхронную функцию и получаем результат. Под капотом есть один нюанс который поможет вам принять решение — стоит ли использовать такую оптимизацию. Дело в том, что greenlet использует Web Workers и по факту такая передача данных (Worker.prototype.postMessage()), в данном случае изображения, достаточно затратна и практически равно вычислению среднего цвета. Поэтому использования Web Workers стоит балансировать тем, что вес времени вычисления больше чем передача данных в отдельный поток.

Возможно в данном случае лучше использовать GPU.JS — запуск вычислений на gpu.

Логика подсчета среднего цвета очень простая, мы складываем все пиксели в формате rgba и делим на количество пикселей.



Исходники

P.S.: Оставляйте идеи, что попробовать, о чем бы хотелось почитать.