File size: 30,902 Bytes
fce6d97
 
406616d
fce6d97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc4ab3b
fce6d97
 
cc4ab3b
fce6d97
cc4ab3b
 
fce6d97
 
cc4ab3b
fce6d97
 
 
 
 
 
cc4ab3b
fce6d97
cc4ab3b
fce6d97
 
ea2cdbf
fce6d97
cc4ab3b
fce6d97
 
cc4ab3b
795ef50
cc4ab3b
795ef50
 
 
 
cc4ab3b
795ef50
fce6d97
 
 
 
 
cc4ab3b
 
fce6d97
 
 
cc4ab3b
406616d
cc4ab3b
406616d
fce6d97
ea2cdbf
 
fce6d97
ea2cdbf
 
 
 
9435666
ea2cdbf
 
 
e0f01de
ea2cdbf
 
 
e0f01de
 
 
ea2cdbf
e0f01de
ea2cdbf
 
 
 
a752acf
 
 
 
ea2cdbf
a752acf
 
 
 
ea2cdbf
 
a752acf
ea2cdbf
 
 
 
e0f01de
ea2cdbf
a752acf
ea2cdbf
a752acf
e0f01de
 
 
a752acf
406616d
8c8b808
 
 
 
 
 
 
 
 
 
 
 
406616d
 
 
 
 
8c8b808
406616d
 
 
 
 
 
 
 
8c8b808
 
 
 
 
 
 
406616d
 
 
 
 
 
 
 
 
a752acf
ea2cdbf
a752acf
bf82529
 
 
 
 
 
 
406616d
 
bf82529
 
 
 
 
 
 
 
 
 
 
 
 
406616d
 
bf82529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406616d
bf82529
 
 
 
406616d
bf82529
 
406616d
bf82529
406616d
bf82529
 
 
 
406616d
bf82529
 
406616d
bf82529
406616d
bf82529
 
 
 
406616d
bf82529
 
406616d
bf82529
ea2cdbf
 
bf82529
406616d
ea2cdbf
a752acf
 
ea2cdbf
 
406616d
ea2cdbf
 
 
 
 
 
a752acf
 
 
 
 
406616d
 
a752acf
 
 
 
 
 
406616d
 
a752acf
406616d
 
a752acf
 
 
 
 
 
 
 
 
406616d
ea2cdbf
 
a752acf
 
 
ea2cdbf
a752acf
ea2cdbf
 
a752acf
406616d
a752acf
fce6d97
ea2cdbf
fce6d97
cc4ab3b
 
406616d
a752acf
 
406616d
cc4ab3b
fce6d97
406616d
 
ea2cdbf
fce6d97
 
ea2cdbf
cc4ab3b
406616d
e0f01de
fce6d97
cc4ab3b
 
ea2cdbf
cc4ab3b
 
 
8c8b808
406616d
 
 
 
 
 
8c8b808
406616d
8c8b808
cc4ab3b
406616d
fce6d97
 
e0f01de
fce6d97
a752acf
 
 
 
fce6d97
406616d
 
cc4ab3b
a752acf
cc4ab3b
 
 
 
406616d
 
 
 
 
cc4ab3b
406616d
 
ea2cdbf
406616d
 
ea2cdbf
 
 
406616d
 
 
 
 
53209d3
 
e0f01de
ea2cdbf
 
8c8b808
53209d3
406616d
53209d3
8c8b808
 
 
 
 
 
406616d
8c8b808
ea2cdbf
 
 
 
 
406616d
8c8b808
ea2cdbf
8c8b808
 
53209d3
8c8b808
e0f01de
 
53209d3
8c8b808
 
ea2cdbf
406616d
e0f01de
 
406616d
e0f01de
 
 
 
406616d
e0f01de
8c8b808
406616d
 
 
8c8b808
406616d
 
fce6d97
 
406616d
fce6d97
406616d
f1cb7bb
406616d
ea2cdbf
 
fce6d97
406616d
 
 
09ff8c4
fce6d97
 
09ff8c4
406616d
 
 
09ff8c4
406616d
 
 
09ff8c4
fce6d97
 
ea2cdbf
a752acf
53209d3
bf82529
 
 
09ff8c4
bf82529
 
 
406616d
bf82529
 
ea2cdbf
 
406616d
 
ea2cdbf
 
 
e0f01de
 
ea2cdbf
 
 
 
 
 
406616d
 
ea2cdbf
a752acf
406616d
 
 
ea2cdbf
 
 
 
bf82529
406616d
 
bf82529
406616d
ea2cdbf
406616d
ea2cdbf
 
406616d
 
 
 
ea2cdbf
406616d
 
 
cc4ab3b
ea2cdbf
406616d
 
cc4ab3b
 
738b0e6
406616d
 
cc4ab3b
 
 
ea2cdbf
 
cc4ab3b
ea2cdbf
 
cc4ab3b
ea2cdbf
 
cc4ab3b
 
 
406616d
 
 
 
