AxeCrafted Blog

Pensamentos errantes de uma mente errante.

EN | PT

Uma Tentativa de Pintar com Números - Parte 1

Publicado em 3 de novembro de 2024

Em algum momento, enquanto você mexia no Instagram (ou talvez em outra plataforma — sem julgamentos), aposto que você recebeu um anúncio de “Pintar com Números”. Esses são kits de pintura em que você recebe uma imagem em preto e branco com contornos e números. Você precisa pintar cada número com uma cor e, ao final, terá uma pintura completamente colorida que você mesmo fez (bem, quase).

O direcionamento de mídia está bem avançado hoje em dia, e é claro que sou o tipo de pessoa que cai nessas coisas. Por outro lado, para o meu próprio bem, também gosto de deixar as coisas “cozinhando” em uma aba por alguns dias ou semanas antes de tomar uma decisão de compra, então não compro nada por impulso. Isso me salvou desta vez porque, depois de duas semanas, o site que eu havia aberto (que parecia bem legítimo) simplesmente não existia mais. Mais um daqueles golpes em que você compra algo e provavelmente não recebe nada (ou um tijolo).

Então comecei a me perguntar: e se eu mesmo conseguisse fazer isso? Eu certamente não gostava da maioria das pinturas que vi naqueles sites — talvez eu pudesse usar minhas próprias fotos — e se eu tivesse algo que funcionasse, talvez eu pudesse até vender e disponibilizar para quem também quisesse criar sua própria versão de pintar com números, com o benefício adicional de não precisar perder um tempo tedioso verificando a legitimidade dos sites.

O plano era bem simples na minha cabeça.

O Plano

  1. Encontrar uma boa imagem;
  2. Limitar a paleta de cores a poucas cores;
  3. “Posterizar” a imagem para criar regiões de cor distintas;
  4. Rotular cada região e contorná-la;
  5. Lucro.

Como em todo projeto em que você não sabe muito, a gente subestima grosseiramente a complexidade — afinal, somos humanos e gostamos de acreditar que somos um pouco mais espertos do que realmente somos. Nesse ponto, eu estava no topo da curva de confiança:

Efeito Dunning-Kruger

Aumentando a Saturação Usando HSV

Dito isso, vamos começar. A ideia é usar Python e bibliotecas como OpenCV para processar fotos que tirei. O objetivo final é transformar esta foto em algo com um espaço de cores limitado (ou seja, apenas algumas cores) e regiões claramente definidas, que eu possa pintar depois. Para a imagem, escolhi uma que tirei em Hallstatt, na Áustria, simplesmente porque gosto dela e acho que funcionaria bem:

Foto de Casas em Hallstatt

Minhas primeiras tentativas nem foram com código, mas sim no Photoshop, brincando com filtros e cores. Infelizmente, não encontrei uma “bala de prata” que resolvesse todos os problemas, mas pude aprender algumas coisas. A primeira coisa que você provavelmente vai notar é que esta imagem está meio “chapada” — falta aquele “tchã”. Está muito sem graça, muito dessaturada. Saturação é a palavra-chave aqui. Já que um dos passos aqui é “reduzir o espaço de cores”, quando você tem uma imagem muito dessaturada, todas as cores finais vão parecer acinzentadas, e no final você acaba convertendo a imagem em uma em escala de cinza.

A solução é aumentar a saturação primeiro. A maneira mais simples de fazer isso é converter a imagem de RGB para HSV. Existe muita matemática envolvida, mas por ora, vamos ficar felizes que o OpenCV faz tudo isso para nós.

HSV significa Hue, Saturation e Value — um modelo de cores que representa cada cor em termos de tonalidade (hue), vibração (saturation) e brilho (value). Em vez de representar a imagem em valores de Vermelho, Verde e Azul para cada canal, o HSV usa um canal direto de Saturação. Então, se escalarmos esse canal por algum fator e garantirmos que ele permaneça entre 0 e 255 (8 bits), conseguimos aumentar a saturação e dar cores mais vivas à imagem:

def saturateImage(image, SCALE_FACTOR=2.5):
    # Convert image from RGB to HSV color space
    hsvImage = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(np.float32)

    # Scale the Saturation channel
    hsvImage[:, :, 1] *= SCALE_FACTOR
    # Clip saturation values to be within the allowable range
    hsvImage[:, :, 1] = np.clip(hsvImage[:, :, 1], 0, 255)

    # Convert back from HSV to RGB
    saturatedImage = cv2.cvtColor(hsvImage.astype(np.uint8), cv2.COLOR_HSV2RGB)
    return saturatedImage

