import gradio as gr from ultralytics import YOLO import torch import cv2 import numpy as np import os import re import math import pandas as pd from datetime import datetime # =========================================================================== # ESCALA IMPRESSA + RECORTE DA ASA (antes em asa_lib.py, agora integrado) # =========================================================================== try: import pytesseract OCR_DISPONIVEL = True except Exception: OCR_DISPONIVEL = False # =========================================================================== # 1) ESCALA # =========================================================================== _UNIT_MM = {"mm": 1.0, "cm": 10.0, "um": 1e-3, "µm": 1e-3, "μm": 1e-3, "nm": 1e-6} _WL = "0123456789.,mµumcn " # largura plausível do campo de visão (mm) — usada para desempatar leituras # ambíguas do OCR, ex. "1 mm" lido como "1 um" (m e u confundem-se). _LARGURA_MM_MIN, _LARGURA_MM_MAX = 0.5, 2000.0 def _valor_mm(texto): """(valor_mm, tem_decimal, match) ou (None, False, None).""" if not texto: return None, False, None t = texto.lower().replace(",", ".").replace("μ", "µ") m = (re.search(r"(\d+(?:\.\d+)?)\s*(mm|cm|µm|um|nm)\b", t) or re.search(r"(\d+(?:\.\d+)?)\s*(mm|cm|µm|um|nm)", t)) if not m: return None, False, None num = m.group(1) return float(num) * _UNIT_MM.get(m.group(2), 1.0), ("." in num), m.group(0) def _ampliar(gray, alvo_h=170): h = max(1, gray.shape[0]) f = min(10.0, max(2.0, alvo_h / h)) return cv2.resize(gray, None, fx=f, fy=f, interpolation=cv2.INTER_CUBIC) def _ocr(im, psm): """(texto, confiança_média) usando image_to_data.""" cfg = f"--psm {psm} -c tessedit_char_whitelist={_WL}" d = pytesseract.image_to_data(im, config=cfg, output_type=pytesseract.Output.DICT) palavras, confs = [], [] for i, t in enumerate(d["text"]): t = t.strip() if not t: continue palavras.append(t) try: c = float(d["conf"][i]) except (TypeError, ValueError): c = -1.0 if c >= 0: confs.append(c) return " ".join(palavras), (float(np.mean(confs)) if confs else 1.0) def _candidatos_valor(crop_bgr, verbose=None): """Recolhe leituras do rótulo -> {valor: [peso, tem_decimal]}.""" if not OCR_DISPONIVEL or crop_bgr is None or crop_bgr.size == 0: return {} gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY) big = _ampliar(gray) _, otsu = cv2.threshold(big, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) nit = cv2.morphologyEx(otsu, cv2.MORPH_CLOSE, np.ones((2, 2), np.uint8)) cands = {} for im in (otsu, big, nit): for psm in (7, 6, 8, 11, 13): try: raw, conf = _ocr(im, psm) except Exception: continue v, dec, _ = _valor_mm(raw) if verbose is not None and raw: verbose.append(f" OCR '{raw}' (conf {conf:.0f}) -> {v}") if v is None: continue e = cands.setdefault(v, [0.0, False]) e[0] += max(conf, 1.0) e[1] = e[1] or dec return cands def _escolher_valor(cands, barra_px, largura_img): """Escolhe o valor mais credível: prefere leituras fisicamente plausíveis, depois com decimal, depois com maior peso (confiança acumulada).""" if not cands: return None, None def plausivel(v): if not barra_px or not largura_img: return True larg_mm = largura_img * (v / barra_px) return _LARGURA_MM_MIN <= larg_mm <= _LARGURA_MM_MAX itens = [(v, p, d, plausivel(v)) for v, (p, d) in cands.items()] ok = [i for i in itens if i[3]] or itens ok.sort(key=lambda i: (i[2], i[1]), reverse=True) # decimal, depois peso escolhido = ok[0][0] nota = "plausível" if ok[0][3] else "sem candidato plausível" if len(itens) > 1: nota += f" (de {len(itens)} leituras: {sorted(v for v, _, _, _ in itens)})" return escolhido, nota def _candidatos_barra(img_bgr): h, w = img_bgr.shape[:2] gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) num, _, stats, _ = cv2.connectedComponentsWithStats(bw, connectivity=8) cands = [] for i in range(1, num): x, y, ww, hh, area = stats[i] if hh == 0 or ww < 25 or ww > 0.6 * w or (ww / hh) < 5: continue m = 8 viz = gray[max(0, y - hh - m):min(h, y + 2 * hh + m), max(0, x - m):min(w, x + ww + m)] if viz.size == 0 or np.mean(viz) < 180: continue cands.append((int(x), int(y), int(ww), int(hh))) cands.sort(key=lambda b: (b[0] + b[2]) + b[1], reverse=True) return cands def _rotulo_da_barra(img_bgr, bar, verbose=None): x, y, ww, hh = bar H, W = img_bgr.shape[:2] banda = int(max(45, 0.6 * ww)) px = int(0.3 * ww) x1, x2 = max(0, x - px), min(W, x + ww + px) todos = {} for onde, yy1, yy2 in (("acima", max(0, y - banda), y), ("abaixo", y + hh, min(H, y + hh + banda))): if yy2 - yy1 < 6: continue if verbose is not None: verbose.append(f" rótulo {onde} da barra @({x},{y},{ww},{hh}):") c = _candidatos_valor(img_bgr[yy1:yy2, x1:x2], verbose) for v, (p, d) in c.items(): e = todos.setdefault(v, [0.0, False]) e[0] += p e[1] = e[1] or d if todos: break return todos def _bar_bbox_em_roi(img_bgr, x1, y1, x2, y2): roi = img_bgr[y1:y2, x1:x2] if roi.size == 0: return None gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) num, _, stats, _ = cv2.connectedComponentsWithStats(bw, connectivity=8) best = (0, None) for i in range(1, num): bx, by, bwd, bht, _ = stats[i] if bht and (bwd / bht) >= 4 and bwd >= 0.15 * roi.shape[1] and bwd > best[0]: best = (bwd, (x1 + bx, y1 + by, bwd, bht)) return best[1] def _medir_barra_precisa(img_bgr, bbox, pad=3): """Distância CENTRO-A-CENTRO das serifas (remove o viés da espessura das marcas); se não houver serifas nítidas, largura da bounding box. -> (comprimento_px, metodo, largura_bbox_px, centros)""" x, y, w, h = bbox H, W = img_bgr.shape[:2] x1, y1 = max(0, x - pad), max(0, y - pad) x2, y2 = min(W, x + w + pad), min(H, y + h + pad) gray = cv2.cvtColor(img_bgr[y1:y2, x1:x2], cv2.COLOR_BGR2GRAY) _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) num, lab, stats, _ = cv2.connectedComponentsWithStats(bw, connectivity=8) if num <= 1: return float(w), "bounding-box", float(w), None idx = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) mask = (lab == idx).astype(np.uint8) cols = np.where(mask.any(axis=0))[0] if cols.size == 0: return float(w), "bounding-box", float(w), None largura_bbox = float(cols.max() - cols.min() + 1) alt = mask.sum(axis=0)[cols] linha_th, max_h = float(np.median(alt)), float(alt.max()) if max_h < 2 * max(linha_th, 1) or (max_h - linha_th) < 2: return largura_bbox, "bounding-box", largura_bbox, None altas = cols[alt >= linha_th + 0.5 * (max_h - linha_th)] if altas.size < 2: return largura_bbox, "bounding-box", largura_bbox, None g = int(np.argmax(np.diff(altas))) esq, dire = altas[:g + 1], altas[g + 1:] if esq.size == 0 or dire.size == 0 or (dire.min() - esq.max()) < 0.3 * largura_bbox: return largura_bbox, "bounding-box", largura_bbox, None cxe, cxd = float(esq.mean()), float(dire.mean()) return (cxd - cxe), "centro-a-centro", largura_bbox, \ (int(x1 + cxe), int(x1 + cxd), int((y1 + y2) // 2)) def detectar_escala(img_bgr, debug=False, verbose=None): """mm por pixel a partir da escala impressa (ou None).""" H, W = img_bgr.shape[:2] cands, bar_bbox, origem = {}, None, None if OCR_DISPONIVEL: try: for bar in _candidatos_barra(img_bgr): # 1) barra-primeiro if verbose is not None: verbose.append(f" candidato barra @{bar}") c = _rotulo_da_barra(img_bgr, bar, verbose) if c: cands, bar_bbox, origem = c, bar, "barra-primeiro" break if not cands: # 2) ROI fundo-direita roi = img_bgr[int(H * 0.80):, int(W * 0.55):] c = _candidatos_valor(roi, verbose) if c: cands, origem = c, "roi-fundo" except Exception: pass if bar_bbox is None: bar_bbox = _bar_bbox_em_roi(img_bgr, int(W * 0.55), int(H * 0.80), W, H) barra_px = metodo = barra_bbox_px = centros = None if bar_bbox is not None: barra_px, metodo, barra_bbox_px, centros = _medir_barra_precisa(img_bgr, bar_bbox) valor_mm, nota = _escolher_valor(cands, barra_px, W) mm_por_pixel = (valor_mm / barra_px if (valor_mm and barra_px and barra_px > 0) else None) if debug: return mm_por_pixel, {"valor_mm": valor_mm, "nota": nota, "barra_px": barra_px, "barra_bbox_px": barra_bbox_px, "metodo_barra": metodo, "mm_por_pixel": mm_por_pixel, "bar_bbox": bar_bbox, "serif_centros": centros, "origem": origem} return mm_por_pixel # =========================================================================== # 2) RECORTE DA ASA (grande plano) # =========================================================================== def _superficie_fundo(sub, fg, passo=4): """Superfície quadrática ajustada ao fundo (corrige iluminação irregular).""" H, W = sub.shape ys, xs = np.nonzero(~fg[::passo, ::passo]) if ys.size < 200: return np.full_like(sub, float(np.percentile(sub, 80))) ys, xs = ys * passo, xs * passo z = sub[ys, xs].astype(np.float64) xn, yn = xs / max(W - 1, 1), ys / max(H - 1, 1) A = np.stack([np.ones_like(xn), xn, yn, xn * xn, yn * yn, xn * yn], axis=1) coef, *_ = np.linalg.lstsq(A, z, rcond=None) gy, gx = np.mgrid[0:H, 0:W] gxn, gyn = gx / max(W - 1, 1), gy / max(H - 1, 1) return (coef[0] + coef[1] * gxn + coef[2] * gyn + coef[3] * gxn * gxn + coef[4] * gyn * gyn + coef[5] * gxn * gyn).astype(np.float32) def _mascara_especime(img_bgr, delta=None): gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) sub = cv2.GaussianBlur(gray, (0, 0), 2.0).astype(np.float32) nivel = float(np.percentile(sub, 80)) fg = (nivel - sub) > max(3.0, 0.02 * nivel) for _ in range(2): fundo = _superficie_fundo(sub, fg) res = fundo - sub bg = res[~fg] if bg.size: mad = float(np.median(np.abs(bg - np.median(bg)))) d = delta if delta is not None else max(3.0, 5.0 * mad) else: d = delta if delta is not None else 3.0 fg = res > d m = fg.astype(np.uint8) k = max(3, (int(0.004 * max(img_bgr.shape[:2])) | 1)) m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((k, k), np.uint8)) m = cv2.morphologyEx(m, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) return m def recortar_asa(img_bgr, margem=0.06, limpar_fundo=True, dilatar=0.02): """Recorta a asa em grande plano e uniformiza o fundo à volta. Normaliza a entrada do modelo: a asa passa a ocupar sempre a mesma fração do enquadramento e artefactos (pó, 2ª asa, brilho do fundo) desaparecem — o que evita que a segmentação varie alguns pixels entre fotos da mesma asa. Devolve (crop_bgr, (x0, y0), info). Em caso de falha devolve a imagem original e (0, 0), para o pipeline continuar a funcionar. """ H, W = img_bgr.shape[:2] info = {"ok": False, "bbox": None, "crop_box": None, "area_frac": None, "motivo": None} mask = _mascara_especime(img_bgr) cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not cnts: info["motivo"] = "sem contorno" return img_bgr, (0, 0), info cnt = max(cnts, key=cv2.contourArea) cheia = np.zeros_like(mask) cv2.drawContours(cheia, [cnt], -1, 1, thickness=-1) # preenche a asa toda x, y, w, h = cv2.boundingRect(cnt) area_frac = float(cheia.sum()) / (H * W) if w < 0.15 * W or h < 0.10 * H or area_frac < 0.02: info["motivo"] = f"objeto pequeno demais (w={w}, h={h}, área={area_frac:.3f})" return img_bgr, (0, 0), info img = img_bgr if limpar_fundo: d = max(3, int(dilatar * max(w, h)) | 1) sup = cv2.dilate(cheia, np.ones((d, d), np.uint8)) val = int(round(float(np.percentile(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY), 80)))) img = img_bgr.copy() img[sup == 0] = val m = int(margem * max(w, h)) x0, y0 = max(0, x - m), max(0, y - m) x1, y1 = min(W, x + w + m), min(H, y + h + m) info.update(ok=True, bbox=(int(x), int(y), int(w), int(h)), crop_box=(int(x0), int(y0), int(x1), int(y1)), area_frac=area_frac) return img[y0:y1, x0:x1], (x0, y0), info # Caminho para o modelo treinado modelo_path = "runs/segment/meu_yolo_seg20/weights/best.pt" model_cache = {} def carregar_modelo(device): if device not in model_cache: model = YOLO(modelo_path) model.to(device) model_cache[device] = model return model_cache[device] def contar_pixels_conexos(mask, minimo): bin_mask = (mask > 0).astype(np.uint8) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(bin_mask) return sum(stat[cv2.CC_STAT_AREA] for stat in stats[1:] if stat[cv2.CC_STAT_AREA] >= minimo) def calcular_distancia(p1, p2): return math.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2) def mascaras_da_predicao(r, cw, ch, metodo="auto"): """Máscaras binárias nas coordenadas da imagem dada ao modelo (cw x ch). metodo="auto" -> usa `masks.xy` (polígonos já em coordenadas da imagem de entrada). Evita o erro do letterbox: `masks.data` vem no tamanho interno do YOLO, com barras de padding, e redimensioná-lo desloca as máscaras de forma que depende do rácio de aspeto (logo, varia de foto para foto). metodo="legado" -> comportamento original (redimensiona `masks.data`). """ if r.masks is None: return [] saida = [] if metodo != "legado": xy = getattr(r.masks, "xy", None) if xy is not None and len(xy): for poly in xy: if poly is None or len(poly) < 3: continue m = np.zeros((ch, cw), np.uint8) cv2.fillPoly(m, [np.asarray(poly, dtype=np.int32)], 1) if m.any(): saida.append(m) if saida: return saida dados = getattr(r.masks, "data", None) if dados is None: return [] for mask in dados: m = mask.cpu().numpy().astype(np.float32) if hasattr(mask, "cpu") else np.asarray(mask, np.float32) m = cv2.resize(m, (cw, ch), interpolation=cv2.INTER_LINEAR) saida.append((m > 0.5).astype(np.uint8)) return saida def processar_uma_imagem(imagem_path, dispositivo, conf_min, pixels_minimos, usar_recorte=True, limpar_fundo=False, metodo_mascara="auto"): device = "cuda" if dispositivo == "GPU" and torch.cuda.is_available() else "cpu" model = carregar_modelo(device) img_bgr = cv2.imread(imagem_path) if img_bgr is None: return None, None, None, None, "erro ao ler", "-" # --- escala: SEMPRE na imagem completa (a barra fica fora do recorte) --- mm_por_pixel, escala_info = detectar_escala(img_bgr, debug=True) # --- recorte da asa em grande plano (normaliza a entrada do modelo) --- if usar_recorte: entrada, (off_x, off_y), rec_info = recortar_asa(img_bgr, limpar_fundo=limpar_fundo) else: entrada, (off_x, off_y), rec_info = img_bgr, (0, 0), {"ok": False, "motivo": "desligado"} results = model.predict(source=entrada, device=device, conf=conf_min, verbose=False) r = results[0] img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) ch, cw = entrada.shape[:2] # --- máscaras (em coordenadas do recorte) --- mascaras_binarias = [] for m in mascaras_da_predicao(r, cw, ch, metodo_mascara): if contar_pixels_conexos(m * 255, pixels_minimos) > 0: mascaras_binarias.append(m) mascaras_fundidas = [] for m in mascaras_binarias: fundida = False for i in range(len(mascaras_fundidas)): if np.count_nonzero(cv2.bitwise_and(m, mascaras_fundidas[i])) > 0: mascaras_fundidas[i] = cv2.bitwise_or(mascaras_fundidas[i], m) fundida = True break if not fundida: mascaras_fundidas.append(m) # --- centróides em float, convertidos para coordenadas da imagem original --- centroides = [] for m in mascaras_fundidas: M = cv2.moments(m) if M["m00"] != 0: cx = M["m10"] / M["m00"] + off_x # float: não arredondar aqui cy = M["m01"] / M["m00"] + off_y centroides.append((cx, cy)) cv2.circle(img_rgb, (int(round(cx)), int(round(cy))), 12, (0, 255, 0), 2) distancia_px = None distancia_mm = None if len(centroides) == 2: p1 = (int(round(centroides[0][0])), int(round(centroides[0][1]))) p2 = (int(round(centroides[1][0])), int(round(centroides[1][1]))) cv2.line(img_rgb, p1, p2, (0, 0, 255), 2) for p in (p1, p2): cv2.circle(img_rgb, p, 6, (0, 0, 255), -1) distancia_px = calcular_distancia(centroides[0], centroides[1]) # float if mm_por_pixel: distancia_mm = distancia_px * mm_por_pixel # --- textos informativos --- if escala_info["valor_mm"] and escala_info["barra_px"]: met = "c2c" if escala_info["metodo_barra"] == "centro-a-centro" else "bbox" escala_str = f"{escala_info['valor_mm']:g} mm / {escala_info['barra_px']:.0f} px ({met})" else: escala_str = "não detetada" if rec_info.get("ok"): bx, by, bw, bh = rec_info["bbox"] recorte_str = f"{bw}x{bh} px" else: recorte_str = f"sem recorte ({rec_info.get('motivo', '-')})" nome = os.path.basename(imagem_path) output_path = os.path.splitext(nome)[0] + "_out.png" cv2.imwrite(output_path, cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)) return output_path, nome, distancia_px, distancia_mm, escala_str, recorte_str def processar_varias(imagens, dispositivo, conf_min, pixels_minimos, usar_recorte=True, limpar_fundo=False, metodo_mascara="auto"): # sem imagens (ex.: alternar a opção antes de carregar ficheiros) if not imagens: vazio = pd.DataFrame({"Imagem": [], "Escala (real)": [], "Asa recortada": [], "Distância (px)": [], "Distância (mm)": []}) return [], vazio, pd.DataFrame({"Métrica": [], "Valor (mm)": []}), None imagens_resultado = [] nomes, pxs, mms, escalas, recortes = [], [], [], [], [] for imagem in imagens: out_path, nome, dist_px, dist_mm, escala_str, recorte_str = processar_uma_imagem( imagem, dispositivo, conf_min, pixels_minimos, usar_recorte, limpar_fundo, metodo_mascara) imagens_resultado.append(out_path) nomes.append(nome) pxs.append(f"{dist_px:.1f}" if dist_px else "-") mms.append(f"{dist_mm:.3f}" if dist_mm else "-") escalas.append(escala_str) recortes.append(recorte_str) valores_mm = [float(x) for x in mms if x != "-"] media = np.mean(valores_mm) if valores_mm else 0 desvio = np.std(valores_mm) if valores_mm else 0 tabela = pd.DataFrame({ "Imagem": nomes, "Escala (real)": escalas, "Asa recortada": recortes, "Distância (px)": pxs, "Distância (mm)": mms, }) resumo_df = pd.DataFrame({"Métrica": ["Média", "Desvio padrão"], "Valor (mm)": [f"{media:.3f}", f"{desvio:.3f}"]}) agora = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") resultados_xlsx_path = f"resultados_medidas_{agora}.xlsx" with pd.ExcelWriter(resultados_xlsx_path) as writer: tabela.to_excel(writer, sheet_name="Resultados", index=False) resumo_df.to_excel(writer, sheet_name="Resumo", index=False) pasta_saida = f"imagens_processadas_{agora}" os.makedirs(pasta_saida, exist_ok=True) for img_path in imagens_resultado: os.replace(img_path, os.path.join(pasta_saida, os.path.basename(img_path))) imagens_resultado = [os.path.join(pasta_saida, os.path.basename(p)) for p in imagens_resultado] legendas = [f"{n} - {m} mm" for n, m in zip(nomes, mms)] return list(zip(imagens_resultado, legendas)), tabela, resumo_df, resultados_xlsx_path # Interface Gradio with gr.Blocks(title="Medição de asas múltiplas") as demo: gr.Markdown("## Medição automática de asas de abelha com normalização à escala impressa") imagens_input = gr.File(label="Subir imagens", file_types=[".png", ".jpg", ".jpeg"], file_count="multiple") dispositivo = gr.Radio(["CPU", "GPU"], label="Dispositivo", value="CPU", visible=False) conf_slider = gr.Slider(0.01, 1.0, value=0.1, step=0.01, label="Confiança mínima", visible=False) pixel_slider = gr.Slider(1, 1000, value=10, step=1, label="Pixels conectados mínimos por máscara", visible=False) imagens_saida = gr.Gallery(label="Resultados das imagens segmentadas", columns=4, preview=True, show_label=True) tabela_resultados = gr.Dataframe(label="Resultados", interactive=False, headers=["Imagem", "Escala (real)", "Asa recortada", "Distância (px)", "Distância (mm)"]) tabela_resumo = gr.Dataframe(label="Resumo estatístico", interactive=False, headers=["Métrica", "Valor (mm)"]) download_btn = gr.File(label="Download XLSX") imagens_input.change( # fixo: máscaras em "auto" (masks.xy), recorte da asa ligado, # sem uniformizar o fundo (isso descaracteriza a imagem e o modelo falha) fn=lambda imagens: processar_varias(imagens, "CPU", 0.1, 10, usar_recorte=True, limpar_fundo=False, metodo_mascara="auto"), inputs=imagens_input, outputs=[imagens_saida, tabela_resultados, tabela_resumo, download_btn] ) if __name__ == "__main__": demo.launch(share=False)