cc4ab3b
fce6d97
406616d
 
 
 
 
 
cc4ab3b
406616d
cc4ab3b
406616d
ea2cdbf
cc4ab3b
406616d
 
 
ea2cdbf
406616d
 
fce6d97
ea2cdbf
a752acf
406616d
09ff8c4
ea2cdbf
09ff8c4
cc4ab3b
 
406616d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea2cdbf
406616d
 
 
 
 
 
 
 
 
fce6d97
 
406616d
 
fce6d97
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
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
import os
import re
import tempfile
from functools import lru_cache

import cv2
import numpy as np
import gradio as gr

# ==========================================
# 0. CONFIG DATASET
# ==========================================
DATASET_DIR = "dataset"
EXPECTED_DATS = ["06", "09", "11", "14", "16", "19", "23", "26"]
SAMPLES = ["A", "B", "C"]
UI_CONDITIONS = ["Stress", "No Stress"]
COND_CODE = {"Stress": "S", "No Stress": "NS"}
FILENAME_RE = re.compile(r"^(S|NS)_([A-Za-z0-9]+)_(\d{2})\.(jpg|jpeg|png)$", re.IGNORECASE)

def _list_dataset_files(dataset_dir: str) -> list[str]:
    if not os.path.isdir(dataset_dir): return []
    return [f for f in os.listdir(dataset_dir) if os.path.isfile(os.path.join(dataset_dir, f))]

def calcola_dats_disponibili(dataset_dir=DATASET_DIR, samples=SAMPLES, expected_dats=EXPECTED_DATS):
    files = _list_dataset_files(dataset_dir)
    if not files: return expected_dats.copy()
    per_combo = { (code, s): set() for code in ("S", "NS") for s in samples }
    for fname in files:
        m = FILENAME_RE.match(fname)
        if m: per_combo[(m.group(1).upper(), m.group(2).upper())].add(m.group(3))
    sets = list(per_combo.values())
    common = set.intersection(*sets) if sets else set()
    out = [d for d in expected_dats if d in common]
    if not out:
        present = set().union(*sets) if sets else set()
        out = [d for d in expected_dats if d in present]
    return out if out else expected_dats.copy()

AVAILABLE_DATS = calcola_dats_disponibili()

# ==========================================
# 1. FUNZIONI DI UTILITÀ
# ==========================================
@lru_cache(maxsize=16)
def _load_rgb(filepath: str) -> np.ndarray | None:
    img_bgr = cv2.imread(filepath)
    if img_bgr is None: return None
    img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
    
    max_dim = 1500
    h, w = img_rgb.shape[:2]
    if max(h, w) > max_dim:
        scale = max_dim / max(h, w)
        img_rgb = cv2.resize(img_rgb, (int(w * scale), int(h * scale)))
    return img_rgb

def carica_da_dataset(condizione: str, campione: str, dat: str):
    codice_cond = COND_CODE.get(condizione, "NS")
    campione = str(campione).upper().strip()
    dat = str(dat).zfill(2)
    candidates = [f"{codice_cond}_{campione}_{dat}.{ext}" for ext in ["jpg", "jpeg", "png"]]
    
    for filename in candidates:
        filepath = os.path.join(DATASET_DIR, filename)
        if os.path.exists(filepath):
            img = _load_rgb(filepath)
            return img, f"✅ Caricato: {filename}"
            
    return None, f"❌ Errore: File mancante {candidates[0]}."

def get_spazio_attivo(main_space, alt_space):
    return alt_space if alt_space != "Nessuno" else main_space

def aggiorna_sliders(spazio_principale, spazio_altri):
    spazio = get_spazio_attivo(spazio_principale, spazio_altri)
    
    if spazio == "HSV":
        return (gr.update(label="Tinta (H) Min", value=30, maximum=179, visible=True), gr.update(label="Tinta (H) Max", value=80, maximum=179, visible=True),
                gr.update(label="Saturazione (S) Min", value=40, visible=True), gr.update(label="Saturazione (S) Max", value=255, visible=True),
                gr.update(label="Valore (V) Min", value=40, visible=True), gr.update(label="Valore (V) Max", value=255, visible=True))
    elif spazio == "ExG":
        return (gr.update(label="Soglia Minima ExG", value=40, maximum=255, visible=True), gr.update(visible=False),
                gr.update(visible=False), gr.update(visible=False),
                gr.update(visible=False), gr.update(visible=False))
    elif spazio == "LAB":
        return (gr.update(label="Luminanza (L) Min", value=0, maximum=255, visible=True), gr.update(label="Luminanza (L) Max", value=255, maximum=255, visible=True),
                gr.update(label="Asse A Min", value=0, visible=True), gr.update(label="Asse A Max", value=110, visible=True),
                gr.update(label="Asse B Min", value=130, visible=True), gr.update(label="Asse B Max", value=255, visible=True))
    else: # RGB
        return (gr.update(label="Rosso (R) Min", value=0, maximum=255, visible=True), gr.update(label="Rosso (R) Max", value=100, maximum=255, visible=True),
                gr.update(label="Verde (G) Min", value=100, visible=True), gr.update(label="Verde (G) Max", value=255, visible=True),
                gr.update(label="Blu (B) Min", value=0, visible=True), gr.update(label="Blu (B) Max", value=100, visible=True))

