Outubro 2021
O comprimento de onda que nossos olhos podem detectar (o espectro de luz visível) é apenas uma fração do espectro de energia eletromagnético. As ondas mais curtas visíveis são as ondas no espectro do azul. Ondas com comprimento de onda mais curtos, como o ultra-violeta ainda são detectáveis, apenas não são visíveis ao olho humano. Na outra ponta do espectro visível temos as ondas vermelhas. Onda infravermelhas, como as usadas em controle-remoto são comunmente usadas, mas não vísiveis.
Se o espectro de luz vísivel for dividido, as cores predominantes são o vermelho, o verde e o azul (Red, Green, Blue - RGB). Essas cores são também chamadas de cores primárias. O modelo RGB é chamado de modelo de cor aditivo pois todas as outras cores são combinações das cores primárias. Por exemplo, o ciano é a mistura do azul puro com o verde puro.
Televisores e monitores de computador (atualmente praticamente indistinguiveis uns dos outros atualmente) criam cores usando as cores primárias da luz. Cada pixel (a menor unidade de reprodução de luz de um dispotivo) em um monitor começa como preto (ausência de luz). Quando as unidades de luz vermelha, verde e azul de um pixel são iluminadas simultaneamente, esse píxel se torna branco. Controlando a quantidade de cada um das unidades de luz, chamadas de canais de cor, nós podemos representar as cores do espectro visível em um monitor.
Atualmente, o valor da cor de cada pixel é armazenado usando os valores de cada canal do modelo RGB. Cada canal é representado por um byte (8 bits). Como um byte pode representar no máximo 256 valores, cada canal varia de 0 (ausência total da cor) a 255 (presença total da cor). Como temos 3 canais de cores, são necessários 3 bytes (24 bits) para cada pixel de uma imagem! Pode não parecer muito atualmente, mas antigamente processar essa quantidade de dados era um grande gargalo...
Além dos três canais de cor (RGB), o modelo RGB Alpha utilizado pelos computadores atuais adicionaram um novo canal para transparência (alpha). Caso o valor do canal seja 0 o pixel será completamente transparente, caso o canal tenha o valor seja 255 o canal será completamente opaco.
Usando essa representação em três canais mais o canal de transparência (alfa), 256 valores por canal, podemos representar:
Geralmente, imagens são descritas em forma de matrizes. Por exemplo, uma imagem 29x20 tem 29 linhas e cada linha tem 20 pixels, num total de 580 pixels! Em geral é isso que chamamos de resolução da imagem. Um monitor Full HD suporta uma resolução de 1920 x 1080 pixels. Um monitor 4K suporta uma resolução de até 3840 x 2129 pixels.
Uma imagem em JavaScript é representada por um array de valores. O array é organizado como uma sequência de pixels e cada pixel é organizado por uma sequência de seus quatro canais: R, G , B , alpha. Imagine que a imagem que você ve na tela do computador foi recortada, linha por linha de pixels e depois colada em uma longa tira.
Dessa forma, se tivéssemos uma imagem 4 x 5 pixels - 4 linhas de 5 pixels cada, teremos um total de 20 pixels. Logo o array que representa essa imagem no JavaScript terá 20 x 4 = 80 elementos! Para a nossa imagem acima (29x20) temos : 29 x 20 x 4 = 2320 elementos!
Por sorte, você não precisa se preocupar com quantos elementos uma imagem terá, pois as Higher-order Functions map, filter e reduce automaticamente percorrem todos os elementos de um array!
Filtros de imagem são manipulações dos pixels de uma imagem de modo a transforma-la mas sem descaracterizá-la por completo. Existem filtros complexos, tais como os filtros no Stories do Instagram, que requerem não apenas a manipulação pixel a pixel da imagem mas também a manipulação de várias características de uma imagem como um todo. Neste projeto específico vamos trabalhar apenas com filtros que manipulam os pixels de uma imagem, pois são mais simples de entender e implementar:
Nas próximas seções vamos apresentar a teoria de cada um dos filtros. Para os filtros negativo e escala de cinza também serão fornecidos exemplos para que vocês possam continuar o desenvolvimento dos demais filtros.
Todos os arquivos necessários para a implementação do projeto estão disponíveis aqui. Vocês podem desenvolver o projeto em uma máquina local, mas eu recomendo fortemente utilizar a REPL criada especialmente para o projeto.
O projeto tem a seguinte estrutura:
Os códigos usados para acessar os pixels de cada imagem já estão corretamente inseridos no arquivo script.js. Explicarei brevemente o que cada parte do código objetiva:
var idata = context.getImageData(0, 0, canvas.width, canvas.height);
negdata = idata.data.map(negativo);
idata.data.set(negdata);
context.putImageData(idata, 0, 0);
O filtro negativo nada mais é do que o inverso de cada canal de cor, exceto o canal alpha de transparência. Para fazer o inverso do canal basta subtrair 255 do valor atual dos canais R, G , B. Por exemplo, se o valor atual de um pixel é [80, 123, 14, 255]
, seu negativo será [175, 132, 241, 255]Como vamos transformar cada elemento de um array, podemos utilizar a função map do JavaScript.
const negativo = function(val, index){
if ((index+1) % 4 == 0){
return val;
}
else{
return 255 - val;
}
}
Veja que foi preciso colocar um critério para "pular" cada quarto elemento (que corresponde ao canal alpha, o qual não deve ser alterado). Para tanto, verificamos se o índice do elemento ( +1 , para ficar mais simples o raciocínio) é múltiplo de 4. Ou seja, todo os índices (+1) múltiplos de 4 -- 4, 8, 12, 16 , 20, etc. -- vão ser "pulados", ou seja, retornamos o valor do elemento. Para todos os outros, retornamos o valor do canal subtraido de 255.
Enquanto o filtro negativo operava em cada canal independentemente, o filtro de escala de cinza é um pouco mais sofisticado. A ideia central do filtro de escala de cinza é calcular a média dos valores de cada canal e atrubuir essa média ponderada a cada canal.
A média ponderada de cada canal é dada pela fórmula: media = R * 0.2126 + G * 0.7152 + B * 0.0722
Por exemplo, se o valor atual de um pixel é [240, 123, 230, 255]
, seu pixel em escala de cinza será:media = (240 * 0.2126) + (123 * 0.7152) + (230 * 0.0722) = 155.96
Como cada pixel só pode ter valores entre 0 e 255, sem resto fracionário, a fração .96 é descartada automaticamente.
O pixel resultante terá o valor de [155, 155, 155, 255]
No exemplo anterior utilizamos o map com os parâmetros val (o valor de cada elemento) e index (o índice de cada elemento). O valor nos permitia acessar cada canal de cor, de forma independente. Entretanto, para implementar o filtro em escala de cinza, nós precisamos ter conhecimento de todos os canais ao mesmo tempo e ser capaz de alterar esses canais simultaneamente.
Para tanto, nós vamos utilizar um terceiro parâmetro da função map, o próprio array!
const gray_scale = function (value, index, array) {
if ((index+1) % 4 === 0){
let r = array[index-3];
let g = array[index-2];
let b = array[index-1];
let gray = r * 0.2126 + g * 0.7152 + b * 0.0722;
array[index-3] = gray;
array[index-2] = gray;
array[index-1] = gray;
}
}
Aqui a estratégia geral é: ignoramos os canais R, G , B até chegar no canal alpha (sempre o canal com índice múltiplo de 4). A partir dai retrocedemos uma posição no array para acessar o canal B (index-1), duas posições para acessar o canal G (index - 2), e 3 posições para acessar o canal R (index - 3). Tendo o valor dos três canais, calculamos a média ponderada conforme a fórmula e posteriormente atribuimos o valor dessa média aos canais RGB (novamente acessados de acordo com o índice relativo de cada canal).
O filtro de sépia da uma cara antiga, "amarelada" às imagens. Para implementar o filtro de sépia, assim como no filtro de escala de cinza, é necessário ter conhecimento dos 3 canais de cores simultaneamente para cálculo de uma média.
A média ponderada de cada canal é dada pela fórmula:
novo_canal_vermelho = R * 0.393 + G * 0.769 + B * 0.189
novo_canal_verde = R * 0.349 + G * 0.686 + B * 0.168
novo_canal_azul = R * 0.272 + G * 0.534 + B * 0.131
Após o cálculo da nova média ponderada de cada canal, basta atribuir (vide exemplo escala de cinza) os novos valores dos canais ao array (em suas respectivas posições.
Enquanto o filtro de escala de cinza transforma uma imagem em tons de cinza, o filtro preto e branco transforma cada pixel da imagem em preto ou branco de acordo com um determinado parâmetro denominado separador.
A forma mais fácil (não necessariamente a melhor...) de calcular o valor separador é assumir que o separador vale 255 e a partir dai ajustar o separador para valores próximos que produzam um resultado semelhante.
Você deve primeiramente somar o valor de cada um dos canais (R+G+B). Caso essa soma seja maior do que o valor do separador, atribuia a todos os canais o valor de 255 (tornando o pixel branco). Caso contrário, se a soma for menor do que o separador, atribua a todos os canais o valor de 0 (tornando o pixel preto)
Nesse filto vocês também vão precisar ter acesso e manipular os três canais de cores simultaneamente.
Para esse filtro, basta trocar os valores dos canais de cores da seguinte forma:
Esse filtro deve ser implementado por 3 funções diferentes. A ideia é intensificar cada canal de cor em 10% a cada click.
Por exemplo, na função de intensificar o canal vermelho, basta multiplicar apenas o canal vermelho por 1.1. Você pode identificar qual canal está sendo alterado pelo map usando seu índice.