Visão computacional é a capacidade de máquinas interpretarem e processarem imagens e vídeos. Está presente em reconhecimento facial, veículos autônomos, diagnóstico médico por imagem, controle de qualidade industrial e leitura de documentos. Python tem duas bibliotecas fundamentais para isso: Pillow (PIL) para manipulação geral de imagens e OpenCV para processamento de imagens em tempo real e visão computacional avançada.
Instalação
pip install pillow "opencv-python<5" numpy matplotlib
# O OpenCV 5.0 tirou o CascadeClassifier do pacote principal e os
# arquivos Haar dos pacotes: a detecção de faces deste artigo pede
# o OpenCV 4 (a 4.14 é a última).
Pillow: Manipulação de Imagens
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
from pathlib import Path
import numpy as np
# Abrindo e inspecionando
img = Image.open("foto.jpg")
print(f"Formato: {img.format}")
print(f"Modo: {img.mode}") # RGB, RGBA, L (grayscale), etc.
print(f"Tamanho: {img.size}") # (largura, altura)
print(f"Dimensões: {img.width} × {img.height}")
# Criando imagem do zero
img_nova = Image.new("RGB", (400, 300), color=(30, 60, 120))
Operações Básicas
def operacoes_basicas(caminho: str, pasta_saida: str = "saida"):
Path(pasta_saida).mkdir(exist_ok=True)
img = Image.open(caminho)
# Redimensionamento
img_pequena = img.resize((200, 200))
img_thumb = img.copy()
img_thumb.thumbnail((300, 300)) # mantém proporção
# Recorte
largura, altura = img.size
centro_x, centro_y = largura // 2, altura // 2
crop_box = (
centro_x - 100, centro_y - 100,
centro_x + 100, centro_y + 100
)
img_crop = img.crop(crop_box)
# Rotação e espelhamento
img_rot90 = img.rotate(90, expand=True)
img_flip_h = img.transpose(Image.FLIP_LEFT_RIGHT)
img_flip_v = img.transpose(Image.FLIP_TOP_BOTTOM)
# Conversão de modo
img_gray = img.convert("L") # escala de cinza
img_rgba = img.convert("RGBA") # com canal alpha
# Salvando em diferentes formatos
img_pequena.save(f"{pasta_saida}/pequena.jpg", quality=85)
img_gray.save(f"{pasta_saida}/cinza.png")
img_crop.save(f"{pasta_saida}/recorte.jpg")
print("Operações básicas concluídas.")
return img
# Filtros
def aplicar_filtros(img: Image.Image) -> dict:
return {
"blur": img.filter(ImageFilter.BLUR),
"sharpen": img.filter(ImageFilter.SHARPEN),
"edge": img.filter(ImageFilter.FIND_EDGES),
"emboss": img.filter(ImageFilter.EMBOSS),
"smooth": img.filter(ImageFilter.SMOOTH_MORE),
"contour": img.filter(ImageFilter.CONTOUR),
"gaussian": img.filter(ImageFilter.GaussianBlur(radius=3)),
"unsharp": img.filter(ImageFilter.UnsharpMask(radius=2, percent=150)),
}
# Ajustes de imagem
def ajustar_imagem(img: Image.Image) -> Image.Image:
img = ImageEnhance.Brightness(img).enhance(1.2) # +20% brilho
img = ImageEnhance.Contrast(img).enhance(1.3) # +30% contraste
img = ImageEnhance.Sharpness(img).enhance(1.5) # +50% nitidez
img = ImageEnhance.Color(img).enhance(1.1) # +10% saturação
return img
Geração de Imagens: Marca D'água e Certificados
from PIL import Image, ImageDraw, ImageFont
import textwrap
def adicionar_marca_dagua(
caminho_img: str,
texto: str,
saida: str,
opacidade: int = 80
):
"""Adiciona marca d'água diagonal com texto transparente."""
img = Image.open(caminho_img).convert("RGBA")
larg, alt = img.size
# Camada transparente para a marca
marca = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(marca)
try:
fonte = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
except:
fonte = ImageFont.load_default()
# Repete o texto em grade diagonal
for x in range(-larg, larg * 2, 300):
for y in range(-alt, alt * 2, 200):
draw.text(
(x, y), texto,
font=fonte,
fill=(200, 200, 200, opacidade),
anchor="mm"
)
# Rotacionar individualmente não é direto — usamos imagem rotacionada
# Rotaciona a marca 45 graus
marca = marca.rotate(45)
resultado = Image.alpha_composite(img, marca)
resultado.convert("RGB").save(saida)
print(f"Marca d'água adicionada: {saida}")
def gerar_certificado(
nome_aluno: str,
curso: str,
data: str,
saida: str,
largura: int = 800,
altura: int = 560
):
"""Gera certificado de conclusão."""
# Fundo com gradiente simulado
img = Image.new("RGB", (largura, altura), color=(245, 245, 250))
draw = ImageDraw.Draw(img)
# Borda decorativa
for i in range(5):
cor = (30 + i * 10, 60 + i * 5, 120 + i * 5)
draw.rectangle([i, i, largura - i, altura - i], outline=cor, width=1)
# Faixa superior
draw.rectangle([0, 0, largura, 80], fill=(30, 60, 120))
try:
fonte_titulo = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
fonte_nome = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
fonte_normal = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
fonte_pequena = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
except:
fonte_titulo = fonte_nome = fonte_normal = fonte_pequena = ImageFont.load_default()
# Textos
draw.text((largura // 2, 40), "CERTIFICADO DE CONCLUSÃO",
font=fonte_titulo, fill=(255, 255, 255), anchor="mm")
draw.text((largura // 2, 160), "Certificamos que",
font=fonte_normal, fill=(80, 80, 80), anchor="mm")
draw.text((largura // 2, 220), nome_aluno.upper(),
font=fonte_nome, fill=(30, 60, 120), anchor="mm")
# Linha decorativa sob o nome
draw.line([(largura//2 - 200, 250), (largura//2 + 200, 250)],
fill=(30, 60, 120), width=2)
draw.text((largura // 2, 290),
"concluiu com êxito o curso de",
font=fonte_normal, fill=(80, 80, 80), anchor="mm")
draw.text((largura // 2, 340), curso,
font=fonte_titulo, fill=(30, 60, 120), anchor="mm")
draw.text((largura // 2, 420),
f"Emitido em {data}",
font=fonte_pequena, fill=(120, 120, 120), anchor="mm")
# Linha de assinatura
draw.line([(200, 480), (400, 480)], fill=(80, 80, 80), width=1)
draw.text((300, 495), "Prof. Ricardo Matos",
font=fonte_pequena, fill=(80, 80, 80), anchor="mm")
img.save(saida, quality=95)
print(f"Certificado gerado: {saida}")
return img
# Gerando certificado
gerar_certificado(
nome_aluno="Ana Silva",
curso="Dominando o Python em 1 Ano",
data="15/03/2024",
saida="certificado_ana.jpg"
)
OpenCV: Processamento de Imagens
import cv2
import numpy as np
import matplotlib.pyplot as plt
def exibir_imagens(imagens: dict, titulo: str = ""):
"""Exibe múltiplas imagens lado a lado com matplotlib."""
n = len(imagens)
fig, axes = plt.subplots(1, n, figsize=(5 * n, 5))
if n == 1:
axes = [axes]
for ax, (nome, img) in zip(axes, imagens.items()):
if len(img.shape) == 3:
ax.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
else:
ax.imshow(img, cmap="gray")
ax.set_title(nome)
ax.axis("off")
plt.suptitle(titulo)
plt.tight_layout()
plt.savefig(f"{titulo.lower().replace(' ', '_')}.png", dpi=120)
plt.show()
# Leitura e operações básicas
img = cv2.imread("foto.jpg")
print(f"Shape: {img.shape}") # (altura, largura, canais)
print(f"Dtype: {img.dtype}")
# Conversão de espaços de cor
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# Redimensionamento
img_metade = cv2.resize(img, (img.shape[1]//2, img.shape[0]//2))
img_200x200 = cv2.resize(img, (200, 200), interpolation=cv2.INTER_CUBIC)
# Recorte (numpy slicing)
img_crop = img[100:300, 150:400]
# Rotação
centro = (img.shape[1]//2, img.shape[0]//2)
matriz = cv2.getRotationMatrix2D(centro, 45, 1.0)
img_rot = cv2.warpAffine(img, matriz, (img.shape[1], img.shape[0]))
exibir_imagens({
"Original": img,
"Cinza": img_gray,
"Recorte": img_crop,
"Rotacionado": img_rot,
}, "Operações Básicas")
Detecção de Bordas e Contornos
def detectar_bordas(img_path: str):
img = cv2.imread(img_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Suavização antes da detecção de bordas
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# Canny — algoritmo clássico de detecção de bordas
canny = cv2.Canny(blur, threshold1=50, threshold2=150)
# Sobel — gradiente nas direções X e Y
sobel_x = cv2.Sobel(blur, cv2.CV_64F, 1, 0, ksize=3)
sobel_y = cv2.Sobel(blur, cv2.CV_64F, 0, 1, ksize=3)
# convertScaleAbs satura em 255; astype(np.uint8) daria a volta (300 → 44)
sobel = cv2.convertScaleAbs(cv2.magnitude(sobel_x, sobel_y))
# Laplaciano
laplacian = cv2.Laplacian(blur, cv2.CV_64F)
laplacian = cv2.convertScaleAbs(laplacian)
exibir_imagens({
"Original": gray,
"Canny": canny,
"Sobel": sobel,
"Laplaciano": laplacian,
}, "Detecção de Bordas")
return canny
def encontrar_contornos(img_path: str):
img = cv2.imread(img_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# findContours procura regiões brancas. Com objetos escuros em fundo
# claro, o INV inverte a máscara; sem ele, o único contorno seria a
# borda da imagem inteira
_, thresh = cv2.threshold(blur, 127, 255, cv2.THRESH_BINARY_INV)
contornos, hierarquia = cv2.findContours(
thresh,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)
img_contornos = img.copy()
cv2.drawContours(img_contornos, contornos, -1, (0, 255, 0), 2)
print(f"Contornos encontrados: {len(contornos)}")
# Filtrando por área
contornos_grandes = [c for c in contornos if cv2.contourArea(c) > 500]
for c in contornos_grandes:
x, y, w, h = cv2.boundingRect(c)
area = cv2.contourArea(c)
perimetro = cv2.arcLength(c, closed=True)
cv2.rectangle(img_contornos, (x, y), (x+w, y+h), (255, 0, 0), 1)
print(f" Contorno: área={area:.0f}, perímetro={perimetro:.0f}")
return img_contornos, contornos
Detecção de Faces com Haar Cascades
def detectar_faces(img_path: str, saida: str = "faces_detectadas.jpg"):
"""Detecção de faces usando classificadores Haar."""
# Carregando classificador pré-treinado
detector_face = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
detector_olhos = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_eye.xml"
)
img = cv2.imread(img_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Detectando faces
faces = detector_face.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=5,
minSize=(30, 30),
flags=cv2.CASCADE_SCALE_IMAGE
)
print(f"Faces detectadas: {len(faces)}")
for i, (x, y, w, h) in enumerate(faces):
# Desenhando retângulo ao redor da face
cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 2)
cv2.putText(
img, f"Face {i+1}",
(x, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.7,
(0, 255, 0), 2
)
# Detectando olhos dentro de cada face
roi_gray = gray[y:y+h, x:x+w]
roi_img = img[y:y+h, x:x+w]
olhos = detector_olhos.detectMultiScale(roi_gray)
for (ox, oy, ow, oh) in olhos:
cv2.circle(
roi_img,
(ox + ow//2, oy + oh//2),
ow//2,
(255, 0, 0), 2
)
cv2.imwrite(saida, img)
print(f"Imagem salva: {saida}")
return img, faces
Processamento de Vídeo
def processar_video(
fonte: int | str = 0,
operacao: str = "gray",
saida: str = None
):
"""
Processa vídeo em tempo real ou arquivo.
fonte=0 usa webcam; fonte="video.mp4" usa arquivo.
"""
cap = cv2.VideoCapture(fonte)
if not cap.isOpened():
print("Não foi possível abrir a fonte de vídeo.")
return
# Configurações do vídeo
fps = cap.get(cv2.CAP_PROP_FPS)
largura = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
altura = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"Resolução: {largura}×{altura} @ {fps:.1f}fps")
# Writer para salvar
writer = None
if saida:
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(saida, fourcc, fps, (largura, altura))
detector_face = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
frame_count = 0
while True:
ret, frame = cap.read()
if not ret:
break
frame_count += 1
# Aplicando operação escolhida
if operacao == "gray":
processado = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
processado = cv2.cvtColor(processado, cv2.COLOR_GRAY2BGR)
elif operacao == "blur":
processado = cv2.GaussianBlur(frame, (15, 15), 0)
elif operacao == "canny":
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)
processado = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
elif operacao == "faces":
processado = frame.copy()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = detector_face.detectMultiScale(gray, 1.1, 5)
for (x, y, w, h) in faces:
cv2.rectangle(processado, (x, y), (x+w, y+h), (0, 255, 0), 2)
else:
processado = frame
# Informações no frame
cv2.putText(
processado,
f"Frame: {frame_count} | {operacao.upper()}",
(10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7,
(0, 255, 0), 2
)
if writer:
writer.write(processado)
# Exibe ao vivo (comentar em ambientes sem display)
# cv2.imshow("Video", processado)
# if cv2.waitKey(1) & 0xFF == ord("q"):
# break
# Para arquivos: limita a 100 frames no exemplo
if isinstance(fonte, str) and frame_count >= 100:
break
cap.release()
if writer:
writer.release()
cv2.destroyAllWindows()
print(f"Processamento concluído: {frame_count} frames")
OCR: Lendo Texto de Imagens
pip install pytesseract
# Instalar Tesseract: https://github.com/tesseract-ocr/tesseract
import pytesseract
from PIL import Image
import cv2
import numpy as np
def preprocessar_para_ocr(img_path: str) -> np.ndarray:
"""Prepara imagem para melhor resultado no OCR."""
img = cv2.imread(img_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Redimensiona para melhor reconhecimento
scale = 2.0
gray = cv2.resize(gray, None, fx=scale, fy=scale,
interpolation=cv2.INTER_CUBIC)
# Remove ruído
denoised = cv2.fastNlMeansDenoising(gray, h=10)
# Binarização adaptativa
binaria = cv2.adaptiveThreshold(
denoised, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
return binaria
def extrair_texto(img_path: str, idioma: str = "por") -> dict:
"""Extrai texto de imagem usando Tesseract OCR."""
img_proc = preprocessar_para_ocr(img_path)
img_pil = Image.fromarray(img_proc)
# Configuração do Tesseract
config = "--psm 6 --oem 3" # PSM 6: bloco uniforme de texto
# Texto simples
texto = pytesseract.image_to_string(
img_pil, lang=idioma, config=config
)
# Dados detalhados por palavra
dados = pytesseract.image_to_data(
img_pil, lang=idioma,
output_type=pytesseract.Output.DICT
)
# Filtra palavras com confiança > 60%
palavras_confiaveis = [
{
"texto": dados["text"][i],
"confianca": dados["conf"][i],
"x": dados["left"][i], "y": dados["top"][i],
"w": dados["width"][i], "h": dados["height"][i]
}
for i in range(len(dados["text"]))
if int(dados["conf"][i]) > 60 and dados["text"][i].strip()
]
return {
"texto": texto.strip(),
"palavras": palavras_confiaveis,
"total": len(palavras_confiaveis)
}
Exemplo Completo: Pipeline de Análise de Documentos
from PIL import Image, ImageDraw, ImageFont
import cv2
import numpy as np
from pathlib import Path
from dataclasses import dataclass
from typing import List, Tuple
import json
@dataclass
class DocumentoAnalisado:
caminho: str
largura: int
altura: int
faces_detectadas: int
contornos: int
texto_extraido: str
qualidade: float
class AnalisadorDocumentos:
def __init__(self):
self.detector_face = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
def avaliar_qualidade(self, img: np.ndarray) -> float:
"""Avalia a nitidez pela variância do Laplaciano: maior = mais bordas nítidas."""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
laplacian = cv2.Laplacian(gray, cv2.CV_64F)
return float(laplacian.var())
def detectar_faces(self, img: np.ndarray) -> List[Tuple]:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = self.detector_face.detectMultiScale(gray, 1.1, 5)
return list(faces)
def contar_contornos(self, img: np.ndarray) -> int:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
canny = cv2.Canny(blur, 50, 150)
contornos, _ = cv2.findContours(
canny, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
return len([c for c in contornos if cv2.contourArea(c) > 200])
def anotar_imagem(
self,
img: np.ndarray,
faces: list,
qualidade: float
) -> np.ndarray:
resultado = img.copy()
# Anotando faces
for i, (x, y, w, h) in enumerate(faces):
cv2.rectangle(resultado, (x, y), (x+w, y+h), (0, 255, 0), 2)
cv2.putText(resultado, f"Face {i+1}",
(x, y-5), cv2.FONT_HERSHEY_SIMPLEX,
0.5, (0, 255, 0), 1)
# Informações da qualidade
status_qual = "Alta" if qualidade > 500 else ("Media" if qualidade > 100 else "Baixa")
cor_qual = (0,255,0) if qualidade > 500 else ((0,165,255) if qualidade > 100 else (0,0,255))
cv2.putText(resultado, f"Qualidade: {status_qual} ({qualidade:.0f})",
(10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.6, cor_qual, 2)
return resultado
def analisar(self, caminho: str, salvar_anotado: bool = True) -> DocumentoAnalisado:
img = cv2.imread(caminho)
if img is None:
raise ValueError(f"Não foi possível carregar: {caminho}")
altura, largura = img.shape[:2]
qualidade = self.avaliar_qualidade(img)
faces = self.detectar_faces(img)
n_contornos = self.contar_contornos(img)
texto = "" # OCR opcional
doc = DocumentoAnalisado(
caminho= caminho,
largura= largura,
altura= altura,
faces_detectadas= len(faces),
contornos= n_contornos,
texto_extraido= texto,
qualidade= round(qualidade, 2)
)
if salvar_anotado:
anotado = self.anotar_imagem(img, faces, qualidade)
# with_stem mexe só no nome do arquivo; replace(".", ...) trocaria
# todos os pontos do caminho, inclusive os de "./" e das pastas
saida = str(Path(caminho).with_stem(Path(caminho).stem + "_anotado"))
cv2.imwrite(saida, anotado)
print(f"Anotado salvo: {saida}")
return doc
def analisar_pasta(self, pasta: str) -> List[DocumentoAnalisado]:
extensoes = {".jpg", ".jpeg", ".png", ".bmp", ".tiff"}
arquivos = [
str(f) for f in Path(pasta).iterdir()
if f.suffix.lower() in extensoes
]
print(f"Analisando {len(arquivos)} imagens...")
resultados = []
for caminho in arquivos:
try:
doc = self.analisar(caminho)
resultados.append(doc)
print(f" ✓ {Path(caminho).name}: "
f"faces={doc.faces_detectadas}, "
f"qualidade={doc.qualidade:.0f}")
except Exception as e:
print(f" ✗ {Path(caminho).name}: {e}")
# Relatório
total_faces = sum(d.faces_detectadas for d in resultados)
media_qual = (sum(d.qualidade for d in resultados) / len(resultados)
if resultados else 0.0) # pasta vazia não divide por zero
print(f"\nResumo: {len(resultados)} imagens processadas")
print(f" Total de faces: {total_faces}")
print(f" Qualidade média: {media_qual:.0f}")
return resultados
# Gerando imagem de teste
def criar_imagem_teste(saida: str = "teste.jpg"):
"""Cria imagem de teste com formas geométricas."""
img = np.ones((400, 600, 3), dtype=np.uint8) * 240
cv2.rectangle(img, (50, 50), (200, 200), (30, 60, 120), -1)
cv2.circle(img, (350, 150), 80, (0, 128, 0), -1)
cv2.ellipse(img, (500, 300), (80, 50),
45, 0, 360, (128, 0, 128), -1)
cv2.putText(img, "Documento Teste",
(150, 350), cv2.FONT_HERSHEY_SIMPLEX,
1.0, (0, 0, 0), 2)
cv2.imwrite(saida, img)
print(f"Imagem de teste criada: {saida}")
return saida
# Executando o pipeline
caminho_teste = criar_imagem_teste()
analisador = AnalisadorDocumentos()
resultado = analisador.analisar(caminho_teste)
print(f"\n=== Resultado da Análise ===")
print(f"Arquivo: {resultado.caminho}")
print(f"Dimensões: {resultado.largura}×{resultado.altura}")
print(f"Faces: {resultado.faces_detectadas}")
print(f"Contornos: {resultado.contornos}")
print(f"Qualidade: {resultado.qualidade}")
Pillow e OpenCV cobrem juntos quase todo o trabalho com imagens em Python. O Pillow é a ferramenta para gerar e ajustar imagens — redimensionar, recortar, aplicar filtros, escrever texto, montar um certificado ou uma marca d'água. O OpenCV trata a imagem como um array NumPy e oferece os algoritmos de visão: conversão de espaços de cor, bordas com Canny e Sobel, contornos, detecção de faces e processamento de vídeo quadro a quadro. Com o Tesseract, o texto da imagem vira string, e o pipeline do fim junta as peças numa análise em lote. É a mesma ideia de Processamento de Linguagem Natural aplicada a pixels: transformar o dado bruto em números com que um programa consegue raciocinar.
Quase todos os defeitos corrigidos neste artigo têm a mesma cara: nenhum levanta exceção. Um limiar sem THRESH_BINARY_INV devolve um único contorno, a borda da imagem; um astype(np.uint8) faz 300 virar 44 e desenha bordas escuras onde elas são mais fortes; uma troca de ponto no caminho grava o arquivo em outro lugar. Por isso, em visão computacional, vale olhar a imagem de saída a cada etapa, e não só os números — e fixar a versão principal do OpenCV, porque a 5.0 tirou do pacote padrão os classificadores de face que este artigo usa.
Fontes e leituras recomendadas
- Pillow — documentação oficial — https://pillow.readthedocs.io/en/stable/
- OpenCV Python — documentação — https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html
- Tesseract OCR — https://github.com/tesseract-ocr/tesseract
- pytesseract — https://github.com/madmaze/pytesseract
- Haar Cascades pré-treinados — https://github.com/opencv/opencv/tree/master/data/haarcascades
- BRADSKI, Gary; KAEHLER, Adrian. Learning OpenCV 3. O'Reilly Media, 2016. — referência clássica e completa de OpenCV.
- SZELISKI, Richard. Computer Vision: Algorithms and Applications. 2. ed. Springer, 2022. — fundamentos teóricos de visão computacional, disponível em https://szeliski.org/Book/
Exercícios
Exercício 1
Uma aplicação de chamada por reconhecimento facial, que rodava havia meses, quebra depois de um pip install -U: AttributeError: module 'cv2' has no attribute 'CascadeClassifier'. Um colega troca o pacote por opencv-contrib-python, o erro some, e agora o programa morre com Assertion failed: !empty() in function 'detectMultiScale'. O que aconteceu nas duas etapas?
Ver resposta
✓ Resposta: A atualização instalou o OpenCV 5.0, que reorganizou os módulos. Medido no 5.0.0: o pacote principal não tem mais CascadeClassifier, e a pasta cv2/data, que trazia os arquivos XML dos classificadores Haar, só tem o __init__.py. No pacote contrib a classe voltou a existir, mas os XML continuam ausentes, e aí está a segunda armadilha: CascadeClassifier(caminho_inexistente) não levanta exceção — imprime um erro no log e devolve um classificador vazio, que só estoura no detectMultiScale. Com o OpenCV 4.14 o pipeline do artigo roda, e a pasta tem 17 XML. As lições são três: fixar a versão principal (opencv-python<5) até migrar de propósito; conferir detector.empty() logo depois de carregar e falhar ali, com mensagem clara; e, para código novo, considerar o FaceDetectorYN, o detector baseado em rede neural que o OpenCV oferece desde a série 4, bem mais preciso que os cascades de 2001.
Exercício 2
A função encontrar_contornos, na primeira versão do artigo, foi rodada na imagem de teste com um quadrado, um círculo e uma elipse. Ela informou "Contornos encontrados: 1", com área de 239.001 pixels. A imagem tem 600 × 400. O que o código encontrou, e o que faltava?
Ver resposta
✓ Resposta: Encontrou a própria imagem. O findContours procura regiões brancas numa máscara binária. Com THRESH_BINARY e limiar 127, o fundo cinza-claro, de valor 240, virou branco, e as figuras escuras viraram buracos pretos dentro dele. Como o modo é RETR_EXTERNAL, só o contorno de fora foi devolvido — o retângulo (0, 0, 600, 400), com área de quase 600 × 400 = 240.000. Com THRESH_BINARY_INV, as figuras passaram a ser o branco da máscara: medido, 17 contornos, dos quais os três maiores têm áreas 22.492, 19.639 e 12.455 — o quadrado de 150 × 150 (22.500), o círculo de raio 80 (π × 80² ≈ 20.106) e a elipse de semieixos 80 e 50 (π × 80 × 50 ≈ 12.566). Os outros 14 são as letras do texto. A regra: objeto claro em fundo escuro pede THRESH_BINARY; objeto escuro em fundo claro, o caso de documentos, pede _INV. Quando a iluminação varia, THRESH_OTSU escolhe o limiar sozinho.
Exercício 3
Na imagem de bordas por Sobel, as bordas mais fortes — entre o quadrado azul-escuro e o fundo claro — aparecem pretas ou acinzentadas, enquanto as bordas suaves aparecem brancas. O código calculava cv2.magnitude(sobel_x, sobel_y).astype(np.uint8). Explique o efeito.
Ver resposta
✓ Resposta: O gradiente de Sobel é calculado em float64 e pode passar muito de 255: na imagem de teste, o máximo medido foi 612, e 6.702 pixels ficaram acima de 255. O astype(np.uint8) não satura, trunca módulo 256 — 300 vira 44, 512 vira 0. Justamente as bordas mais fortes dão a volta e ficam escuras, e o resultado parece um filtro com defeito. O cv2.convertScaleAbs faz o que se espera: toma o valor absoluto e satura em 255 (300 vira 255). Outra saída é normalizar para a faixa inteira com cv2.normalize(..., 0, 255, cv2.NORM_MINMAX), que preserva as diferenças relativas. No Laplaciano da mesma imagem o máximo foi 118 e o erro não aparecia, o que é pior: o código parecia certo até receber uma imagem com mais contraste. Conversão de tipo em imagem nunca é detalhe; np.clip ou as funções do OpenCV com saturação evitam a surpresa.
Exercício 4
O pipeline de análise de documentos roda num servidor com as fotos em ./uploads/turma.2025/foto.jpg. As imagens anotadas não aparecem em pasta nenhuma, e o log diz "Anotado salvo: _anotado./uploads/turma_anotado.2025/foto_anotado.jpg". De onde saiu esse caminho?
Ver resposta
✓ Resposta: Do caminho.replace(".", "_anotado.") da primeira versão: str.replace troca todas as ocorrências, e o caminho tinha três pontos — o de ./, o do nome da pasta e o da extensão. O resultado aponta para uma pasta que não existe, e o cv2.imwrite não levanta exceção quando não consegue gravar: devolve False, que o código ignorava, e o log anunciava sucesso. Nos testes, com teste.jpg na pasta atual, havia um ponto só e tudo funcionava. A correção do artigo usa Path(caminho).with_stem(Path(caminho).stem + "_anotado"), que mexe só no nome do arquivo: uploads/turma.2025/foto_anotado.jpg. Caminho de arquivo se manipula com pathlib, nunca com operações de string, e o retorno do imwrite deve ser conferido.
Exercício 5
O analisador classifica a qualidade das fotos de documentos pela variância do Laplaciano: acima de 500 é "Alta", abaixo de 100 é "Baixa". Uma foto perfeitamente nítida de uma folha quase em branco saiu como "Baixa", e a mesma imagem de teste, ampliada para o dobro, caiu de "Alta" para "Media". A medida está errada?
Ver resposta
✓ Resposta: Ela mede outra coisa. O Laplaciano responde a variações bruscas de intensidade, então a variância dele mede quanta borda forte a imagem tem — o que depende da nitidez, mas também do conteúdo e da escala. Medido: a imagem de teste deu 1.681; borrada com um kernel gaussiano 9 × 9, 15; com 21 × 21, 1. Até aí, um bom detector de desfoque. Mas uma imagem sintética nítida, com um retângulo de tom quase igual ao fundo, também deu 1 — sem bordas fortes, não há o que medir. E a imagem de teste ampliada para 1200 × 800 caiu para 127, porque a interpolação espalha cada borda por mais pixels. Os limiares 500 e 100 só fazem sentido para imagens do mesmo tipo e da mesma resolução. Na prática, calibre com amostras reais rotuladas, redimensione tudo para um tamanho padrão antes de medir e trate o valor como indício, não como veredito.