def converti_spazio_colore(image, color_space):
    if color_space == "HSV": return cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
    elif color_space == "LAB": return cv2.cvtColor(image, cv2.COLOR_RGB2LAB)
    return image.copy()

def cattura_colore(image, evt: gr.SelectData, s_main, s_alt):
    if image is None: return (0, 255, 0, 255, 0, 255)
    x, y = evt.index
    h, w = image.shape[:2]
    if not (0 <= x < w and 0 <= y < h): return (0, 255, 0, 255, 0, 255)
    
    spazio = get_spazio_attivo(s_main, s_alt)
    pixel_rgb = image[y, x]
    
    if spazio == "ExG":
        r, g, b = float(pixel_rgb[0]), float(pixel_rgb[1]), float(pixel_rgb[2])
        exg = int(np.clip(2 * g - r - b, 0, 255))
        return (max(0, exg-20), 255, 0, 255, 0, 255)
    
    pixel_img = np.uint8([[pixel_rgb]])
    pixel_conv = converti_spazio_colore(pixel_img, spazio)[0][0]
    v1, v2, v3 = [int(v) for v in pixel_conv]
    
    max_v1 = 179 if spazio == "HSV" else 255
    return (max(0, v1-25), min(max_v1, v1+25), max(0, v2-25), min(255, v2+25), max(0, v3-25), min(255, v3+25))

def disegna_etichetta_pianta(img, mask, quad_vertices):
    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours: return img

    cv2.drawContours(img, contours, -1, (255, 0, 255), 2)

    c = max(contours, key=cv2.contourArea)
    topmost = tuple(c[c[:, :, 1].argmin()][0])
    
    tail = (max(20, topmost[0] - 120), max(40, topmost[1] - 80))
    cv2.arrowedLine(img, tail, topmost, (0, 255, 255), 3, tipLength=0.2)

    px_area = int(np.sum(mask == 255))
    h, w = img.shape[:2]
    
    # QoL 4 & 5: Calcolo percentuale dinamico
    if quad_vertices is not None and len(quad_vertices) > 0:
        valid_area = np.zeros((h, w), dtype=np.uint8)
        cv2.fillPoly(valid_area, [quad_vertices], 255)
        ref_area = np.sum(valid_area == 255)
        ref_type = "della Cornice"
    else:
        ref_area = h * w
        ref_type = "della Foto"

    perc = (px_area / ref_area) * 100 if ref_area > 0 else 0
    text = f"Pianta: {px_area} px ({perc:.1f}% {ref_type})"

    text_pos = (max(10, tail[0] - 50), max(20, tail[1] - 15))
    cv2.putText(img, text, text_pos, cv2.FONT_HERSHEY_SIMPLEX, 1.1, (0, 0, 0), 6)
    cv2.putText(img, text, text_pos, cv2.FONT_HERSHEY_SIMPLEX, 1.1, (0, 255, 255), 3)

    return img

def salva_temp_pulita(img_rgb):
    """QoL 1: Salva l'immagine senza scritte per il download"""
    if img_rgb is None: return None
    img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
    fd, path = tempfile.mkstemp(suffix=".png", prefix="pianta_pulita_")
    os.close(fd)
    cv2.imwrite(path, img_bgr)
    return path

# ==========================================
# 2. MOTORE GEOMETRICO (FASE 1)
# ==========================================
def _ordina_vertici(pts):
    pts = pts.reshape((4, 2))
    rect = np.zeros((4, 2), dtype=np.float32)
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)] # TL
    rect[2] = pts[np.argmax(s)] # BR
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)] # TR
    rect[3] = pts[np.argmax(diff)] # BL
    return rect

def _genera_maschera_auto(image, bg_color, frame_color, step):
    hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
    gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
    mask = np.zeros(gray.shape, dtype=np.uint8)

    s_min = max(50, 150 - step * 25)
    v_min = max(50, 150 - step * 25)
    v_max_dark = min(200, 50 + step * 25)
    v_min_light = max(100, 200 - step * 20)

    if frame_color == "Rossa":
        lower1, upper1 = np.array([0, s_min, v_min]), np.array([10, 255, 255])
        lower2, upper2 = np.array([170, s_min, v_min]), np.array([179, 255, 255])
        mask = cv2.bitwise_or(cv2.inRange(hsv, lower1, upper1), cv2.inRange(hsv, lower2, upper2))
    elif frame_color == "Blu":
        mask = cv2.inRange(hsv, np.array([100, s_min, v_min]), np.array([140, 255, 255]))
    elif frame_color == "Viola":
        mask = cv2.inRange(hsv, np.array([125, s_min, v_min]), np.array([165, 255, 255]))
    elif frame_color == "Bianca":
        mask = cv2.inRange(gray, v_min_light, 255)
    elif frame_color == "Nera":
        mask = cv2.inRange(gray, 0, v_max_dark)
    return mask

