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,
|
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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user