Visão Computacional com OpenCV e PIL

Visão Computacional com OpenCV e PIL

Pillow e OpenCV na prática: filtros, certificados, bordas, contornos, faces, vídeo, OCR e análise de documentos em lote — e os erros que não levantam exceção, como limiar sem INV, uint8 que faz 300 virar 44 e o OpenCV 5 sem os classificadores de face.
Python

• • 21 min de leitura

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

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.

Comentários

Mais em Python

MySQL, MariaDB, PostgreSQL e MongoDB
MySQL, MariaDB, PostgreSQL e MongoDB

MySQL, MariaDB, PostgreSQL e MongoDB a partir do Python, com os drivers de…

Consumindo APIs REST com requests e httpx
Consumindo APIs REST com requests e httpx

Consumindo APIs REST em Python com requests e httpx, do GET simples ao cliente…

Concorrência: threads, processos e async/await
Concorrência: threads, processos e async/await

Threads, processos e asyncio resolvem problemas diferentes, e escolher errado…