AxeCrafted Blog

Pensamentos errantes de uma mente errante.

EN | PT

Uma Tentativa em Pintar Com Números - Parte 2

Publicado em 1 de Janeiro de 2025

Vamos recapitular onde essa épica história parou: temos uma imagem saturada, com cores reduzidas e pinceladas que queremos transformar em uma composição de "Pintar com Números". O principal desafio é a abundância de pequenas regiões – se as deixarmos como estão, o resultado final será uma bagunça de pequenas áreas de pixels, quase impossível de rotular ou pintar.

Tentativa 1 - Transformações Morfológicas

Depois de consultar meu irmão gênio fotógrafo de pássaros, ele recomendou Transformações Morfológicas - operações fundamentais no processamento de imagens que são usadas para manipular a estrutura de uma imagem alterando sua "forma" (daí o termo morfológico). Para aplicá-las, é necessário uma imagem binária (preto e branco) e um "elemento estruturante" (um kernel).

Existem duas transformações essenciais e duas que as combinam:


  • Erosão: encurta as regiões brancas (primeiro plano) da imagem erodindo suas bordas.
  • Dilatação: expande as regiões brancas aumentando suas bordas.
  • Abertura: uma combinação de erosão seguida de dilatação - útil para preservar as formas dos objetos enquanto remove o ruído.
  • Fechamento: dilatação seguida de erosão - útil para fechar pequenas lacunas e buracos nos objetos.

Vamos visualizar isso para entender melhor as transformações.

Demonstração Básica de Transformações Morfológicas

Erosão e dilatação são relativamente diretas. Abertura e fechamento podem ser mais complicados, então pense neles em termos de suas transformações básicas:


  • Abertura primeiro erode pequenos objetos, depois dilatam para restaurar as formas principais, mas os objetos minúsculos que desapareceram permanecem ausentes.
  • Fechamento faz o oposto, primeiro preenchendo buracos e lacunas, depois erodindo apenas as partes que foram expandidas.

Demonstração de Transformações Morfológicas Compostas

Esse problema parece suspeitamente provável de se beneficiar de uma operação de abertura que remove pequenos pixels e regiões. Como as transformações morfológicas exigem imagens em tons de cinza, você deve aplicá-las ao canal de Valor no HSV. Em seguida, mescle o canal de Valor transformado de volta com os canais originais de Matiz e Saturação. O resultado dessa mescla pode produzir incompatibilidades de cores entre os canais, então uma maneira de retornar a uma paleta de 16 cores é atribuir cada pixel à cor mais próxima, como na parte 1.

def applyMorphologicalTransformationHSV(image, KERNEL_SIZE=3, ITERATIONS=3):
    # Convert to HSV color space
    hsv = cv2.cvtColor(image.astype(np.uint8), cv2.COLOR_RGB2HSV)
    h, s, v = cv2.split(hsv)

    kernelSize = (KERNEL_SIZE, KERNEL_SIZE)
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, kernelSize)

    # Apply morphological operations to the Value channel
    eroded = cv2.erode(v, kernel, iterations=ITERATIONS)
    dilated = cv2.dilate(eroded, kernel, iterations=ITERATIONS)

    # Merge channels back
    hsvProcessed = cv2.merge([h, s, dilated])
    processedImage = cv2.cvtColor(hsvProcessed, cv2.COLOR_HSV2RGB)

    return processedImage

def mapColorsToPalette(image, palette):
    # Reshape image for efficient color mapping
    h, w, c = image.shape
    reshaped_image = image.reshape(-1, 3)

    # Use KDTree for efficient nearest neighbor search
    tree = KDTree(palette)
    _, indices = tree.query(reshaped_image)
    closest_colors = palette[indices]

    # Reshape back to original image dimensions
    mapped_image = closest_colors.reshape(h, w, 3)
    return mapped_image

Abaixo está a imagem após aplicar a transformação. Diferentes iterações e tamanhos de kernel produzirão resultados variados – há um equilíbrio entre "regiões limpas" e "definição da imagem". Aumentar o tamanho do kernel cria áreas mais uniformes, mas torna a imagem menos reconhecível.

Imagem de Hallstatt Após a Transformação Morfológica

Ok, então isso funcionou? Em resumo: não. Depois de aplicar o mesmo método de componentes conectados da parte 1, a imagem resultante tem muito mais de 1000 regiões. Aumentar o tamanho do kernel reduz a contagem de regiões, mas reduz tanto o detalhe que a imagem mal se assemelha à original.

Tentativa 2 - Força Bruta?

