fix: robustezza web/gui/legacy (lock matcher, LRU cache, clamp ROI, overlay)

- server: lock globale matcher (race nel threadpool FastAPI), LRU su
  _IMG_CACHE e _RECIPE_MATCHERS (leak), clamp ROI in tutti gli endpoint
  (400/422 invece di crash 500, check train senza varianti),
  filtro_fp=off disabilita davvero il verify NCC, fallback FILTRO_FP_MAP
  = medio, verify_threshold ricetta allineato a 0.4, _draw_matches su
  crop locale (era warp+Sobel full-frame per ogni match), spread_radius
  default 5->4
- gui: centro overlay edge (W-1)/2 -> W/2 (coerenza col train),
  spread_radius 5->4
- matcher legacy: _angle_list include estremo, cap candidati top-level,
  save/load persiste template_gray
- auto_tune: ref centrato fuori dal loop angoli
- test_suite: check imread con errore chiaro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:54:40 +00:00
parent cc811fdc94
commit 9458173ad0
5 changed files with 282 additions and 145 deletions
+5
View File
@@ -36,6 +36,11 @@ CONFIGS = [
def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str, def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str,
cfg_name: str, cfg: dict) -> dict: cfg_name: str, cfg: dict) -> dict:
scene = cv2.imread(str(TEST_DIR / img_path)) 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 y0, y1, x0, x1 = roi_box
roi = scene[y0:y1, x0:x1].copy() roi = scene[y0:y1, x0:x1].copy()
m = LineShapeMatcher( m = LineShapeMatcher(
+2 -1
View File
@@ -61,6 +61,8 @@ def detect_rotational_symmetry(
center = (w / 2.0, h / 2.0) center = (w / 2.0, h / 2.0)
ref = mag ref = mag
# ref è costante nel loop sugli angoli: centra una volta sola
rm = ref - ref.mean()
correlations: list[tuple[float, float]] = [] correlations: list[tuple[float, float]] = []
for ang in np.arange(step_deg, 360.0, step_deg): for ang in np.arange(step_deg, 360.0, step_deg):
@@ -68,7 +70,6 @@ def detect_rotational_symmetry(
rot = cv2.warpAffine( rot = cv2.warpAffine(
mag, M, (w, h), borderValue=0.0, mag, M, (w, h), borderValue=0.0,
) )
rm = ref - ref.mean()
rs = rot - rot.mean() rs = rot - rot.mean()
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9 denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
c = float((rm * rs).sum() / denom) c = float((rm * rs).sum() / denom)
+7 -3
View File
@@ -196,8 +196,10 @@ def _warp_template_edges_to_scene(
edge = cv2.Canny(template_gray, canny_low, canny_high) edge = cv2.Canny(template_gray, canny_low, canny_high)
# Matrice affine: scala + rotazione attorno al centro template, poi traslazione # Matrice affine: scala + rotazione attorno al centro template, poi traslazione
Ht, Wt = h, w Ht, Wt = h, w
cx_t = (Wt - 1) / 2.0 # Centro coerente con la convenzione train (center = w / 2.0, no -1):
cy_t = (Ht - 1) / 2.0 # (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) M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
# Traslazione per portare centro template a (cx, cy) della scena # Traslazione per portare centro template a (cx, cy) della scena
M[0, 2] += cx - cx_t M[0, 2] += cx - cx_t
@@ -492,7 +494,9 @@ def run(
num_features: int = 96, num_features: int = 96,
weak_grad: float = 30.0, weak_grad: float = 30.0,
strong_grad: float = 60.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, pyramid_levels: int = 3,
min_score: float = 0.55, min_score: float = 0.55,
max_matches: int = 25, max_matches: int = 25,
+26 -2
View File
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
a0, a1 = self.angle_range_deg a0, a1 = self.angle_range_deg
if self.angle_step_deg <= 0 or a0 >= a1: if self.angle_step_deg <= 0 or a0 >= a1:
return [float(a0)] return [float(a0)]
n = int(np.floor((a1 - a0) / self.angle_step_deg)) # n+1 valori per includere l'estremo superiore del range: con il
return [float(a0 + i * self.angle_step_deg) for i in range(n)] # 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: def train(self, template_bgr: np.ndarray) -> int:
"""Genera varianti per tutte le combinazioni (angolo, scala).""" """Genera varianti per tutte le combinazioni (angolo, scala)."""
@@ -222,6 +230,14 @@ class EdgeShapeMatcher:
for y, x in zip(ys, xs): for y, x in zip(ys, xs):
candidates.append((float(res[y, x]), int(x), int(y), ti)) 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 # Refinement a risoluzione piena: per ogni candidato top, finestra locale
refined: list[tuple[float, int, int, int]] = [] refined: list[tuple[float, int, int, int]] = []
margin = sf + 4 margin = sf + 4
@@ -294,6 +310,10 @@ class EdgeShapeMatcher:
) )
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)} 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)}) 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) np.savez_compressed(path, params=params, meta=meta, **arrays)
@classmethod @classmethod
@@ -312,6 +332,10 @@ class EdgeShapeMatcher:
top_score_factor=float(p[12]) if len(p) > 12 else 0.6, top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
) )
m.template_size = (int(p[8]), int(p[9])) 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"] meta = z["meta"]
for i in range(len(meta)): for i in range(len(meta)):
m.templates.append( m.templates.append(
+242 -139
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
import tempfile import tempfile
import threading
import time import time
import uuid import uuid
from collections import OrderedDict from collections import OrderedDict
@@ -64,14 +65,23 @@ STATIC_DIR.mkdir(exist_ok=True)
CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache" CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache"
CACHE_DIR.mkdir(exist_ok=True) CACHE_DIR.mkdir(exist_ok=True)
# Cache in-memory (soft, ricaricata da disco se mancante) # Cache in-memory (soft, ricaricata da disco se mancante).
_IMG_CACHE: dict[str, np.ndarray] = {} # 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 # Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher
# LRU con capacità limitata # LRU con capacità limitata
_MATCHER_CACHE: OrderedDict = OrderedDict() _MATCHER_CACHE: OrderedDict = OrderedDict()
_MATCHER_CACHE_SIZE = 8 _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: def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h = hashlib.md5() h = hashlib.md5()
@@ -102,23 +112,32 @@ def _cache_put_matcher(key: str, matcher) -> None:
_MATCHER_CACHE.popitem(last=False) _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: def _store_image(img: np.ndarray) -> str:
iid = uuid.uuid4().hex[:12] iid = uuid.uuid4().hex[:12]
cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img) cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img)
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return iid return iid
def _load_image(iid: str) -> np.ndarray | None: def _load_image(iid: str) -> np.ndarray | None:
cached = _IMG_CACHE.get(iid) cached = _IMG_CACHE.get(iid)
if cached is not None: if cached is not None:
_IMG_CACHE.move_to_end(iid) # LRU touch
return cached return cached
p = CACHE_DIR / f"{iid}.png" p = CACHE_DIR / f"{iid}.png"
if not p.exists(): if not p.exists():
return None return None
img = cv2.imread(str(p)) img = cv2.imread(str(p))
if img is not None: if img is not None:
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return img return img
app = FastAPI(title="PM2D Webapp", version="1.0.0") app = FastAPI(title="PM2D Webapp", version="1.0.0")
@@ -131,6 +150,39 @@ def _encode_png(img: np.ndarray) -> bytes:
return buf.tobytes() 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], def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None, template_gray: np.ndarray | None,
matcher: "LineShapeMatcher | None" = None) -> np.ndarray: 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 # `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
# introduceva uno shift di 0.5px per template di lato pari. # introduceva uno shift di 0.5px per template di lato pari.
cx_t = tw / 2.0; cy_t = th / 2.0 cx_t = tw / 2.0; cy_t = th / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) # Lavora su un CROP locale della scena di lato = diagonale del
M[0, 2] += m.cx - cx_t # template ruotato+scalato (+margine), come _verify_ncc: warp
M[1, 2] += m.cy - cy_t # + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno
warped_gray = cv2.warpAffine( # (costosissimo su scene grandi con molti match).
t, M, (W_scene, H_scene), diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8
flags=cv2.INTER_LINEAR, borderValue=0) x0 = int(round(m.cx)) - diag // 2
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno y0 = int(round(m.cy)) - diag // 2
mask_src = (matcher._train_mask if matcher._train_mask is not None gx0 = max(0, x0); gy0 = max(0, y0)
else np.full((th, tw), 255, dtype=np.uint8)) gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag)
warped_mask = cv2.warpAffine( cw, ch_ = gx1 - gx0, gy1 - gy0
mask_src, M, (W_scene, H_scene), if cw >= 3 and ch_ >= 3:
flags=cv2.INTER_NEAREST, borderValue=0) M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
# Erode minimo (3x3) per togliere SOLO artefatti border-padding # Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu' M[0, 2] += (m.cx - gx0) - cx_t
# grande spostava visualmente l'edge verso l'interno e creava M[1, 2] += (m.cy - gy0) - cy_t
# apparente "traslazione fissa" rispetto al bordo del pezzo. warped_gray = cv2.warpAffine(
kernel_er = np.ones((3, 3), np.uint8) t, M, (cw, ch_),
warped_mask = cv2.erode(warped_mask, kernel_er) flags=cv2.INTER_LINEAR, borderValue=0)
mag, _ = matcher._gradient(warped_gray) # Maschera: train_mask se disponibile, altrimenti rettangolo pieno
if matcher.weak_grad < matcher.strong_grad: mask_src = (matcher._train_mask if matcher._train_mask is not None
edge_mask = matcher._hysteresis_mask(mag) else np.full((th, tw), 255, dtype=np.uint8))
else: warped_mask = cv2.warpAffine(
edge_mask = mag >= matcher.strong_grad mask_src, M, (cw, ch_),
edge_mask = edge_mask & (warped_mask > 0) flags=cv2.INTER_NEAREST, borderValue=0)
if edge_mask.any(): # Erode minimo (3x3) per togliere SOLO artefatti border-padding
edge_overlay = np.zeros_like(out) # (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
# Ciano (cambiato da verde): non collide col verde dell'asse # grande spostava visualmente l'edge verso l'interno e creava
# Y dell'UCS che altrimenti scompariva nell'overlay edge. # apparente "traslazione fissa" rispetto al bordo del pezzo.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR) kernel_er = np.ones((3, 3), np.uint8)
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0) 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)) L = max(20, int(L_base * m.scale))
# X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin) # X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin)
x_end = (int(cx + L * ca), int(cy - L * sa)) x_end = (int(cx + L * ca), int(cy - L * sa))
@@ -246,7 +315,9 @@ class MatchParams(BaseModel):
num_features: int = 96 num_features: int = 96
weak_grad: float = 30.0 weak_grad: float = 30.0
strong_grad: float = 60.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 pyramid_levels: int = 3
verify_threshold: float = 0.4 verify_threshold: float = 0.4
@@ -407,7 +478,13 @@ def _simple_to_technical(
"min_score": p.min_score, "min_score": p.min_score,
"max_matches": p.max_matches, "max_matches": p.max_matches,
"nms_radius": 0, "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, "scale_penalty": p.penalita_scala,
} }
@@ -541,9 +618,7 @@ def match(p: MatchParams):
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi x, y, w, h = p.roi
x = max(0, x); y = max(0, y) x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
tech_for_cache = { tech_for_cache = {
@@ -557,33 +632,38 @@ def match(p: MatchParams):
"pyramid_levels": p.pyramid_levels, "pyramid_levels": p.pyramid_levels,
} }
key = _matcher_cache_key(roi_img, tech_for_cache) key = _matcher_cache_key(roi_img, tech_for_cache)
m = _cache_get_matcher(key) # Lock globale: matcher condivisi tra thread del pool FastAPI
if m is None: with _MATCHER_LOCK:
m = LineShapeMatcher( m = _cache_get_matcher(key)
num_features=p.num_features, if m is None:
weak_grad=p.weak_grad, strong_grad=p.strong_grad, m = LineShapeMatcher(
angle_range_deg=(p.angle_min, p.angle_max), num_features=p.num_features,
angle_step_deg=p.angle_step, weak_grad=p.weak_grad, strong_grad=p.strong_grad,
scale_range=(p.scale_min, p.scale_max), angle_range_deg=(p.angle_min, p.angle_max),
scale_step=p.scale_step, angle_step_deg=p.angle_step,
spread_radius=p.spread_radius, scale_range=(p.scale_min, p.scale_max),
pyramid_levels=p.pyramid_levels, 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 t_find = 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
# Render annotated image # Render annotated image
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m) annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
@@ -610,9 +690,7 @@ def match_simple(p: SimpleMatchParams):
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi x, y, w, h = p.roi
x = max(0, x); y = max(0, y) x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
tech = _simple_to_technical(p, roi_img) 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-mode init params: incidono sul training, includere in cache key
halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}" halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}"
key = key + halcon_init_key key = key + halcon_init_key
m = _cache_get_matcher(key) # Lock globale: matcher condivisi tra thread del pool FastAPI
if m is None: with _MATCHER_LOCK:
m = LineShapeMatcher( m = _cache_get_matcher(key)
num_features=tech["num_features"], if m is None:
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], m = LineShapeMatcher(
angle_range_deg=(tech["angle_min"], tech["angle_max"]), num_features=tech["num_features"],
angle_step_deg=tech["angle_step"], weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
scale_range=(tech["scale_min"], tech["scale_max"]), angle_range_deg=(tech["angle_min"], tech["angle_max"]),
scale_step=tech["scale_step"], angle_step_deg=tech["angle_step"],
spread_radius=tech["spread_radius"], scale_range=(tech["scale_min"], tech["scale_max"]),
min_feature_spacing=tech.get("min_feature_spacing", 3), scale_step=tech["scale_step"],
pyramid_levels=tech["pyramid_levels"], spread_radius=tech["spread_radius"],
use_polarity=p.use_polarity, min_feature_spacing=tech.get("min_feature_spacing", 3),
use_gpu=p.use_gpu, 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 t_find = 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
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m) annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
@@ -681,6 +764,7 @@ def tune(p: TuneParams):
if model is None: if model is None:
raise HTTPException(404, "Immagine non trovata") raise HTTPException(404, "Immagine non trovata")
x, y, w, h = p.roi 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] roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img) t = auto_tune(roi_img)
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation, # Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
@@ -814,6 +898,7 @@ def save_recipe(p: SaveRecipeParams):
if model is None: if model is None:
raise HTTPException(404, "Modello non trovato") raise HTTPException(404, "Modello non trovato")
x, y, w, h = p.roi 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] roi_img = model[y:y + h, x:x + w]
sp = SimpleMatchParams( sp = SimpleMatchParams(
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi, 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_polarity=p.use_polarity,
use_gpu=p.use_gpu, 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 "._-") safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
if not safe_name: if not safe_name:
raise HTTPException(400, "Nome ricetta non valido") raise HTTPException(400, "Nome ricetta non valido")
@@ -864,6 +952,14 @@ _RECIPE_MATCHERS: OrderedDict = OrderedDict()
_RECIPE_MATCHERS_SIZE = 4 _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") @app.post("/recipes/{name}/load")
def load_recipe(name: str): def load_recipe(name: str):
"""Carica ricetta .npz e popola cache matcher in memoria. """Carica ricetta .npz e popola cache matcher in memoria.
@@ -878,10 +974,8 @@ def load_recipe(name: str):
if not path.is_file(): if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}") raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path)) m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m with _MATCHER_LOCK:
_RECIPE_MATCHERS.move_to_end(safe_name) _recipe_matchers_put(safe_name, m)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
return { return {
"name": safe_name, "name": safe_name,
"n_variants": len(m.variants), "n_variants": len(m.variants),
@@ -905,7 +999,9 @@ class RecipeMatchParams(BaseModel):
greediness: float = 0.0 greediness: float = 0.0
refine_pose_joint: bool = False refine_pose_joint: bool = False
search_roi: list[int] | None = None 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 scale_penalty: float = 0.0
@@ -913,37 +1009,44 @@ class RecipeMatchParams(BaseModel):
def match_recipe(p: RecipeMatchParams): def match_recipe(p: RecipeMatchParams):
"""Match con ricetta pre-trained: zero training, solo find.""" """Match con ricetta pre-trained: zero training, solo find."""
safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz" 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) scene = _load_image(p.scene_id)
if scene is None: if scene is None:
raise HTTPException(404, "Scena non trovata") raise HTTPException(404, "Scena non trovata")
search_roi_t = tuple(p.search_roi) if p.search_roi else None search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time() # Lock globale: matcher condivisi tra thread del pool FastAPI
matches = m.find( with _MATCHER_LOCK:
scene, m = _RECIPE_MATCHERS.get(safe_name)
min_score=p.min_score, max_matches=p.max_matches, if m is not None:
verify_threshold=p.verify_threshold, _RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch
scale_penalty=p.scale_penalty, else:
min_recall=p.min_recall, # Auto-load on demand: stessa eviction LRU di load_recipe
use_soft_score=p.use_soft_score, # (senza cap la cache cresceva senza limite)
subpixel_lm=p.subpixel_lm, path = RECIPES_DIR / safe_name
nms_iou_threshold=p.nms_iou_threshold, if not path.is_file():
coarse_stride=p.coarse_stride, raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
pyramid_propagate=p.pyramid_propagate, m = LineShapeMatcher.load_model(str(path))
greediness=p.greediness, _recipe_matchers_put(safe_name, m)
refine_pose_joint=p.refine_pose_joint, t0 = time.time()
search_roi=search_roi_t, matches = m.find(
) scene,
t_find = time.time() - t0 min_score=p.min_score, max_matches=p.max_matches,
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) verify_threshold=p.verify_threshold,
annotated = _draw_matches(scene, matches, tg, matcher=m) # 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) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
matches=[MatchResult( matches=[MatchResult(