def _trova_quad_in_maschera(mask):
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
    mask_closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
    edges = cv2.Canny(mask_closed, 50, 150)
    lines = cv2.HoughLines(edges, rho=1, theta=np.pi / 180, threshold=100)
    if lines is None: return None

    orizzontali, verticali = [], []
    for line in lines:
        rho, theta = line[0]
        angolo = theta * 180 / np.pi
        if 45 < angolo < 135: orizzontali.append((rho, theta))
        else: verticali.append((rho, theta, rho / np.cos(theta) if np.cos(theta) != 0 else rho))

    if len(orizzontali) >= 2 and len(verticali) >= 2:
        orizzontali.sort(key=lambda x: x[0])
        verticali.sort(key=lambda x: x[2])
        top, bottom = orizzontali[0], orizzontali[-1]
        left, right = verticali[0][:2], verticali[-1][:2]

        def intersezione(l1, l2):
            A = np.array([[np.cos(l1[1]), np.sin(l1[1])], [np.cos(l2[1]), np.sin(l2[1])]])
            b = np.array([l1[0], l2[0]])
            try: return [int(round(np.linalg.solve(A, b)[0])), int(round(np.linalg.solve(A, b)[1]))]
            except: return None

        vertici = [intersezione(top, left), intersezione(top, right), intersezione(bottom, right), intersezione(bottom, left)]
        if None not in vertici:
            quad = np.array(vertici, dtype=np.float32)
            h, w = mask.shape
            if (w * h * 0.05) < cv2.contourArea(quad) < (w * h * 0.95):
                return _ordina_vertici(quad)
    return None

def elabora_riferimento_automatico(image, bg_color, frame_color):
    if image is None: return None, None, "0", "In attesa...", None
    if not bg_color or not frame_color:
        gr.Warning("⚠️ Seleziona sia il Colore Sfondo che il Colore Cornice!")
        return None, None, "0", "Errore: Colori non selezionati.", None

    valid_quads, masks_debug = [], []
    for step in range(5):
        mask = _genera_maschera_auto(image, bg_color, frame_color, step)
        masks_debug.append(mask)
        quad = _trova_quad_in_maschera(mask)
        if quad is not None: valid_quads.append(quad)

    if not valid_quads:
        return cv2.cvtColor(masks_debug[2], cv2.COLOR_GRAY2RGB), image.copy(), "0", "❌ Fallito: Nessun quadrilatero.", None

    median_quad = np.median(np.array(valid_quads), axis=0).astype(np.int32)
    quad_state_val = median_quad.reshape((-1, 1, 2))

    debug_img = image.copy()
    cv2.polylines(debug_img, [quad_state_val], isClosed=True, color=(0, 255, 0), thickness=4)
    for v in median_quad: cv2.circle(debug_img, tuple(v), 15, (255, 0, 0), -1)

    area_pixel = int(abs(cv2.contourArea(median_quad)))
    return cv2.cvtColor(masks_debug[2], cv2.COLOR_GRAY2RGB), debug_img, str(area_pixel), f"✅ Consenso: {len(valid_quads)}/5 step.", quad_state_val

def ordina_min_max(v1, v2): return int(min(v1, v2)), int(max(v1, v2))

