from __future__ import annotations import os import tempfile from pathlib import Path from typing import Any os.environ.setdefault("YOLO_CONFIG_DIR", "/tmp/Ultralytics") import cv2 import gradio as gr import numpy as np from PIL import Image, ImageDraw, ImageFont from reportlab.lib.pagesizes import A4 from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas from fastsam_shared import load_fastsam_cpu from advanced_14_16 import build_14_16_tab try: import spaces except ImportError: # Consente test locali fuori da Hugging Face. class _SpacesFallback: @staticmethod def GPU(*args, **kwargs): def decorator(func): return func return decorator spaces = _SpacesFallback() DATASET_DIR = Path("dataset") DAYS = ["06", "09", "11", "14", "16", "19", "23", "26"] CONDITIONS = ["NS", "S"] REPLICATES = ["A", "B", "C"] @spaces.GPU(duration=1) def _zerogpu_runtime_anchor(): """Ancora richiesta dal runtime ZeroGPU; non è collegata all’interfaccia.""" return "ready" DEFAULT_HSV = { "h_min": 50.0, "h_max": 200.0, "s_min": 14.0, "s_max": 100.0, "v_min": 10.0, "v_max": 100.0, } CSS = """ .note {background:#eef6f0;border-left:5px solid #2e7d32;padding:10px;margin:8px 0} .info {background:#edf4fb;border-left:5px solid #326a9a;padding:10px;margin:8px 0} .warning {background:#fff4d6;border-left:5px solid #b67800;padding:10px;margin:8px 0} .fastsam-instruction {background:#e8f3ff;border:2px solid #245f91;border-radius:8px;padding:12px 14px;margin:10px 0;font-size:1.02rem} .compact p {margin:0.35rem 0} .matrix-help {font-size:0.95rem} .action-button button, button.action-button { background:#1f6f43 !important; color:#ffffff !important; border:1px solid #155333 !important; font-weight:700 !important; border-radius:7px !important; min-height:42px !important; } .action-button button:hover, button.action-button:hover {background:#155333 !important} .action-button button:disabled, button.action-button:disabled {background:#8ca79a !important;color:#f6f6f6 !important} .result-card img {border-radius:12px !important} .workflow-panel {border:1px solid #cbd8cf !important;border-radius:10px !important;padding:14px !important;margin:10px 0 18px 0 !important;background:#ffffff !important} .manual-panel {border:1px solid #9fb3a7 !important;border-left:5px solid #6f8f7a !important;border-radius:8px !important;padding:14px !important;margin:14px 0 !important;background:#f8faf8 !important} .panel-kicker {font-weight:800;color:#17653a;letter-spacing:.04em;font-size:1.02rem;margin-bottom:8px} .step-strip {display:flex;gap:7px;flex-wrap:wrap;margin:10px 0 18px 0} .step-box {display:flex;align-items:center;gap:8px;min-width:145px;flex:1;background:#edf6ef;border:1px solid #b9d2c0;border-radius:9px;padding:9px 12px;color:#194e31} .step-box span {display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:#1f6f43;color:white;font-weight:800} .guide-panel,.docs-panel {background:#f7faf8;border:1px solid #d2ddd5;border-radius:10px;padding:14px;margin:10px 0;line-height:1.42} .guide-panel h3,.docs-panel h3 {color:#17653a;margin-top:0} .metrics-wrap {margin:8px 0 12px}.metrics-title{font-weight:800;color:#194e31;margin-bottom:7px}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px}.metric-card{border:1px solid #d4ded7;border-radius:8px;padding:10px;background:#f8fbf9}.metric-card span{display:block;font-size:.82rem;color:#596660}.metric-card strong{display:block;font-size:1.35rem;color:#153c28;margin-top:3px} @media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.step-box{min-width:45%}} """ def ensure_rgb(image: Any) -> np.ndarray | None: if image is None: return None if isinstance(image, Image.Image): image = np.asarray(image) arr = np.asarray(image) if arr.ndim == 2: arr = cv2.cvtColor(arr.astype(np.uint8), cv2.COLOR_GRAY2RGB) if arr.ndim != 3: raise ValueError("Formato immagine non supportato.") if arr.shape[-1] == 4: arr = cv2.cvtColor(arr.astype(np.uint8), cv2.COLOR_RGBA2RGB) if arr.shape[-1] != 3: raise ValueError("L'immagine deve avere tre canali RGB.") return arr.astype(np.uint8) def read_image(path: Path) -> np.ndarray: bgr = cv2.imread(str(path)) if bgr is None: raise FileNotFoundError(path) image = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) max_dim = 1800 h, w = image.shape[:2] if max(h, w) > max_dim: scale = max_dim / max(h, w) image = cv2.resize( image, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA, ) return image def dataset_name(condition: str, replicate: str, day: str) -> str: return f"{condition}_{replicate}_{day}.jpg" def load_dataset_image(condition: str, replicate: str, day: str): name = dataset_name(condition, replicate, day) path = DATASET_DIR / name try: return read_image(path), ( f"Caricata {name}. NS = non stress; S = stress idrico; " "A/B/C = replica; il numero indica i giorni dal trapianto." ) except Exception as exc: return None, f"Immagine non disponibile: {name}. Dettaglio: {exc}" # ----------------------------------------------------------------------------- # 6-9: scheda PDF a quadretti # ----------------------------------------------------------------------------- def prepare_grid_image( image: Any, rows: int, cols: int, lighten: float, use_color: bool, ) -> np.ndarray: rgb = ensure_rgb(image) if rgb is None: raise ValueError("Caricare una fotografia.") pil = Image.fromarray(rgb) if not use_color: pil = pil.convert("L").convert("RGB") pil = Image.blend(pil, Image.new("RGB", pil.size, "white"), float(lighten)) # La griglia viene disegnata sull'immagine finale, con spessore proporzionato. draw = ImageDraw.Draw(pil) width, height = pil.size line_width = max(1, round(min(width, height) / 500)) for col in range(1, int(cols)): x = round(col * width / int(cols)) draw.line((x, 0, x, height), fill=(15, 15, 15), width=line_width) for row in range(1, int(rows)): y = round(row * height / int(rows)) draw.line((0, y, width, y), fill=(15, 15, 15), width=line_width) return np.asarray(pil) def create_grid_pdf( image: Any, rows: int, cols: int, lighten: float, use_color: bool, activity_title: str, ): try: grid = prepare_grid_image(image, rows, cols, lighten, use_color) except Exception as exc: return None, None, f"Impossibile generare la scheda: {exc}" title = (activity_title or "Piante a quadretti").strip() fd_pdf, pdf_path = tempfile.mkstemp(prefix="piante_a_quadretti_", suffix=".pdf") os.close(fd_pdf) page_w, page_h = A4 margin_x = 42 top_y = page_h - 42 title_h = 34 note_h = 42 available_w = page_w - 2 * margin_x available_h = page_h - 2 * 42 - title_h - note_h - 16 img_h, img_w = grid.shape[:2] scale = min(available_w / img_w, available_h / img_h) draw_w = img_w * scale draw_h = img_h * scale draw_x = (page_w - draw_w) / 2 draw_y = top_y - title_h - draw_h pdf = canvas.Canvas(pdf_path, pagesize=A4) pdf.setTitle(title) pdf.setFont("Helvetica-Bold", 16) pdf.drawCentredString(page_w / 2, top_y, title) pdf.setFont("Helvetica", 9) pdf.drawCentredString( page_w / 2, top_y - 17, f"Griglia: {int(rows)} righe × {int(cols)} colonne", ) # ReportLab incorpora il JPEG già compresso, riducendo sensibilmente il peso del PDF. fd_jpg, jpg_path = tempfile.mkstemp(prefix="griglia_pdf_", suffix=".jpg") os.close(fd_jpg) Image.fromarray(grid).save(jpg_path, "JPEG", quality=92, optimize=True) pdf.drawImage( jpg_path, draw_x, draw_y, width=draw_w, height=draw_h, preserveAspectRatio=True, mask="auto", ) line_y = 52 pdf.setLineWidth(0.8) pdf.line(margin_x, line_y + 18, page_w - margin_x, line_y + 18) pdf.setFont("Helvetica", 11) pdf.drawString( margin_x, line_y, "Numero di quadratini verdi identificati/colorati: ____________________", ) pdf.showPage() pdf.save() try: os.remove(jpg_path) except OSError: pass # L'anteprima riproduce la pagina del PDF in modo leggibile dentro l'interfaccia. preview_w = 1240 preview_h = round(preview_w * page_h / page_w) preview = Image.new("RGB", (preview_w, preview_h), "white") d = ImageDraw.Draw(preview) try: font_title = ImageFont.truetype("DejaVuSans-Bold.ttf", 34) font_small = ImageFont.truetype("DejaVuSans.ttf", 20) font_note = ImageFont.truetype("DejaVuSans.ttf", 23) except OSError: font_title = font_small = font_note = None d.text((preview_w / 2, 48), title, fill="black", anchor="ma", font=font_title) d.text( (preview_w / 2, 98), f"Griglia: {int(rows)} righe × {int(cols)} colonne", fill="black", anchor="ma", font=font_small, ) grid_pil = Image.fromarray(grid) max_w = preview_w - 150 max_h = preview_h - 300 pscale = min(max_w / grid_pil.width, max_h / grid_pil.height) resized = grid_pil.resize( (round(grid_pil.width * pscale), round(grid_pil.height * pscale)), Image.Resampling.LANCZOS, ) px = (preview_w - resized.width) // 2 py = 135 preview.paste(resized, (px, py)) note_y = py + resized.height + 48 d.line((75, note_y - 24, preview_w - 75, note_y - 24), fill="black", width=2) d.text( (75, note_y), "Numero di quadratini verdi identificati/colorati: ____________________", fill="black", font=font_note, ) return np.asarray(preview), pdf_path, "Scheda PDF generata." # ----------------------------------------------------------------------------- # 9-11: esplorazione di pixel e trasformazioni additive del colore # ----------------------------------------------------------------------------- def didactic_hsv(image: np.ndarray) -> np.ndarray: hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(np.float32) hsv[:, :, 0] = hsv[:, :, 0] * (360.0 / 180.0) hsv[:, :, 1] = hsv[:, :, 1] * (100.0 / 255.0) hsv[:, :, 2] = hsv[:, :, 2] * (100.0 / 255.0) return hsv def channel_choices(space: str): choices = ["R", "G", "B"] if space == "RGB" else ["H", "S", "V"] return gr.update(choices=choices, value=choices[0]) def extract_channel(image: np.ndarray, space: str, channel: str) -> np.ndarray: if space == "RGB": idx = {"R": 0, "G": 1, "B": 2}[channel] return image[:, :, idx].astype(np.float32) hsv = didactic_hsv(image) idx = {"H": 0, "S": 1, "V": 2}[channel] return hsv[:, :, idx] def selection_bounds(x: int, y: int, size: int, width: int, height: int): size = int(size) half = size // 2 x0 = min(max(x - half, 0), max(width - size, 0)) y0 = min(max(y - half, 0), max(height - size, 0)) return x0, y0, min(x0 + size, width), min(y0 + size, height) def _load_ui_font(size: int, bold: bool = False): candidates = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", ] for candidate in candidates: if Path(candidate).exists(): return ImageFont.truetype(candidate, size=size) return ImageFont.load_default() def _pixel_triplet(crop: np.ndarray, space: str) -> np.ndarray: if space == "RGB": return crop.astype(np.float32) return didactic_hsv(crop) def _pixel_label(values: np.ndarray, space: str) -> str: if space == "RGB": r, g, b = [int(round(v)) for v in values] return f"R {r:3d}\nG {g:3d}\nB {b:3d}" h, sat, val = values return f"H {int(round(h)):3d}°\nS {int(round(sat)):3d}%\nV {int(round(val)):3d}%" def build_annotated_pixel_patch(crop: np.ndarray, space: str, canvas_size: int = 800) -> np.ndarray: """Ingrandisce il patch 5x5 e scrive i tre valori del colore su ogni pixel.""" size = crop.shape[0] cell = canvas_size // size width = cell * size height = cell * size board = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(board) values = _pixel_triplet(crop, space) font = _load_ui_font(max(17, cell // 8), bold=True) for row in range(size): for col in range(size): x0, y0 = col * cell, row * cell x1, y1 = x0 + cell, y0 + cell color = tuple(int(v) for v in crop[row, col]) draw.rectangle((x0, y0, x1, y1), fill=color, outline=(255, 255, 255), width=2) label = _pixel_label(values[row, col], space) bbox = draw.multiline_textbbox((0, 0), label, font=font, spacing=2, align="center") tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] pad_x, pad_y = 8, 5 bx0 = x0 + (cell - tw) // 2 - pad_x by0 = y0 + (cell - th) // 2 - pad_y bx1 = bx0 + tw + 2 * pad_x by1 = by0 + th + 2 * pad_y overlay = Image.new("RGBA", board.size, (0, 0, 0, 0)) od = ImageDraw.Draw(overlay) od.rounded_rectangle((bx0, by0, bx1, by1), radius=8, fill=(0, 0, 0, 155)) board = Image.alpha_composite(board.convert("RGBA"), overlay).convert("RGB") draw = ImageDraw.Draw(board) draw.multiline_text( (x0 + cell / 2, y0 + cell / 2), label, font=font, fill=(255, 255, 255), anchor="mm", align="center", spacing=2, ) return np.asarray(board) def render_pixel_selection( image: Any, space: str, coords: list[int] | tuple[int, int] | None, ): rgb = ensure_rgb(image) size = 5 if rgb is None: return None, "Caricare una fotografia una sola volta nel pannello superiore.", coords if not coords or coords[0] is None: return None, "Fare click sulla fotografia per scegliere il centro del patch 5×5.", coords x, y = map(int, coords) h, w = rgb.shape[:2] x = min(max(x, 0), w - 1) y = min(max(y, 0), h - 1) x0, y0, x1, y1 = selection_bounds(x, y, size, w, h) crop = rgb[y0:y1, x0:x1] if crop.shape[:2] != (size, size): return None, "Selezione troppo vicina al bordo: scegliere un punto leggermente più interno.", [x, y] patch = build_annotated_pixel_patch(crop, space, canvas_size=800) status = ( f"Patch 5×5 centrato vicino a x={x}, y={y}. " + ("Ogni cella mostra R, G e B (0-255)." if space == "RGB" else "Ogni cella mostra H in gradi, S e V in percentuale.") ) return patch, status, [x, y] def select_pixel_region(image, space, evt: gr.SelectData): try: x, y = map(int, evt.index) except Exception: return None, "Impossibile leggere il punto selezionato.", [None, None] return render_pixel_selection(image, space, [x, y]) def refresh_pixel_region(image, space, coords): return render_pixel_selection(image, space, coords) def editor_value_from_image(image: Any): rgb = ensure_rgb(image) if rgb is None: return None, None, "Caricare un'immagine nel campo sorgente." value = {"background": rgb, "layers": [], "composite": rgb} return value, rgb, "Immagine caricata nel laboratorio del colore. Dipingere la zona da modificare." def editor_background_and_mask(editor_value: dict[str, Any] | None): if not editor_value: raise ValueError("Caricare un'immagine nel laboratorio del colore.") background = ensure_rgb(editor_value.get("background")) if background is None: raise ValueError("Sfondo dell'editor non disponibile.") mask = np.zeros(background.shape[:2], dtype=bool) for layer in editor_value.get("layers") or []: arr = np.asarray(layer) if arr.ndim == 3 and arr.shape[2] == 4: alpha = arr[:, :, 3] if alpha.shape != mask.shape: alpha = cv2.resize(alpha, (mask.shape[1], mask.shape[0]), interpolation=cv2.INTER_NEAREST) mask |= alpha > 0 elif arr.ndim == 3: layer_rgb = ensure_rgb(arr) if layer_rgb.shape[:2] != mask.shape: layer_rgb = cv2.resize(layer_rgb, (mask.shape[1], mask.shape[0]), interpolation=cv2.INTER_NEAREST) mask |= np.any(layer_rgb != 0, axis=2) # Fallback: usa la differenza fra composito e sfondo se il livello non espone alpha. if not mask.any() and editor_value.get("composite") is not None: composite = ensure_rgb(editor_value.get("composite")) if composite.shape[:2] != background.shape[:2]: composite = cv2.resize(composite, (background.shape[1], background.shape[0]), interpolation=cv2.INTER_LINEAR) mask = np.any(np.abs(composite.astype(np.int16) - background.astype(np.int16)) > 2, axis=2) return background, mask def apply_additive_brush(editor_value, space: str, channel: str, delta: float): try: background, mask = editor_background_and_mask(editor_value) except Exception as exc: return editor_value, None, f"Modifica non applicata: {exc}" if not mask.any(): return editor_value, background, "Dipingere almeno una zona prima di applicare la modifica." delta = float(delta) if space == "RGB": idx = {"R": 0, "G": 1, "B": 2}[channel] work = background.astype(np.int16) work[:, :, idx][mask] = np.clip(work[:, :, idx][mask] + round(delta), 0, 255) result = work.astype(np.uint8) explanation = f"Canale {channel}: variazione additiva {delta:+.0f}, con limite 0-255." else: hsv = cv2.cvtColor(background, cv2.COLOR_RGB2HSV).astype(np.float32) idx = {"H": 0, "S": 1, "V": 2}[channel] if channel == "H": # OpenCV usa 0-179; l'interfaccia usa gradi 0-359. hsv[:, :, idx][mask] = (hsv[:, :, idx][mask] + delta / 2.0) % 180.0 explanation = ( f"Canale H: variazione {delta:+.0f}°. H è circolare: superati 360° si riparte da 0°." ) else: step = delta * 255.0 / 100.0 hsv[:, :, idx][mask] = np.clip(hsv[:, :, idx][mask] + step, 0, 255) explanation = f"Canale {channel}: variazione {delta:+.0f} punti percentuali, con limite 0-100%." result = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2RGB) new_value = {"background": result, "layers": [], "composite": result} selected = int(np.count_nonzero(mask)) return new_value, result, f"Modificati {selected} pixel. {explanation} Per sommare un altro passaggio, dipingere di nuovo e premere ancora." # ----------------------------------------------------------------------------- # 9-11: quanto verde? # ----------------------------------------------------------------------------- def automatic_kernel(image: np.ndarray) -> int: return 3 if min(image.shape[:2]) < 1200 else 5 def clean_mask_auto(mask: np.ndarray, image: np.ndarray) -> np.ndarray: kernel_size = automatic_kernel(image) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) return cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel) def hsv_bounds_to_cv(h_min, h_max, s_min, s_max, v_min, v_max): low = np.array([ np.clip(float(h_min) / 2.0, 0, 179), np.clip(float(s_min) * 255.0 / 100.0, 0, 255), np.clip(float(v_min) * 255.0 / 100.0, 0, 255), ], dtype=np.uint8) high = np.array([ np.clip(float(h_max) / 2.0, 0, 179), np.clip(float(s_max) * 255.0 / 100.0, 0, 255), np.clip(float(v_max) * 255.0 / 100.0, 0, 255), ], dtype=np.uint8) return low, high def hsv_mask(image: np.ndarray, h_min, h_max, s_min, s_max, v_min, v_max): hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV) low, high = hsv_bounds_to_cv(h_min, h_max, s_min, s_max, v_min, v_max) if low[0] <= high[0]: mask = cv2.inRange(hsv, low, high) else: # Intervallo H che attraversa 360°/0°. low_a = low.copy(); high_a = high.copy() high_a[0] = 179 low_b = low.copy(); high_b = high.copy() low_b[0] = 0 mask = cv2.bitwise_or(cv2.inRange(hsv, low_a, high_a), cv2.inRange(hsv, low_b, high_b)) return clean_mask_auto(mask, image) def exg_mask(image: np.ndarray, threshold: float): values = image.astype(np.float32) exg = 2.0 * values[:, :, 1] - values[:, :, 0] - values[:, :, 2] mask = np.where(exg > float(threshold), 255, 0).astype(np.uint8) return clean_mask_auto(mask, image) def build_overlay(image: np.ndarray, mask: np.ndarray, tint=(0, 235, 70)) -> np.ndarray: overlay = image.copy() color = np.zeros_like(image) color[:, :] = np.array(tint, dtype=np.uint8) overlay[mask > 0] = cv2.addWeighted(image[mask > 0], 0.55, color[mask > 0], 0.45, 0) return overlay def mask_result(image: Any, mask: np.ndarray, label: str): rgb = ensure_rgb(image) overlay = build_overlay(rgb, mask) pixels = int(np.count_nonzero(mask)) pct = 100.0 * pixels / mask.size message = ( f"{label}: {pixels:,} pixel selezionati ({pct:.1f}% dell'immagine). " f"Pulizia automatica con kernel {automatic_kernel(rgb)}×{automatic_kernel(rgb)}." ).replace(",", ".") return cv2.cvtColor(mask, cv2.COLOR_GRAY2RGB), overlay, message, mask def default_hsv_outputs(image: Any): rgb = ensure_rgb(image) if rgb is None: return None, None, "Caricare un'immagine.", None mask = hsv_mask(rgb, **DEFAULT_HSV) return mask_result(rgb, mask, "Metodo cromatico HSV") def custom_hsv_outputs(image, h_min, h_max, s_min, s_max, v_min, v_max): rgb = ensure_rgb(image) if rgb is None: return None, None, "Caricare un'immagine.", None mask = hsv_mask(rgb, h_min, h_max, s_min, s_max, v_min, v_max) return mask_result(rgb, mask, "HSV con soglie modificate") def exg_outputs(image, threshold): rgb = ensure_rgb(image) if rgb is None: return None, None, "Caricare un'immagine.", None mask = exg_mask(rgb, threshold) return mask_result(rgb, mask, f"ExG > {float(threshold):.0f}") def green_focus_overlay(image: np.ndarray, mask: np.ndarray) -> np.ndarray: """Mantiene invariata la zona verde e rende il resto scuro e quasi monocromatico.""" rgb = ensure_rgb(image) gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) dark = np.clip(gray.astype(np.float32) * 0.23, 0, 255).astype(np.uint8) dark_rgb = cv2.cvtColor(dark, cv2.COLOR_GRAY2RGB) # Una lieve tinta fredda rende la zona esclusa più leggibile senza competere con il verde. dark_rgb = np.clip(dark_rgb.astype(np.int16) + np.array([0, 2, 5], dtype=np.int16), 0, 255).astype(np.uint8) out = dark_rgb out[mask > 0] = rgb[mask > 0] return out def _draw_crown(draw: ImageDraw.ImageDraw, center_x: int, top_y: int, scale: int = 1): w, h = 62 * scale, 38 * scale x0 = center_x - w // 2 points = [ (x0, top_y + h), (x0 + 7 * scale, top_y + 11 * scale), (x0 + 20 * scale, top_y + 23 * scale), (x0 + 31 * scale, top_y), (x0 + 43 * scale, top_y + 23 * scale), (x0 + 56 * scale, top_y + 11 * scale), (x0 + w, top_y + h), ] draw.polygon(points, fill=(246, 190, 35), outline=(151, 103, 0)) draw.rectangle((x0 + 4 * scale, top_y + h - 7 * scale, x0 + w - 4 * scale, top_y + h + 2 * scale), fill=(246, 190, 35), outline=(151, 103, 0)) def comparison_card(image: np.ndarray, mask: np.ndarray, pct: float, label: str, winner: bool, tie: bool = False) -> np.ndarray: overlay = green_focus_overlay(image, mask) card_w = 760 photo_h = 500 header_h = 155 card = Image.new("RGB", (card_w, header_h + photo_h), (248, 250, 249)) draw = ImageDraw.Draw(card) title_font = _load_ui_font(25, bold=True) pct_font = _load_ui_font(64, bold=(winner or tie)) badge_font = _load_ui_font(19, bold=True) draw.text((28, 18), label, font=title_font, fill=(42, 55, 48)) pct_text = f"{pct:.1f}%" pct_bbox = draw.textbbox((0, 0), pct_text, font=pct_font) pct_w = pct_bbox[2] - pct_bbox[0] px = (card_w - pct_w) // 2 if winner: badge = (px - 28, 48, px + pct_w + 28, 132) draw.rounded_rectangle(badge, radius=22, fill=(255, 235, 128), outline=(222, 171, 20), width=4) _draw_crown(draw, min(card_w - 48, px + pct_w + 62), 54, scale=1) elif tie: badge = (px - 28, 48, px + pct_w + 28, 132) draw.rounded_rectangle(badge, radius=22, fill=(229, 238, 232), outline=(112, 138, 120), width=3) draw.text((card_w // 2, 88), pct_text, font=pct_font, fill=(24, 57, 35), anchor="mm") if winner: draw.text((card_w // 2, 137), "PIÙ VERDE", font=badge_font, fill=(105, 75, 0), anchor="mm") elif tie: draw.text((card_w // 2, 137), "PARITÀ", font=badge_font, fill=(65, 83, 70), anchor="mm") pil = Image.fromarray(overlay) scale = min(card_w / pil.width, photo_h / pil.height) nw, nh = max(1, round(pil.width * scale)), max(1, round(pil.height * scale)) pil = pil.resize((nw, nh), Image.Resampling.LANCZOS) bg = Image.new("RGB", (card_w, photo_h), (14, 18, 16)) bg.paste(pil, ((card_w - nw) // 2, (photo_h - nh) // 2)) card.paste(bg, (0, header_h)) return np.asarray(card) def compare_green_pair(image_a: Any, image_b: Any): a = ensure_rgb(image_a) b = ensure_rgb(image_b) if a is None or b is None: msg = "Caricare entrambe le fotografie prima di avviare il confronto." return ( gr.update(value=None, visible=False), gr.update(value=None, visible=False), gr.update(value=None, visible=False), gr.update(value=None, visible=False), msg, ) mask_a = hsv_mask(a, **DEFAULT_HSV) mask_b = hsv_mask(b, **DEFAULT_HSV) pct_a = 100.0 * float(np.count_nonzero(mask_a)) / mask_a.size pct_b = 100.0 * float(np.count_nonzero(mask_b)) / mask_b.size tie = abs(pct_a - pct_b) < 0.05 winner_a = pct_a > pct_b and not tie winner_b = pct_b > pct_a and not tie card_a = comparison_card(a, mask_a, pct_a, "Fotografia A", winner_a, tie) card_b = comparison_card(b, mask_b, pct_b, "Fotografia B", winner_b, tie) mask_a_rgb = cv2.cvtColor(mask_a, cv2.COLOR_GRAY2RGB) mask_b_rgb = cv2.cvtColor(mask_b, cv2.COLOR_GRAY2RGB) if tie: msg = f"Parità: entrambe le fotografie contengono circa {pct_a:.1f}% di verde secondo le soglie HSV." elif winner_a: msg = f"Fotografia A contiene più verde: {pct_a:.1f}% contro {pct_b:.1f}% nella fotografia B." else: msg = f"Fotografia B contiene più verde: {pct_b:.1f}% contro {pct_a:.1f}% nella fotografia A." return ( gr.update(value=card_a, visible=True), gr.update(value=card_b, visible=True), gr.update(value=mask_a_rgb, visible=True), gr.update(value=mask_b_rgb, visible=True), msg, ) # ----------------------------------------------------------------------------- # 11-14: confronto progressivo fra HSV, ExG e FastSAM # ----------------------------------------------------------------------------- def sync_fastsam_source(image: Any): """Sincronizza la copia cliccabile e azzera i risultati precedenti.""" rgb = ensure_rgb(image) instruction = ( "1. Cliccare sulla pianta nell’immagine qui sotto. " "2. Controllare il punto rosso. 3. Premere il pulsante di segmentazione." ) return ( rgb, None, gr.update(value=None, visible=False), gr.update(value=None, visible=False), instruction, None, ) def select_fastsam_point(source_image: Any, evt: gr.SelectData): """Registra il punto senza avviare l’inferenza e lo mostra sulla copia locale.""" rgb = ensure_rgb(source_image) if rgb is None: return None, None, "Caricare prima una fotografia." try: x, y = map(int, evt.index) except Exception: return rgb, None, "Impossibile leggere il punto selezionato." h, w = rgb.shape[:2] x = min(max(x, 0), w - 1) y = min(max(y, 0), h - 1) marked = rgb.copy() radius = max(7, round(min(h, w) / 90)) cv2.circle(marked, (x, y), radius, (255, 35, 35), -1) cv2.circle(marked, (x, y), radius + 3, (255, 255, 255), 2) return ( marked, [x, y], f"Punto registrato: x={x}, y={y}. Ora premere ‘Segmenta l’oggetto indicato’.", ) def run_fastsam_cpu(image: Any, point: Any, progress=gr.Progress()): """Esegue FastSAM su CPU; nessuna quota ZeroGPU viene richiesta.""" hidden = gr.update(value=None, visible=False) rgb = ensure_rgb(image) if rgb is None: return hidden, hidden, "Caricare un’immagine.", None if not point or len(point) != 2 or point[0] is None or point[1] is None: return hidden, hidden, "Cliccare prima sulla pianta nell’immagine FastSAM.", None x, y = map(int, point) h, w = rgb.shape[:2] x = min(max(x, 0), w - 1) y = min(max(y, 0), h - 1) # Riduzione prudenziale: accelera la CPU e limita l’uso di memoria. max_side = 1024 scale = min(1.0, max_side / max(h, w)) if scale < 1.0: infer_rgb = cv2.resize( rgb, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA ) infer_x, infer_y = round(x * scale), round(y * scale) else: infer_rgb = rgb infer_x, infer_y = x, y try: progress(0.05, desc="Caricamento di FastSAM su CPU") model = load_fastsam_cpu() progress(0.25, desc="Segmentazione dell’oggetto indicato") results = model.predict( infer_rgb, points=[[infer_x, infer_y]], labels=[1], device="cpu", imgsz=1024, retina_masks=True, verbose=False, ) if not results or results[0].masks is None or len(results[0].masks.data) == 0: return hidden, hidden, "FastSAM non ha trovato un oggetto associato al punto.", None data = results[0].masks.data.detach().float().cpu().numpy() candidates = [] ih, iw = infer_rgb.shape[:2] for raw in data: candidate_small = cv2.resize(raw, (iw, ih), interpolation=cv2.INTER_NEAREST) > 0.5 if candidate_small[infer_y, infer_x]: candidates.append(candidate_small) selected_small = ( min(candidates, key=lambda m: int(m.sum())) if candidates else cv2.resize(data[0], (iw, ih), interpolation=cv2.INTER_NEAREST) > 0.5 ) selected = cv2.resize( selected_small.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST ) > 0 mask = selected.astype(np.uint8) * 255 except Exception as exc: return hidden, hidden, f"FastSAM non disponibile: {exc}", None progress(0.90, desc="Preparazione dei risultati") overlay = build_overlay(rgb, mask, tint=(205, 35, 205)) cv2.circle(overlay, (x, y), max(6, round(min(h, w) / 100)), (255, 30, 30), -1) pixels = int(np.count_nonzero(mask)) pct = 100.0 * pixels / mask.size pixel_text = f"{pixels:,}".replace(",", ".") message = ( f"FastSAM su CPU: {pixel_text} pixel ({pct:.1f}%) nella regione associata " f"al punto x={x}, y={y}." ) mask_rgb = cv2.cvtColor(mask, cv2.COLOR_GRAY2RGB) return ( gr.update(value=mask_rgb, visible=True), gr.update(value=overlay, visible=True), message, overlay.copy(), ) def compare_overlays(color_overlay, ai_overlay): if color_overlay is None or ai_overlay is None: return None first = ensure_rgb(color_overlay) second = ensure_rgb(ai_overlay) h = min(first.shape[0], second.shape[0]) a = cv2.resize(first, (round(first.shape[1] * h / first.shape[0]), h)) b = cv2.resize(second, (round(second.shape[1] * h / second.shape[0]), h)) return np.concatenate([a, b], axis=1) # ----------------------------------------------------------------------------- # Interfaccia # ----------------------------------------------------------------------------- with gr.Blocks(title="Computer vision e piante", theme=gr.themes.Soft(primary_hue="green"), css=CSS) as demo: gr.Markdown("# Computer vision e piante - percorsi 6-16 anni") gr.Markdown( "Lo Space raccoglie quattro percorsi progressivi. Il dataset sperimentale completo rimane condiviso " "per le attività che richiedono fotografie già acquisite." ) with gr.Tab("6-9"): gr.Markdown("## Piante a quadretti") gr.HTML( '