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:
+242
-139
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user