def elabora_riferimento_manuale(image, s_main, s_alt, c1_min, c1_max, c2_min, c2_max, c3_min, c3_max):
    if image is None: return None, None, "0", "In attesa...", None
    spazio = get_spazio_attivo(s_main, s_alt)
    min1, max1 = ordina_min_max(c1_min, c1_max)

    if spazio == "ExG":
        img_float = image.astype(np.float32)
        exg = np.clip(2 * img_float[:,:,1] - img_float[:,:,0] - img_float[:,:,2], 0, 255).astype(np.uint8)
        mask = cv2.inRange(exg, min1, 255)
    else:
        min2, max2 = ordina_min_max(c2_min, c2_max)
        min3, max3 = ordina_min_max(c3_min, c3_max)
        img_conv = converti_spazio_colore(image, spazio)
        mask = cv2.inRange(img_conv, np.array([min1, min2, min3]), np.array([max1, max2, max3]))

    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
    mask_closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
    mask_visiva = cv2.cvtColor(mask_closed, cv2.COLOR_GRAY2RGB)

    debug_img, area_pixel, quad_state_val = image.copy(), 0, None
    lines = cv2.HoughLines(cv2.Canny(mask_closed, 50, 150), 1, np.pi / 180, 100)

    if lines is not None:
        orizzontali, verticali = [], []
        for line in lines:
            rho, theta = line[0]
            a, b = np.cos(theta), np.sin(theta)
            pt1 = (int(a * rho + 10000 * (-b)), int(b * rho + 10000 * (a)))
            pt2 = (int(a * rho - 10000 * (-b)), int(b * rho - 10000 * (a)))
            cv2.line(debug_img, pt1, pt2, (0, 50, 255), 1)
            
            if 45 < theta * 180 / np.pi < 135: orizzontali.append((rho, theta))
            else: verticali.append((rho, theta, rho / np.cos(theta) if np.cos(theta) != 0 else rho))

        if len(orizzontali) >= 2 and len(verticali) >= 2:
            orizzontali.sort(key=lambda x: x[0])
            verticali.sort(key=lambda x: x[2])
            top, bottom = orizzontali[0], orizzontali[-1]
            left, right = verticali[0][:2], verticali[-1][:2]

            def intersezione(l1, l2):
                A, b = np.array([[np.cos(l1[1]), np.sin(l1[1])], [np.cos(l2[1]), np.sin(l2[1])]]), np.array([l1[0], l2[0]])
                try: return (int(round(np.linalg.solve(A, b)[0])), int(round(np.linalg.solve(A, b)[1])))
                except: return None

            vertici = [intersezione(top, left), intersezione(top, right), intersezione(bottom, right), intersezione(bottom, left)]
            if None not in vertici:
                quad_state_val = np.array(vertici, dtype=np.int32).reshape((-1, 1, 2))
                for v in vertici: cv2.circle(debug_img, v, 15, (255, 0, 0), -1)
                cv2.polylines(debug_img, [quad_state_val], isClosed=True, color=(0, 255, 0), thickness=4)
                area_pixel = int(abs(cv2.contourArea(quad_state_val)))

    return mask_visiva, debug_img, str(area_pixel), "Elaborazione manuale completata.", quad_state_val

# ==========================================
# 3. MOTORE SEGMENTAZIONE PIANTA (FASE 2)
# ==========================================
def applica_morfologia(mask, tipo, intensita):
    if tipo == "Nessuna" or intensita == 0: return mask
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, ((intensita * 2) + 1, (intensita * 2) + 1))
    if tipo == "Opening (Rimuove Rumore)": return cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
    elif tipo == "Closing (Chiude Buchi)": return cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
    elif tipo == "Open + Close": return cv2.morphologyEx(cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel), cv2.MORPH_CLOSE, kernel)
    return mask

def elabora_pianta(image, s_main, s_alt, c1_min, c1_max, c2_min, c2_max, c3_min, c3_max, morfo_tipo, morfo_int, quad_state):
    if image is None: return None, "0", gr.update(visible=False)
    spazio = get_spazio_attivo(s_main, s_alt)
    min1, max1 = ordina_min_max(c1_min, c1_max)

    if spazio == "ExG":
        img_float = image.astype(np.float32)
        exg = np.clip(2 * img_float[:,:,1] - img_float[:,:,0] - img_float[:,:,2], 0, 255).astype(np.uint8)
        mask = cv2.inRange(exg, min1, 255)
    else:
        min2, max2 = ordina_min_max(c2_min, c2_max)
        min3, max3 = ordina_min_max(c3_min, c3_max)
        img_conv = converti_spazio_colore(image, spazio)
        mask = cv2.inRange(img_conv, np.array([min1, min2, min3]), np.array([max1, max2, max3]))

    mask_pulita = applica_morfologia(mask, morfo_tipo, morfo_int)
    
    # Crea l'immagine pulita (solo pixel) per il download
    segmented_clean = cv2.bitwise_and(image, image, mask=mask_pulita)
    clean_path = salva_temp_pulita(segmented_clean)
    
    # Crea l'immagine UI (con etichetta e percentuale)
    segmented_ui = segmented_clean.copy()
    if np.sum(mask_pulita == 255) > 500:
        segmented_ui = disegna_etichetta_pianta(segmented_ui, mask_pulita, quad_state)

    pixel_count = int(np.sum(mask_pulita == 255))
    return segmented_ui, f"{pixel_count} px isolati.", gr.update(value=clean_path, visible=True)

# ==========================================
# 4. MOTORE AI FASTSAM (FASE 3)
# ==========================================
@lru_cache(maxsize=1)
def carica_fastsam():
    from ultralytics import FastSAM
    return FastSAM("FastSAM-s.pt")

def segmenta_ai_manuale(image, evt: gr.SelectData, quad_vertices):
    if image is None: return None, "Nessuna immagine", gr.update(visible=False)
    x, y = evt.index
    model = carica_fastsam()
    
    results = model.predict(image, points=[[x, y]], labels=[1], device="cpu", verbose=False)
    if len(results) > 0 and results[0].masks is not None:
        mask = results[0].masks.data[0].cpu().numpy()
        mask = (cv2.resize(mask, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_NEAREST) * 255).astype(np.uint8)
        
        segmented_clean = cv2.bitwise_and(image, image, mask=mask)
        clean_path = salva_temp_pulita(segmented_clean)
        segmented_ui = disegna_etichetta_pianta(segmented_clean.copy(), mask, quad_vertices)
        
        return segmented_ui, f"{int(np.sum(mask == 255))} px estratti.", gr.update(value=clean_path, visible=True)
    return image, "Nessun oggetto trovato.", gr.update(visible=False)

