CoolFace
Apppublic

HardwareVision/hardware-vision-api

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
main.py1970 linesDownload Raw Back to root
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, "

Showing the first 1,200 of 1970 lines. Download the file for the rest.