wings_length / test.py
pjsr's picture
Update test.py
341043e verified
Raw History Blame Contribute Delete
23.1 kB
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)