def segmenta_ai_automatico(image, quad_vertices):
    if image is None: return image, "Errore: Nessuna immagine.", gr.update(visible=False)

    h, w = image.shape[:2]
    valid_area = np.zeros((h, w), dtype=np.uint8)
    if quad_vertices is not None:
        cv2.fillPoly(valid_area, [quad_vertices], 255)
    else:
        valid_area.fill(255) # Fallback: usa tutta l'immagine se manca la calibrazione

    quad_area_px = np.sum(valid_area == 255)
    exclude_mask = cv2.bitwise_not(valid_area)
    instances = []
    model = carica_fastsam()

    for step in range(5):
        allowed_area = cv2.bitwise_not(exclude_mask)
        if (np.sum(allowed_area == 255) / quad_area_px) < 0.10: break

        if step == 0:
            M = cv2.moments(valid_area)
            cx, cy = int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])
        else:
            dist = cv2.distanceTransform(allowed_area, cv2.DIST_L2, 5)
            _, max_val, _, max_loc = cv2.minMaxLoc(dist)
            if max_val < 5: break 
            cx, cy = max_loc

        results = model.predict(image, points=[[cx, cy]], labels=[1], device="cpu", verbose=False)
        
        if len(results) > 0 and results[0].masks is not None:
            mask = results[0].masks.data[0].cpu().numpy()
            mask = (cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST) * 255).astype(np.uint8)
            mask = cv2.bitwise_and(mask, valid_area) 
            
            new_pixels = cv2.bitwise_and(mask, cv2.bitwise_not(exclude_mask))
            if np.sum(new_pixels == 255) < 100: 
                cv2.circle(exclude_mask, (cx, cy), max(15, int(max_val if step > 0 else 20)), 255, -1)
                continue 
            
            instances.append(mask)
            exclude_mask = cv2.bitwise_or(exclude_mask, mask) 
        else:
            cv2.circle(exclude_mask, (cx, cy), 20, 255, -1)

    if not instances: return cv2.bitwise_and(image, image, mask=valid_area), "Fallimento AI.", gr.update(visible=False)

    img_float = image.astype(np.float32)
    exg_img = np.clip(2 * img_float[:,:,1] - img_float[:,:,0] - img_float[:,:,2], 0, 255).astype(np.uint8)

    max_exg, plant_idx = -1, -1
    for i, mask in enumerate(instances):
        mean_exg = cv2.mean(exg_img, mask=mask)[0]
        if mean_exg > max_exg: max_exg, plant_idx = mean_exg, i

    best_mask = instances[plant_idx]
    segmented_clean = cv2.bitwise_and(image, image, mask=best_mask)
    clean_path = salva_temp_pulita(segmented_clean)
    segmented_ui = disegna_etichetta_pianta(segmented_clean.copy(), best_mask, quad_vertices)
    
    return segmented_ui, f"Esplorazione completata. Pianta isolata ({int(np.sum(best_mask == 255))} px).", gr.update(value=clean_path, visible=True)


