diff --git a/benchmarks/test_suite.py b/benchmarks/test_suite.py index 4518892..414f13d 100644 --- a/benchmarks/test_suite.py +++ b/benchmarks/test_suite.py @@ -36,6 +36,11 @@ CONFIGS = [ def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str, cfg_name: str, cfg: dict) -> dict: scene = cv2.imread(str(TEST_DIR / img_path)) + if scene is None: + # cv2.imread ritorna None silenzioso: senza check il crash arriva + # dopo, sullo slice, con un errore criptico. + raise FileNotFoundError( + f"Immagine di test non trovata o non leggibile: {TEST_DIR / img_path}") y0, y1, x0, x1 = roi_box roi = scene[y0:y1, x0:x1].copy() m = LineShapeMatcher( diff --git a/pm2d/auto_tune.py b/pm2d/auto_tune.py index f62f96e..93545a0 100644 --- a/pm2d/auto_tune.py +++ b/pm2d/auto_tune.py @@ -61,6 +61,8 @@ def detect_rotational_symmetry( center = (w / 2.0, h / 2.0) ref = mag + # ref è costante nel loop sugli angoli: centra una volta sola + rm = ref - ref.mean() correlations: list[tuple[float, float]] = [] for ang in np.arange(step_deg, 360.0, step_deg): @@ -68,7 +70,6 @@ def detect_rotational_symmetry( rot = cv2.warpAffine( mag, M, (w, h), borderValue=0.0, ) - rm = ref - ref.mean() rs = rot - rot.mean() denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9 c = float((rm * rs).sum() / denom) diff --git a/pm2d/gui.py b/pm2d/gui.py index 316f787..2e32e57 100644 --- a/pm2d/gui.py +++ b/pm2d/gui.py @@ -196,8 +196,10 @@ def _warp_template_edges_to_scene( edge = cv2.Canny(template_gray, canny_low, canny_high) # Matrice affine: scala + rotazione attorno al centro template, poi traslazione Ht, Wt = h, w - cx_t = (Wt - 1) / 2.0 - cy_t = (Ht - 1) / 2.0 + # Centro coerente con la convenzione train (center = w / 2.0, no -1): + # (Wt-1)/2 introduceva uno shift di 0.5px per template di lato pari. + cx_t = Wt / 2.0 + cy_t = Ht / 2.0 M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale) # Traslazione per portare centro template a (cx, cy) della scena M[0, 2] += cx - cx_t @@ -492,7 +494,9 @@ def run( num_features: int = 96, weak_grad: float = 30.0, strong_grad: float = 60.0, - spread_radius: int = 5, + # 4 allineato col default del matcher: raggio 5 peggiora la precisione + # di rotazione (spread troppo largo appiattisce il picco angolare). + spread_radius: int = 4, pyramid_levels: int = 3, min_score: float = 0.55, max_matches: int = 25, diff --git a/pm2d/matcher.py b/pm2d/matcher.py index d90e620..14b5e90 100644 --- a/pm2d/matcher.py +++ b/pm2d/matcher.py @@ -91,8 +91,16 @@ class EdgeShapeMatcher: a0, a1 = self.angle_range_deg if self.angle_step_deg <= 0 or a0 >= a1: return [float(a0)] - n = int(np.floor((a1 - a0) / self.angle_step_deg)) - return [float(a0 + i * self.angle_step_deg) for i in range(n)] + # n+1 valori per includere l'estremo superiore del range: con il + # solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one). + n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1 + angles = [float(a0 + i * self.angle_step_deg) for i in range(n)] + if a1 - a0 >= 360.0: + # Range che copre il giro completo: a0+360° è la stessa pose di + # a0, escludi il duplicato (variante inutile in train/find). + eps = 1e-6 + angles = [a for a in angles if a < a0 + 360.0 - eps] + return angles def train(self, template_bgr: np.ndarray) -> int: """Genera varianti per tutte le combinazioni (angolo, scala).""" @@ -222,6 +230,14 @@ class EdgeShapeMatcher: for y, x in zip(ys, xs): candidates.append((float(res[y, x]), int(x), int(y), ti)) + # Cap candidati top-level: senza limite np.where con soglia bassa + # può generare migliaia di candidati, ognuno con un matchTemplate + # full-res nel refinement. Tieni solo i migliori per score. + max_candidates = max(1, max_matches * 10) + if len(candidates) > max_candidates: + candidates.sort(key=lambda c: -c[0]) + candidates = candidates[:max_candidates] + # Refinement a risoluzione piena: per ogni candidato top, finestra locale refined: list[tuple[float, int, int, int]] = [] margin = sf + 4 @@ -294,6 +310,10 @@ class EdgeShapeMatcher: ) arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)} arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)}) + # Persisti anche il grayscale originale: senza, l'overlay edge + # spariva dopo load() (template_gray restava None). + if self.template_gray is not None: + arrays["template_gray"] = self.template_gray np.savez_compressed(path, params=params, meta=meta, **arrays) @classmethod @@ -312,6 +332,10 @@ class EdgeShapeMatcher: top_score_factor=float(p[12]) if len(p) > 12 else 0.6, ) m.template_size = (int(p[8]), int(p[9])) + # Retrocompatibilità: modelli salvati prima non hanno template_gray + # (resta None: overlay edge non disponibile ma find() funziona). + if "template_gray" in z.files: + m.template_gray = z["template_gray"] meta = z["meta"] for i in range(len(meta)): m.templates.append( diff --git a/pm2d/web/server.py b/pm2d/web/server.py index f1d58fb..7a77872 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -12,6 +12,7 @@ from __future__ import annotations import hashlib import os import tempfile +import threading import time import uuid from collections import OrderedDict @@ -64,14 +65,23 @@ STATIC_DIR.mkdir(exist_ok=True) CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache" CACHE_DIR.mkdir(exist_ok=True) -# Cache in-memory (soft, ricaricata da disco se mancante) -_IMG_CACHE: dict[str, np.ndarray] = {} +# Cache in-memory (soft, ricaricata da disco se mancante). +# LRU con capacità limitata: senza eviction le immagini si accumulavano +# senza limite (leak di memoria su server long-running). +_IMG_CACHE: OrderedDict[str, np.ndarray] = OrderedDict() +_IMG_CACHE_SIZE = 64 # Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher # LRU con capacità limitata _MATCHER_CACHE: OrderedDict = OrderedDict() _MATCHER_CACHE_SIZE = 8 +# Lock globale matcher: gli endpoint girano nel threadpool FastAPI ma i +# matcher condivisi (_MATCHER_CACHE, _RECIPE_MATCHERS) mutano stato interno +# durante train()/find(). Serializzare il matching è la soluzione semplice +# e corretta (un lock per-ricetta sarebbe over-engineering). +_MATCHER_LOCK = threading.Lock() + def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str: h = hashlib.md5() @@ -102,23 +112,32 @@ def _cache_put_matcher(key: str, matcher) -> None: _MATCHER_CACHE.popitem(last=False) +def _img_cache_put(key: str, value: np.ndarray) -> None: + """Inserisce in _IMG_CACHE con eviction LRU (cap _IMG_CACHE_SIZE).""" + _IMG_CACHE[key] = value + _IMG_CACHE.move_to_end(key) + while len(_IMG_CACHE) > _IMG_CACHE_SIZE: + _IMG_CACHE.popitem(last=False) + + def _store_image(img: np.ndarray) -> str: iid = uuid.uuid4().hex[:12] cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img) - _IMG_CACHE[iid] = img + _img_cache_put(iid, img) return iid def _load_image(iid: str) -> np.ndarray | None: cached = _IMG_CACHE.get(iid) if cached is not None: + _IMG_CACHE.move_to_end(iid) # LRU touch return cached p = CACHE_DIR / f"{iid}.png" if not p.exists(): return None img = cv2.imread(str(p)) if img is not None: - _IMG_CACHE[iid] = img + _img_cache_put(iid, img) return img app = FastAPI(title="PM2D Webapp", version="1.0.0") @@ -131,6 +150,39 @@ def _encode_png(img: np.ndarray) -> bytes: return buf.tobytes() +def _clamp_roi(x: int, y: int, w: int, h: int, + img_w: int, img_h: int) -> tuple[int, int, int, int]: + """Clampa la ROI dentro i limiti immagine. + + Una ROI fuori immagine causava slice vuote → crash 500 negli endpoint + che non clampavano. Solleva 400 se la ROI risultante è degenere + (lato < 16 px: sotto questa soglia il train non estrae abbastanza + edge feature e produce 0 varianti → find() esplode con 500). + """ + x = max(0, min(int(x), img_w - 1)) + y = max(0, min(int(y), img_h - 1)) + w = min(int(w), img_w - x) + h = min(int(h), img_h - y) + if w < 16 or h < 16: + raise HTTPException( + 400, f"ROI fuori immagine o degenere: [{x}, {y}, {w}, {h}] " + f"su immagine {img_w}x{img_h} (lato minimo 16 px)") + return x, y, w, h + + +def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None: + """Solleva 422 se il train non ha prodotto varianti. + + Succede con ROI senza contrasto (sfondo uniforme) o troppo piccola: + senza questo check il find() successivo esplode con RuntimeError → 500. + """ + if n_variants <= 0 or not m.variants: + raise HTTPException( + 422, "La ROI non contiene abbastanza edge feature per il " + "training (zona troppo uniforme o piccola): scegliere " + "una regione con contorni netti") + + def _draw_matches(scene: np.ndarray, matches: list[Match], template_gray: np.ndarray | None, matcher: "LineShapeMatcher | None" = None) -> np.ndarray: @@ -172,36 +224,53 @@ def _draw_matches(scene: np.ndarray, matches: list[Match], # `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2 # introduceva uno shift di 0.5px per template di lato pari. cx_t = tw / 2.0; cy_t = th / 2.0 - M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) - M[0, 2] += m.cx - cx_t - M[1, 2] += m.cy - cy_t - warped_gray = cv2.warpAffine( - t, M, (W_scene, H_scene), - flags=cv2.INTER_LINEAR, borderValue=0) - # Maschera: train_mask se disponibile, altrimenti rettangolo pieno - mask_src = (matcher._train_mask if matcher._train_mask is not None - else np.full((th, tw), 255, dtype=np.uint8)) - warped_mask = cv2.warpAffine( - mask_src, M, (W_scene, H_scene), - flags=cv2.INTER_NEAREST, borderValue=0) - # Erode minimo (3x3) per togliere SOLO artefatti border-padding - # (~1px di bordo nero da warpAffine borderValue=0). Erode piu' - # grande spostava visualmente l'edge verso l'interno e creava - # apparente "traslazione fissa" rispetto al bordo del pezzo. - kernel_er = np.ones((3, 3), np.uint8) - warped_mask = cv2.erode(warped_mask, kernel_er) - mag, _ = matcher._gradient(warped_gray) - if matcher.weak_grad < matcher.strong_grad: - edge_mask = matcher._hysteresis_mask(mag) - else: - edge_mask = mag >= matcher.strong_grad - edge_mask = edge_mask & (warped_mask > 0) - if edge_mask.any(): - edge_overlay = np.zeros_like(out) - # Ciano (cambiato da verde): non collide col verde dell'asse - # Y dell'UCS che altrimenti scompariva nell'overlay edge. - edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR) - out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0) + # Lavora su un CROP locale della scena di lato = diagonale del + # template ruotato+scalato (+margine), come _verify_ncc: warp + # + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno + # (costosissimo su scene grandi con molti match). + diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8 + x0 = int(round(m.cx)) - diag // 2 + y0 = int(round(m.cy)) - diag // 2 + gx0 = max(0, x0); gy0 = max(0, y0) + gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag) + cw, ch_ = gx1 - gx0, gy1 - gy0 + if cw >= 3 and ch_ >= 3: + M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) + # Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop + M[0, 2] += (m.cx - gx0) - cx_t + M[1, 2] += (m.cy - gy0) - cy_t + warped_gray = cv2.warpAffine( + t, M, (cw, ch_), + flags=cv2.INTER_LINEAR, borderValue=0) + # Maschera: train_mask se disponibile, altrimenti rettangolo pieno + mask_src = (matcher._train_mask if matcher._train_mask is not None + else np.full((th, tw), 255, dtype=np.uint8)) + warped_mask = cv2.warpAffine( + mask_src, M, (cw, ch_), + flags=cv2.INTER_NEAREST, borderValue=0) + # Erode minimo (3x3) per togliere SOLO artefatti border-padding + # (~1px di bordo nero da warpAffine borderValue=0). Erode piu' + # grande spostava visualmente l'edge verso l'interno e creava + # apparente "traslazione fissa" rispetto al bordo del pezzo. + kernel_er = np.ones((3, 3), np.uint8) + warped_mask = cv2.erode(warped_mask, kernel_er) + mag, _ = matcher._gradient(warped_gray) + if matcher.weak_grad < matcher.strong_grad: + edge_mask = matcher._hysteresis_mask(mag) + else: + edge_mask = mag >= matcher.strong_grad + edge_mask = edge_mask & (warped_mask > 0) + if edge_mask.any(): + # Edge ritraslati nel sistema scena: blend solo sul crop + # (addWeighted lascia invariati i pixel con overlay nullo, + # quindi l'output visivo è identico al full-frame). + sub = out[gy0:gy1, gx0:gx1] + edge_overlay = np.zeros_like(sub) + # Ciano (cambiato da verde): non collide col verde dell'asse + # Y dell'UCS che altrimenti scompariva nell'overlay edge. + edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR) + out[gy0:gy1, gx0:gx1] = cv2.addWeighted( + sub, 1.0, edge_overlay, 0.6, 0) L = max(20, int(L_base * m.scale)) # X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin) x_end = (int(cx + L * ca), int(cy - L * sa)) @@ -246,7 +315,9 @@ class MatchParams(BaseModel): num_features: int = 96 weak_grad: float = 30.0 strong_grad: float = 60.0 - spread_radius: int = 5 + # 4 allineato col default del matcher: raggio 5 peggiora la precisione + # di rotazione (spread troppo largo appiattisce il picco angolare). + spread_radius: int = 4 pyramid_levels: int = 3 verify_threshold: float = 0.4 @@ -407,7 +478,13 @@ def _simple_to_technical( "min_score": p.min_score, "max_matches": p.max_matches, "nms_radius": 0, - "verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35), + # Fallback = livello "medio" della mappa (no valore hardcoded + # che divergerebbe se la mappa cambia). + "verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, FILTRO_FP_MAP["medio"]), + # "off" deve disabilitare DAVVERO il verify NCC: passare solo + # verify_threshold=0.0 lascerebbe attivo il calcolo NCC (che può + # comunque scartare match con score negativo / patch uniformi). + "verify_ncc": p.filtro_fp != "off", "scale_penalty": p.penalita_scala, } @@ -541,9 +618,7 @@ def match(p: MatchParams): if model is None or scene is None: raise HTTPException(404, "Immagini non trovate") x, y, w, h = p.roi - x = max(0, x); y = max(0, y) - w = max(1, min(w, model.shape[1] - x)) - h = max(1, min(h, model.shape[0] - y)) + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] tech_for_cache = { @@ -557,33 +632,38 @@ def match(p: MatchParams): "pyramid_levels": p.pyramid_levels, } key = _matcher_cache_key(roi_img, tech_for_cache) - m = _cache_get_matcher(key) - if m is None: - m = LineShapeMatcher( - num_features=p.num_features, - weak_grad=p.weak_grad, strong_grad=p.strong_grad, - angle_range_deg=(p.angle_min, p.angle_max), - angle_step_deg=p.angle_step, - scale_range=(p.scale_min, p.scale_max), - scale_step=p.scale_step, - spread_radius=p.spread_radius, - pyramid_levels=p.pyramid_levels, + # Lock globale: matcher condivisi tra thread del pool FastAPI + with _MATCHER_LOCK: + m = _cache_get_matcher(key) + if m is None: + m = LineShapeMatcher( + num_features=p.num_features, + weak_grad=p.weak_grad, strong_grad=p.strong_grad, + angle_range_deg=(p.angle_min, p.angle_max), + angle_step_deg=p.angle_step, + scale_range=(p.scale_min, p.scale_max), + scale_step=p.scale_step, + spread_radius=p.spread_radius, + pyramid_levels=p.pyramid_levels, + ) + t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 + _check_trained(m, n) + _cache_put_matcher(key, m) + else: + n = len(m.variants); t_train = 0.0 + nms = p.nms_radius if p.nms_radius > 0 else None + t0 = time.time() + matches = m.find( + scene, min_score=p.min_score, max_matches=p.max_matches, + nms_radius=nms, verify_threshold=p.verify_threshold, + # Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC + verify_ncc=p.verify_threshold > 0.0, ) - t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 - _cache_put_matcher(key, m) - else: - n = len(m.variants); t_train = 0.0 - nms = p.nms_radius if p.nms_radius > 0 else None - t0 = time.time() - matches = m.find( - scene, min_score=p.min_score, max_matches=p.max_matches, - nms_radius=nms, verify_threshold=p.verify_threshold, - ) - t_find = time.time() - t0 + t_find = time.time() - t0 - # Render annotated image - tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) - annotated = _draw_matches(scene, matches, tg, matcher=m) + # Render annotated image + tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) + annotated = _draw_matches(scene, matches, tg, matcher=m) ann_id = _store_image(annotated) return MatchResp( @@ -610,9 +690,7 @@ def match_simple(p: SimpleMatchParams): if model is None or scene is None: raise HTTPException(404, "Immagini non trovate") x, y, w, h = p.roi - x = max(0, x); y = max(0, y) - w = max(1, min(w, model.shape[1] - x)) - h = max(1, min(h, model.shape[0] - y)) + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] tech = _simple_to_technical(p, roi_img) @@ -621,47 +699,52 @@ def match_simple(p: SimpleMatchParams): # Halcon-mode init params: incidono sul training, includere in cache key halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}" key = key + halcon_init_key - m = _cache_get_matcher(key) - if m is None: - m = LineShapeMatcher( - num_features=tech["num_features"], - weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], - angle_range_deg=(tech["angle_min"], tech["angle_max"]), - angle_step_deg=tech["angle_step"], - scale_range=(tech["scale_min"], tech["scale_max"]), - scale_step=tech["scale_step"], - spread_radius=tech["spread_radius"], - min_feature_spacing=tech.get("min_feature_spacing", 3), - pyramid_levels=tech["pyramid_levels"], - use_polarity=p.use_polarity, - use_gpu=p.use_gpu, + # Lock globale: matcher condivisi tra thread del pool FastAPI + with _MATCHER_LOCK: + m = _cache_get_matcher(key) + if m is None: + m = LineShapeMatcher( + num_features=tech["num_features"], + weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], + angle_range_deg=(tech["angle_min"], tech["angle_max"]), + angle_step_deg=tech["angle_step"], + scale_range=(tech["scale_min"], tech["scale_max"]), + scale_step=tech["scale_step"], + spread_radius=tech["spread_radius"], + min_feature_spacing=tech.get("min_feature_spacing", 3), + pyramid_levels=tech["pyramid_levels"], + use_polarity=p.use_polarity, + use_gpu=p.use_gpu, + ) + t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 + _check_trained(m, n) + _cache_put_matcher(key, m) + else: + n = len(m.variants); t_train = 0.0 + nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None + search_roi_t = tuple(p.search_roi) if p.search_roi else None + t0 = time.time() + matches = m.find( + scene, min_score=tech["min_score"], max_matches=tech["max_matches"], + nms_radius=nms, verify_threshold=tech["verify_threshold"], + # filtro_fp="off" → verify NCC davvero disabilitato + verify_ncc=tech.get("verify_ncc", True), + scale_penalty=tech.get("scale_penalty", 0.0), + # Halcon-mode flags + min_recall=p.min_recall, + use_soft_score=p.use_soft_score, + subpixel_lm=p.subpixel_lm, + nms_iou_threshold=p.nms_iou_threshold, + coarse_stride=p.coarse_stride, + pyramid_propagate=p.pyramid_propagate, + greediness=p.greediness, + refine_pose_joint=p.refine_pose_joint, + search_roi=search_roi_t, ) - t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 - _cache_put_matcher(key, m) - else: - n = len(m.variants); t_train = 0.0 - nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None - search_roi_t = tuple(p.search_roi) if p.search_roi else None - t0 = time.time() - matches = m.find( - scene, min_score=tech["min_score"], max_matches=tech["max_matches"], - nms_radius=nms, verify_threshold=tech["verify_threshold"], - scale_penalty=tech.get("scale_penalty", 0.0), - # Halcon-mode flags - min_recall=p.min_recall, - use_soft_score=p.use_soft_score, - subpixel_lm=p.subpixel_lm, - nms_iou_threshold=p.nms_iou_threshold, - coarse_stride=p.coarse_stride, - pyramid_propagate=p.pyramid_propagate, - greediness=p.greediness, - refine_pose_joint=p.refine_pose_joint, - search_roi=search_roi_t, - ) - t_find = time.time() - t0 + t_find = time.time() - t0 - tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) - annotated = _draw_matches(scene, matches, tg, matcher=m) + tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) + annotated = _draw_matches(scene, matches, tg, matcher=m) ann_id = _store_image(annotated) return MatchResp( @@ -681,6 +764,7 @@ def tune(p: TuneParams): if model is None: raise HTTPException(404, "Immagine non trovata") x, y, w, h = p.roi + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] t = auto_tune(roi_img) # Esponi parametri tecnici + meta diagnostica (_self_score, _validation, @@ -814,6 +898,7 @@ def save_recipe(p: SaveRecipeParams): if model is None: raise HTTPException(404, "Modello non trovato") x, y, w, h = p.roi + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] sp = SimpleMatchParams( model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi, @@ -838,7 +923,10 @@ def save_recipe(p: SaveRecipeParams): use_polarity=p.use_polarity, use_gpu=p.use_gpu, ) - m.train(roi_img) + # Lock globale: serializza il training pesante col matching in corso + with _MATCHER_LOCK: + n_var = m.train(roi_img) + _check_trained(m, n_var) safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-") if not safe_name: raise HTTPException(400, "Nome ricetta non valido") @@ -864,6 +952,14 @@ _RECIPE_MATCHERS: OrderedDict = OrderedDict() _RECIPE_MATCHERS_SIZE = 4 +def _recipe_matchers_put(name: str, matcher: LineShapeMatcher) -> None: + """Inserisce in _RECIPE_MATCHERS con eviction LRU (cap _RECIPE_MATCHERS_SIZE).""" + _RECIPE_MATCHERS[name] = matcher + _RECIPE_MATCHERS.move_to_end(name) + while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE: + _RECIPE_MATCHERS.popitem(last=False) + + @app.post("/recipes/{name}/load") def load_recipe(name: str): """Carica ricetta .npz e popola cache matcher in memoria. @@ -878,10 +974,8 @@ def load_recipe(name: str): if not path.is_file(): raise HTTPException(404, f"Ricetta non trovata: {safe_name}") m = LineShapeMatcher.load_model(str(path)) - _RECIPE_MATCHERS[safe_name] = m - _RECIPE_MATCHERS.move_to_end(safe_name) - while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE: - _RECIPE_MATCHERS.popitem(last=False) + with _MATCHER_LOCK: + _recipe_matchers_put(safe_name, m) return { "name": safe_name, "n_variants": len(m.variants), @@ -905,7 +999,9 @@ class RecipeMatchParams(BaseModel): greediness: float = 0.0 refine_pose_joint: bool = False search_roi: list[int] | None = None - verify_threshold: float = 0.5 + # Allineato a MatchParams.verify_threshold (0.4): valori divergenti + # davano risultati diversi tra /match e /match_recipe a parità di scena. + verify_threshold: float = 0.4 scale_penalty: float = 0.0 @@ -913,37 +1009,44 @@ class RecipeMatchParams(BaseModel): def match_recipe(p: RecipeMatchParams): """Match con ricetta pre-trained: zero training, solo find.""" safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz" - m = _RECIPE_MATCHERS.get(safe_name) - if m is None: - # Auto-load on demand - path = RECIPES_DIR / safe_name - if not path.is_file(): - raise HTTPException(404, f"Ricetta non trovata: {safe_name}") - m = LineShapeMatcher.load_model(str(path)) - _RECIPE_MATCHERS[safe_name] = m scene = _load_image(p.scene_id) if scene is None: raise HTTPException(404, "Scena non trovata") search_roi_t = tuple(p.search_roi) if p.search_roi else None - t0 = time.time() - matches = m.find( - scene, - min_score=p.min_score, max_matches=p.max_matches, - verify_threshold=p.verify_threshold, - scale_penalty=p.scale_penalty, - min_recall=p.min_recall, - use_soft_score=p.use_soft_score, - subpixel_lm=p.subpixel_lm, - nms_iou_threshold=p.nms_iou_threshold, - coarse_stride=p.coarse_stride, - pyramid_propagate=p.pyramid_propagate, - greediness=p.greediness, - refine_pose_joint=p.refine_pose_joint, - search_roi=search_roi_t, - ) - t_find = time.time() - t0 - tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) - annotated = _draw_matches(scene, matches, tg, matcher=m) + # Lock globale: matcher condivisi tra thread del pool FastAPI + with _MATCHER_LOCK: + m = _RECIPE_MATCHERS.get(safe_name) + if m is not None: + _RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch + else: + # Auto-load on demand: stessa eviction LRU di load_recipe + # (senza cap la cache cresceva senza limite) + path = RECIPES_DIR / safe_name + if not path.is_file(): + raise HTTPException(404, f"Ricetta non trovata: {safe_name}") + m = LineShapeMatcher.load_model(str(path)) + _recipe_matchers_put(safe_name, m) + t0 = time.time() + matches = m.find( + scene, + min_score=p.min_score, max_matches=p.max_matches, + verify_threshold=p.verify_threshold, + # Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC + verify_ncc=p.verify_threshold > 0.0, + scale_penalty=p.scale_penalty, + min_recall=p.min_recall, + use_soft_score=p.use_soft_score, + subpixel_lm=p.subpixel_lm, + nms_iou_threshold=p.nms_iou_threshold, + coarse_stride=p.coarse_stride, + pyramid_propagate=p.pyramid_propagate, + greediness=p.greediness, + refine_pose_joint=p.refine_pose_joint, + search_roi=search_roi_t, + ) + t_find = time.time() - t0 + tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) + annotated = _draw_matches(scene, matches, tg, matcher=m) ann_id = _store_image(annotated) return MatchResp( matches=[MatchResult(