AxeCrafted Blog

Pensamentos errantes de uma mente errante.

EN | PT

Uma Tentativa em Pintar Com Números - Parte 3

Publicado em 25 de Janeiro de 2025

Nas partes anteriores desta série, tentamos várias técnicas para simplificar imagens e alcançar um efeito de “Pintar com Números”. Vimos como as transformações morfológicas não funcionaram como esperávamos, mas descobrimos que componentes conectados poderiam eliminar, de forma eficaz, regiões pequenas e indesejadas. Feito isso, estamos agora focados no problema das “regiões finas” que ainda persistem na imagem. Até o final deste post, também vamos mergulhar em detecção de contorno e rotulagem, para dar à nossa imagem aquele estilo clássico de “Pintar por Números”.

Removendo Regiões Finas

Quando pensamos em remover regiões finas, duas ideias iniciais podem vir à mente:


  • Razão de largura para altura. Uma região é considerada “fina” se sua largura é muito maior do que sua altura, ou vice-versa.
  • Área da região comparada à área do bounding box. Uma razão pequena significa que a região ocupa apenas uma fração do seu bounding box, sugerindo um formato “fino”.

Primeiro, considere a razão de largura para altura usando o bounding box de uma região. Se a largura do bounding box for muito maior do que sua altura (ou vice-versa), a região se qualifica como “fina”. Embora isso pareça promissor, na prática costuma ser menos eficaz. As regiões tendem a ter formatos irregulares ou inclinados, e mesmo uma “linha reta” pode não estar alinhada horizontal ou verticalmente. Como resultado, essa razão isolada acaba removendo apenas algumas poucas regiões problemáticas.

Depois, podemos comparar a área de pixels de uma região com a área do seu bounding box. Uma razão pequena indica que a região ocupa apenas uma fração do seu bounding box, classificando-a como “fina”. Isso funciona bem para filtrar formas como regiões em L, mas encontra dificuldade em lidar com formas complexas, esparsas ou repletas de buracos — como as folhas em uma árvore — onde a área total é pequena, mas espalhada em vários pequenos agrupamentos, que não são exatamente finos.

Exemplo de região esparsa com baixa razão de área Exemplo de região fina com alta razão de área

Observe estes dois exemplos. A região à esquerda é uma “região esparsa” proveniente de árvores, com uma razão de 26% entre área da região e bounding box — ou seja, 74% de seu bounding box é efetivamente “fundo”. Enquanto isso, a região à direita vem de uma cerca, ampliada, e tem apenas alguns pixels de largura, mas registra 37% nessa razão. Se usássemos um limiar de 30%, removeríamos incorretamente a região maior à esquerda (que queremos manter) mas falharíamos em remover a região menor e realmente fina à direita. Poderíamos tentar combinar múltiplas variáveis — como área total, razão largura/altura ou descritores de formato — mas o resultado frequentemente parece arbitrário e pode funcionar apenas para essa imagem em particular.

A abordagem lógica quando não surge uma solução elegante? Às vezes, o jeito é partir para a força bruta.

Removendo Regiões Finas com Varreduras

Ao cortarmos a imagem horizontalmente em linhas de 1 pixel de altura, cada linha vira uma série de segmentos coloridos com comprimentos distintos. Se algum segmento for menor que um certo limiar, substituímos pela cor dominante à esquerda ou à direita — “dominante” aqui significa o segmento vizinho com a maior largura. Vamos analisar um caso como exemplo.

Faixa Fina com 1 Pixel de Altura

Contando cores e regiões da esquerda para a direita, temos quatro segmentos: um azul-claro, um cinza, um marrom-escuro e, por fim, um pequeno “bege” que queremos remover. À esquerda, há um grande segmento marrom-escuro; à direita, há um pequeno segmento marrom-claro. Como o segmento à esquerda é maior, consideramos essa cor a dominante e a utilizamos para substituir a faixa bege. No final, essa técnica simples de força bruta resolve nosso problema.

