Pintar com Números - Vitória Afinal?
Postado em 12 de Dezembro de 2025
Após um hiato de 10 meses (inspirado pelo Togashi-sama), finalmente estamos de volta para a parte final da série mais esperada de 2025!
A pausa não foi planejada — eu simplesmente fiquei cansado de lutar com este problema e mudei minha atenção para outros projetos. Mas, depois de alguns dias de descanso do trabalho, a energia para enfrentar este desafio voltou com tudo. Às vezes, se afastar é exatamente o que você precisa para retornar com olhos renovados e entusiasmo.
A Parte 3 terminou em alta — criamos com sucesso um efeito de pintar com números que realmente funcionou. Ainda assim, eu não estava totalmente satisfeito com os resultados. O resultado estava muito pixelado e algumas escolhas de cor soavam... estranhas. Então, voltei para polir as coisas.
Alerta de spoiler: valeu a pena.
Se você perdeu os posts anteriores:
Vamos começar pelo resultado final?
Imagem Original
Prévia de Pintar Com Números

Prévia da Tela
Vamos mergulhar mais fundo no que mudou e como conseguimos isso.
Agrupamento de Cores e Espaços de Cor
A primeira grande mudança é, na verdade, simples: obter cores melhores e mais consistentes da clusterização K‑Means. A versão anterior usava o espaço RGB para agrupar cores semelhantes — o que, às vezes, gerava cores estranhas — e as cores escolhidas pelo algoritmo variavam muito a cada execução. Isso é esperado — o K‑Means é não determinístico —, mas os resultados variavam demais.
A razão para isso é que, no fim das contas, o K‑Means usa a distância Euclidiana para calcular o quão semelhante uma cor é de outra:
$$ ext{Distância} = \sqrt{(x_{2} - x_{1})^{2} + (y_{2} - y_{1})^{2} + (z_{2} - z_{1})^{2}} $$
Para ilustrar isso, vamos usar 3 cores em RGB como exemplo: vermelho escuro (100, 0, 0), amarelo escuro (100, 100, 0) e cinza escuro (100, 100, 100).
Em RGB, a distância de vermelho escuro para amarelo escuro é a mesma que a distância de amarelo escuro para cinza escuro - em um cenário hipotético, se K-Means tiver que escolher uma aproximação para amarelo escuro entre esses dois, poderia ser igualmente provável escolher vermelho escuro quanto cinza escuro - perceptualmente para humanos (pelo menos para mim) eles são muito diferentes um do outro.
Em contraste, o CIELAB tenta representar cores de uma forma mais sintonizada com a percepção humana. Há um canal L (luminosidade) que indica quão brilhante ou escura é a cor — variando de preto puro (0) a branco puro (100). O canal a representa o eixo vermelho–verde (valores negativos são verde, valores positivos são vermelho), e o canal b representa o eixo azul–amarelo (valores negativos são azul, valores positivos são amarelo).
No exemplo acima, quando convertemos para CIELAB, o amarelo escuro fica mais próximo do cinza do que do vermelho escuro porque compartilham luminosidade semelhante — e ir de amarelo para cinza implica um deslocamento menor nos eixos de cor do que ir de amarelo para vermelho (que é uma mudança completa de matiz). Isso faz com que o K‑Means agrupe cores perceptualmente semelhantes, e não apenas valores RGB matematicamente próximos.
O resultado prático? Converter de RGB para CIELAB antes da clusterização melhora significativamente a qualidade das cores finais e produz resultados mais consistentes. As cores parecem mais naturais e, ao executar o algoritmo várias vezes, as paletas ficam muito mais estáveis — nada de oscilações malucas a cada execução.
Remoção de Pequenas Regiões
Nosso método anterior, embora funcional, tinha algumas desvantagens. Primeiro, sua dependência de loops Python - embora estivéssemos usando ThreadPoolExecutor para paralelizar operações, ainda é um pouco lento. E segundo, a cor das pequenas regiões estava sendo determinada por "voto" (cor vizinha mais comum), o que funciona bem para pequenas regiões com apenas uma cor vizinha, mas tende a criar manchas estranhas para pequenas regiões que fazem fronteira com várias cores - uma estratégia melhor seria, se possível, preencher cada pixel com a cor mais próxima.
Na Parte 2, trabalhamos com transformações morfológicas — uma delas, a dilatação, tem o efeito de remover buracos. Operações de dilatação têm uma propriedade interessante: elas “preservam a cor”. Como a dilatação não é uma convolução baseada em médias (que poderiam alterar o espaço de cor), a imagem dilatada final retém as mesmas cores da imagem original. Isso ocorre porque, ao dilatar, se você tem um pixel $P$ com $N$ vizinhos, o novo valor $P^{\prime}$ é:
$$ P^{\prime} = \underset{(i, j) \in N}{\text{max}} P_{i,j} $$
Estamos apenas buscando um máximo local na vizinhança. O que podemos fazer, então, é:
- Transformar a imagem multi-canal (em RGB ou CIELab) em uma matriz onde cada cor é um rótulo - por exemplo, (255, 0, 0) é mapeado para 1 - isso cria uma imagem de canal único com rótulos de cores onde podemos aplicar dilatação;
- Ainda usamos a mesma abordagem de usar
connectedComponentsWithStatspara encontrar regiões conectadas e suas áreas - filtramos áreas menores que o limite de remoção e as usamos para criar uma máscara - essas regiões serão tratadas como buracos; - Então, aplicamos iterativamente dilatação até que não haja mais buracos;
- Finalmente, convertemos a representação de rótulo de volta para a representação de cor usando a paleta.
Aqui está um pequeno exemplo de como esse processo iterativo se parece:

Exemplo de Iteração de Dilatação
Claro, isso não foi tão simples quanto parece. Minhas primeiras tentativas com dilatação foram agressivas demais, fundindo regiões que deveriam permanecer separadas. Depois de alguma experimentação, descobri que controlar o número de iterações e usar um kernel mais conservador produzia os melhores resultados — o suficiente para preencher pequenos buracos sem destruir a estrutura da imagem.
Remoção de Regiões Finas
Esta foi provavelmente a parte mais feia do algoritmo anterior: fazer varreduras horizontais/verticais na imagem para remover faixas finas tinha o efeito colateral indesejado de deixar a imagem “quadrada”, já que as passagens eram retangulares — sem falar que era uma operação pesadíssima que precisava rodar iterativamente.
Aqui, novamente, vamos recorrer às transformações morfológicas: usaremos a abertura morfológica, que é uma erosão seguida de uma dilatação. Isso remove pequenos objetos do primeiro plano (menores do que o kernel). Na prática, se houver regiões conectadas por tiras finas, elas somem — e usamos a mesma dilatação de antes para preencher a área com a cor apropriada. Aqui está um exemplo de como essa operação se parece:

Exemplo de Abertura Morfológica
De quebra, isso às vezes “arredonda” as bordas — um efeito desejável: a imagem final deixa de parecer pixelada e ganha formas mais orgânicas.
Suavizando Bordas
Para garantir bordas orgânicas (e não serrilhadas), há um último passo opcional — borda pixelada me incomoda, confesso. Sabemos como funciona o desfoque: fazemos a média das cores dos pixels numa vizinhança. E se, em vez de média, usássemos a “mediana”? Isso preserva as cores e suaviza bordas duras. Depois, podemos rodar a remoção de pequenas áreas novamente para evitar artefatos.
Este é um exemplo de como um Filtro de Mediana age na imagem:

Exemplo de Desfoque de Mediana
Confesso que, no início, fiquei cético. “Outro desfoque? Sério?” Mas o filtro de mediana acabou sendo o toque final perfeito: suavizou as bordas irregulares sem transformar a imagem em papa. Às vezes, as soluções mais simples são as melhores.
Gerando Contornos
Antes, eu usava o findContours do OpenCV em cada canal de cor para desenhar as fronteiras. Dá para simplificar usando — de novo — transformações morfológicas: o Gradiente Morfológico. Basicamente, calculamos a diferença entre uma dilatação e uma erosão — isso corresponde às bordas. É bem mais leve do que buscar o contorno pixel a pixel de cada região.
Esta é uma visualização para ajudar a entender este processo:

Exemplo de Gradiente Morfológico
Resultados Finais
A parte final do script continua igual: usamos o “polo de inacessibilidade” para posicionar os números dentro das regiões a serem pintadas. O tamanho do número escala com a área da região (rótulos menores para áreas pequenas). A borda da imagem foi expandida para evitar corte de números.
Vamos comparar a abordagem antiga da Parte 3 com a nova versão refinada:
Resultado da Parte 3 (Pixelado)
Resultado Final (Suave)
A diferença é do dia para a noite. A nova versão tem bordas orgânicas e fluidas em vez de bordas pixeladas duras. As cores são mais consistentes e de aparência natural. As regiões são bem definidas sem serem artificiais. Isso é o que eu estava buscando o tempo todo.
Considerações Finais
Lembra como isso tudo começou? Eu quase caí em um site de golpe de pintar com números lá na Parte 1. O plano era simples: evitar ser enganado e criar algo que eu pudesse realmente usar para transformar minhas próprias fotos em projetos de pintar com números.
Missão cumprida.
Olhando para trás, aprendi que, às vezes, a solução “inteligente” não é a certa. A simulação de pincelada da Parte 1 parecia engenhosa, mas era dolorosamente lenta e produzia resultados medianos. As varreduras horizontal/vertical da Parte 3 eram um hack de força bruta que criava artefatos quadrados. Enquanto isso, as transformações morfológicas — uma técnica fundamental que eu inicialmente negligenciei — eram a solução elegante escondida à vista de todos.
A maior lição? Faça pausas. Sério. Passei semanas batendo cabeça nesses problemas, depois me afastei por 10 meses. Quando voltei, as soluções pareciam óbvias. Espaço de cores CIELAB? Claro. Transformações morfológicas para tudo? Por que não pensei nisso antes? Às vezes, o cérebro precisa de tempo para processar as coisas em segundo plano.
Eu usaria isso? Com certeza. Na verdade, já tenho algumas fotos na fila para imprimir. Vou transformar isso em um negócio e competir com esses sites de golpe? Talvez. A ideia de oferecer uma alternativa legítima é tentadora — ajudar pessoas a criarem pinturas com números personalizadas a partir das próprias fotos, sem medo de fraude. Vamos ver onde isso dá.
O Código
Se você quiser experimentar isso, o código completo está disponível no GitHub:
Repositório do Gerador de Pintar com Números
O repositório inclui: - Implementação completa em Python com todas as otimizações - Imagens de exemplo e resultados - Documentação sobre ajuste de parâmetros para diferentes tipos de imagem
Sinta-se livre para experimentar, melhorar ou usar para seus próprios projetos. Se você criar algo legal, adoraria ver!
Vitória, de Fato
Então, essa é vitória "afinal"? O ponto de interrogação no título sugere que ainda não estou totalmente certo. Mas sabe de uma coisa? Estou declarando vitória. Depois de quatro partes, 10 meses, incontáveis tentativas fracassadas e mais transformações morfológicas do que qualquer pessoa deveria razoavelmente encontrar, estou satisfeito com este resultado.
Não é perfeito - nenhum algoritmo de processamento de imagem jamais é. Mas é bom o suficiente, e às vezes bom o suficiente é exatamente o que você precisa.
Obrigado por acompanhar esta jornada. Agora, se me dá licença, tenho umas imagens para pintar.