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.
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.
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.
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.
- Selecionar pequenas regiões.
- Para cada uma dessas pequenas regiões:
- Identificar pixels circundantes.
- Calcular a cor circundante mais frequente.
- 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ó:
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:
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.