O algoritmo funciona iterando sobre cada linha e criando uma versão deslocada por um pixel. Ao comparar a linha original com a deslocada, detectamos apenas os pontos de transição e marcamos os limites no primeiro e último índices de pixel. Para cada segmento entre esses limites, aplicamos a mesma lógica de remoção mencionada acima. Se precisarmos de uma varredura vertical, transpomos a imagem, executamos o mesmo procedimento e depois voltamos a transpor.

O código que implementa esse processo está demonstrado abaixo:

def removeThinRegions(image, minLength=5, direction='horizontal'):
    h, w, _ = image.shape

    # For vertical scanning, transpose the image to reuse horizontal logic
    transposed = False
    if direction.lower() == 'vertical':
        image = np.transpose(image, (1, 0, 2))  # Shape becomes (W, H, 3)
        h, w = w, h
        transposed = True

    for rowIndex in range(h):
        # Extract current row (or column, if transposed)
        row = image[rowIndex]

        # Find indexes for color transitions between regions
        transitions = np.any(row[:-1] != row[1:], axis=1)
        boundaries = np.where(transitions)[0] + 1
        boundaries = np.concatenate(([0], boundaries, [w]))

        # Compile all "strips" of same color in current row/column
        strips = []
        for boundaryIndex in range(len(boundaries) - 1):
            start = boundaries[boundaryIndex]
            end = boundaries[boundaryIndex + 1]
            stripLength = end - start
            strips.append({'start': start, 'end': end, 'length': stripLength, 'color': row[start]})

        # Identify strips smaller than threshold and define dominant color based on left/right neighbors
        stripsToRecolor = []

        for i, strip in enumerate(strips):
            if strip['length'] < minLength:
                # Determine left and right neighbors
                leftStrip = strips[i - 1] if i > 0 else None
                rightStrip = strips[i + 1] if i < len(strips) - 1 else None

                # Get lengths of neighboring runs
                leftLength = leftStrip['length'] if leftStrip else 0
                rightLength = rightStrip['length'] if rightStrip else 0

                # Choose fill color based on longer neighbor run
                if leftLength >= rightLength:
                    fillColor = leftStrip['color']
                elif rightLength > leftLength:
                    fillColor = rightStrip['color']

                stripsToRecolor.append((strip['start'], strip['end'], fillColor))

        # Recolor the identified thin strips
        for (start, end, fillColor) in stripsToRecolor:
            image[rowIndex, start:end] = fillColor

    # If the image was transposed, transpose it back to original orientation
    if transposed:
        image = np.transpose(image, (1, 0, 2))

    return image

def removeThinStrips2D(image, minLength=5, iterations=2):
    # Apply horizontal and vertical scan iteratively
    out = image.copy()
    for i in range(iterations):
        out = removeThinRegions(out, minLength=minLength, direction='horizontal')
        out = removeThinRegions(out, minLength=minLength, direction='vertical')
    return out

Depois de aplicar esse processo, você pode notar alguns artefatos, pois cada varredura só olha para a linha atual. No entanto, uma segunda passagem pelo algoritmo anterior (que remove regiões pequenas) normalmente resolve essas falhas. O resultado final — obtido rodando com limiar de 7 pixels por 3 iterações, seguido de 3 rodadas de remoção de regiões pequenas — fica assim:

Imagem de Hallstatt com Regiões Finas Removidas

Fantástico!

Agora, vamos seguir para a detecção de contornos, rotulagem e posicionamento dos rótulos.

Detecção de Regiões e Contornos

Regiões

Recapitulando rapidamente: agora temos uma imagem dividida em regiões bem definidas, de tamanho razoável, com uma paleta de cores limitada. Nosso objetivo é gerar uma versão em preto e branco que mostre o contorno de cada região e depois rotular cada região pelo número de sua cor (por exemplo: azul = 1, marrom = 2, etc.). Com isso, poderemos pintar por cima, como numa imagem real de “Pintar por Números”.

