
Olá a todos, hoje iremos desenvolver um aplicativo que determina a cor média de uma imagem em um stream separado e mostra uma prévia da imagem (útil ao criar formulários de upload de imagens).
Esta é uma nova série de artigos voltada principalmente para iniciantes. Não tenho certeza se esse material seria interessante, mas decidi tentar. Se tudo bem, vou filmar vidos, para quem está melhor absorvendo informações visualmente.
Pelo que?
Não há necessidade urgente para isso, mas definir as cores de uma imagem costuma ser usado para:
- Pesquise por cor
- Determinação do fundo da imagem (se não ocupar toda a tela, de forma a ser de alguma forma combinada com o resto da tela)
- Miniaturas coloridas para otimizar o carregamento da página (mostrar paleta de cores em vez de imagem compactada)
Nós vamos usar:
- Dactilografado
- React junto com Create React App - por que não? Criaremos rapidamente um ambiente de trabalho e seremos capazes de construir nosso projeto
- API HTML Drag and Drop - para arrastar uma imagem da área de trabalho para o navegador
- Trabalhadores da Web e Greenlet - para levar cálculos complexos em um segmento separado
- nomes de classe
- API de arquivo
- URLs de dados
Treinamento
Antes de começar a codificar, vamos descobrir as dependências. Suspeito que você tenha Node, js e NPM / NPX, então vamos começar a criar um aplicativo React em branco e instalar as dependências:
npx create-react-app average-color-app --template typescript
Teremos um projeto com a seguinte estrutura:

Para iniciar o projeto, você pode usar:
npm start
Todas as alterações irão atualizar automaticamente a página no navegador.
Em seguida, instale o Greenlet:
npm install greenlet
Falaremos sobre isso um pouco mais tarde.
Arrastar e soltar
Claro, você pode encontrar uma biblioteca conveniente para trabalhar com arrastar e soltar, mas no nosso caso será supérflua. A API Drag and Drop é muito fácil de usar e para nossa tarefa de "pegar" a imagem é suficiente.
Primeiro, vamos remover tudo desnecessário e fazer um modelo para nossa "zona de soltar":
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;
Se desejar, você pode separar a zona de lançamento em um componente separado, para simplificar, deixaremos assim.
Das coisas interessantes, vale a pena prestar atenção em onDrop, onDragEnter, onDragLeave.
- onDrop - ouvinte para o evento de soltar, quando o usuário solta o mouse sobre esta área, "solta" o objeto arrastado.
- onDragEnter - quando o usuário arrasta um objeto para a área de arrastar e soltar
- onDragLeave - o usuário arrastou o mouse para longe
O trabalhador para nós é onDrop, com a ajuda dele vamos receber uma imagem do computador. Mas precisamos de onDragEnter e onDragLeave para melhorar a UX, para que o usuário entenda o que está acontecendo.
Algum CSS para a zona de soltar:
App.css
.drop-zone {
height: 100vh;
box-sizing: border-box; // , .
}
.drop-zone-over {
border: black 10px dashed;
}
Nossa UI / UX é muito simples, o principal é mostrar a borda quando o usuário arrasta a imagem sobre a zona de soltar. Vamos modificar nosso JS um pouco:
/// ...
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>
);
/// ...
No decorrer da escrita, percebi que não seria supérfluo mostrar o uso do pacote classnames. Geralmente torna mais fácil trabalhar com classes em JSX.
Para instalar:
npm install classnames @types/classnames
No trecho de código acima, criamos uma variável de estado local e escrevemos o over e deixamos o tratamento de eventos. Infelizmente, acontece um pouco de lixo devido a e.preventDefault (), mas sem ele o navegador simplesmente abrirá o arquivo. E e.stopPropagation () nos permite ter certeza de que o evento não vai além da zona de soltar.
Se isOver for verdadeiro, uma classe será adicionada ao elemento da zona para soltar que exibe a borda:

Pré-visualização de imagem
Para exibir a visualização, precisamos lidar com o evento onDrop recebendo um link ( URL de dados ) para a imagem.
O FileReader nos ajudará com isso:
// ...
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();
}
// ...
Assim como em outros métodos, precisamos escrever preventDefault e stopPropagation. Além disso, para arrastar e soltar para funcionar, um manipulador onDragOver é necessário. Não o usaremos de forma alguma, mas tem que ser.
FileReader são parte da API File com a qual podemos ler arquivos. Os manipuladores de arrastar e soltar obtêm arquivos arrastados e usando reader.readAsDataURL podemos obter um link, que substituiremos no src da imagem. Usamos o estado local do componente para salvar o link.
Isso nos permite renderizar imagens como esta:
// ...
{fileData ? <img alt="Preview" src={fileData.toString()}></img> : null}
// ...
Para fazer com que tudo pareça bom, vamos adicionar alguns CSS para a visualização:
img {
display: block;
width: 500px;
margin: auto;
margin-top: 10%;
box-shadow: 1px 1px 20px 10px grey;
pointer-events: none;
}
Nada complicado, apenas definir a largura da imagem para que ela ficasse no tamanho padrão e pudesse ser centralizada usando a margem. ponteiro-eventos: nenhum usado para torná-lo transparente para o mouse. Isso nos permitirá evitar casos em que o usuário deseja reenviar a imagem e soltá-la na imagem carregada que não é uma zona para soltar.

Lendo uma imagem
Agora precisamos obter os pixels da imagem para que possamos destacar a cor média da imagem. Para isso, precisamos do Canvas. Tenho certeza de que podemos de alguma forma tentar analisar o Blob, mas o Canvas torna isso mais fácil para nós. A essência principal da abordagem é que renderizamos imagens no Canvas e usamos getImageData para obter os dados da própria imagem em um formato conveniente. getImageData usa argumentos de coordenadas para obter os dados da imagem. Precisamos de todas as imagens, então especificamos a largura e a altura da imagem a partir de 0, 0.
Função para obter o tamanho da imagem:
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,
};
}
Você pode alimentar a imagem Canvas usando o elemento Image. Felizmente, temos uma visualização que podemos usar. Para fazer isso, você precisará fazer uma referência ao elemento da imagem.
//...
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>
// ...
Tal finta com nossos ouvidos, estamos esperando o ref aparecer no elemento e a imagem é carregada usando fileData.
ctx!.drawImage(image, 0, 0);
Esta linha é responsável por renderizar uma imagem em um Canvas "virtual", declarada fora do componente:
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
Em seguida, usando getImageData, obtemos a matriz de dados da imagem que representa o Uint8ClampedArray.
ctx!.getImageData(0, 0, width, height).data
Os valores em que "fixado" estão no intervalo 0-255. Como você provavelmente sabe, este intervalo contém os valores das cores rgb.
rgba(255, 0, 0, 0.3) /* */
Apenas a transparência neste caso será expressa não em 0-1, mas em 0-255.
Pegue a cor da imagem
A questão ficou com os pequenos, ou seja, obter a cor média da imagem.
Como esta é uma operação potencialmente cara, usaremos uma linha separada para calcular a cor. Claro, esta é uma tarefa ligeiramente fictícia, mas servirá por exemplo.
A função getA takingColor é o "fluxo separado" que criamos com o 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(",")})`;
});
Usar o greenlet é o mais simples possível. Nós apenas passamos uma função assíncrona lá e obtemos o resultado. Há uma nuance subjacente que o ajudará a decidir se deve usar essa otimização. O fato é que o greenlet usa Web Workers e, de fato, essa transferência de dados ( Worker.prototype.postMessage () ), no caso a imagem, é bastante cara e é praticamente igual ao cálculo da cor média. Portanto, o uso de Web Workers deve ser equilibrado pelo fato de que o peso do tempo de computação é maior do que a transferência de dados para um thread separado.
Talvez neste caso seja melhor usar GPU.JS - execute cálculos no GPU.
A lógica de cálculo da cor média é muito simples, adicionamos todos os pixels no formato rgba e dividimos pelo número de pixels.

Fontes
PS: Deixe ideias, o que tentar, o que você gostaria de ler.