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.


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.
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:
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”:
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:


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?
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.