O pensamento agora é: se houver uma maneira de encontrar cada região (e há, usando componentes conectados), pode-se iterar por cada região, calcular a área da região e, se a área for menor que um limiar, preencher essa área com a cor dominante fora da área. Em pseudocódigo:


  • Iterar por todas as cores.
    1. Selecionar pequenas regiões.
    2. Para cada uma dessas pequenas regiões:
      1. Identificar pixels circundantes.
      2. Calcular a cor circundante mais frequente.
      3. Alterar a região para essa cor na imagem original.

Isso apresenta alguns desafios, o principal sendo a velocidade de processamento. Primeiro de tudo, encontrar os componentes conectados para cada uma das 16 cores já é uma operação lenta. Além disso, esta imagem tem 1440 x 2560 pixels em RGB - quase 3,6 milhões de pixels por canal. Essa escala pode se tornar computacionalmente intensiva.

A naive approach (nested loops, full-array processing) took more than 10 minutes per color, though results were promising. Optimizing the procedure led to much faster performance:

Uma abordagem ingênua (loops aninhados, processamento de arrays completos) levou mais de 10 minutos por cor, embora os resultados fossem promissores. Otimizar o procedimento levou a um desempenho muito mais rápido:


  • Processamento Paralelo - usar ThreadPoolExecutor para encontrar componentes conectados para cada cor em paralelo acelera consideravelmente o fluxo de trabalho.
  • Sub-Arrays - cada pequena região pode ser reduzida apenas aos pixels da região e às coordenadas de sua caixa delimitadora (x0, x1, y0, y1) - tamanhos de arrays reduzidos reduzem significativamente os tempos de computação.
  • Evitar Loops Diretos - técnicas como convolução ou métodos do Open-CV ajudam a evitar loops iterativos pixel por pixel, como extrair os pixels fora das regiões com uma operação de dilatação.

O código final se parece assim:

def processSingleColor(image, color, minArea=500, margin=5):
    h, w, c = image.shape
    # Create a binary mask for the current color
    colorMask = np.all(image == color, axis=-1).astype(np.uint8)

    # Find connected components in the binary mask
    numLabels, labels, stats, centroids = cv2.connectedComponentsWithStats(
        colorMask, connectivity=4
    )

    # Collect small regions
    smallRegions = []
    for i in range(1, numLabels):
        area = stats[i, cv2.CC_STAT_AREA]
        if area < minArea:
            x, y, wr, hr, _ = stats[i]
            # Add margin to the bounding-box
            x0 = max(x - margin, 0)
            y0 = max(y - margin, 0)
            x1 = min(x + wr + margin, w)
            y1 = min(y + hr + margin, h)
            # Collect the region data
            smallRegions.append(
                {
                    'label': i,
                    'bbox': (x0, y0, x1, y1)
                }
            )

    return {"color": color, "smallRegions": smallRegions, "labels": labels}

def processSmallRegion(image, labels, regionInfo, color):
    label = regionInfo['label']
    x0, y0, x1, y1 = regionInfo['bbox']

    # Extract the sub-image
    subImage = image[y0:y1, x0:x1].copy()

    # Extract the sub-region mask
    subLabels = labels[y0:y1, x0:x1]
    regionMask = (subLabels == label).astype(np.uint8)

    # Create the mask for the dilated region
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    dilatedRegionMask = cv2.dilate(regionMask, kernel, iterations=1)
    borderMask = dilatedRegionMask & (~regionMask)

    # Get surrounding pixels
    surroundingPixels = subImage[borderMask.astype(bool)]

    # Exclude pixels that match the region color
    mask = ~np.all(surroundingPixels == color, axis=1)
    surroundingPixels = surroundingPixels[mask]

    if surroundingPixels.size == 0:
        dominantColor = color
    else:
        surroundingPixelsTuples = [tuple(pixel) for pixel in surroundingPixels]
        dominantColor = Counter(surroundingPixelsTuples).most_common(1)[0][0]

    return {
        "coordinates": [x0, y0, x1, y1],
        "regionMask": regionMask,
        "dominantColor": dominantColor
    }

def getRegionsParallel(image, palette, minArea=500, margin=5):
    regions = []
    with ThreadPoolExecutor() as executor:
        futures = [
            executor.submit(processSingleColor, image, color, minArea, margin)
            for color in palette
        ]
        for future in futures:
            regions.append(future.result())

    return [
        {"color": color["color"], "labels": color["labels"], "regionInfo": region}
        for color in regions
        for region in color["smallRegions"]
    ]

def getUpdatesParallel(image, regions):
    updates = []
    with ThreadPoolExecutor() as executor:
        futures = [
            executor.submit(processSmallRegion, image, region["labels"], region["regionInfo"], region["color"])
            for region in regions
        ]
        for future in futures:
            updates.append(future.result())

    return updates

