{
  "version": "https://jsonfeed.org/version/1",
  "title": "Meus 10 Centavos",
  "home_page_url": "https://meus10centavos.vercel.app/",
  "feed_url": "https://meus10centavos.vercel.app//tag-ia.json",
  "description": "Meus 10 centavos de contribuição para uma discussão que ninguém pediu minha opinião",
  "items": [
    {
      "id": "https://meus10centavos.vercel.app//cv-aplicada.html",
      "url": "https://meus10centavos.vercel.app//cv-aplicada.html",
      "title": "Computer Vision: notas técnicas de um pipeline completa",
      "content_html": "<!-- Content Injected to every content markdown header -->\n<h2><a href=\"#computer-vision-para-avaliação-de-feridas-notas-técnicas-de-um-pipeline-end-to-end\" aria-hidden=\"true\" class=\"anchor\" id=\"computer-vision-para-avaliação-de-feridas-notas-técnicas-de-um-pipeline-end-to-end\"></a><strong>Computer Vision para avaliação de feridas: notas técnicas de um pipeline end-to-end</strong></h2>\n<p>Este post documenta o que aprendi ao implementar um sistema de inferência CV para segmentação, classificação e mensuração métrica de feridas crônicas. O foco é arquitetura, algoritmos e decisões de engenharia.</p>\n<p>Este post resume o que estudei, aprendi, e apliquei ao longo desse caminho.</p>\n<hr />\n<h2><a href=\"#o-problema-de-pixels-a-centímetros\" aria-hidden=\"true\" class=\"anchor\" id=\"o-problema-de-pixels-a-centímetros\"></a><strong>O problema: de pixels a centímetros</strong></h2>\n<p>Avaliar feridas crônicas envolve duas tarefas distintas de visão computacional:</p>\n<ol>\n<li><strong>Segmentação</strong> — encontrar exatamente onde está a lesão na imagem.</li>\n<li><strong>Classificação</strong> — inferir sinais clínicos (infecção, exsudato, etc.).</li>\n</ol>\n<p>Mas há um terceiro desafio, que só aparece quando você coloca o sistema no mundo real: <strong>como converter pixels em centímetros?</strong> Sem escala espacial, uma máscara bonita ainda não responde à pergunta que importa: <em>qual é a área da ferida?</em></p>\n<p>Esse detalhe mudou completamente minha forma de pensar sobre CV.</p>\n<h2><a href=\"#1-arquitetura-do-sistema\" aria-hidden=\"true\" class=\"anchor\" id=\"1-arquitetura-do-sistema\"></a><strong>1. Arquitetura do sistema</strong></h2>\n<p>O pipeline é distribuído em três camadas:</p>\n<table>\n<thead>\n<tr>\n<th><strong>Camada</strong></th>\n<th><strong>Stack</strong></th>\n<th><strong>Responsabilidade</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Mobile</td>\n<td>React Native / Expo</td>\n<td>Captura, upload via presigned URL S3</td>\n</tr>\n<tr>\n<td>Backend</td>\n<td>NestJS + MongoDB</td>\n<td>Auth JWT, metadados clínicos, orquestração</td>\n</tr>\n<tr>\n<td>CV Service</td>\n<td>FastAPI + PyTorch/OpenCV</td>\n<td>Inferência, pós-processamento, geometria, calibração</td>\n</tr>\n</tbody>\n</table>\n<p>O serviço CV expõe <code>POST /infer</code> (JSON <code>{ image_url }</code> ou multipart <code>file</code>), autenticado via header <code>x-api-key</code>, com cache Redis (<code>SHA-256</code> do payload) e rate limiting (60 req/min).</p>\n<p>Além dos algoritmos, aprendi a estruturar um serviço de inferência de produção:</p>\n<ul>\n<li><strong>FastAPI</strong> como microserviço de CV, separado do backend NestJS.</li>\n<li><strong>Orquestrador</strong> que coordena segmentação, classificação, calibração e medição.</li>\n<li><strong>Modo debug</strong> que salva artefatos intermediários (01–09 + meta.json) para inspecionar cada etapa.</li>\n<li><strong>Erros tipados</strong> com mensagens técnicas e amigáveis para o frontend.</li>\n<li><strong>Cache Redis</strong> e rate limiting para uso em produção.</li>\n<li><strong>Testes unitários</strong> para detecção ArUco, geometria, pós-processamento e validação.</li>\n</ul>\n<p>A lição: em CV aplicada, <strong>observabilidade</strong> importa tanto quanto acurácia. Sem debug visual, você debugga no escuro.</p>\n<p>Dois modelos rodam em produção:</p>\n<ul>\n<li><strong>Segmentação</strong>: U-Net + EfficientNet-B3 → <strong>TorchScript</strong> (<code>torch.jit.trace</code>)</li>\n<li><strong>Classificação</strong>: EfficientNet-B3 (<code>timm</code>) → <strong>ONNX opset 17</strong> com batch dinâmico</li>\n</ul>\n<hr />\n<h2><a href=\"#2-treinamento\" aria-hidden=\"true\" class=\"anchor\" id=\"2-treinamento\"></a><strong>2. Treinamento</strong></h2>\n<h3><a href=\"#21-segmentação\" aria-hidden=\"true\" class=\"anchor\" id=\"21-segmentação\"></a><strong>2.1 Segmentação</strong></h3>\n<p>Para segmentação, usei uma U-Net com encoder EfficientNet-B3, pré-treinado no ImageNet, via segmentation_models_pytorch. A escolha faz sentido: EfficientNet oferece um bom equilíbrio entre capacidade e eficiência, e a U-Net é o padrão de ouro para máscaras semânticas.</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">smp.Unet(\n\nencoder_name=&quot;efficientnet-b3&quot;,\n\nencoder_weights=&quot;imagenet&quot;,\n\nin_channels=3,\n\nclasses=1,\n\n)\n</code></pre>\n<table>\n<thead>\n<tr>\n<th><strong>Hiperparâmetro</strong></th>\n<th><strong>Valor</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Loss</td>\n<td><code>DiceLoss(mode=&quot;binary&quot;)</code></td>\n</tr>\n<tr>\n<td>Optimizer</td>\n<td>AdamW, lr = 1e-3</td>\n</tr>\n<tr>\n<td>Batch size</td>\n<td>4</td>\n</tr>\n<tr>\n<td>Image size</td>\n<td>512×512</td>\n</tr>\n<tr>\n<td>Early stopping</td>\n<td>patience = 5 (maximize val Dice)</td>\n</tr>\n<tr>\n<td>Mixed precision</td>\n<td>AMP (<code>GradScaler</code>)</td>\n</tr>\n</tbody>\n</table>\n<p><strong>Augmentações</strong> (<code>albumentations</code>):</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">A.Resize(512, 512)\n\nA.HorizontalFlip(p=0.5)\n\nA.VerticalFlip(p=0.2)\n\nA.RandomRotate90(p=0.5)\n\nA.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=20, p=0.5)\n\nA.ColorJitter(p=0.3)\n\nA.Normalize()\n</code></pre>\n<h3><a href=\"#o-que-aprendi-no-treinamento\" aria-hidden=\"true\" class=\"anchor\" id=\"o-que-aprendi-no-treinamento\"></a><strong>O que aprendi no treinamento</strong></h3>\n<ul>\n<li><strong>Dice Loss</strong> funciona melhor que BCE pura para classes desbalanceadas, feridas geralmente ocupam uma fração pequena da imagem.</li>\n<li><strong>Mixed precision (AMP)</strong> reduz uso de memória e acelera o treino sem perda perceptível de qualidade.</li>\n<li><strong>Augmentações</strong> (flip, rotação, color jitter, shift/scale) são essenciais, mas insuficientes se o dataset não refletir condições reais de captura.</li>\n<li>Métricas como <strong>IoU e Dice</strong> no validation set são úteis, mas não garantem nada em fotos mobile com flash, sombra ou compressão JPEG agressiva.</li>\n</ul>\n<p>Exportei o modelo para <strong>TorchScript</strong>, o que me ensinou que o caminho treino → produção tem requisitos próprios: normalização idêntica, tamanho de entrada fixo e cuidado com operadores suportados na exportação.</p>\n<h3><a href=\"#22-classificação\" aria-hidden=\"true\" class=\"anchor\" id=\"22-classificação\"></a><strong>2.2 Classificação</strong></h3>\n<p>EfficientNet-B3 via <code>timm</code>, <code>BCEWithLogitsLoss</code>, lr = 3e-4, batch = 8, image size = 384. Métrica: F1.</p>\n<h3><a href=\"#23-export\" aria-hidden=\"true\" class=\"anchor\" id=\"23-export\"></a><strong>2.3 Export</strong></h3>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\"># TorchScript (seg)\n\nscripted = torch.jit.trace(model, dummy_input)  *# dummy: (1, 3, 512, 512)*\n\n# ONNX (cls)\n\ntorch.onnx.export(..., opset_version=17, dynamic_axes={&quot;input&quot;: {0: &quot;batch&quot;}})\n</code></pre>\n<p><strong>Constraint crítico</strong>: a normalização e o resize da inferência devem ser idênticos ao treino. Qualquer divergência degrada a distribuição de logits da U-Net e desloca o threshold ótimo de binarização.</p>\n<hr />\n<h2><a href=\"#3-pipeline-de-inferência-orquestrador\" aria-hidden=\"true\" class=\"anchor\" id=\"3-pipeline-de-inferência-orquestrador\"></a><strong>3. Pipeline de inferência (orquestrador)</strong></h2>\n<p>Fluxo completo em <code>run_inference_pipeline()</code>:</p>\n<p>original (HxW) → cap MAX_WORKING_DIMENSION=1536 (INTER_AREA) → preprocess_mobile_rgb (CLAHE + brightness norm + unsharp) → letterbox 512×512 (scale = 512/max(h,w), pad simétrico) → tensor (C,H,W) normalizado → U-Net → prob map (512×512) → unletterbox → working resolution → postprocess_wound_mask (threshold sweep + morfologia + CC) → extract_wound_geometry (PCA + minAreaRect + Feret) → overlay PNG base64</p>\n<p>A mesma informação de forma mais bonita:</p>\n<img src=\"/media/diagrama.png\" alt=\"Diagrama de fluxo da análise de feridas\" width=\"1000\" height=\"1000\">\n<h3><a href=\"#31-letterbox--unletterbox\" aria-hidden=\"true\" class=\"anchor\" id=\"31-letterbox--unletterbox\"></a><strong>3.1 Letterbox / Unletterbox</strong></h3>\n<p>Dado <code>target_size = 512</code>:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">scale = target_size / max(h, w)\n\ncontent_w = round(w * scale)\n\ncontent_h = round(h * scale)\n\npad_left = (512 - content_w) // 2\n\npad_top  = (512 - content_h) // 2\n</code></pre>\n<p>A máscara inferida é recortada da região <code>[pad_top:pad_top+content_h, pad_left:pad_left+content_w]</code> e redimensionada para <code>(working_h, working_w)</code> com <code>INTER_NEAREST</code> (preserva binarização).</p>\n<p><strong>Por que importa</strong>: resize direto para 512×512 distorce aspect ratio e altera a geometria da ferida. Letterbox mantém proporções; o custo é propagar <code>LetterboxMeta</code> (scale, pad_left, pad_top) em todo o pipeline.</p>\n<h3><a href=\"#32-pré-processamento-mobile\" aria-hidden=\"true\" class=\"anchor\" id=\"32-pré-processamento-mobile\"></a><strong>3.2 Pré-processamento mobile</strong></h3>\n<p>Aplicado no espaço <strong>LAB</strong> (preserva crominância):</p>\n<ol>\n<li><strong>CLAHE</strong> no canal L: <code>clipLimit=2.0</code>, <code>tileGridSize=(8,8)</code></li>\n<li><strong>Normalização de brilho</strong>: se <code>mean(gray) &lt; 90</code> ou <code>&gt; 200</code>, escala por <code>α = clip(128/mean, 0.85, 1.15)</code></li>\n<li><strong>Unsharp mask</strong>: <code>out = 1.25·img - 0.25·GaussianBlur(img, σ=1.0)</code></li>\n</ol>\n<p>Ativado via <code>MOBILE_IMAGE_PREPROCESS=true</code>. Não altera geometria — apenas distribuição de intensidade.</p>\n<hr />\n<h2><a href=\"#4-pós-processamento-de-máscara\" aria-hidden=\"true\" class=\"anchor\" id=\"4-pós-processamento-de-máscara\"></a><strong>4. Pós-processamento de máscara</strong></h2>\n<p>Entrada: mapa probabilístico <code>P ∈ [0,1]^{H×W}</code>. Saída: máscara binária + contorno + <code>geometry_confidence</code>.</p>\n<p>Em vez de um threshold fixo, implementei um <strong>sweep automático</strong> (0.35, 0.40, 0.45, 0.50, 0.55) que escolhe o valor que gera componentes utilizáveis. Depois:</p>\n<ul>\n<li>Morfologia leve para fechar buracos sem fragmentar a região.</li>\n<li>Análise de <strong>componentes conectados</strong> com filtros de plausibilidade (área, aspect ratio, compactness, solidity).</li>\n<li>Fallback para regiões pequenas mas clinicamente plausíveis quando nenhum componente “estrito” passa nos critérios.</li>\n</ul>\n<h3><a href=\"#41-threshold-sweep\" aria-hidden=\"true\" class=\"anchor\" id=\"41-threshold-sweep\"></a><strong>4.1 Threshold sweep</strong></h3>\n<p><code>sweep = [0.35, 0.40, 0.45, 0.50, 0.55]  # default produção</code></p>\n<p><code>binary_t = (GaussianBlur(P, k=3) &gt; t).astype(uint8)</code></p>\n<p>Para cada <code>t</code>, computa score baseado em componentes conectados utilizáveis. Seleciona o threshold com melhor score.</p>\n<p><strong>Observação empírica</strong>: threshold 0.65 (comum em papers) produz máscaras vazias em fotos mobile com logits calibrados para distribuição diferente do val set. Default de produção: <strong>0.40</strong>.</p>\n<h3><a href=\"#42-morfologia\" aria-hidden=\"true\" class=\"anchor\" id=\"42-morfologia\"></a><strong>4.2 Morfologia</strong></h3>\n<p>open:  kernel 3×3, 1 iteração  (remove ruído pontual)</p>\n<p>close: kernel 5×5, 1 iteração (fecha buracos internos)</p>\n<p>Parâmetros tunáveis via env (<code>MASK_OPEN_KERNEL</code>, <code>MASK_CLOSE_KERNEL</code>).</p>\n<h3><a href=\"#43-seleção-de-componentes-conectados\" aria-hidden=\"true\" class=\"anchor\" id=\"43-seleção-de-componentes-conectados\"></a><strong>4.3 Seleção de componentes conectados</strong></h3>\n<p><code>cv2.connectedComponentsWithStats</code> → filtros em cascata:</p>\n<table>\n<thead>\n<tr>\n<th><strong>Filtro</strong></th>\n<th><strong>Default</strong></th>\n<th><strong>Função</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>max_area_fraction</code></td>\n<td>0.50</td>\n<td>Rejeita blob &gt; 50% da imagem</td>\n</tr>\n<tr>\n<td><code>max_aspect_ratio</code></td>\n<td>10.0</td>\n<td>Rejeita estruturas filamentares</td>\n</tr>\n<tr>\n<td><code>min_compactness</code></td>\n<td>0.04</td>\n<td><code>4π·A / P²</code> — rejeita formas irregulares demais</td>\n</tr>\n<tr>\n<td><code>min_solidity</code></td>\n<td>0.15</td>\n<td><code>A / A_convex_hull</code></td>\n</tr>\n<tr>\n<td><code>min_area_fraction</code></td>\n<td>0.0002</td>\n<td>Área mínima relativa</td>\n</tr>\n<tr>\n<td><code>min_dimension_px</code></td>\n<td>12</td>\n<td>Dimensão mínima do bbox</td>\n</tr>\n</tbody>\n</table>\n<p>Seleção em <strong>3 níveis</strong>:</p>\n<ol>\n<li><strong>Strict</strong> — passa todos os filtros</li>\n<li><strong>Relaxed</strong> — filtros parcialmente relaxados</li>\n<li><strong>Plausible fallback</strong> — se única candidata razoável, aceita região pequena (<code>MASK_ALLOW_PLAUSIBLE_FALLBACK=true</code>)</li>\n</ol>\n<p>Isso evita o cenário clássico: modelo acerta parcialmente a ferida, mas CC analysis descarta o único componente válido.</p>\n<h3><a href=\"#44-validação-pré-geometria\" aria-hidden=\"true\" class=\"anchor\" id=\"44-validação-pré-geometria\"></a><strong>4.4 Validação pré-geometria</strong></h3>\n<p>Antes de extrair contorno, <code>validate_wound_presence()</code> verifica se <code>prob_max</code> e <code>area_fraction@0.5</code> excedem limites mínimos. Falha → <code>WoundNotDetectedError</code> com <code>prob_max</code> no payload técnico.</p>\n<p>Aprendi que pós-processamento é metade do produto em segmentação médica. O modelo diz onde provavelmente está a ferida; o pipeline decide o que mostrar ao clínico.</p>\n<hr />\n<h3><a href=\"#5-clahe\" aria-hidden=\"true\" class=\"anchor\" id=\"5-clahe\"></a><strong>5 CLAHE</strong></h3>\n<p>O <strong>Contrast Limited Adaptive Histogram Equalization</strong> melhora contraste local em fotos com sombras ou pele escura/clara desigual. É processamento clássico de CV, mas faz diferença real antes da inferência neural.</p>\n<hr />\n<h2><a href=\"#6-análise-de-coloração-tecidual\" aria-hidden=\"true\" class=\"anchor\" id=\"6-análise-de-coloração-tecidual\"></a>6. Análise de coloração tecidual</h2>\n<p>Esta foi uma das partes mais interessantes do projeto: traduzir cor percebida em percentuais clínicos sem treinar um classificador de cor separado.</p>\n<h3><a href=\"#61-fundamentação-clínica\" aria-hidden=\"true\" class=\"anchor\" id=\"61-fundamentação-clínica\"></a>6.1 Fundamentação clínica</h3>\n<p>Na prática de curativos, a coloração do leito da ferida orienta conduta. O sistema mapeia pixels para seis categorias inspiradas na escala RYB (Red-Yellow-Black), estendida com roxo e branco:</p>\n<table>\n<thead>\n<tr>\n<th>Classe</th>\n<th>Interpretação clínica (heurística)</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Vermelho</td>\n<td>Granulação — tecido de cicatrização</td>\n</tr>\n<tr>\n<td>Amarelo</td>\n<td>Esfacelo — possível necessidade de desbridamento</td>\n</tr>\n<tr>\n<td>Preto</td>\n<td>Necrose/eschar — desbridamento</td>\n</tr>\n<tr>\n<td>Roxo</td>\n<td>Hematoma/equimose perilesional</td>\n</tr>\n<tr>\n<td>Branco</td>\n<td>Tecido pálido/isquêmico, biofilme</td>\n</tr>\n<tr>\n<td>Outros</td>\n<td>Pixels que não se encaixam nas regras</td>\n</tr>\n</tbody>\n</table>\n<p>A API retorna isso via WoundColorAnalysis (Pydantic), com valores em percentual (0–100) arredondados a 2 casas:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">class WoundColorAnalysis(BaseModel):\n  vermelho: float   # granulação\n  amarelo: float    # esfacelo/infecção\n  preto: float      # necrose\n  roxo: float       # hematoma\n  branco: float     # isquêmico/biofilme\n  outros: float\n\n</code></pre>\n<p>No mobile, os percentuais alimentam a tela de nova ferida, o <code>TissueColorPieChart</code> e a seção de evolução temporal (<code>WoundEvolutionSection</code>), permitindo comparar coloração entre capturas do mesmo paciente.</p>\n<h3><a href=\"#62-pipeline-de-roi-para-coloração\" aria-hidden=\"true\" class=\"anchor\" id=\"62-pipeline-de-roi-para-coloração\"></a>6.2 Pipeline de ROI para coloração</h3>\n<p>Função: <code>analyze_wound_colors(image_rgb, mask_prob)</code>.</p>\n<p>Passo 1 — Alinhamento e binarização permissiva</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">mask_aligned = align_mask_to_image(image, mask, &quot;analyze_wound_colors&quot;)\nbinary = (mask_aligned &gt; 0.20).astype(uint8)  # threshold baixo: incluir bordas difusas\n</code></pre>\n<p>A análise só é chamada após <code>validate_wound_presence()</code>, então o threshold de 0.20 (vs 0.40 da geometria) é intencional: captura toda a região provável de ferimento, incluindo halos de baixa confiança na borda.</p>\n<p>Passo 2 — Limpeza morfológica</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">MORPH_CLOSE(kernel 3×3, 1 iter)\nMORPH_OPEN(kernel 3×3, 1 iter)\n→ largest connected component\n</code></pre>\n<p>Passo 3 — Máscara de borda</p>\n<p>Remove 1% das margens da imagem para evitar contaminação por pele circundante ou artefatos de borda:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">margin_h = max(1, int(h * 0.01))\nmargin_w = max(1, int(w * 0.01))\nedge_mask[0:margin_h, :] = 0\nedge_mask[h-margin_h:, :] = 0\n# idem para colunas\nbinary = binary * edge_mask\n</code></pre>\n<p>Passo 4 — Fallback</p>\n<p>Se <code>wound_pixels == 0</code> após filtros, reutiliza <code>mask &gt; 0.15</code> sem máscara de borda, evita retorno zerado em feridas pequenas ou próximas à borda do frame.</p>\n<h3><a href=\"#63-classificação-pixel-a-pixel-hsv--rgb\" aria-hidden=\"true\" class=\"anchor\" id=\"63-classificação-pixel-a-pixel-hsv--rgb\"></a>6.3 Classificação pixel a pixel (HSV + RGB)</h3>\n<p>Conversão para HSV OpenCV:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">image_hsv = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2HSV)\n# H ∈ [0, 179], S ∈ [0, 255], V ∈ [0, 255]\n# Normalização: h/179, s/255, v/255\n</code></pre>\n<p>Para cada pixel <code>(r,g,b)</code> e <code>(h,s,v)</code> dentro da ROI:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">intensity = (r + g + b) / 3\nmax_rgb   = max(r, g, b)\nmin_rgb   = min(r, g, b)\n</code></pre>\n<p>Ordem de decisão (first-match wins — evita ambiguidade):</p>\n<p><code>Preto → Branco → Roxo → Vermelho → Amarelo → Outros</code></p>\n<p>Preto (necrose)</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">if v &lt; 0.12 or (intensity &lt; 0.15 and max_rgb &lt; 0.2):\n    → preto\nelif v &lt; 0.18 and intensity &lt; 0.20 and s &lt; 0.25:\n    → preto\n\n</code></pre>\n<p>Prioridade máxima: sombras profundas e tecido necrótico têm valor HSV baixo.</p>\n<p>Branco (isquêmico / biofilme)</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">elif s &lt; 0.12 and v &gt; 0.65:\n    → branco\nelif s &lt; 0.18 and v &gt; 0.75 and max_rgb &gt; 0.7:\n    → branco\nelif s &lt; 0.20 and v &gt; 0.70 and |r-g| &lt; 0.1 and |g-b| &lt; 0.1:\n    → branco  # tons acinzentados claros\n</code></pre>\n<p>Baixa saturação + alto valor = tecido pálido ou esbranquiçado.</p>\n<p>Roxo (hematoma)\nRegras HSV e RGB combinadas:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">elif 0.68 &lt;= h &lt;= 0.92 and s &gt; 0.25 and v &gt; 0.2:\n    → roxo\nelif r &gt; 0.35 and b &gt; 0.35 and g &lt; 0.35 and s &gt; 0.18:\n    → roxo  # R e B dominantes, G suprimido\n</code></pre>\n<p>Vermelho (granulação)</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">elif (h &lt; 0.04 or h &gt; 0.96) and s &gt; 0.35 and v &gt; 0.25:\n    → vermelho  # matiz no extremo do círculo H (vermelho puro)\nelif r &gt; 0.55 and r &gt; g*1.25 and r &gt; b*1.25 and s &gt; 0.25:\n    → vermelho  # dominância de R no RGB\nelif r &gt; 0.50 and r &gt; g*1.2 and s &gt; 0.30 and 0.2 &lt; v &lt; 0.5:\n    → vermelho  # granulação mais escura\n</code></pre>\n<p>Amarelo (esfacelo)</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">elif 0.12 &lt;= h &lt;= 0.28 and s &gt; 0.35 and v &gt; 0.3:\n    → amarelo\nelif r &gt; 0.45 and g &gt; 0.45 and b &lt; 0.35 and s &gt; 0.25:\n    → amarelo  # R+G altos, B baixo\nelif 0.28 &lt;= h &lt;= 0.40 and s &gt; 0.30 and g &gt; r and g &gt; b:\n    → amarelo  # amarelo-esverdeado (infecção)\nelif 0.10 &lt;= h &lt;= 0.25 and s &gt; 0.25 and 0.25 &lt; v &lt; 0.55:\n    → amarelo  # esfacelo âmbar/escuro\n</code></pre>\n<p>Outros\nPixels que não satisfazem nenhuma regra acima.</p>\n<h3><a href=\"#64-agregação\" aria-hidden=\"true\" class=\"anchor\" id=\"64-agregação\"></a>6.4 Agregação</h3>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">total = len(wound_region)  # pixels na ROI filtrada\ncolor_percentages = {\n  'vermelho': (red_count   / total) * 100.0,\n  'amarelo':  (yellow_count / total) * 100.0,\n  'preto':    (black_count  / total) * 100.0,\n  'roxo':     (purple_count / total) * 100.0,\n  'branco':   (white_count  / total) * 100.0,\n  'outros':   (other_count  / total) * 100.0,\n}\n# Σ ≈ 100%\n</code></pre>\n<h3><a href=\"#65-limitações-e-aprendizados-técnicos\" aria-hidden=\"true\" class=\"anchor\" id=\"65-limitações-e-aprendizados-técnicos\"></a>6.5 Limitações e aprendizados técnicos</h3>\n<p>Iluminação domina crominância. Flash direto, sombra e pele circundante alteram <code>(h,s,v)</code> de forma não linear. O código detecta alta variação (<code>std(RGB) &gt; 40</code>) e loga aviso, mas ainda não aplica correção adaptativa de thresholds — ponto de melhoria.</p>\n<p>Segmentação ≠ coloração. Um pixel classificado como vermelho pode ser pele saudável se a máscara vazou. A máscara de borda (1%) e o largest CC mitigam, mas não eliminam o problema.</p>\n<p>Rule-based vs ML. Escolhi regras explícitas porque:</p>\n<ul>\n<li>Interpretabilidade clínica (cada threshold é auditável)</li>\n<li>Zero dependência de dataset rotulado por cor</li>\n<li>Latência desprezível (~O(n) por pixel na ROI)</li>\n</ul>\n<p>O custo: sensibilidade a white balance da câmera e calibração de cor do dispositivo. Um classificador treinado em LAB normalizado ou um modelo multi-classe por patch seria o próximo passo natural.</p>\n<p>Dual color space é necessário. HSV isola matiz (útil para vermelho/amarelo/roxo), mas falha em tons acromáticos (preto/branco) — daí as regras RGB complementares com dominância de canal.</p>\n<p>Threshold de máscara diferente da geometria. Geometria usa binarização conservadora (0.40 + CC strict); coloração usa 0.20 permissivo. Aprendi que otimizar uma única máscara para ambos os fins é subótimo.</p>\n<h2><a href=\"#7-calibração-espacial\" aria-hidden=\"true\" class=\"anchor\" id=\"7-calibração-espacial\"></a><strong>7. Calibração espacial</strong></h2>\n<p>Sem <code>pixels_per_cm</code>, área em px² não tem unidade clínica. Implementei duas fontes:</p>\n<h3><a href=\"#71-aruco-default\" aria-hidden=\"true\" class=\"anchor\" id=\"71-aruco-default\"></a><strong>7.1 ArUco (default)</strong></h3>\n<p>Dictionary: DICT_4X4_50</p>\n<p>Marker ID:  0 (estrito)</p>\n<p>Size:       2.0 cm (lado físico)</p>\n<p>Pipeline de detecção:</p>\n<ol>\n<li>Grayscale + CLAHE</li>\n<li><code>cv2.aruco.ArucoDetector</code> (compatível com API legada <code>detectMarkers</code>)</li>\n<li>Filtra <code>marker_id == 0</code>; se múltiplos, seleciona o de maior perímetro</li>\n<li>Ordena cantos: TL → TR → BR → BL</li>\n<li><code>pixels_per_cm = mean(side_lengths_px) / marker_size_cm</code></li>\n</ol>\n<p><strong>Correção de perspectiva</strong> (opcional, <code>ARUCO_APPLY_PERSPECTIVE_CORRECTION=true</code>):</p>\n<p><code>src = corners[4×2]  (quadrilátero detectado)</code></p>\n<p><code>side = max(edge)  (lado em px)</code></p>\n<p><code>dst = [[0,0], [side,0], [side,side], [0,side]]</code></p>\n<p><code>H = cv2.getPerspectiveTransform(src, dst)</code></p>\n<p><code>rectified = cv2.warpPerspective(image, H, (side, side))</code></p>\n<p><code>mask' = cv2.warpPerspective(mask, H, ..., INTER_NEAREST)</code></p>\n<p>Homografia planar assume superfície do marcador (e da ferida) aproximadamente coplanar.\nViolação → erro sistemático em área.</p>\n<p>Erros tipados:</p>\n<ul>\n<li><code>marker_not_detected</code></li>\n<li><code>invalid_marker</code> (ID errado)</li>\n<li><code>calibration_failed</code> (marcador muito pequeno, &lt; ~24 px de lado)</li>\n</ul>\n<p><strong>Retry inteligente</strong>: se ArUco falha na imagem inteira, re-detecta usando preview binário da máscara como ROI hint.</p>\n<h3><a href=\"#72-régua-clínica-alternativa\" aria-hidden=\"true\" class=\"anchor\" id=\"72-régua-clínica-alternativa\"></a><strong>7.2 Régua clínica (alternativa)</strong></h3>\n<p>Como alternativa, implementei detecção de régua clínica com CV clássica:</p>\n<ol>\n<li>CLAHE → Canny → contornos</li>\n<li>Filtra contornos com <code>aspect_ratio &gt; 4.0</code> (corpo alongado)</li>\n<li><code>minAreaRect</code> → eixo principal</li>\n<li>Extrai strip 1D ao longo do eixo (60% da largura)</li>\n<li>Perfil de gradiente → detecção de picos periódicos (marcações cm)</li>\n<li><code>pixels_per_cm = median(Δx_entre_picos) / RULER_CM_PER_MAJOR_TICK</code></li>\n</ol>\n<p>Foi fascinante ver que não precisa de deep learning para tudo. Análise de sinais 1D sobre contornos resolve um problema real com interpretabilidade e baixo custo computacional.</p>\n<p>Confiança: CV do espaçamento entre picos + número de ticks + aspect ratio.</p>\n<h3><a href=\"#73-fallback\" aria-hidden=\"true\" class=\"anchor\" id=\"73-fallback\"></a><strong>7.3 Fallback</strong></h3>\n<p>Se <code>ALLOW_ASSUMED_FOV_FALLBACK=true</code> e calibração falha, usa <code>default_assumed_calibration(image_side_px)</code> — escala heurística baseada no FOV. <strong>Não é escala clínica</strong>; flag <code>calibration.is_clinical_scale()</code> distingue.</p>\n<p>Aprendi que ArUco é elegante na teoria e exigente na prática: o marcador precisa estar visível, plano, bem iluminado e com tamanho mínimo em pixels. Por isso o sistema retorna erros específicos (marker_not_detected, invalid_marker, calibration_failed) em vez de falhar silenciosamente.</p>\n<hr />\n<h2><a href=\"#8-extração-geométrica\" aria-hidden=\"true\" class=\"anchor\" id=\"8-extração-geométrica\"></a><strong>8. Extração geométrica</strong></h2>\n<p>Com a máscara binária e a calibração, extraio métricas clínicas:</p>\n<ul>\n<li><strong>Área</strong> e <strong>perímetro</strong> a partir do contorno real.</li>\n<li><strong>Comprimento × largura</strong> via PCA sobre os pontos do contorno, mais robusto que <code>boundingRect</code> para formas irregulares.</li>\n<li>Referência cruzada com <code>minAreaRect</code> e <strong>Feret diameter</strong> no convex hull.</li>\n<li>Dimensão final como <strong>mediana robusta</strong> entre PCA e retângulo rotacionado.</li>\n</ul>\n<p>Entrada: contorno <code>C = {(x_i, y_i)}</code>, calibração <code>{pixel_to_cm, confidence}</code>.</p>\n<h3><a href=\"#81-métricas-primárias-contorno-real\" aria-hidden=\"true\" class=\"anchor\" id=\"81-métricas-primárias-contorno-real\"></a><strong>8.1 Métricas primárias (contorno real)</strong></h3>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">area_px       = cv2.contourArea(C)\n\nperimeter_px  = cv2.arcLength(C, closed=True)\n</code></pre>\n<h3><a href=\"#82-dimensões-clínicas--pca-2d\" aria-hidden=\"true\" class=\"anchor\" id=\"82-dimensões-clínicas--pca-2d\"></a><strong>8.2 Dimensões clínicas — PCA 2D</strong></h3>\n<p>Centro: <code>μ = mean(C)</code></p>\n<p>Matriz de covariância: <code>Σ = cov(C - μ)</code></p>\n<p>Decomposição: <code>Σ = V Λ Vᵀ</code> via <code>np.linalg.eigh</code></p>\n<p>Autovetor <code>v₁</code> (maior autovalor) = eixo principal:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">proj_major = (C - μ) · v₁\n\nmajor_len  = max(proj_major) - min(proj_major)\n\nproj_minor = (C - μ) · v₂\n\nminor_len  = max(proj_minor) - min(proj_minor)\n\nangle_deg  = atan2(v₁_y, v₁_x) · 180/π\n</code></pre>\n<h3><a href=\"#83-referência-cruzada\" aria-hidden=\"true\" class=\"anchor\" id=\"83-referência-cruzada\"></a><strong>8.3 Referência cruzada</strong></h3>\n<ul>\n<li><code>minAreaRect(C)</code> → <code>(rect_major, rect_minor, rect_angle)</code></li>\n<li><strong>Feret diameter</strong> no convex hull: <code>(feret_max, feret_min)</code></li>\n</ul>\n<p>Dimensão final:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">width_px  = median(pca_major, rect_major)\n\nheight_px = median(pca_minor, rect_minor)\n</code></pre>\n<p><strong>Motivação</strong>: <code>cv2.boundingRect</code> superestima feridas alongadas e rotacionadas. PCA captura orientação; mediana entre PCA e minAreaRect reduz sensibilidade a outliers no contorno.</p>\n<h3><a href=\"#84-conversão-métrica\" aria-hidden=\"true\" class=\"anchor\" id=\"84-conversão-métrica\"></a><strong>8.4 Conversão métrica</strong></h3>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner\">area_cm²      = area_px       × pixel_to_cm²\n\nperimeter_cm  = perimeter_px   × pixel_to_cm\n\nwidth_cm      = width_px      × pixel_to_cm\n\nheight_cm     = height_px     × pixel_to_cm\n\ndiameter_cm   = max(width_cm, height_cm)\n</code></pre>\n<h3><a href=\"#85-validação-geométrica\" aria-hidden=\"true\" class=\"anchor\" id=\"85-validação-geométrica\"></a><strong>8.5 Validação geométrica</strong></h3>\n<p><code>geometry_confidence = min(calibration_confidence, mask_geometry_confidence)</code></p>\n<p>Checks de plausibilidade (<code>GEOMETRY_REQUIRE_VALID=true</code>):</p>\n<ul>\n<li>área mínima em cm²</li>\n<li>aspect ratio dentro de limites</li>\n<li>solidity/compactness acima de thresholds</li>\n</ul>\n<p>Falha → <code>InvalidGeometryError</code>.</p>\n<hr />\n<h2><a href=\"#9-taxonomia-de-erros\" aria-hidden=\"true\" class=\"anchor\" id=\"9-taxonomia-de-erros\"></a><strong>9. Taxonomia de erros</strong></h2>\n<p>O pipeline retorna erros estruturados (HTTP 422) com <code>message</code> (frontend) e <code>message_technical</code>:</p>\n<table>\n<thead>\n<tr>\n<th><strong>Exception</strong></th>\n<th><strong>Código</strong></th>\n<th><strong>Trigger</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>WoundNotDetectedError</code></td>\n<td><code>no_wound_detected</code></td>\n<td><code>prob_max</code> baixo, área @ 0.5 &lt; threshold</td>\n</tr>\n<tr>\n<td><code>MarkerNotDetectedError</code></td>\n<td><code>marker_not_detected</code></td>\n<td>ArUco ausente, <code>ARUCO_REQUIRED=true</code></td>\n</tr>\n<tr>\n<td><code>InvalidMarkerError</code></td>\n<td><code>invalid_marker</code></td>\n<td>ID ≠ 0</td>\n</tr>\n<tr>\n<td><code>CalibrationFailedError</code></td>\n<td><code>calibration_failed</code></td>\n<td>Marcador inválido / escala não clínica</td>\n</tr>\n<tr>\n<td><code>InvalidSegmentationError</code></td>\n<td><code>invalid_segmentation</code></td>\n<td>CC analysis falhou em todos os níveis</td>\n</tr>\n<tr>\n<td><code>GeometryExtractionError</code></td>\n<td><code>geometry_extraction_failed</code></td>\n<td>Contorno &lt; 5 pontos</td>\n</tr>\n<tr>\n<td><code>InvalidGeometryError</code></td>\n<td><code>invalid_geometry</code></td>\n<td>Validação geométrica falhou</td>\n</tr>\n</tbody>\n</table>\n<hr />\n<h2><a href=\"#10-observabilidade-e-debug\" aria-hidden=\"true\" class=\"anchor\" id=\"10-observabilidade-e-debug\"></a>10. Observabilidade e Debug</h2>\n<p>O sistema possui um modo avançado de observabilidade para facilitar a inspeção de toda a pipeline de segmentação e análise de cores. Quando <code>DEBUG_SEGMENTATION=true</code>, são aplicadas configurações mais permissivas para facilitar a identificação de problemas durante o processamento:</p>\n<ul>\n<li>Filtros de Componentes Conectados (CC) relaxados:\n<ul>\n<li><code>min_solidity = 0.08</code></li>\n<li><code>max_aspect_ratio = 20</code></li>\n</ul>\n</li>\n<li><code>GEOMETRY_MIN_CONFIDENCE = 0.20</code></li>\n</ul>\n<p>Para cada requisição é criado o diretório <code>debug_outputs/{request_id}/</code>, contendo todos os artefatos intermediários da pipeline:</p>\n<ul>\n<li><code>01_original.png</code></li>\n<li><code>02_...</code></li>\n<li><code>...</code></li>\n<li><code>09_overlay.png</code></li>\n<li><code>meta.json</code></li>\n</ul>\n<p>O arquivo <code>meta.json</code> reúne informações detalhadas do processamento, incluindo:</p>\n<ul>\n<li>Dimensões de cada etapa da pipeline;</li>\n<li>Thresholds utilizados;</li>\n<li>Estatísticas da máscara de probabilidade;</li>\n<li>Métricas de segmentação;</li>\n<li>Diagnósticos completos utilizados pela API.</li>\n</ul>\n<p>Além disso, a resposta da API passa a incluir o campo <code>segmentation_debug</code>, contendo informações detalhadas para auxiliar na investigação de falhas.</p>\n<p>Cada requisição também propaga um <code>ImagePipelineContext</code>, que registra automaticamente as dimensões produzidas em cada etapa da pipeline (<code>shape_log</code>), permitindo identificar rapidamente desalinhamentos entre a imagem original, a inferência da U-Net e a máscara final.</p>\n<h3><a href=\"#101-exemplo-de-shape_log\" aria-hidden=\"true\" class=\"anchor\" id=\"101-exemplo-de-shape_log\"></a>10.1 Exemplo de <code>shape_log</code></h3>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner language-text\">original_loaded:           shape=(3024, 4032, 3)\nworking_capped:            shape=(1152, 1536, 3)\npreprocessed_mobile:       shape=(1152, 1536, 3)\ninference_letterboxed:     shape=(512, 512, 3)\npredicted_mask_inference:  shape=(512, 512)\nmask_working_prob:         shape=(1152, 1536)\n</code></pre>\n<p>Esses registros são fundamentais para diagnosticar problemas como diferenças de escala, padding incorreto, redimensionamentos inadequados e desalinhamentos entre a máscara segmentada e a imagem original.</p>\n<h3><a href=\"#102-logs-da-análise-de-cores\" aria-hidden=\"true\" class=\"anchor\" id=\"102-logs-da-análise-de-cores\"></a>10.2 Logs da análise de cores</h3>\n<p>A etapa de análise de cores também possui observabilidade dedicada. Durante a execução são registrados:</p>\n<ul>\n<li>Quantidade de pixels analisados;</li>\n<li>Número de pixels classificados em cada cor;</li>\n<li>Percentuais finais de cada classe.</li>\n</ul>\n<p>Exemplo:</p>\n<pre class=\"marmite-code\"><code class=\"marmite-code-inner language-text\">🎨 [ColorAnalysis] Pixels analisados: 4821\n🎨 [ColorAnalysis] Color counts - Red: 2104, Yellow: 891, Black: 312, ...\n🎨 [ColorAnalysis] Final percentages:\n{\n  'vermelho': 43.6,\n  'amarelo': 18.5,\n  ...\n}\n</code></pre>\n<p>Esses logs permitem validar tanto a qualidade da segmentação quanto a consistência da classificação cromática, tornando o processo de depuração significativamente mais eficiente.</p>\n<hr />\n<h2><a href=\"#11-testes-automatizados\" aria-hidden=\"true\" class=\"anchor\" id=\"11-testes-automatizados\"></a><strong>11. Testes automatizados</strong></h2>\n<p>Cobertura unitária em:</p>\n<ul>\n<li><code>test_aruco_detection.py</code> — detecção, filtro de ID, cálculo de escala</li>\n<li><code>test_ruler_detection.py</code> — picos periódicos, aspect ratio</li>\n<li><code>test_measurement_geometry.py</code> — PCA, Feret, conversão cm</li>\n<li><code>test_segmentation_postprocess.py</code> — threshold sweep, CC fallback</li>\n<li><code>test_pipeline_validation.py</code> — presença de ferida, erros tipados</li>\n</ul>\n<hr />\n<h2><a href=\"#12-lições-técnicas-consolidadas\" aria-hidden=\"true\" class=\"anchor\" id=\"12-lições-técnicas-consolidadas\"></a><strong>12. Lições técnicas consolidadas</strong></h2>\n<p><strong>1. Domain shift mobile &gt;&gt; ganho de arquitetura</strong></p>\n<p>Val Dice/IoU em dataset limpo não prediz comportamento em fotos 12 MP com flash, JPEG agressivo e glare. O pipeline de inferência (CLAHE, threshold sweep, CC fallback) compensa mais do que trocar encoder de B3 para B4.</p>\n<p><strong>2. Threshold é parâmetro de produção, não de treino</strong></p>\n<p>O sweep <code>[0.35…0.55]</code> é configurável via env. Diferentes dispositivos/câmeras exigem calibração empírica do threshold sem retreinar.</p>\n<p><strong>3. DL + CV clássica são complementares</strong></p>\n<table>\n<thead>\n<tr>\n<th><strong>Tarefa</strong></th>\n<th><strong>Abordagem</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Segmentação semântica</td>\n<td>U-Net (DL)</td>\n</tr>\n<tr>\n<td>Pré-processamento</td>\n<td>CLAHE, unsharp (CV clássica)</td>\n</tr>\n<tr>\n<td>Binarização robusta</td>\n<td>Threshold sweep + morfologia + CC</td>\n</tr>\n<tr>\n<td>Escala métrica</td>\n<td>ArUco / régua (CV clássica)</td>\n</tr>\n<tr>\n<td>Dimensões clínicas</td>\n<td>PCA + minAreaRect (álgebra linear)</td>\n</tr>\n<tr>\n<td>Correção de perspectiva</td>\n<td>Homografia (projective geometry)</td>\n</tr>\n</tbody>\n</table>\n<p><strong>4. Propagação de coordenadas é a fonte #1 de bugs</strong></p>\n<p>Letterbox → inferência → unletterbox → homografia (opcional) → overlay. Cada transformação precisa de metadados reversíveis. <code>assert_image_mask_aligned()</code> valida shape/dtype antes de geometria.</p>\n<p><strong>5. Fail-fast com diagnóstico</strong></p>\n<p>Retornar <code>prob_max=0.12</code> em <code>WoundNotDetectedError</code> é mais útil que retornar máscara vazia silenciosamente. Permite tuning de threshold e detecção de model drift em produção.</p>\n<p><strong>6. Export ≠ treino</strong></p>\n<p>TorchScript trace congela o grafo para <code>(1,3,512,512)</code>. Qualquer mudança de input size ou normalização exige re-export. ONNX com <code>dynamic_axes</code> no batch é mais flexível para classificação.</p>\n<hr />\n<h2><a href=\"#stack\" aria-hidden=\"true\" class=\"anchor\" id=\"stack\"></a><strong>Stack</strong></h2>\n<ul>\n<li>PyTorch 2.x + segmentation_models_pytorch + timm</li>\n<li>OpenCV 4.7+ (aruco, morfologia, homografia)</li>\n<li>FastAPI + uvicorn</li>\n<li>TorchScript (seg) / ONNX opset 17 (cls)</li>\n<li>Redis (cache + rate limit)</li>\n<li>pytest (unit tests)</li>\n</ul>\n<h2><a href=\"#o-que-eu-faria-diferente-e-o-que-recomendo-estudar\" aria-hidden=\"true\" class=\"anchor\" id=\"o-que-eu-faria-diferente-e-o-que-recomendo-estudar\"></a><strong>O que eu faria diferente (e o que recomendo estudar)</strong></h2>\n<ol>\n<li><strong>Dataset mobile-first</strong> — incluir desde o início fotos com flash, blur, glare, fundos clínicos variados e exemplos negativos (pele sã, tatuagens).</li>\n<li><strong>Validar IoU em holdout exclusivamente mobile</strong> — métricas de desktop enganam.</li>\n<li><strong>Não subestimar CV clássica</strong> — ArUco, morfologia, CLAHE e análise de contornos são ferramentas maduras que complementam deep learning.</li>\n<li><strong>Pensar em falhas desde o design</strong> — o sistema precisa dizer <em>por que</em> falhou, não apenas <em>que</em> falhou.</li>\n<li><strong>Threshold não é hiperparâmetro de treino</strong> — é parâmetro de produto, ajustável por ambiente.</li>\n</ol>\n<!-- Content Injected to every content markdown footer -->\n",
      "summary": "",
      "date_published": "2026-07-08T00:00:00-00:00",
      "image": "/media/diagrama.png",
      "authors": [
        {
          "name": "Fabrício Santos",
          "url": "",
          "avatar": "https://avatars.githubusercontent.com/u/90152237?v=4"
        }
      ],
      "tags": [
        "IA",
        "computer vision"
      ],
      "language": "pt-BR"
    }
  ]
}