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:
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
+135
-32
@@ -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,17 +224,29 @@ 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
|
||||
# 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)
|
||||
M[0, 2] += m.cx - cx_t
|
||||
M[1, 2] += m.cy - cy_t
|
||||
# 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, (W_scene, H_scene),
|
||||
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, (W_scene, H_scene),
|
||||
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'
|
||||
@@ -197,11 +261,16 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
|
||||
edge_mask = mag >= matcher.strong_grad
|
||||
edge_mask = edge_mask & (warped_mask > 0)
|
||||
if edge_mask.any():
|
||||
edge_overlay = np.zeros_like(out)
|
||||
# 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 = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0)
|
||||
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,6 +632,8 @@ def match(p: MatchParams):
|
||||
"pyramid_levels": p.pyramid_levels,
|
||||
}
|
||||
key = _matcher_cache_key(roi_img, tech_for_cache)
|
||||
# Lock globale: matcher condivisi tra thread del pool FastAPI
|
||||
with _MATCHER_LOCK:
|
||||
m = _cache_get_matcher(key)
|
||||
if m is None:
|
||||
m = LineShapeMatcher(
|
||||
@@ -570,6 +647,7 @@ def match(p: MatchParams):
|
||||
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
|
||||
@@ -578,6 +656,8 @@ def match(p: MatchParams):
|
||||
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,
|
||||
)
|
||||
t_find = time.time() - t0
|
||||
|
||||
@@ -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,6 +699,8 @@ 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
|
||||
# Lock globale: matcher condivisi tra thread del pool FastAPI
|
||||
with _MATCHER_LOCK:
|
||||
m = _cache_get_matcher(key)
|
||||
if m is None:
|
||||
m = LineShapeMatcher(
|
||||
@@ -637,6 +717,7 @@ def match_simple(p: SimpleMatchParams):
|
||||
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
|
||||
@@ -646,6 +727,8 @@ def match_simple(p: SimpleMatchParams):
|
||||
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,
|
||||
@@ -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,23 +1009,30 @@ 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
|
||||
# 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,
|
||||
|
||||
Reference in New Issue
Block a user