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,
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(
+2 -1
View File
@@ -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)
+7 -3
View File
@@ -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,
+26 -2
View File
@@ -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(
+242 -139
View File
@@ -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(