Foto Após Aplicar Filtro de Saturação

Bem melhor. Esta imagem tem muitas cores únicas e, como pretendo pintá-la, seria bom reduzi-la para um número gerenciável — algo como uma potência de 2, provavelmente 16, já que 8 é muito pouco e 32 é demais. Poderíamos tentar pegar amostras de pixels para formar a paleta ou definir manualmente, mas isso parece chato e não escalável. A maneira mais rápida seria extrair a paleta de cores automaticamente.

Extraindo uma Paleta de Cores com K-Means Clustering

A ideia agora é identificar quais cores melhor representam a imagem, para que ao reduzirmos o espaço de cores, a aparência geral seja preservada. Isso soa muito como um problema de clustering, então vamos aplicar o algoritmo K-Means para encontrar 16 cores que melhor representem a imagem.

O K-Means é um algoritmo que particiona dados em K clusters distintos, baseado em semelhança de características. No nosso caso, ele agrupa cores parecidas, permitindo identificar as cores mais representativas da imagem. Para acelerar o processo, podemos reduzir a imagem, pois isso preserva suas cores de forma geral, e não precisamos das formas exatas para o K-Means.

def getColorPalette(image, RESIZE_FACTOR=5, N_CLUSTERS=16):
    # Resize image to speed up K-Means processing
    pixels = cv2.resize(
        image,
        (int(width / RESIZE_FACTOR), int(height / RESIZE_FACTOR))
    ).reshape(-1, 3)

    # Apply K-Means to find cluster centers representing the main colors
    kmeans = KMeans(n_clusters=N_CLUSTERS)
    kmeans.fit(pixels)
    colors = kmeans.cluster_centers_.astype(int)
    return colors

Paleta de Cores Extraída com K-Means

Isso nem sempre resultará exatamente nas mesmas cores, mas será próximo o suficiente para que, se reconstruirmos a imagem usando essa paleta, não notaremos muita diferença. Agora, a ideia é reduzir o espaço de cores da imagem para corresponder à paleta. Para cada pixel, encontramos a cor mais próxima da paleta. Pense em “distância” aqui como quão similar uma cor é da outra.

Se você realmente quiser pensar na matemática, imagine as cores como vetores em um espaço 3D, em que cada eixo representa uma cor (por exemplo, RGB — Vermelho, Verde, Azul). Qualquer cor, como esta aqui, pode ser descrita pelos comprimentos dos vetores nesses três eixos — no caso, (70, 130, 180). Para calcular a distância entre essa cor e, digamos, esta outra (180, 70, 94), basta usar a distância euclidiana: ( d = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2 + (z_2 - z_1)^2} ), que aqui resulta em 154,27.

Reduzindo as Cores da Imagem para Pintar com Números

def recolorImageWithPalette(image, palette):
    # Flatten the image array to a list of pixels
    pixels = image.reshape(-1, 3)

    # Compute the distance between each pixel and the palette colors
    distances = cdist(pixels, palette, 'euclidean')

    # Find the index of the closest palette color for each pixel
    closestPaletteColors = palette[np.argmin(distances, axis=1)]

    # Reshape the array back to the image dimensions
    recoloredImage = closestPaletteColors.reshape(image.shape)
    return recoloredImage

E não é incrível como apenas 16 cores podem recriar a imagem tão bem? Veja:

Imagem com Paleta de 16 Cores Aplicada

Um efeito colateral interessante desse processo é que já temos regiões relativamente bem definidas em algumas partes, como o céu. Entretanto, ainda há algumas alterações de cor “erráticas” do lado esquerdo, especialmente no muro de pedra, e vamos precisar resolver isso em seguida.

Suavizando as Regiões de Cor Usando Simulação de Pincel

Até aqui tudo correu sem muitos problemas — eu estava surfando na onda de confiança no topo da curva Dunning-Kruger. Mas aí veio a realidade: como transformar isso em uma imagem “pintada com pincel”? A ideia principal na minha cabeça era: eu precisava “pincelar” a imagem, selecionando uma área suficientemente grande, calculando a média de cores nessa área e então preenchendo com a cor da paleta mais próxima dessa média. Isso, em teoria, eliminaria pixels isolados de cores diferentes e criaria um efeito semelhante a um traço de pincel.

No entanto, as coisas não são tão simples. Começar com uma região quadrada deixou a imagem final muito pixelada — se meu objetivo fosse pixel art, estaria perfeito, mas eu queria outra coisa. Então, decidi usar regiões circulares, um pouco mais complexas, mas que parecem mais naturais.

