File size: 23,137 Bytes
c69ddf0
 
 
 
 
 
7fcf9f8
c69ddf0
2dfc921
 
c69ddf0
7fcf9f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e6551e
c69ddf0
 
3e6551e
c69ddf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0cd1a45
e833cac
7fcf9f8
 
e833cac
 
 
 
 
 
7fcf9f8
 
 
 
e833cac
 
 
 
 
 
 
 
 
 
 
 
 
 
7fcf9f8
 
 
 
 
 
 
 
 
 
0cd1a45
341043e
c69ddf0
 
3e6551e
c69ddf0
 
0cd1a45
3e6551e
0cd1a45
3e6551e
 
0cd1a45
 
e833cac
0cd1a45
 
 
 
 
 
 
 
 
 
c69ddf0
e833cac
7fcf9f8
 
3e6551e
c69ddf0
 
 
 
 
 
 
 
 
 
3e6551e
0cd1a45
c69ddf0
 
 
 
0cd1a45
 
c69ddf0
0cd1a45
3e6551e
c69ddf0
 
 
0cd1a45
 
 
 
 
 
c69ddf0
 
3e6551e
0cd1a45
3e6551e
b7f80b4
 
3e6551e
 
 
0cd1a45
 
 
 
 
 
2dfc921
 
c69ddf0
3e6551e
0cd1a45
 
2dfc921
e833cac
341043e
7fcf9f8
 
 
 
 
 
2dfc921
0cd1a45
3e6551e
2dfc921
0cd1a45
e833cac
 
2dfc921
 
 
 
3e6551e
0cd1a45
3e6551e
2dfc921
 
 
3e6551e
 
 
 
0cd1a45
3e6551e
 
 
0cd1a45
 
3e6551e
2dfc921
 
 
 
 
3e6551e
2dfc921
 
 
0cd1a45
2dfc921
 
3e6551e
2dfc921
 
0cd1a45
2dfc921
 
9edb870
3e6551e
2dfc921
e833cac
2dfc921
 
 
3e6551e
2dfc921
0cd1a45
 
 
2dfc921
 
3e6551e
341043e
 
 
 
 
 
 
 
 
 
3e6551e
7fcf9f8
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
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)