# ==========================================
# 5. INTERFACCIA UTENTE (UI) & SINCRONIZZAZIONE
# ==========================================
# QoL 7: Tema Soft e moderno
with gr.Blocks(theme=gr.themes.Glass(primary_hue="emerald", neutral_hue="slate")) as app:
    gr.Markdown("# 🥬 Computer Vision in Agricoltura: Segmentazione")
    
    quad_state = gr.State(None)

    # Header Unificato (QoL 2 & 3)
    with gr.Row():
        with gr.Column(scale=1):
            gr.Markdown("### 📂 1. Carica dal Dataset (default fornito - consigliato per esplorazione funzionalità)")
            ui_cond = gr.Dropdown(UI_CONDITIONS, label="Condizione", value="No Stress")
            ui_camp = gr.Dropdown(SAMPLES, label="Campione", value="A")
            ui_dat = gr.Dropdown(AVAILABLE_DATS, label="Giorni dal trapianto", value=AVAILABLE_DATS[0])
            btn_carica = gr.Button("⬇️ Carica Dataset", variant="primary")
            stato_db = gr.Textbox(label="Log di Caricamento", interactive=False)
        with gr.Column(scale=1):
            gr.Markdown("### 🗂️ 2. Oppure Carica File Manualmente")
            global_in_img = gr.Image(label="Immagine Sorgente Globale", type="numpy", height=320)

    # Warning dinamici se manca la calibrazione (QoL 5)
    warning_html = "<div style='background-color: #332200; color: #ffcc00; padding: 10px; border-radius: 5px; text-align: center; font-weight: bold;'>⚠️ Riferimento non calibrato. Consigliato eseguire la Fase 1.</div>"

    with gr.Tabs():
        # --- TAB 1: GEOMETRIA ---
        with gr.Tab("📐 Fase 1: Calibrazione Riferimento"):
            gr.Markdown("**Isola l'area di riferimento**. L'algoritmo calcolerà il quadrilatero interno per contenere la pianta.")
            modalita_rif = gr.Radio(["Manuale", "Automatica"], value="Manuale", label="Modalità di Calibrazione")

            with gr.Group(visible=False) as box_automatico:
                gr.Markdown("Seleziona i colori attesi per trovare automaticamente il quadrilatero tramite un algoritmo deterministico (no AI).")
                with gr.Row():
                    auto_bg = gr.Dropdown(["Nero", "Bianco"], label="Colore Sfondo")
                    auto_frame = gr.Dropdown(["Nera", "Bianca", "Viola", "Rossa", "Blu"], label="Colore Cornice")
                btn_calcola_auto = gr.Button("🪄 Esegui Calibrazione Automatica", variant="primary")

            with gr.Group(visible=True) as box_manuale:
                with gr.Row():
                    sp_main_r = gr.Radio(["HSV", "ExG"], value="HSV", label="Spazio Colore Consigliato")
                    with gr.Accordion("Altri Spazi (Avanzato)", open=False):
                        sp_altri_r = gr.Radio(["Nessuno", "RGB", "LAB"], value="Nessuno", label="Override Spazio")
                
                with gr.Row():
                    with gr.Column():
                        r1_min = gr.Slider(0, 179, 20, label="Tinta (H) Min")
                        r1_max = gr.Slider(0, 179, 80, label="Tinta (H) Max")
                    with gr.Column():
                        r2_min = gr.Slider(0, 255, 40, label="Saturazione (S) Min")
                        r2_max = gr.Slider(0, 255, 255, label="Saturazione (S) Max")
                    with gr.Column():
                        r3_min = gr.Slider(0, 255, 40, label="Valore (V) Min")
                        r3_max = gr.Slider(0, 255, 255, label="Valore (V) Max")
                # QoL 6: Bottone Calcola Manuale
                btn_calc_f1_man = gr.Button("▶️ Esegui Calcolo Manuale", variant="secondary")

            with gr.Row():
                img_rif_in = gr.Image(label="Immagine in analisi", interactive=False, height=350)
                img_mask_out = gr.Image(label="Maschera Corrente", height=350)
                img_geom_out = gr.Image(label="Geometria Trovata", height=350)

            with gr.Row():
                pixel_rif_out = gr.Textbox(label="🔥 AREA CORNICE (Pixel²)")
                log_geom = gr.Textbox(label="Log di Sistema")
            
            # Switch view Tab 1
            modalita_rif.change(fn=lambda c: (gr.update(visible=(c == "Manuale")), gr.update(visible=(c == "Automatica"))), inputs=modalita_rif, outputs=[box_manuale, box_automatico])

            # Logic Tab 1
            sliders_r = [r1_min, r1_max, r2_min, r2_max, r3_min, r3_max]
            for sp in [sp_main_r, sp_altri_r]: sp.change(fn=aggiorna_sliders, inputs=[sp_main_r, sp_altri_r], outputs=sliders_r)
            img_rif_in.select(fn=cattura_colore, inputs=[img_rif_in, sp_main_r, sp_altri_r], outputs=sliders_r)
            
            fn_manuale_f1 = lambda *args: elabora_riferimento_manuale(*args)
            inputs_man_f1 = [img_rif_in, sp_main_r, sp_altri_r] + sliders_r
            outputs_f1 = [img_mask_out, img_geom_out, pixel_rif_out, log_geom, quad_state]
            
            for s in sliders_r + [sp_main_r, sp_altri_r]:
                s.change(fn=fn_manuale_f1, inputs=inputs_man_f1, outputs=outputs_f1)
            btn_calc_f1_man.click(fn=fn_manuale_f1, inputs=inputs_man_f1, outputs=outputs_f1)
            btn_calcola_auto.click(fn=elabora_riferimento_automatico, inputs=[img_rif_in, auto_bg, auto_frame], outputs=outputs_f1)

        # --- TAB 2: COLORE PIANTA ---
        with gr.Tab("🌱 Fase 2: Segmentazione Colore"):
            warn_f2 = gr.HTML(warning_html, visible=True)
            with gr.Group():
                with gr.Row():
                    sp_main_l = gr.Radio(["HSV", "ExG"], value="ExG", label="consigliato default in RGB, con indice di eccesso di verde ExG = 2G - R - B")
                    with gr.Accordion("Altri Spazi (Avanzato)", open=False):
                        sp_altri_l = gr.Radio(["Nessuno", "RGB", "LAB"], value="Nessuno", label="Override Spazio")
                
                with gr.Row():
                    with gr.Column():
                        l1_min = gr.Slider(0, 255, 40, label="Soglia Minima ExG")
                        l1_max = gr.Slider(0, 255, 255, label="Tinta (H) Max", visible=False)
                    with gr.Column():
                        l2_min = gr.Slider(0, 255, 40, label="Saturazione (S) Min", visible=False)
                        l2_max = gr.Slider(0, 255, 255, label="Saturazione (S) Max", visible=False)
                    with gr.Column():
                        l3_min = gr.Slider(0, 255, 40, label="Valore (V) Min", visible=False)
                        l3_max = gr.Slider(0, 255, 255, label="Valore (V) Max", visible=False)
                
                with gr.Row():
                    morfo_tipo = gr.Radio(["Nessuna", "Opening (Rimuove Rumore)", "Closing (Chiude Buchi)", "Open + Close"], value="Nessuna", label="Pulizia Morfologica")
                    morfo_int = gr.Slider(1, 10, value=3, step=1, label="Intensità Pulizia (Kernel)")
                
                # QoL 6: Bottone Calcola Segmentazione
                btn_calc_f2 = gr.Button("▶️ Esegui Segmentazione", variant="secondary")

            with gr.Row():
                img_lat_in = gr.Image(label="Immagine in analisi", interactive=False, height=350)
                with gr.Column():
                    img_lat_out = gr.Image(label="Pianta Segmentata", height=350)
                    btn_down_f2 = gr.DownloadButton("💾 Scarica Immagine Pulita", visible=False) # QoL 1

            pixel_lat_out = gr.Textbox(label="Dati Estratti")

            # Logic Tab 2
            sliders_l = [l1_min, l1_max, l2_min, l2_max, l3_min, l3_max]
            for sp in [sp_main_l, sp_altri_l]: sp.change(fn=aggiorna_sliders, inputs=[sp_main_l, sp_altri_l], outputs=sliders_l)
            img_lat_in.select(fn=cattura_colore, inputs=[img_lat_in, sp_main_l, sp_altri_l], outputs=sliders_l)
            
            inputs_f2 = [img_lat_in, sp_main_l, sp_altri_l] + sliders_l + [morfo_tipo, morfo_int, quad_state]
            outputs_f2 = [img_lat_out, pixel_lat_out, btn_down_f2]

            for s in sliders_l + [sp_main_l, sp_altri_l, morfo_tipo, morfo_int]:
                s.change(fn=elabora_pianta, inputs=inputs_f2, outputs=outputs_f2)
            btn_calc_f2.click(fn=elabora_pianta, inputs=inputs_f2, outputs=outputs_f2)

        # --- TAB 3: AI FASTSAM ---
        with gr.Tab("🧠 Fase 3: AI (FastSAM)"):
            warn_f3 = gr.HTML(warning_html, visible=True)
            gr.Markdown("**Clicca sull'immagine** L'auto-segmentataore IA (FastSAM) isolerà l'oggetto cliccato.")
            
            btn_auto_sam = gr.Button("🤖 Segmenta Automaticamente la pianta- Algoritmo ibrido (Loop deterministico con chiamate IA)", variant="primary")
            
            with gr.Row():
                img_ai_in = gr.Image(label="Clicca sull'oggetto", interactive=False, height=350)
                with gr.Column():
                    img_ai_out = gr.Image(label="Risultato AI", height=350)
                    btn_down_f3 = gr.DownloadButton("💾 Scarica Immagine Pulita", visible=False) # QoL 1

            pixel_ai_out = gr.Textbox(label="Dati AI")

            img_ai_in.select(fn=segmenta_ai_manuale, inputs=[img_ai_in, quad_state], outputs=[img_ai_out, pixel_ai_out, btn_down_f3])
            btn_auto_sam.click(fn=segmenta_ai_automatico, inputs=[img_ai_in, quad_state], outputs=[img_ai_out, pixel_ai_out, btn_down_f3])

    # Sincronizzazione Globale QoL 2, 3 e 5
    def imposta_immagine_globale(img):
        return (
            img, img, img,                           # Le 3 immagini di input nelle tab
            None, None, "0", "", None,               # Output Fase 1
            None, "", gr.update(visible=False),      # Output Fase 2
            None, "", gr.update(visible=False)       # Output Fase 3
        )

    # Aggiorna banner di avviso quando si ricalcola o resetta la cornice
    quad_state.change(
        fn=lambda q: (gr.update(visible=(q is None)), gr.update(visible=(q is None))),
        inputs=quad_state, 
        outputs=[warn_f2, warn_f3]
    )

    global_in_img.change(
        fn=imposta_immagine_globale,
        inputs=global_in_img,
        outputs=[
            img_rif_in, img_lat_in, img_ai_in,
            img_mask_out, img_geom_out, pixel_rif_out, log_geom, quad_state,
            img_lat_out, pixel_lat_out, btn_down_f2,
            img_ai_out, pixel_ai_out, btn_down_f3
        ]
    )

    btn_carica.click(fn=carica_da_dataset, inputs=[ui_cond, ui_camp, ui_dat], outputs=[global_in_img, stato_db])

app.launch()