Mesmo com regiões circulares, passar pela imagem com um raio fixo resultou em algo borrado. Para chegar ao efeito que eu queria, precisei percorrer pixel a pixel, o que tornou tudo muito mais pesado computacionalmente — e eu não sou a pessoa mais paciente. Depois de muito teste, descobri que equilibrar o tamanho do pincel em relação ao tamanho e aos detalhes da imagem podia resultar em algo realmente com cara de pintura para colorir.

Imagem com Simulação de Efeito de Pincel

Como você pode ver, o efeito começa a aparecer. Ajustando o tamanho do “pincel”, a imagem final foi ficando mais uniforme, como se tivesse sido pintada. Mas, claro, esse processo foi só o começo dos desafios de verdade.

def findClosestPaletteColor(color, palette):
    # Calculate the Euclidean distance between the given color and all colors in the palette
    distances = cdist([color], palette, 'euclidean')
    return palette[np.argmin(distances)]

def createCircularMask(radius):
    # Create a mask with a circular shape
    mask = np.zeros((2 * radius + 1, 2 * radius + 1), dtype=bool)
    center = radius
    for y in range(2 * radius + 1):
        for x in range(2 * radius + 1):
            if (x - center) ** 2 + (y - center) ** 2 <= radius ** 2:
                mask[y, x] = True
    return mask

def recolorImageWithRadius(image, palette, RADIUS=5):
    # Get image dimensions
    h, w, _ = image.shape
    # Copy the original image to avoid modifying it directly
    smoothedImage = np.copy(image)
    # Create a circular mask for applying the brush effect
    circularMask = createCircularMask(RADIUS)

    # Iterate over each pixel in the image
    for y in range(0, h, 1):
        for x in range(0, w, 1):
            # Define the region around the current pixel, with boundary checks
            y1, y2 = max(0, y - RADIUS), min(h, y + RADIUS)
            x1, x2 = max(0, x - RADIUS), min(w, x + RADIUS)

            # Extract the current region and apply the circular mask
            region = image[y1:y2, x1:x2]
            mask = circularMask[:y2 - y1, :x2 - x1]
            circularRegionPixels = region[mask]

            # Calculate the average color of the selected region
            meanColor = np.mean(circularRegionPixels.reshape(-1, 3), axis=0)
            # Find the closest color from the palette
            closestColor = findClosestPaletteColor(meanColor, palette)
            # Apply the closest color to the corresponding region
            smoothedImage[y1:y2, x1:x2][mask] = closestColor

    return smoothedImage

(Tentando) Encontrar e Rotular as Regiões de Cor

Nesse ponto, eu já tinha uma imagem recolorida e suavizada. Mas agora vinha o próximo passo: como pego essa imagem, identifico as regiões coloridas e rotulo cada uma com o número correspondente à cor da paleta? Minha confiança começou a cair, e eu embarquei na famigerada descida da “curva de Dunning-Kruger”.

Minha primeira ideia foi usar o método “Connected Components” do OpenCV, que encontra grupos de pixels conectados com o mesmo valor. É possível escolher entre conectividade diagonal (8-connected) ou apenas horizontal/vertical (4-connected). O plano era iterar pelas 16 cores, mascarar a imagem para isolar cada cor e então encontrar cada região conectada para identificar as áreas da mesma cor.

Naturalmente, executei essa ideia e descobri que minha imagem final tinha mais de 9000 regiões. O quê?! Sim, ainda havia MUITAS regiões pequenas, quase de 1 pixel, mesmo na imagem suavizada, embora a olho nu ela parecesse decente. Definitivamente não era o resultado que eu esperava.

Veja só:

Imagem em Paleta Espectral Mostrando Defeitos

Consegue ver o problema? Primeiro, há muitas regiões realmente muito pequenas — analisando cada região conectada, é possível ver que algumas têm um ou dois pixels. Isso cria muitos problemas para encontrar limites e rotular as regiões. O segundo problema são as bordas — é possível notar que quase toda região tem uma borda fina de outra cor em volta — o tamanho dessas bordas (em número de pixels, altura e largura) é grande o suficiente para inviabilizar soluções simples do tipo “encontrar cada região menor que X e preenchê-la com a cor dominante ao redor”, pois essas regiões grandes nem seriam identificadas.

Mas esse é um problema para a próxima parte desta série. Te vejo lá.

Foto de Leonardo Machado

Leonardo Machado

Um cara do Brasil. Ama sua esposa, gatos, café e dados. Frequentemente encontrado tentando dar sentido aos números ou cozinhando algo duvidoso.