HardwareVision/hardware-vision-api
0
1# ═══════════════════════════════════════════════════════════════════════════════2# main.py — Hardware Vision API (Render)3# Pipeline completo: YOLO + Gemini + SORT Tracking4# ═══════════════════════════════════════════════════════════════════════════════5import os, json, re, urllib.parse, time6from fastapi import FastAPI, File, UploadFile, Form7from fastapi.middleware.cors import CORSMiddleware8from PIL import Image9import io, numpy as np10import cv211import uvicorn12 13from ultralytics import YOLO # best_v4.pt14from google import genai15from google.genai import types16 17# ── Configurações ────────────────────────────────────────────────────────────18GEMINI_KEY = os.environ.get("GEMINI_KEY", "")19CONFIANCA_MIN = 0.15 # baixo — YOLO localiza, Gemini classifica20MODELOS_GEMINI = [21 "gemini-3.1-flash-lite",22 "gemini-3.1-flash-lite-preview",23 "gemini-3.5-flash",24]25 26# ── SORT Tracker leve — associa detecções entre frames via IoU ────────────────27class Track:28 """Representa um objeto rastreado com ID estável."""29 _next_id = 130 31 def __init__(self, bbox, nome):32 self.id = Track._next_id33 Track._next_id += 134 self.bbox = bbox # [x1,y1,x2,y2] normalizado35 self.nome = nome36 self.idade = 0 # frames desde última detecção37 self.ativo = True38 39 def atualizar(self, bbox, nome):40 self.bbox = bbox41 self.nome = nome42 self.idade = 043 self.ativo = True44 45class SORTLeve:46 """47 SORT simplificado sem Kalman — usa IoU para associar detecções a tracks.48 Leve o suficiente para rodar no container gratuito do HF Spaces.49 """50 def __init__(self, iou_min=0.3, max_idade=3):51 self.tracks = []52 self.iou_min = iou_min # IoU mínimo para associar53 self.max_idade = max_idade # frames sem detecção antes de remover54 55 @staticmethod56 def iou(a, b):57 """Calcula IoU entre dois bboxes [x1,y1,x2,y2]."""58 xi1 = max(a[0], b[0]); yi1 = max(a[1], b[1])59 xi2 = min(a[2], b[2]); yi2 = min(a[3], b[3])60 inter = max(0, xi2-xi1) * max(0, yi2-yi1)61 if inter == 0:62 return 0.063 area_a = (a[2]-a[0]) * (a[3]-a[1])64 area_b = (b[2]-b[0]) * (b[3]-b[1])65 return inter / (area_a + area_b - inter)66 67 def atualizar(self, deteccoes):68 """69 deteccoes: lista de dicts com bbox [x1,y1,x2,y2] e nome70 Retorna lista de dicts com track_id adicionado71 """72 # Envelhece todos os tracks73 for t in self.tracks:74 t.idade += 175 76 associados_track = set()77 associados_det = set()78 79 # Matriz de IoU entre tracks ativos e novas detecções80 tracks_ativos = [t for t in self.tracks if t.ativo or t.idade <= self.max_idade]81 for i, det in enumerate(deteccoes):82 melhor_iou = self.iou_min83 melhor_track = None84 for j, track in enumerate(tracks_ativos):85 if j in associados_track:86 continue87 iou_val = self.iou(det["bbox"], track.bbox)88 if iou_val > melhor_iou:89 melhor_iou = iou_val90 melhor_track = j91 if melhor_track is not None:92 tracks_ativos[melhor_track].atualizar(det["bbox"], det["nome"])93 det["track_id"] = tracks_ativos[melhor_track].id94 associados_track.add(melhor_track)95 associados_det.add(i)96 97 # Detecções sem match → novo track98 for i, det in enumerate(deteccoes):99 if i not in associados_det:100 novo = Track(det["bbox"], det["nome"])101 self.tracks.append(novo)102 det["track_id"] = novo.id103 104 # Remove tracks muito antigos105 self.tracks = [t for t in self.tracks if t.idade <= self.max_idade]106 107 return deteccoes108 109# Tracker global — persiste entre requisições no mesmo processo110_tracker = SORTLeve(iou_min=0.3, max_idade=3)111_tracker_reset = time.time() # resetar se ficar muito tempo sem chamadas112 113# ── Inicializa Gemini ─────────────────────────────────────────────────────────114client = genai.Client(api_key=GEMINI_KEY, http_options={'api_version': 'v1beta'})115 116# ── Aliases de componentes (texto do usuário → classe do modelo) ──────────────117ALIASES = {118 # RAM → classe V4: "RAM"119 "ram": "RAM",120 "memória ram": "RAM",121 "memoria ram": "RAM",122 "memória": "RAM",123 "memoria": "RAM",124 "pente de ram": "RAM",125 "pente ram": "RAM",126 # SSD → classe V4: "SSD"127 "ssd": "SSD",128 "ssd sata": "SSD",129 "ssd nvme": "SSD",130 "ssd m.2": "SSD",131 "nvme": "SSD",132 "m.2": "SSD",133 "disco sólido": "SSD",134 "disco solido": "SSD",135 # HDD → classe V4: "HDD"136 "hd": "HDD",137 "hdd": "HDD",138 "hd sata": "HDD",139 "hdd sata": "HDD",140 "disco": "HDD",141 "disco rígido": "HDD",142 "disco rigido": "HDD",143 "armazenamento": "HDD",144 "drive": "HDD",145 # CPU → classe V4: "cpu"146 "processador": "cpu",147 "proc": "cpu",148 "processor": "cpu",149 # GPU → classe V4: "GPU"150 "placa de vídeo": "GPU",151 "placa de video": "GPU",152 "gpu": "GPU",153 "placa gráfica": "GPU",154 "placa grafica": "GPU",155 "video card": "GPU",156 # Classes V2/V3 mantidas para fallback157 "cooler": "cpu_cooler",158 "cooler cpu": "cpu_cooler",159 "ventoinha": "cpu_cooler",160 "fonte": "psu",161 "fonte de alimentação": "psu",162 "fonte de alimentacao": "psu",163 "power supply": "psu",164 "placa mãe": "motherboard",165 "placa mae": "motherboard",166 "placa-mãe": "motherboard",167 "placa-mae": "motherboard",168 "mainboard": "motherboard",169 "slot ram": "ram_slot",170 "slot de ram": "ram_slot",171 "painel frontal": "front_panel",172 "io traseiro": "rear_io",173 "rear io": "rear_io",174 "drive óptico": "optical_drive",175 "drive optico": "optical_drive",176 "dvd": "optical_drive",177 "leitor dvd": "optical_drive",178 "slot gpu": "gpu_slot",179 "slot de gpu": "gpu_slot",180 "slot pcie": "gpu_slot",181}182 183# ── Tradução de nomes técnicos → português ───────────────────────────────────184NOMES_PT = {185 # Classes do V4 (GPU, HDD, RAM, SSD, cpu)186 "gpu": "Placa de Vídeo",187 "hdd": "HD",188 "ram": "Memória RAM",189 "ssd": "SSD",190 "cpu": "Processador",191 # Classes do V3/V2 (fallback)192 "ram_stick": "Memória RAM",193 "disk_drive": "SSD/HD",194 "cpu_cooler": "Cooler CPU",195 "gpu_slot": "Slot PCIe",196 "motherboard": "Placa-Mãe",197 "optical_drive": "Drive Óptico",198 "psu": "Fonte",199 "ram_slot": "Slot RAM",200 "front_panel": "Painel Frontal",201 "rear_io": "Painel Traseiro",202}203 204def traduzir_nome(nome: str) -> str:205 """Converte classe técnica do YOLO para nome amigável em português."""206 return NOMES_PT.get(nome.lower().strip(), nome)207 208def normalizar_componente(texto: str) -> str:209 """Mapeia o texto livre do usuário para a classe do modelo YOLO."""210 t = texto.lower().strip()211 return ALIASES.get(t, t)212 213 214def normalizar_nome_exibicao(nome: str) -> str:215 """216 Normaliza o nome final que vai virar LABEL na tela (AR/popup).217 218 Regras:219 - Nunca exibir "SSD/HD" genérico — decidir entre SSD SATA / SSD NVMe / HD.220 - Bateria: exibir apenas "Bateria" (nunca "Bateria de notebook").221 - Se o Gemini devolver um nome COMPOSTO (ex: "Memória RAM, SSD/HD"),222 manter só o PRIMEIRO componente — evita label com dois nomes quando223 uma peça está sobreposta à outra.224 """225 if not nome:226 return nome227 228 # 1) Nome composto ("Memória RAM, SSD/HD" / "RAM e SSD" / "RAM / SSD")229 # -> fica só o primeiro. A separação por vírgula é a mais comum do Gemini.230 for sep in [",", " e ", " / ", "/"]:231 # cuidado: "SSD/HD" usa "/" mas é um token único conhecido;232 # só quebramos em "/" se NÃO for exatamente o par ssd/hd ou hd/ssd233 if sep in ("/", " / "):234 teste = nome.lower().replace(" ", "")235 if teste in ("ssd/hd", "hd/ssd"):236 continue237 if sep in nome:238 nome = nome.split(sep)[0].strip()239 break240 241 n = nome.strip().lower()242 # versão "achatada" (sem espaços) para comparar tokens tipo "ssd / hd"243 nflat = n.replace(" ", "")244 245 # 2) Bateria — sempre curto246 if "bateria" in n:247 return "Bateria"248 249 # 3) Memória RAM — normaliza formas abreviadas250 if n in ("ram", "memoria ram", "memória ram", "dimm", "so-dimm", "sodimm"):251 return "Memória RAM"252 253 # 4) Armazenamento — desambiguar SSD/HD254 if "nvme" in n or "m.2" in n or "m2" in nflat:255 return "SSD NVMe"256 if "sata" in n and "ssd" in n:257 return "SSD SATA"258 if nflat in ("ssd/hd", "hd/ssd", "ssdouhd", "ssd", "discosólido", "discosolido"):259 # sem info suficiente para saber a interface -> assume o caso mais comum (2.5")260 return "SSD SATA"261 if n in ("hd", "hdd") or "mecânico" in n or "mecanico" in n or "disco rígido" in n:262 return "HD"263 264 return nome265 266 267# ── TARGET 2.0 — resolução de ALVO ───────────────────────────────────────────268# Diferente de ALIASES (que mapeia texto livre -> CLASSE DO YOLO, usado pelo269# /target e /raiox legados), este mapa converte o texto livre do usuário nos270# NOMES DE EXIBIÇÃO que o /detectar realmente devolve (os mesmos produzidos por271# normalizar_nome_exibicao). Sem ele, pedir "SSD NVMe" cairia em ALIASES como272# "SSD" e traria também os SATA -- justamente a distincao que queremos manter.273#274# Match HIERARQUICO: um alvo generico casa com seus filhos.275# "SSD" -> SSD SATA + SSD NVMe (pai casa com os dois)276# "SSD NVMe" -> so SSD NVMe (filho casa so consigo)277ALVO_FAMILIAS = {278 "SSD": ["SSD SATA", "SSD NVMe"],279 "Armazenamento": ["SSD SATA", "SSD NVMe", "HD"],280 "Cabo interno": ["Cabo SATA", "Cabo Flat/FFC", "Cabo de alimentacao",281 "Cabo de bateria", "Cabo de antena Wi-Fi",282 "Cabo da tela", "Cabo interno"],283}284 285ALIASES_ALVO = {286 # ── Memoria RAM ──287 "ram": "Memória RAM", "memoria": "Memória RAM", "memória": "Memória RAM",288 "memoria ram": "Memória RAM", "memória ram": "Memória RAM",289 "pente": "Memória RAM", "pente de ram": "Memória RAM",290 "pente ram": "Memória RAM", "dimm": "Memória RAM", "so-dimm": "Memória RAM",291 "sodimm": "Memória RAM",292 # ── SSD generico (casa com SATA e NVMe) ──293 "ssd": "SSD", "disco solido": "SSD", "disco sólido": "SSD",294 "estado solido": "SSD", "estado sólido": "SSD",295 # ── SSD especifico ──296 "ssd sata": "SSD SATA", "ssd 2.5": "SSD SATA", "ssd sata 2.5": "SSD SATA",297 "ssd nvme": "SSD NVMe", "nvme": "SSD NVMe", "m.2": "SSD NVMe",298 "m2": "SSD NVMe", "ssd m.2": "SSD NVMe", "ssd m2": "SSD NVMe",299 # ── HD ──300 "hd": "HD", "hdd": "HD", "disco rigido": "HD", "disco rígido": "HD",301 "disco": "HD", "hd mecanico": "HD", "hd mecânico": "HD",302 # ── Armazenamento (generico amplo) ──303 "armazenamento": "Armazenamento", "storage": "Armazenamento",304 # ── Processador ──305 "cpu": "Processador", "processador": "Processador", "proc": "Processador",306 "processor": "Processador",307 # ── Placa de Video ──308 "gpu": "Placa de Vídeo", "placa de video": "Placa de Vídeo",309 "placa de vídeo": "Placa de Vídeo", "placa grafica": "Placa de Vídeo",310 "placa gráfica": "Placa de Vídeo", "video card": "Placa de Vídeo",311 "vga": "Placa de Vídeo",312 # ── Placa-Mae ──313 "placa mae": "Placa-Mãe", "placa mãe": "Placa-Mãe",314 "placa-mae": "Placa-Mãe", "placa-mãe": "Placa-Mãe",315 "mobo": "Placa-Mãe", "motherboard": "Placa-Mãe",316 # ── Fonte ──317 "fonte": "Fonte", "psu": "Fonte", "fonte de alimentacao": "Fonte",318 "fonte de alimentação": "Fonte",319 # ── Cooler ──320 "cooler": "Cooler CPU", "cooler cpu": "Cooler CPU",321 "cooler do processador": "Cooler CPU", "dissipador": "Cooler CPU",322 "ventoinha": "Cooler CPU", "fan": "Cooler CPU",323 # ── Bateria ──324 "bateria": "Bateria", "bateria de notebook": "Bateria",325 "bateria do notebook": "Bateria",326 # ── Drive Optico ──327 "drive optico": "Drive Óptico", "drive óptico": "Drive Óptico",328 "dvd": "Drive Óptico", "cd": "Drive Óptico", "leitor de dvd": "Drive Óptico",329 # ── Cabos ──330 "cabo": "Cabo interno", "cabos": "Cabo interno",331 "cabo interno": "Cabo interno",332 "cabo sata": "Cabo SATA",333 "cabo flat": "Cabo Flat/FFC", "flat": "Cabo Flat/FFC",334 "ffc": "Cabo Flat/FFC", "cabo ffc": "Cabo Flat/FFC",335 "cabo de alimentacao": "Cabo de alimentação",336 "cabo de alimentação": "Cabo de alimentação",337 "cabo de energia": "Cabo de alimentação",338 "cabo de bateria": "Cabo de bateria",339 "cabo da bateria": "Cabo de bateria",340 "cabo de antena": "Cabo de antena Wi-Fi",341 "cabo wifi": "Cabo de antena Wi-Fi",342 "cabo wi-fi": "Cabo de antena Wi-Fi",343 "antena": "Cabo de antena Wi-Fi",344 "cabo da tela": "Cabo da tela", "cabo do display": "Cabo da tela",345 "cabo lvds": "Cabo da tela",346}347 348 349def resolver_alvo(texto: str) -> str | None:350 """351 Converte o texto livre do usuario no NOME DE EXIBICAO canonico do alvo.352 Retorna None se o texto for vazio (= sem filtro, modo Radar normal).353 354 Tolera acento ausente, plural simples e ruido de fala ("procurar o ssd",355 "quero achar a memoria ram"). Se nao reconhecer, devolve o texto limpo --356 assim um alvo desconhecido simplesmente nao casa com nada, em vez de357 silenciosamente virar outro componente.358 """359 if not texto:360 return None361 t = texto.strip().lower()362 if not t:363 return None364 365 # Remove ruido de comando de voz mais comum366 for lixo in ("procurar ", "procure ", "localizar ", "localize ",367 "encontrar ", "encontre ", "achar ", "ache ", "buscar ",368 "busque ", "quero ", "o ", "a ", "os ", "as ", "um ", "uma "):369 if t.startswith(lixo):370 t = t[len(lixo):].strip()371 372 if t in ALIASES_ALVO:373 return ALIASES_ALVO[t]374 375 # plural simples376 if t.endswith("s") and t[:-1] in ALIASES_ALVO:377 return ALIASES_ALVO[t[:-1]]378 379 # match por substring (ex.: "pente de memoria ram ddr4")380 # do alias mais longo para o mais curto -> "ssd nvme" ganha de "ssd"381 for alias in sorted(ALIASES_ALVO, key=len, reverse=True):382 if alias in t:383 return ALIASES_ALVO[alias]384 385 return texto.strip()386 387 388def alvo_casa(alvo_canonico: str, nome_detectado: str) -> bool:389 """390 True se o componente detectado satisfaz o alvo pedido.391 392 Hierarquico: se o alvo for uma familia ("SSD"), qualquer membro casa393 ("SSD SATA", "SSD NVMe"). Se for especifico, exige igualdade.394 """395 if not alvo_canonico:396 return True397 if nome_detectado == alvo_canonico:398 return True399 filhos = ALVO_FAMILIAS.get(alvo_canonico)400 if filhos and nome_detectado in filhos:401 return True402 return False403 404 405 406 407# ── Download automático do best_v4.pt via huggingface_hub ────────────────────408try:409 from huggingface_hub import hf_hub_download410 if not os.path.exists("best_v4.pt"):411 print("📥 Baixando best_v4.pt do Hugging Face...")412 hf_hub_download(413 repo_id="HardwareVision/hardware-vision-api",414 filename="best_v4.pt",415 repo_type="space",416 local_dir="."417 )418 print("✅ best_v4.pt baixado!")419 else:420 print("✅ best_v4.pt já existe localmente!")421except Exception as e:422 print(f"⚠️ Não foi possível baixar best_v4.pt: {e}")423 424# ── Inicializa YOLO — v4 principal, v3 fallback, v2 último recurso ───────────425try:426 modelo_yolo = YOLO("best_v4.pt")427 print(f"✅ YOLO v4 carregado! Classes: {list(modelo_yolo.names.values())}")428except Exception as e:429 print(f"⚠️ best_v4.pt não encontrado, tentando best_v3.pt: {e}")430 try:431 modelo_yolo = YOLO("best_v3.pt")432 print(f"✅ YOLO v3 (fallback) carregado! Classes: {list(modelo_yolo.names.values())}")433 except Exception as e2:434 print(f"⚠️ best_v3.pt não encontrado, tentando best_v2.pt: {e2}")435 try:436 modelo_yolo = YOLO("best_v2.pt")437 print(f"✅ YOLO v2 (fallback) carregado! Classes: {list(modelo_yolo.names.values())}")438 except Exception as e3:439 print(f"❌ Erro YOLO: {e3}")440 modelo_yolo = None441 442 443 444# ── FastAPI ───────────────────────────────────────────────────────────────────445app = FastAPI(title="Hardware Vision API")446app.add_middleware(CORSMiddleware, allow_origins=["*"],447 allow_methods=["*"], allow_headers=["*"])448 449# ── Helpers ───────────────────────────────────────────────────────────────────450 451# ══════════════════════════════════════════════════════════════════════════════452# ANTI-REDUNDÂNCIA DE MARCAÇÃO453#454# O YOLO frequentemente desenha DUAS OU MAIS caixas sobre o MESMO objeto físico455# (ex.: um único SSD recebendo "SSD SATA (1)" e "SSD SATA (2)"). Isso não é um456# erro da numeração — são caixas duplicadas chegando do detector.457#458# Aqui suprimimos essas caixas ANTES de chamar o Gemini, o que:459# 1) elimina os labels duplicados sobre o mesmo componente;460# 2) economiza chamadas à API (cada caixa a menos = uma chamada a menos).461#462# Dois testes complementares:463# • IoU alto → mesma área ocupada = mesmo objeto.464# • Contenção (overlap) → uma caixa quase toda DENTRO da outra. Pega o caso465# que o IoU deixa passar: caixa pequena dentro de uma bem maior tem IoU466# baixo (a união é grande), mas obviamente é o mesmo objeto.467#468# ATENÇÃO — o que NÃO deve ser suprimido: dois componentes iguais e DISTINTOS469# (2 pentes de RAM lado a lado). Esses têm caixas separadas, com IoU ~0 e sem470# contenção — passam intactos pelos dois testes.471# ══════════════════════════════════════════════════════════════════════════════472 473def _iou_box(a, b):474 """IoU entre dois bboxes [x1,y1,x2,y2]."""475 xi1 = max(a[0], b[0]); yi1 = max(a[1], b[1])476 xi2 = min(a[2], b[2]); yi2 = min(a[3], b[3])477 inter = max(0, xi2 - xi1) * max(0, yi2 - yi1)478 if inter == 0:479 return 0.0480 area_a = (a[2] - a[0]) * (a[3] - a[1])481 area_b = (b[2] - b[0]) * (b[3] - b[1])482 uniao = area_a + area_b - inter483 return inter / uniao if uniao > 0 else 0.0484 485def _contencao_box(a, b):486 """Fração da MENOR caixa que está dentro da outra (0..1).487 488 Diferente do IoU, não é penalizada pela diferença de tamanho: uma caixa489 pequena 100% dentro de uma grande retorna 1.0.490 """491 xi1 = max(a[0], b[0]); yi1 = max(a[1], b[1])492 xi2 = min(a[2], b[2]); yi2 = min(a[3], b[3])493 inter = max(0, xi2 - xi1) * max(0, yi2 - yi1)494 if inter == 0:495 return 0.0496 area_a = (a[2] - a[0]) * (a[3] - a[1])497 area_b = (b[2] - b[0]) * (b[3] - b[1])498 menor = min(area_a, area_b)499 return inter / menor if menor > 0 else 0.0500 501def suprimir_caixas_redundantes(boxes, iou_max=0.35, contencao_max=0.70):502 """NMS: remove caixas que marcam o MESMO objeto físico.503 504 boxes: lista de tuplas (conf, x1, y1, x2, y2)505 Mantém sempre a caixa de MAIOR confiança de cada grupo sobreposto.506 Retorna a lista filtrada.507 508 Observação: aqui ainda não sabemos o NOME (o Gemini só classifica depois),509 então a supressão é puramente geométrica — o que é o correto: duas caixas510 no mesmo lugar são o mesmo objeto, seja ele qual for.511 """512 if not boxes:513 return []514 # Maior confiança primeiro — ela é a "dona" da região.515 ordenadas = sorted(boxes, key=lambda t: t[0], reverse=True)516 mantidas = []517 for cand in ordenadas:518 c_box = cand[1:5]519 redundante = False520 for m in mantidas:521 m_box = m[1:5]522 iou = _iou_box(c_box, m_box)523 cont = _contencao_box(c_box, m_box)524 if iou >= iou_max or cont >= contencao_max:525 redundante = True526 print(f" ↳ caixa conf={cand[0]:.2f} suprimida "527 f"(IoU={iou:.2f}, contenção={cont:.2f}) — mesmo objeto")528 break529 if not redundante:530 mantidas.append(cand)531 return mantidas532 533def contar_componentes_na_cena(cena_bytes: bytes, nomes: list[str]) -> dict:534 """Pergunta ao Gemini QUANTOS componentes de cada tipo existem na CENA.535 536 Esta é a validação semântica do frame completo: o YOLO pode marcar o mesmo537 SSD duas vezes, mas quem olha a cena inteira sabe que existe apenas UM SSD538 físico ali. Usamos essa contagem como TETO para o número de labels.539 540 nomes: tipos únicos que o Gemini já identificou nos recortes.541 Retorna {nome: quantidade}. Em caso de falha, retorna {} (sem limite).542 """543 if not cena_bytes or not nomes:544 return {}545 lista = "\n".join(f"- {n}" for n in nomes)546 prompt = (547 "Você é especialista em hardware interno de PCs e notebooks.\n"548 "Olhe a CENA COMPLETA da imagem e conte quantas UNIDADES FÍSICAS "549 "DISTINTAS de cada componente abaixo aparecem nela.\n\n"550 f"{lista}\n\n"551 "REGRAS CRÍTICAS:\n"552 "1. Conte OBJETOS FÍSICOS SEPARADOS, não partes do mesmo objeto.\n"553 " Ex.: um único SSD visto de cima é 1 (mesmo que tenha etiquetas,\n"554 " parafusos ou reflexos que pareçam dividi-lo).\n"555 "2. Dois pentes de memória RAM lado a lado = 2. Um pente só = 1.\n"556 "3. Se o componente não aparece na cena, responda 0.\n"557 "4. Conte apenas o que você VÊ com clareza.\n\n"558 "Responda APENAS um JSON, sem texto extra, no formato:\n"559 '{"Memória RAM": 2, "SSD SATA": 1}'560 )561 try:562 data = gemini_json(prompt, cena_bytes)563 if not isinstance(data, dict):564 return {}565 # Sanitiza: só nomes pedidos, valores inteiros >= 0566 limpo = {}567 for n in nomes:568 v = data.get(n)569 if isinstance(v, (int, float)) and v >= 0:570 limpo[n] = int(v)571 return limpo572 except Exception as e:573 print(f"⚠️ contar_componentes_na_cena erro: {e}")574 return {}575 576def bytes_para_cv2(b):577 nparr = np.frombuffer(b, np.uint8)578 return cv2.imdecode(nparr, cv2.IMREAD_COLOR)579 580def gemini_gerar(prompt, imagem_bytes=None):581 """582 imagem_bytes pode ser:583 - None (só texto)584 - bytes (uma imagem)585 - lista de bytes (várias imagens, enviadas na ordem dada)586 """587 key = os.environ.get("GEMINI_KEY", GEMINI_KEY)588 if isinstance(imagem_bytes, (bytes, bytearray)):589 imagens = [imagem_bytes]590 elif imagem_bytes:591 imagens = list(imagem_bytes)592 else:593 imagens = []594 for modelo in MODELOS_GEMINI:595 try:596 c = genai.Client(api_key=key, http_options={"api_version": "v1beta"})597 contents = [prompt]598 for img in imagens:599 if img:600 contents.append(601 types.Part.from_bytes(data=img, mime_type="image/jpeg"))602 resp = c.models.generate_content(model=modelo, contents=contents)603 return resp.text604 except Exception as e:605 print(f"⚠️ {modelo} falhou: {e}")606 raise Exception("Todos os modelos Gemini falharam")607 608def gemini_json(prompt, imagem_bytes=None):609 txt = gemini_gerar(prompt, imagem_bytes)610 txt = txt.replace("```json","").replace("```","").strip()611 match = re.search(r'\{[\s\S]*\}', txt)612 if match:613 return json.loads(match.group(0))614 return json.loads(txt)615 616def gemini_identificar_recorte(617 recorte_bytes: bytes,618 cena_bytes: bytes | None = None,619 posicao: str | None = None,620) -> str | None:621 tem_contexto = cena_bytes is not None622 prompt = (623 "Você é especialista em hardware interno de PCs e notebooks.\n"624 + (625 "Você receberá DUAS imagens:\n"626 "1) A CENA COMPLETA (a tela inteira da câmera), só para você entender o "627 "contexto e o que está ao redor.\n"628 f"2) O RECORTE a ser identificado"629 + (f", localizado em: {posicao} da cena.\n" if posicao else ".\n")630 + "IDENTIFIQUE APENAS o componente do RECORTE (imagem 2). Use a cena "631 "(imagem 1) apenas como apoio para ter CERTEZA — por exemplo, para "632 "distinguir uma memória RAM de um SSD quando o recorte está escuro ou "633 "ambíguo. NÃO descreva a cena inteira.\n\n"634 if tem_contexto else635 "Observe este recorte com MUITO CUIDADO e identifique o componente.\n\n"636 )637 ) + (638 "COMPONENTES INTERNOS VÁLIDOS — responda EXATAMENTE um destes nomes:\n"639 "Componentes principais:\n"640 "- Memória RAM: módulo DIMM/SO-DIMM, placa pequena com chips enfileirados e conector dourado\n"641 "- SSD SATA: disco de estado sólido 2.5 polegadas, caixa metálica retangular, conector SATA\n"642 "- SSD NVMe: placa fina pequena (M.2) com poucos chips, encaixe único, sem caixa metálica\n"643 "- HD: disco rígido mecânico, mais grosso/pesado, geralmente com etiqueta de RPM\n"644 "- Processador: chip CPU encaixado no socket\n"645 "- Placa de Vídeo: GPU com cooler\n"646 "- Placa-Mãe: placa principal grande com vários slots\n"647 "- Fonte: caixa metálica com ventilador e cabos de energia\n"648 "- Cooler CPU: dissipador com ventoinha sobre o processador\n"649 "- Bateria de notebook: bateria Li-Ion interna\n"650 "- Drive Óptico: leitor DVD/CD\n"651 "Cabos internos (identifique com precisão):\n"652 "- Cabo SATA: cabo fino com conector em L nas pontas\n"653 "- Cabo Flat/FFC: fita plana fina branca/bege/preta\n"654 "- Cabo de alimentação: cabo com conectores de energia\n"655 "- Cabo de bateria: cabo fino conectado à bateria do notebook\n"656 "- Cabo de antena Wi-Fi: cabo coaxial fino\n"657 "- Cabo da tela: cabo flat que conecta o display\n"658 "\n"659 "RESPONDA NENHUM para periféricos externos:\n"660 "- Teclado, mouse, monitor, webcam, caixas de som\n"661 "- Cabo USB, cabo HDMI, cabo de rede, carregador externo\n"662 "- Mãos, dedos, mesa, fundo, parafusos, suportes\n"663 "\n"664 "REGRA CRÍTICA: NUNCA confunda cabo com Memória RAM ou outro componente principal.\n"665 "Se for cabo interno mas incerto do tipo, responda: Cabo interno\n"666 "\n"667 "REGRA CRÍTICA 2: este recorte contém UM ÚNICO componente — o que ocupa\n"668 "a MAIOR ÁREA / está mais ao CENTRO do recorte. Se houver outra peça atrás\n"669 "ou sobreposta, IGNORE-A. Responda apenas UM nome. NUNCA responda dois nomes\n"670 "juntos (ex: nunca 'Memória RAM, SSD'). Escolha o componente dominante.\n"671 "\n"672 "Responda SOMENTE o nome exato ou NENHUM. Nada mais."673 )674 try:675 # Ordem das imagens: [cena completa, recorte] — o prompt referencia676 # "imagem 1 = cena" e "imagem 2 = recorte" nessa ordem.677 if tem_contexto:678 imgs = [cena_bytes, recorte_bytes]679 else:680 imgs = recorte_bytes681 txt = gemini_gerar(prompt, imgs)682 txt = txt.strip().strip('"').strip("'")683 if not txt or txt.upper() == "NENHUM":684 return None685 return normalizar_nome_exibicao(txt)686 except Exception as e:687 print(f"⚠️ gemini_identificar_recorte erro: {e}")688 return None689 690def recortar_box(img_cv2, x1, y1, x2, y2, padding=4) -> bytes:691 # padding pequeno (4px) evita que o recorte de uma peça invada a etiqueta692 # de uma peça vizinha colada — antes eram 10px, que com 2 RAMs empilhadas693 # capturavam a etiqueta da outra e o Gemini fundia os dados.694 h, w = img_cv2.shape[:2]695 x1c = max(0, int(x1) - padding)696 y1c = max(0, int(y1) - padding)697 x2c = min(w, int(x2) + padding)698 y2c = min(h, int(y2) + padding)699 recorte = img_cv2[y1c:y2c, x1c:x2c]700 _, buf = cv2.imencode('.jpg', recorte, [cv2.IMWRITE_JPEG_QUALITY, 85])701 return buf.tobytes()702 703def descrever_com_gemini(imagem_bytes, nome):704 prompt = (705 f"Aja como especialista em hardware. Componente: {nome}.\n"706 f"IMPORTANTE: a imagem pode conter, nas BORDAS, pedaços de OUTRA peça "707 f"vizinha. Descreva SOMENTE a peça CENTRAL (a que ocupa o meio do "708 f"recorte). Se aparecer a etiqueta de outra peça na borda, IGNORE — "709 f"não misture marcas, modelos ou capacidades de peças diferentes.\n"710 f"Responda APENAS JSON puro sem markdown:\n"711 f'{{"tipo":"SSD","modelo":"SA400S37/240G","descricao":"Descrição técnica em até 25 palavras.","dica":"Dica prática curta."}}\n'712 f"- modelo: exatamente como aparece na etiqueta física da peça CENTRAL, null se não visível\n"713 f"- NUNCA invente modelos e NUNCA combine dados de duas peças (ex: 'hynix / ProMOS' ou '512MB / 1GB' está ERRADO)"714 )715 dados = gemini_json(prompt, imagem_bytes)716 dados.setdefault("modelo", None)717 return dados718 719# ════════════════════════════════════════════════════════════════════════════720# ROTAS721# ════════════════════════════════════════════════════════════════════════════722 723@app.get("/")724def health():725 classes = list(modelo_yolo.names.values()) if modelo_yolo else []726 return {"status": "online", "modelo": "Hardware Vision API", "classes": classes}727 728@app.get("/ping")729def ping():730 return {"status": "online", "yolo": modelo_yolo is not None}731 732# ── /detectar — YOLO + Gemini + SORT tracking ────────────────────────────────733@app.post("/detectar")734async def detectar(735 imagem: UploadFile = File(...),736 alvo: str = Form(""), # TARGET 2.0 — vazio = Modo Radar normal737):738 global _tracker, _tracker_reset739 try:740 alvo_canonico = resolver_alvo(alvo)741 if alvo_canonico:742 print(f"🎯 Modo Target — alvo: {alvo_canonico!r}")743 b = await imagem.read()744 img = bytes_para_cv2(b)745 h, w = img.shape[:2]746 747 if modelo_yolo is None:748 return {"sucesso": False, "erro": "YOLO offline"}749 750 if min(h, w) < 640:751 scale = 640 / min(h, w)752 img = cv2.resize(img, (int(w*scale), int(h*scale)))753 h, w = img.shape[:2]754 755 # Reset do tracker se ficar mais de 10s sem chamadas (câmera pausada)756 agora = time.time()757 if agora - _tracker_reset > 10:758 _tracker = SORTLeve(iou_min=0.3, max_idade=3)759 _tracker_reset = agora760 761 # ── Etapa 1: YOLO localiza bounding boxes (conf baixa) ───────────────762 results = modelo_yolo(img, verbose=False)[0]763 boxes_raw = []764 for box in results.boxes:765 conf = float(box.conf[0])766 if conf >= CONFIANCA_MIN:767 x1,y1,x2,y2 = box.xyxy[0].tolist()768 boxes_raw.append((conf, x1, y1, x2, y2))769 770 if not boxes_raw:771 if alvo_canonico:772 return {"sucesso": False, "detectados": [],773 "alvo": alvo_canonico, "alvo_encontrado": False,774 "dica": "vazio", "outros": 0}775 return {"sucesso": False, "detectados": []}776 777 # ── Etapa 1.5: ANTI-REDUNDÂNCIA (NMS) ────────────────────────────────778 # O YOLO costuma desenhar várias caixas sobre o MESMO objeto físico.779 # Suprimimos aqui, ANTES do Gemini: evita labels duplicados no mesmo780 # componente (ex.: "SSD SATA (1)" e "SSD SATA (2)" no mesmo SSD) e781 # economiza uma chamada de API por caixa removida.782 # Componentes iguais mas DISTINTOS (2 RAMs lado a lado) não têm783 # sobreposição e passam intactos.784 antes = len(boxes_raw)785 boxes_raw = suprimir_caixas_redundantes(boxes_raw)786 if len(boxes_raw) < antes:787 print(f"🧹 NMS: {antes} caixas → {len(boxes_raw)} "788 f"({antes - len(boxes_raw)} duplicadas no mesmo objeto)")789 790 # ── Cena completa (reduzida) como CONTEXTO para o Gemini ─────────────791 # Reduz a largura para no máx. 720px — leve para upload, suficiente792 # para o Gemini "enxergar" o entorno de cada recorte.793 cena_bytes = None794 try:795 cena_w = 720796 if w > cena_w:797 escala = cena_w / w798 cena_img = cv2.resize(img, (cena_w, int(h * escala)))799 else:800 cena_img = img801 _, cena_buf = cv2.imencode('.jpg', cena_img,802 [cv2.IMWRITE_JPEG_QUALITY, 70])803 cena_bytes = cena_buf.tobytes()804 except Exception as e:805 print(f"⚠️ Falha ao preparar cena de contexto: {e}")806 807 def _posicao_texto(cx, cy):808 vert = "topo" if cy < 0.33 else ("centro" if cy < 0.66 else "base")809 horz = "esquerda" if cx < 0.33 else ("meio" if cx < 0.66 else "direita")810 return f"{vert}-{horz}"811 812 # ── Etapa 2: Gemini classifica cada recorte (com contexto da cena) ────813 deteccoes = []814 for conf, x1, y1, x2, y2 in boxes_raw:815 recorte = recortar_box(img, x1, y1, x2, y2)816 cx = (x1 + x2) / 2 / w817 cy = (y1 + y2) / 2 / h818 nome = gemini_identificar_recorte(819 recorte,820 cena_bytes=cena_bytes,821 posicao=_posicao_texto(cx, cy),822 )823 if nome is None:824 print(f"⚠️ Gemini descartou box conf={conf:.2f} — não é componente")825 continue826 deteccoes.append({827 "nome": nome,828 "confianca": round(conf, 2),829 "x": round((x1+x2)/2/w, 4),830 "y": round((y1+y2)/2/h, 4),831 "w": round((x2-x1)/w, 4),832 "h": round((y2-y1)/h, 4),833 "bbox": [x1/w, y1/h, x2/w, y2/h], # normalizado para SORT834 "tipo": "", "descricao": "", "dica": "", "modelo": None,835 })836 837 print(f"🔍 Detectados após Gemini: {[d['nome'] for d in deteccoes]}")838 if not deteccoes:839 if alvo_canonico:840 return {"sucesso": False, "detectados": [],841 "alvo": alvo_canonico, "alvo_encontrado": False,842 "dica": "vazio", "outros": 0}843 return {"sucesso": False, "detectados": []}844 845 # ── TARGET 2.0 — FILTRO DE ALVO ──────────────────────────────────────846 # Fica AQUI, e nao antes do Gemini, por um motivo estrutural: no847 # /detectar quem sabe o NOME de cada caixa e o proprio Gemini. O YOLO848 # so entrega coordenadas + confianca. Nao ha como pular um recorte por849 # nome antes de perguntar qual e o nome dele.850 #851 # O ganho real de filtrar aqui (e nao no app):852 # - pula contar_componentes_na_cena() quando sobra <=1 alvo853 # - o SORT so rastreia o alvo -> track_ids limpos, sem IDs fantasmas854 # de componentes que o app ia descartar mesmo855 # - resposta menor no fio856 dica_alvo = None857 outros_qtd = 0858 if alvo_canonico:859 alvos = [d for d in deteccoes if alvo_casa(alvo_canonico, d["nome"])]860 outros = [d for d in deteccoes if not alvo_casa(alvo_canonico, d["nome"])]861 outros_qtd = len(outros)862 863 if not alvos:864 # Alvo nao esta na cena. Devolve contexto para o app escrever a865 # dica na tela (efeito typewriter) em vez de so "nao encontrado".866 nomes_outros = sorted({d["nome"] for d in outros})867 print(f"🎯 Alvo {alvo_canonico!r} ausente — "868 f"cena tem: {nomes_outros or 'nada'}")869 return {870 "sucesso": False, "detectados": [],871 "alvo": alvo_canonico, "alvo_encontrado": False,872 "dica": "outros" if outros else "vazio",873 "outros": outros_qtd,874 "outros_nomes": nomes_outros,875 }876 877 # Alvo encontrado: segue o pipeline so com ele.878 deteccoes = alvos879 print(f"🎯 Alvo {alvo_canonico!r}: {len(alvos)} encontrado(s), "880 f"{outros_qtd} outro(s) descartado(s)")881 882 # Dica de enquadramento: bbox encostando na borda = peca cortada.883 # So faz sentido quando ha UM alvo (com varios, o app nao sabe a884 # qual a dica se refere).885 if len(alvos) == 1:886 x1n, y1n, x2n, y2n = alvos[0]["bbox"]887 margem = 0.02888 if (x1n <= margem or y1n <= margem889 or x2n >= 1 - margem or y2n >= 1 - margem):890 dica_alvo = "cortado"891 elif alvos[0]["w"] * alvos[0]["h"] < 0.02:892 dica_alvo = "longe"893 894 # ── Etapa 2.5: VALIDAÇÃO SEMÂNTICA COM A CENA COMPLETA ───────────────895 # O NMS geométrico pega caixas sobrepostas, mas o YOLO pode marcar o896 # mesmo objeto com caixas afastadas (ex.: duas metades de um SSD grande).897 # Aqui perguntamos ao Gemini, olhando a CENA INTEIRA, quantas unidades898 # físicas de cada tipo existem de fato — e usamos isso como TETO.899 # Se ele vê 1 SSD mas temos 2 marcações de SSD, fica só a de maior900 # confiança. Se ele vê 2 RAMs e temos 2 marcações, ambas permanecem.901 nomes_repetidos = [n for n in {d["nome"] for d in deteccoes}902 if sum(1 for d in deteccoes if d["nome"] == n) > 1]903 if nomes_repetidos and cena_bytes:904 limites = contar_componentes_na_cena(cena_bytes, nomes_repetidos)905 if limites:906 print(f"🧠 Cena diz: {limites}")907 filtradas = []908 for nome in {d["nome"] for d in deteccoes}:909 grupo = [d for d in deteccoes if d["nome"] == nome]910 teto = limites.get(nome)911 # Só corta se o Gemini deu um número válido E menor que o912 # que temos. Nunca corta abaixo de 1 (se ele errou e disse913 # 0, mantemos a melhor — o recorte já foi confirmado antes).914 if teto is not None and 0 < teto < len(grupo):915 grupo.sort(key=lambda d: d["confianca"], reverse=True)916 cortadas = len(grupo) - teto917 grupo = grupo[:teto]918 print(f" ↳ '{nome}': {cortadas} marcação(ões) extra "919 f"removida(s) — a cena mostra {teto}")920 filtradas.extend(grupo)921 deteccoes = filtradas922 923 # ── Etapa 3: SORT associa IDs estáveis entre frames ──────────────────924 deteccoes = _tracker.atualizar(deteccoes)925 926 # Remove bbox do retorno (campo interno do SORT)927 for d in deteccoes:928 d.pop("bbox", None)929 930 print(f"🎯 Track IDs: {[d['track_id'] for d in deteccoes]}")931 resp = {"sucesso": True, "detectados": deteccoes}932 if alvo_canonico:933 resp["alvo"] = alvo_canonico934 resp["alvo_encontrado"] = True935 resp["outros"] = outros_qtd936 if dica_alvo:937 resp["dica"] = dica_alvo938 return resp939 except Exception as e:940 return {"sucesso": False, "erro": str(e)}941 942# ── /descrever — Gemini descreve componentes ─────────────────────────────────943@app.post("/descrever")944async def descrever(945 imagem: UploadFile = File(...),946 componentes: str = Form("[]"),947):948 try:949 b = await imagem.read()950 comp = json.loads(componentes)951 resultado = []952 for det in comp:953 try:954 info = descrever_com_gemini(b, det["nome"])955 resultado.append({**det, **info})956 except:957 resultado.append({**det,"tipo":"Hardware",958 "descricao":"Componente identificado visualmente.",959 "dica":"Consulte o manual para especificações.","modelo":None})960 return {"sucesso": True, "componentes": resultado}961 except Exception as e:962 return {"sucesso": False, "erro": str(e)}963 964# ── /analisar — YOLO + Gemini combinados ─────────────────────────────────────965@app.post("/analisar")966async def analisar(imagem: UploadFile = File(...)):967 try:968 b = await imagem.read()969 img = bytes_para_cv2(b)970 h, w = img.shape[:2]971 972 if modelo_yolo is None:973 return {"sucesso": False, "erro": "YOLO offline"}974 975 results = modelo_yolo(img, verbose=False)[0]976 deteccoes = []977 for box in results.boxes:978 conf = float(box.conf[0])979 if conf >= CONFIANCA_MIN:980 x1,y1,x2,y2 = box.xyxy[0].tolist()981 nome_raw = modelo_yolo.names[int(box.cls[0])]982 nome = normalizar_nome_exibicao(traduzir_nome(nome_raw))983 deteccoes.append({984 "nome": nome, "confianca": round(conf,2),985 "x": round((x1+x2)/2/w,4), "y": round((y1+y2)/2/h,4),986 "w": round((x2-x1)/w,4), "h": round((y2-y1)/h,4),987 })988 989 if not deteccoes:990 return {"sucesso": False, "componentes": []}991 992 componentes_finais = []993 for det in deteccoes:994 try:995 info = descrever_com_gemini(b, det["nome"])996 componentes_finais.append({**det, **info})997 except:998 componentes_finais.append({**det,"tipo":"Hardware",999 "descricao":"Componente identificado.","dica":"","modelo":None})1000 1001 return {"sucesso": True, "detectados": deteccoes, "componentes": componentes_finais}1002 except Exception as e:1003 return {"sucesso": False, "erro": str(e)}1004 1005# ── /target — Target Vision (YOLO + Gemini) ───────────────────────────────────1006@app.post("/target")1007async def target(1008 imagem: UploadFile = File(...),1009 componente: str = Form("componente de hardware"),1010 prompt: str = Form(""),1011 preciso: str = Form("0"),1012):1013 try:1014 b = await imagem.read()1015 eh_preciso = preciso == "1"1016 1017 # ── ETAPA 1: best_v3 (pipeline principal) ─────────────────────────────1018 if eh_preciso and modelo_yolo:1019 try:1020 img = bytes_para_cv2(b)1021 h, w = img.shape[:2]1022 results = modelo_yolo(img, verbose=False)[0]1023 detectados = []1024 comp_norm = normalizar_componente(componente)1025 for box in results.boxes:1026 conf = float(box.conf[0])1027 nome_raw = modelo_yolo.names[int(box.cls[0])]1028 nome = traduzir_nome(nome_raw)1029 match = (1030 nome.lower() == comp_norm or1031 comp_norm in nome.lower() or1032 nome.lower() in comp_norm1033 )1034 if conf >= 0.35 and match:1035 x1,y1,x2,y2 = box.xyxy[0].tolist()1036 detectados.append({1037 "encontrado": True,1038 "confianca": int(conf*100),1039 "posicao": {1040 "x": round((x1+x2)/2/w,4),1041 "y": round((y1+y2)/2/h,4),1042 "largura": round((x2-x1)/w,4),1043 "altura": round((y2-y1)/h,4),1044 }1045 })1046 if detectados:1047 melhor = max(detectados, key=lambda d: d["confianca"])1048 print(f"✅ Target best_v3: {componente} conf={melhor['confianca']}%")1049 return {"sucesso": True, **melhor}1050 except Exception as e:1051 print(f"⚠️ Target best_v3 erro: {e}")1052 1053 # Gemini como fallback1054 prompt_final = prompt if prompt else (1055 f'Localize "{componente}" nesta imagem. '1056 f'Responda SOMENTE JSON: {{"encontrado":true/false,"confianca":0-100,'1057 f'"posicao":{{"x":0.0-1.0,"y":0.0-1.0,"largura":0.0-1.0,"altura":0.0-1.0}}}}'1058 )1059 dados = gemini_json(prompt_final, b)1060 return {1061 "sucesso": True,1062 "encontrado": dados.get("encontrado", False),1063 "confianca": dados.get("confianca", 0),1064 "posicao": dados.get("posicao",1065 {"x":0.5,"y":0.5,"largura":0.3,"altura":0.3}),1066 }1067 except Exception as e:1068 print(f"❌ Target erro: {e}")1069 return {"sucesso": True, "encontrado": False, "confianca": 0,1070 "posicao": {"x":0.5,"y":0.5,"largura":0.3,"altura":0.3}}1071 1072 1073# ── /doutor ───────────────────────────────────────────────────────────────────1074# ══════════════════════════════════════════════════════════════════════════════1075# /map — MODO MAP (anatomia educativa)1076#1077# SUBSTITUI o antigo /raiox. Em vez de "diagnosticar saúde" (que a IA não1078# consegue fazer por foto — um SSD desgastado e um zerado são a mesma imagem),1079# este endpoint explica a ESTRUTURA da peça: cada ponto tocável tem nome1080# técnico, função e uma nota prática.1081#1082# COMO INSTALAR:1083# 1. Cole este bloco no main.py, no lugar do bloco atual do "@app.post("/raiox")"1084# (linhas 1073-1107 do seu arquivo).1085# 2. Se quiser manter o /raiox vivo por um tempo (app antigo instalado),1086# apenas ADICIONE este bloco e deixe o /raiox onde está — eles não1087# conflitam. Depois que o app novo estiver rodando, apague o /raiox.1088# ══════════════════════════════════════════════════════════════════════════════1089 1090@app.post("/map")1091async def mapa_anatomia(1092 imagem: UploadFile = File(None),1093 componente: str = Form("componente de hardware"),1094 x: str = Form(""), # centro normalizado 0-1 (opcional)1095 y: str = Form(""),1096 w: str = Form(""), # largura/altura normalizadas 0-11097 h: str = Form(""),1098):1099 """Devolve a anatomia educativa de UMA peça.1100 1101 Resposta:1102 {1103 "sucesso": true,1104 "mapa": {1105 "peca": "Memória RAM DDR2 SODIMM",1106 "peca_curta": "Memória RAM",1107 "resumo": "2 frases sobre a peça.",1108 "divergente": false,1109 "pontos": [1110 {"id":1, "x":500, "y":300, "label":"Notch",1111 "tipo":"conector",1112 "oque":"Chave de encaixe do pente.",1113 "funcao":"Impede encaixar o pente invertido no slot.",1114 "pratica":"Ao comprar um pente usado, confira o notch antes.",1115 "curiosidade":"A posição dele muda a cada geração de DDR."}1116 ]1117 }1118 1119 NOTA: as coordenadas dos pontos são SEMPRE relativas à imagem CHEIA1120 enviada pelo app, mesmo quando houve recorte por bbox internamente.1121 }1122 """1123 try:1124 b = await imagem.read() if imagem else None1125 if not b:1126 return {"sucesso": True, "mapa": {1127 "peca": componente,1128 "resumo": f"Pause a câmera sobre o {componente} e tente novamente.",1129 "divergente": False,1130 "pontos": []}}1131 1132 # ── Recorte por bbox ──────────────────────────────────────────────────1133 # Mesma lógica do /ocr_componente e do /info: quando o app manda as1134 # coordenadas, recortamos SÓ aquela peça. Sem isso, com duas peças1135 # iguais no quadro a IA mistura os pontos das duas.1136 #1137 # ⚠️ DIFERENÇA IMPORTANTE em relação ao /info e ao /ocr_componente:1138 # aqueles devolvem TEXTO, então o recorte é invisível para o app. O1139 # /map devolve COORDENADAS, e o Gemini as calcula em relação ao1140 # RECORTE (0-1000 dentro do pedaço). Se devolvermos assim, o app1141 # desenha os pontos sobre a imagem INTEIRA e eles caem fora da peça.1142 # Por isso guardamos aqui a região exata que foi cortada — no fim da1143 # função os pontos são convertidos de volta para o quadro completo.1144 recortou = False1145 crop_box = None # (x1c, y1c, x2c, y2c) em pixels da imagem cheia1146 img_dim = None # (W, H) da imagem cheia1147 try:1148 if x != "" and y != "" and w != "" and h != "":1149 arr = np.frombuffer(b, np.uint8)1150 img_cv = cv2.imdecode(arr, cv2.IMREAD_COLOR)1151 if img_cv is not None:1152 H, W = img_cv.shape[:2]1153 cx, cy = float(x) * W, float(y) * H1154 bw, bh = float(w) * W, float(h) * H1155 1156 # FOLGA de 12% em cada dimensão. O recorte do YOLO às vezes1157 # sai um pouco torto ou apertado — cortando parte da peça ou1158 # a etiqueta. Uma folga proporcional faz o recorte capturar1159 # a peça inteira mesmo quando o YOLO erra um pouco. Não salva1160 # um enquadramento muito errado, mas resolve os casos de1161 # "quase certo". O clamp nas bordas evita estourar a imagem.1162 _FOLGA = 0.121163 bw *= (1 + _FOLGA)1164 bh *= (1 + _FOLGA)1165 1166 x1 = int(cx - bw / 2); y1 = int(cy - bh / 2)1167 x2 = int(cx + bw / 2); y2 = int(cy + bh / 2)1168 1169 # Replica EXATAMENTE o que o recortar_box faz (padding de 41170 # e clamp nas bordas). Precisa bater pixel a pixel, senão a1171 # conversão dos pontos fica deslocada.1172 _PAD = 41173 x1c = max(0, x1 - _PAD)1174 y1c = max(0, y1 - _PAD)1175 x2c = min(W, x2 + _PAD)1176 y2c = min(H, y2 + _PAD)1177 1178 # Recorte degenerado (bbox inválida) → usa imagem cheia1179 if x2c - x1c >= 8 and y2c - y1c >= 8:1180 b = recortar_box(img_cv, x1, y1, x2, y2)1181 recortou = True1182 crop_box = (x1c, y1c, x2c, y2c)1183 img_dim = (W, H)1184 except Exception as e:1185 print(f"⚠️ /map: falha ao recortar, usando imagem cheia: {e}")1186 1187 nota_recorte = (1188 "- A imagem já está RECORTADA nesta peça. Se aparecer pedaço de "1189 "outra peça na borda, IGNORE.\n"1190 if recortou else1191 "- Pode haver mais de uma peça na imagem. Foque na peça principal, "1192 "a mais central e nítida.\n"1193 )1194 1195 prompt = (1196 f"Você é um PROFESSOR de hardware explicando a peça '{componente}' "1197 f"para um estudante de manutenção de computadores.\n"1198 f"Sua tarefa é ensinar a ANATOMIA da peça: apontar as partes "1199 f"visíveis e explicar o que cada uma é e para que serve.\n\n"1200 f"Sistema de coordenadas: x=0 esquerda, x=1000 direita, "