O processo se divide em: primeiro, detectamos as regiões (via Componentes Conectados); segundo, desenhamos os contornos entre essas regiões; terceiro, encontramos o “polo de inacessibilidade” de cada região; e quarto, usamos esse polo para posicionar um rótulo que identifica a cor da região. Podemos até mesmo escalonar o tamanho da fonte do rótulo de acordo com a área da região — rótulos menores para áreas menores, maiores para áreas maiores.

Já exploramos Componentes Conectados antes. Em essência, criamos uma máscara para cada cor da paleta e então executamos connectedComponentsWithStats para localizar e medir cada região contígua (usando conectividade 4: cima, baixo, esquerda, direita). A partir daí, coletamos as áreas das regiões para referência e construímos um mapeamento de rótulos para cores.

def generateLabelImageAndMapping(image, palette):
    h, w, c = image.shape
    # Prepare output
    labelImage = np.zeros((h, w), dtype=np.int32)
    labelToPaletteIndex = []
    labelAreas = []

    currentLabel = 1
    # For each unique color, find connected components
    for paletteIndex, color in enumerate(palette):
        colorMask = cv2.inRange(image, color, color)
            numLabels, labels, stats, _ = cv2.connectedComponentsWithStats(
            colorMask, 
            connectivity=4.
        )

        # Label each region found (skip 0 = background)
        mask = labels > 0
        labelImage[mask] = np.arange(currentLabel, currentLabel + numLabels - 1)[labels[mask] - 1]
        labelToPaletteIndex.extend([paletteIndex] * (numLabels - 1))
        labelAreas.extend(stats[1:, cv2.CC_STAT_AREA])
        currentLabel += numLabels - 1

    # Convert labelToPaletteIndex and labelAreas to dictionaries
    labelToPaletteIndex = {label: paletteIndex for label, paletteIndex in enumerate(labelToPaletteIndex, start=1)}
    labelAreas = {label: area for label, area in enumerate(labelAreas, start=1)}

    return labelImage, labelToPaletteIndex, labelAreas

Uma otimização valiosa que descobri foi usar cv2.inRange para fazer mascaramento de cores em vez de operações com NumPy. É visivelmente mais rápido, a ponto de eu ter revisado partes anteriores do código que precisavam de máscaras. Depois de executar componentes conectados, pode ser tentador percorrer cada rótulo e criar máscaras repetidamente, mas, na prática, trabalhar com arrays e minimizar loops explícitos se mostra mais eficiente.

Contornos

Para desenhar contornos, o OpenCV faz quase todo o trabalho com a função findContours. Usar RETR_LIST garante que você pegue todos os contornos, incluindo buracos e contornos aninhados, enquanto CHAIN_APPROX_SIMPLE simplifica os dados do contorno e agiliza a execução — uma combinação ideal quando você não precisa de contornos com fidelidade de pixel exata.