def fillSmallRegions(image, palette, minArea=500, margin=10):
    regions = getRegionsParallel(image, palette, minArea=minArea, margin=margin)
    updates = getUpdatesParallel(image, regions)

    filledImage = image.copy()
    # Apply the updates to the original image
    for update in updates:
        x0, y0, x1, y1 = update["coordinates"]
        regionMask = update["regionMask"].astype(bool)
        dominantColor = np.array(update["dominantColor"], dtype=filledImage.dtype)

        # Apply dominant color to the masked area
        filledImage[y0:y1, x0:x1][regionMask] = dominantColor

    return filledImage

Finalmente, cada cor é pesquisada em paralelo para encontrar componentes conectados - qualquer região menor que um parâmetro MIN_AREA é coletada, juntamente com sua caixa delimitadora. Cada região também é processada em paralelo: a caixa delimitadora é usada para copiar essa área da imagem original, uma máscara é criada e uma operação de dilatação é usada para descobrir a cor dominante circundante. Uma vez computadas, todas as atualizações são aplicadas à imagem. Embora paralelizar essa etapa final de atualização seja mais complicado (devido ao acesso concorrente à memória), um único loop geralmente é rápido o suficiente.

Tempo final de processamento: 3,6 segundos. O resultado fala por si só:

Imagem de Hallstatt Após Remover Pequenas Regiões

Isso é realmente bom. As pequenas regiões desapareceram e a imagem preservou sua forma. Talvez uma imagem de Pintar com Números esteja a poucos passos de distância?

Talvez Pintar com Números

Gerar o Pintar por Números final é simples: use componentes conectados para encontrar regiões, rotule cada cor (por exemplo, verde = 1, verde claro = 2, etc.), calcule o centro de cada região para posicionar o índice de cor e desenhe contornos.

Vamos pular esse código por enquanto, porque ainda temos um problema. Este é o resultado:

Imagem de Hallstatt com Efeito Pintar por Números - Primeira Tentativa, Ainda Não Está Perfeita

Alguns problemas e o plano para resolvê-los:


  • Presença de Pequenas Regiões Não Removidas - esse problema provavelmente se deve à parte do código de cor dominante e ao uso de conectividade 4, o que às vezes faz com que a cor dominante corresponda à cor da região. Possíveis soluções:
    • Iteração Simples - aplique a etapa de remoção de pequenas regiões várias vezes e espere que as regiões restantes sejam eliminadas.
    • Excluir a Cor da Região da Busca pela Cor Dominante - use a segunda cor dominante em caso de correspondência.
  • Centralização dos Números - este é um problema complicado - para encontrar o centro da região, as primeiras ideias que vêm à mente são usar x/2 e y/2 da região - isso não funciona se a região tem um formato estranho. A segunda ideia é calcular o centróide da região - pense nele como o ponto de equilíbrio da região, onde você poderia equilibrar esta folha perfeitamente na ponta de um alfinete sem que ela caia, como um centro de massa. Assim como a primeira abordagem, isso não resolve todos os problemas - pense em um donut - seu centróide está fora das bordas do donut. Pesquisando como resolver isso:
    • Pólo de Inacessibilidade - este é um conceito de geografia definido como a localização do ponto mais distante em uma massa de terra - é o ponto que está mais distante de todas as bordas de uma região - isso garante que o ponto estará sempre dentro da região (se não houver buracos) - o desafio é implementar isso.
  • Regiões Grandes e Finas - esse defeito aparece nas bordas da casa do lado direito e do poste de luz - esta é uma região conectada fina, mas longa - já que o algoritmo anterior usa a área como critério para remover uma região, essas regiões longas mas finas não são removidas porque sua área é maior que o limiar. Aumentar o limiar não é uma opção porque também removerá outras regiões que não deveriam ser removidas. Possíveis abordagens:
    • Proporção Altura-Largura da Caixa Delimitadora - essas regiões podem ter uma grande altura mas uma largura pequena ou vice-versa - uma ideia é usar a proporção entre esses valores e um limiar para decidir se uma região é removida - se a proporção for muito pequena (altura muito maior que a largura, por exemplo), remova a região. O problema é que as regiões podem ter formatos estranhos. Por exemplo, uma região em forma de L não seria removida.
    • Proporção da Área da Região e da Área da Caixa Delimitadora - para compensar regiões com formatos estranhos, há uma abordagem alternativa que usa a proporção entre a região e sua caixa delimitadora. Dessa forma, uma região em forma de L teria uma caixa delimitadora grande, mas a área real da região seria pequena. Se a proporção for muito pequena, remova a região.

Na parte 3, nos aprofundaremos para refinar esses métodos e enfrentar os desafios restantes. E até lá, vamos orar.

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.