def drawContour(image, palette, COUNTOUR_THICKNESS = 1):    
    h, w, _ = image.shape

    # Start with a blank white image
    outputImage = np.full((h, w, 3), 255, dtype=np.uint8)
    boundaryMask = np.zeros((h, w), dtype=np.uint8)

    # For each label, find and draw external contours on boundary_mask
    for color in palette:
        colorMask = cv2.inRange(image, color, color)
        contours, _ = cv2.findContours(colorMask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
        cv2.drawContours(boundaryMask, contours, -1, 255, COUNTOUR_THICKNESS)

    # Convert boundary_mask into black contours on our white image
    outputImage[boundaryMask == 255] = (0, 0, 0)

    return outputImage

O resultado FINALMENTE está com cara de “Pintar com Números”:

Imagem de Hallstatt com Contornos Desenhados

Polos de Inacessibilidade e Rotulagem

“Polo de inacessibilidade” é um conceito geográfico que descreve o ponto de uma região mais distante de suas fronteiras. Ele é perfeito para posicionar rótulos, pois um centro geométrico simples nem sempre garante que o texto vai ficar dentro da região.

Uma forma de visualizar isso é imaginar que cada fronteira da região seja “incendiada” ao mesmo tempo — o fogo avança até convergir no ponto mais protegido das chamas. De forma equivalente, é como se aplicássemos erosão sucessivamente até sobrar apenas um único pixel. Felizmente, o OpenCV implementa essa técnica com distanceTransform. Um bom exemplo é o mapa do meu estado, Minas Gerais:

Mapa em preto e branco do estado de Minas Gerais Mapa do estado de Minas Gerais com distance transform aplicado

Fazer tudo em paralelo acelera ainda mais esses cálculos, e ajustar o tamanho e a espessura da fonte do rótulo de acordo com a área da região acaba sendo surpreendentemente simples.

def findCoordinate(labelImage, label, paletteIndex, labelAreas, minFontScale=0.5, maxFontScale=1.5, minThickness=1, maxThickness=5):
    regionMask = cv2.inRange(labelImage, label, label)

    # Distance transform to find a point farthest from the boundary
    distTransform = cv2.distanceTransform(regionMask, cv2.DIST_L2, 5)
    _, _, _, maxLoc = cv2.minMaxLoc(distTransform)
    centerX, centerY = maxLoc

    # Retrieve area for dynamic text scaling
    area = labelAreas[label]
    minArea, maxArea = min(labelAreas.values()), max(labelAreas.values())
    # Scale font size and thickness by area
    areaFraction = (area - minArea) / float(maxArea - minArea)
    fontScale = minFontScale + areaFraction * (maxFontScale - minFontScale)
    thickness = int(minThickness + (areaFraction * (maxThickness - minThickness)))

    return str(paletteIndex), (centerX, centerY), fontScale, thickness

def getCoordinatesParallel(labelImage, labelToPaletteIndex, palette):
    updates = []
    with ThreadPoolExecutor() as executor:
        futures = [
            executor.submit(findCoordinate, labelImage, label, paletteIndex, labelAreas)
            for label, paletteIndex in labelToPaletteIndex.items()
        ]
        for future in futures:
            updates.append(future.result())

    return updates

Por fim, só resta encarar a (pouco necessária) complexidade de renderizar o texto. Como o OpenCV trata o ponto especificado como a “origem na parte inferior esquerda,” precisamos recalcular uma posição de centro real para o texto usando o tamanho da fonte. Também adicionamos lógica extra para evitar cortes nas bordas da imagem, garantindo que cada rótulo fique centralizado e totalmente visível.

def applyLabels(image, updates):
    def fixPoint(imageDimension, newPoint, textSize, coordinate):
        minBorder = 0 + int(textSize/2) if coordinate == 'x' else 0 + int(textSize)
        maxBorder = imageDimension - int(textSize) if coordinate == 'x' else imageDimension - int(textSize/2)
        if (newPoint <= minBorder) or (newPoint >= maxBorder):
            point = minBorder
        else:
            point = newPoint
        return point

    imageCopy = image.copy()
    h, w, _ = imageCopy.shape
    for update in updates:
        text = update[0]
        x, y = update[1]
        fontScale = update[2]
        thickness = update[3]
        font = cv2.FONT_HERSHEY_SIMPLEX

        # Calculate the size of the text
        (textWidth, textHeight), baseline = cv2.getTextSize(text, font, fontScale, thickness)

        # Calculate the new origin point to center the text
        centerX = fixPoint(w, int(x - textWidth / 2), textWidth, 'x')
        centerY = fixPoint(h, int(y - textHeight / 2), textHeight, 'y')

        cv2.putText(
            imageCopy, 
            text, 
            (centerX, centerY), 
            font, 
            fontScale, 
            (0, 0, 0), 
            thickness, 
            cv2.LINE_AA
        )

    return imageCopy

O (quase) Resultado Final

Preparado para o resultado?

Imagem de Hallstatt com Efeito de Pintar com Números

Estou extremamente satisfeito. Vou encerrar esta exploração por aqui, mas retornaremos para uma parte (esperançosamente) final, onde testaremos o método em diferentes imagens, discutiremos a ordem das operações, calcularemos tempos e falaremos de mais otimizações. Mas, por ora, alegre-se, meu amigo.

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.