merge: precisione rotazione + perf propagate + robustezza server

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:54:48 +00:00
8 changed files with 828 additions and 467 deletions
+39 -4
View File
@@ -2,6 +2,36 @@
Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale. Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale.
## Fase 2 COMPLETATA (precisione rotazione + robustezza + perf)
Root cause della rotazione imprecisa: lo score satura a 1.0 sulla spread
bitmap dilatata (raggio 4-5) → il refine non vedeva gradiente né in angolo
né in posizione, e `cv2.minMaxLoc` sul plateau saturo spostava il centro
sull'angolo della finestra (errore sistematico 3·√2 ≈ 4.24 px).
| Fix | Dettaglio |
|---|---|
| Refine su bitmap fine | `_refine_angle` ottimizza su spread raggio 1 (`spread_fine`, cached); score finale ricalcolato su spread coarse per mantenere semantica soglie |
| Picco sub-pixel nel refine | centroide plateau / fit quadratico al posto di minMaxLoc (bias top-left) |
| LM least-squares pos+angolo | `_subpixel_refine_lm` riscritto: snap edge ±2px lungo normale + LSQ 3x3 (dx, dy, dθ), ON di default |
| Round feature offsets | troncamento `astype(int32)``np.round` (bias ~0.25 px) |
| Centro rotazione coerente | `_prepare_padded_template`: rotazione attorno al centro reale del template nel padding (bias ≤0.5 px dipendente dall'angolo) |
| `_angle_list` include estremo | range parziali ±tol ora testano anche +tol |
| `_refine_pose_joint` rimosso | Nelder-Mead su funzione a gradini satura: terminava subito; param ora alias di refine_angle |
| pyramid_propagate di default | kernel windowed (feature campionano l'intera scena: prima il crop troncava le feature → score 0); picchi = massimi locali (non top-K pixel); disattivato automaticamente per template elongati (>2:1) dove il picco top-level non localizza |
| Piramide 3 livelli default | con clamp automatico sulla dimensione template (min 12 px al top) |
| Cache scena: hash completo | prima hashava solo i primi 64KB → collisioni tra scene con stessa banda superiore → risultati della scena sbagliata |
| Web server | lock matcher (race con threadpool FastAPI), LRU `_IMG_CACHE`, clamp ROI ovunque (400/422 invece di 500), `filtro_fp=off` disabilita davvero NCC, `_draw_matches` su crop locale |
| GUI/legacy | centro overlay `(W-1)/2``W/2`, spread_radius default 5→4, EdgeShapeMatcher: angle list endpoint + cap candidati + save template_gray |
Misure (GT sintetica 7 pose, scena 900x700, VPS 2 core):
- Errore angolare mediano: **2.3° → 0.05°** (step 5°); a step 2° era 4.4° → **0.03°**
- Errore posizione mediano: **4.24 px → 0.04 px**
- find GT scene: 4.7s → 1.7s; scena reale 646x482: 1.14s → 0.81s
- Benchmark suite 16 scenari: 96.5s → 84.2s, match count ≥ baseline
(eccezioni: dado_full -1 = match borderline su parte diversa;
lama_part_preciso 25→18 con baseline al cap max_matches)
## Fase 1 COMPLETATA (branch `speedFase1`) ## Fase 1 COMPLETATA (branch `speedFase1`)
| ID | Voce | Status | Note | | ID | Voce | Status | Note |
@@ -84,9 +114,14 @@ Benchmark suite 16 scenari (4 immagini × full/part × fast/preciso):
## Target performance produzione ## Target performance produzione
Obiettivi da documento tecnico Vision Suite (Fase Beta): Obiettivi da documento tecnico Vision Suite (Fase Beta):
- [ ] **Precisione posizionale mediana**: <0.5 px → **raggiunto con subpixel (attualmente ~0.1-0.3 px atteso)** - [x] **Precisione posizionale mediana**: <0.5 px → **0.04 px misurato su GT sintetica (Fase 2)**
- [ ] **Precisione angolare mediana**: <1.0° → **raggiunto con refinement (~0.5°)** - [x] **Precisione angolare mediana**: <1.0° → **0.05° misurato su GT sintetica (Fase 2)**
- [ ] **Latency mediana**: <50 ms su 1920×1080 → **attuale ~1.7s su 830×822 (serve GPU o ulteriore CPU)** - [ ] **Latency mediana**: <50 ms su 1920×1080 → **~0.8s su 646×482 con 2 core; da misurare su hardware produzione**
- [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico** - [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico**
Prossimo blocker per target: **latency**. Via più promettente: GPU (CuPy) o coarse-to-fine angolare. Prossimo blocker per target: **latency**. Nota: i kernel hot sono gia'
Numba JIT (≈ velocita' C, prange parallelo): un port C++ dei kernel vale
solo il margine SIMD esplicito (~2-4x con AVX2 su AND+popcount byte-wise).
Prima di scriverlo conviene esaurire le vie algoritmiche rimaste:
riduzione varianti al top-level (auto angle step per livello, stile
Halcon), greediness di default, e GPU (CuPy/OpenCL) per scene 1080p.
+5
View File
@@ -36,6 +36,11 @@ CONFIGS = [
def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str, def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str,
cfg_name: str, cfg: dict) -> dict: cfg_name: str, cfg: dict) -> dict:
scene = cv2.imread(str(TEST_DIR / img_path)) scene = cv2.imread(str(TEST_DIR / img_path))
if scene is None:
# cv2.imread ritorna None silenzioso: senza check il crash arriva
# dopo, sullo slice, con un errore criptico.
raise FileNotFoundError(
f"Immagine di test non trovata o non leggibile: {TEST_DIR / img_path}")
y0, y1, x0, x1 = roi_box y0, y1, x0, x1 = roi_box
roi = scene[y0:y1, x0:x1].copy() roi = scene[y0:y1, x0:x1].copy()
m = LineShapeMatcher( m = LineShapeMatcher(
+144
View File
@@ -271,6 +271,108 @@ if HAS_NUMBA:
acc[y, x] = 0.0 acc[y, x] = 0.0
return acc return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_window(
spread: np.ndarray, # uint8 (H, W) - scena INTERA
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
bg: np.ndarray, # float32 (H, W) - scena intera
y0: nb.int64, x0: nb.int64,
wh: nb.int64, ww: nb.int64,
) -> np.ndarray:
"""Score rescored valutato SOLO nella finestra (y0, x0, wh, ww).
Le feature campionano lo spread dell'intera scena (bounds-checked
sui bordi scena): a differenza di chiamare il kernel su un crop,
le feature che escono dalla finestra NON contano come miss.
Usato dal path pyramid_propagate: costo ∝ area finestra.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((wh, ww), dtype=np.float32)
for yi in nb.prange(wh):
y = y0 + yi
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
yy = y + dy[i]
if yy < 0 or yy >= H:
continue
ddx = dx[i]
xi_lo = 0
xi_hi = ww
lo = -(x0 + ddx)
if lo > xi_lo:
xi_lo = lo
hi = W - (x0 + ddx)
if hi < xi_hi:
xi_hi = hi
for xi in range(xi_lo, xi_hi):
if spread[yy, x0 + xi + ddx] & mask:
acc[yi, xi] += 1.0
if N > 0:
inv = 1.0 / N
for yi in nb.prange(wh):
for xi in range(ww):
v = acc[yi, xi] * inv
bgv = bg[y0 + yi, x0 + xi]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[yi, xi] = r if r > 0.0 else 0.0
else:
acc[yi, xi] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_window_u16(
spread: np.ndarray, # uint16 (H, W)
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint16,
bg: np.ndarray,
y0: nb.int64, x0: nb.int64,
wh: nb.int64, ww: nb.int64,
) -> np.ndarray:
"""Versione uint16 (polarity 16-bin) del kernel windowed."""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((wh, ww), dtype=np.float32)
for yi in nb.prange(wh):
y = y0 + yi
for i in range(N):
b = bins[i]
mask = np.uint16(1) << b
if (bit_active & mask) == 0:
continue
yy = y + dy[i]
if yy < 0 or yy >= H:
continue
ddx = dx[i]
xi_lo = 0
xi_hi = ww
lo = -(x0 + ddx)
if lo > xi_lo:
xi_lo = lo
hi = W - (x0 + ddx)
if hi < xi_hi:
xi_hi = hi
for xi in range(xi_lo, xi_hi):
if spread[yy, x0 + xi + ddx] & mask:
acc[yi, xi] += 1.0
if N > 0:
inv = 1.0 / N
for yi in nb.prange(wh):
for xi in range(ww):
v = acc[yi, xi] * inv
bgv = bg[y0 + yi, x0 + xi]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[yi, xi] = r if r > 0.0 else 0.0
else:
acc[yi, xi] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False) @nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_top_max_per_variant( def _jit_top_max_per_variant(
spread: np.ndarray, # uint8 (H, W) spread: np.ndarray, # uint8 (H, W)
@@ -426,6 +528,9 @@ if HAS_NUMBA:
_jit_top_max_per_variant( _jit_top_max_per_variant(
spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx, spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx,
) )
_jit_score_bitmap_rescored_window(
spread, dx, dy, b, np.uint8(0xFF), bg, 4, 4, 8, 8,
)
_jit_popcount_density(spread) _jit_popcount_density(spread)
spread16 = np.zeros((32, 32), dtype=np.uint16) spread16 = np.zeros((32, 32), dtype=np.uint16)
_jit_score_bitmap_rescored_u16( _jit_score_bitmap_rescored_u16(
@@ -447,6 +552,12 @@ else: # pragma: no cover
def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride): def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_window(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_window_u16(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness): def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
@@ -524,6 +635,39 @@ def score_bitmap_rescored(
return np.maximum(0.0, out).astype(np.float32) return np.maximum(0.0, out).astype(np.float32)
def score_bitmap_rescored_window(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, bg: np.ndarray,
y0: int, x0: int, wh: int, ww: int,
) -> np.ndarray:
"""Score rescored solo nella finestra (y0, x0, wh, ww) della scena.
Le feature campionano l'INTERA scena: feature fuori finestra ma dentro
scena contano correttamente (chiamare il kernel su un crop le tratta
come miss e azzera lo score — il bug che rendeva inutilizzabile il
path pyramid_propagate). Fallback no-numba: kernel pieno + slice.
"""
if HAS_NUMBA and len(dx) > 0:
dx_c = np.ascontiguousarray(dx, dtype=np.int32)
dy_c = np.ascontiguousarray(dy, dtype=np.int32)
bins_c = np.ascontiguousarray(bins, dtype=np.int8)
bg_c = np.ascontiguousarray(bg, dtype=np.float32)
if spread.dtype == np.uint16:
return _jit_score_bitmap_rescored_window_u16(
np.ascontiguousarray(spread, dtype=np.uint16),
dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
int(y0), int(x0), int(wh), int(ww),
)
return _jit_score_bitmap_rescored_window(
np.ascontiguousarray(spread, dtype=np.uint8),
dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
int(y0), int(x0), int(wh), int(ww),
)
# Fallback (lento, solo senza numba): score full-frame + slice finestra
full = score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg)
return full[y0:y0 + wh, x0:x0 + ww]
def score_bitmap_greedy( def score_bitmap_greedy(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, min_score: float, greediness: float, bit_active: int, min_score: float, greediness: float,
+2 -1
View File
@@ -61,6 +61,8 @@ def detect_rotational_symmetry(
center = (w / 2.0, h / 2.0) center = (w / 2.0, h / 2.0)
ref = mag ref = mag
# ref è costante nel loop sugli angoli: centra una volta sola
rm = ref - ref.mean()
correlations: list[tuple[float, float]] = [] correlations: list[tuple[float, float]] = []
for ang in np.arange(step_deg, 360.0, step_deg): for ang in np.arange(step_deg, 360.0, step_deg):
@@ -68,7 +70,6 @@ def detect_rotational_symmetry(
rot = cv2.warpAffine( rot = cv2.warpAffine(
mag, M, (w, h), borderValue=0.0, mag, M, (w, h), borderValue=0.0,
) )
rm = ref - ref.mean()
rs = rot - rot.mean() rs = rot - rot.mean()
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9 denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
c = float((rm * rs).sum() / denom) c = float((rm * rs).sum() / denom)
+7 -3
View File
@@ -196,8 +196,10 @@ def _warp_template_edges_to_scene(
edge = cv2.Canny(template_gray, canny_low, canny_high) edge = cv2.Canny(template_gray, canny_low, canny_high)
# Matrice affine: scala + rotazione attorno al centro template, poi traslazione # Matrice affine: scala + rotazione attorno al centro template, poi traslazione
Ht, Wt = h, w Ht, Wt = h, w
cx_t = (Wt - 1) / 2.0 # Centro coerente con la convenzione train (center = w / 2.0, no -1):
cy_t = (Ht - 1) / 2.0 # (Wt-1)/2 introduceva uno shift di 0.5px per template di lato pari.
cx_t = Wt / 2.0
cy_t = Ht / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale) M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
# Traslazione per portare centro template a (cx, cy) della scena # Traslazione per portare centro template a (cx, cy) della scena
M[0, 2] += cx - cx_t M[0, 2] += cx - cx_t
@@ -492,7 +494,9 @@ def run(
num_features: int = 96, num_features: int = 96,
weak_grad: float = 30.0, weak_grad: float = 30.0,
strong_grad: float = 60.0, strong_grad: float = 60.0,
spread_radius: int = 5, # 4 allineato col default del matcher: raggio 5 peggiora la precisione
# di rotazione (spread troppo largo appiattisce il picco angolare).
spread_radius: int = 4,
pyramid_levels: int = 3, pyramid_levels: int = 3,
min_score: float = 0.55, min_score: float = 0.55,
max_matches: int = 25, max_matches: int = 25,
+348 -303
View File
@@ -40,6 +40,7 @@ from pm2d._jit_kernels import (
score_by_shift as _jit_score_by_shift, score_by_shift as _jit_score_by_shift,
score_bitmap as _jit_score_bitmap, score_bitmap as _jit_score_bitmap,
score_bitmap_rescored as _jit_score_bitmap_rescored, score_bitmap_rescored as _jit_score_bitmap_rescored,
score_bitmap_rescored_window as _jit_score_bitmap_rescored_window,
score_bitmap_greedy as _jit_score_bitmap_greedy, score_bitmap_greedy as _jit_score_bitmap_greedy,
top_max_per_variant as _jit_top_max_per_variant, top_max_per_variant as _jit_top_max_per_variant,
popcount_density as _jit_popcount, popcount_density as _jit_popcount,
@@ -172,7 +173,7 @@ class LineShapeMatcher:
scale_step: float = 0.1, scale_step: float = 0.1,
spread_radius: int = 4, spread_radius: int = 4,
min_feature_spacing: int = 3, min_feature_spacing: int = 3,
pyramid_levels: int = 2, pyramid_levels: int = 3,
top_score_factor: float = 0.5, top_score_factor: float = 0.5,
n_threads: int | None = None, n_threads: int | None = None,
use_polarity: bool = False, use_polarity: bool = False,
@@ -473,8 +474,46 @@ class LineShapeMatcher:
step = self._effective_angle_step() step = self._effective_angle_step()
if step <= 0 or a0 >= a1: if step <= 0 or a0 >= a1:
return [float(a0)] return [float(a0)]
n = int(np.floor((a1 - a0) / step)) # Include l'estremo superiore: con range parziali (es. ±15°) il
return [float(a0 + i * step) for i in range(n)] # +15° deve essere testato quanto il -15°. Se il range copre 360°
# interi l'estremo coincide con a0 (mod 360) e viene escluso per
# non duplicare la variante.
n = int(np.floor((a1 - a0) / step + 1e-9)) + 1
angles = [float(a0 + i * step) for i in range(n)]
if a1 - a0 >= 360.0:
angles = [a for a in angles if a - a0 < 360.0 - 1e-9]
return angles
def _prepare_padded_template(
self, template_gray: np.ndarray, mask_full: np.ndarray, scale: float,
) -> tuple[np.ndarray, np.ndarray, tuple[float, float], int]:
"""Scala + padda template e mask; ritorna (gray_p, mask_p, center, diag).
`center` e' il centro REALE del template dentro l'immagine paddata
(px + sw/2, py + sh/2): con padding floor differisce da diag/2 fino
a 0.5 px. Ruotare attorno a diag/2 (come si faceva prima) faceva
orbitare il centro-modello attorno al centro di rotazione, con un
bias di posizione dipendente dall'angolo. Tutti i percorsi che
ricostruiscono il template ruotato devono usare QUESTO helper.
"""
h, w = template_gray.shape
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2
px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE,
)
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (px + sw / 2.0, py + sh / 2.0)
return gray_p, mask_p, center, diag
# --- Training ------------------------------------------------------ # --- Training ------------------------------------------------------
@@ -504,6 +543,13 @@ class LineShapeMatcher:
h, w = gray.shape h, w = gray.shape
self.template_size = (w, h) self.template_size = (w, h)
self.template_gray = gray.copy() self.template_gray = gray.copy()
# Clamp livelli piramide alla dimensione template: al top-level il
# lato minimo deve restare >= 12 px, sotto le feature collassano
# tutte negli stessi (dx,dy) e lo score top diventa rumore.
max_lv = 1
while min(w, h) / (2 ** max_lv) >= 12 and max_lv < 4:
max_lv += 1
self.pyramid_levels = max(1, min(self.pyramid_levels, max_lv))
if mask is None: if mask is None:
mask_full = np.full((h, w), 255, dtype=np.uint8) mask_full = np.full((h, w), 255, dtype=np.uint8)
else: else:
@@ -566,24 +612,10 @@ class LineShapeMatcher:
Estrazione algorithm identica al train() originale, separato per Estrazione algorithm identica al train() originale, separato per
riuso da add_template_view (multi-template ensemble). riuso da add_template_view (multi-template ensemble).
""" """
h, w = gray.shape
for s in self._scale_list(): for s in self._scale_list():
sw = max(16, int(round(w * s))) gray_p, mask_p, center, diag = self._prepare_padded_template(
sh = max(16, int(round(h * s))) gray, mask_full, s,
gray_s = cv2.resize(gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2
px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE,
) )
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
for ang in self._angle_list(): for ang in self._angle_list():
M = cv2.getRotationMatrix2D(center, ang, 1.0) M = cv2.getRotationMatrix2D(center, ang, 1.0)
@@ -600,10 +632,10 @@ class LineShapeMatcher:
if len(fx) < 8: if len(fx) < 8:
continue continue
cx_c = diag / 2.0 # round (non truncation): astype(int32) tronca verso zero
cy_c = diag / 2.0 # e introduceva un bias sistematico ~0.25 px verso il centro.
dx = (fx - cx_c).astype(np.int32) dx = np.round(fx - center[0]).astype(np.int32)
dy = (fy - cy_c).astype(np.int32) dy = np.round(fy - center[1]).astype(np.int32)
x0 = int(dx.min()); x1 = int(dx.max()) x0 = int(dx.min()); x1 = int(dx.max())
y0 = int(dy.min()); y1 = int(dy.max()) y0 = int(dy.min()); y1 = int(dy.max())
@@ -687,8 +719,13 @@ class LineShapeMatcher:
try: try:
import hashlib import hashlib
h = hashlib.md5() h = hashlib.md5()
sample = gray.tobytes()[:65536] # Hash dell'INTERA scena: hashare solo i primi 64KB (prime
h.update(sample) # ~80 righe a 830px) faceva collidere scene con la stessa
# banda superiore (es. sfondo uniforme da camera fissa) →
# find() ritornava i risultati della scena sbagliata.
# tobytes() copiava gia' tutto il buffer, il costo extra
# dell'md5 completo e' ~1ms.
h.update(gray.tobytes())
h.update(f"|{gray.shape}|{gray.dtype}".encode()) h.update(f"|{gray.shape}|{gray.dtype}".encode())
h.update( h.update(
f"|{self.weak_grad}|{self.strong_grad}" f"|{self.weak_grad}|{self.strong_grad}"
@@ -717,11 +754,17 @@ class LineShapeMatcher:
while len(self._scene_cache) > self._SCENE_CACHE_SIZE: while len(self._scene_cache) > self._SCENE_CACHE_SIZE:
self._scene_cache.popitem(last=False) self._scene_cache.popitem(last=False)
def _spread_bitmap(self, gray: np.ndarray) -> np.ndarray: def _spread_bitmap(
self, gray: np.ndarray, radius: int | None = None,
) -> np.ndarray:
"""Spread bitmap: bit b acceso dove bin b è presente nel raggio. """Spread bitmap: bit b acceso dove bin b è presente nel raggio.
dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity). dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity).
Se use_gpu=True: Sobel + dilate via cv2.UMat (OpenCL kernel GPU). Se use_gpu=True: Sobel + dilate via cv2.UMat (OpenCL kernel GPU).
radius: override del raggio di spread (default self.spread_radius).
radius=0/1 produce una bitmap "fine" senza tolleranza, usata nel
refine finale: sulla bitmap dilatata lo score satura e il refine
non distingue pose entro ±spread_radius px / ±atan(spread/R) gradi.
""" """
if self.use_gpu and not isinstance(gray, cv2.UMat): if self.use_gpu and not isinstance(gray, cv2.UMat):
gray_in = cv2.UMat(np.ascontiguousarray(gray)) gray_in = cv2.UMat(np.ascontiguousarray(gray))
@@ -729,7 +772,8 @@ class LineShapeMatcher:
gray_in = gray gray_in = gray
mag, bins = self._gradient(gray_in) mag, bins = self._gradient(gray_in)
valid = mag >= self.weak_grad valid = mag >= self.weak_grad
k = 2 * self.spread_radius + 1 r = self.spread_radius if radius is None else max(0, int(radius))
k = 2 * r + 1
kernel = np.ones((k, k), dtype=np.uint8) kernel = np.ones((k, k), dtype=np.uint8)
H, W = (gray.shape if isinstance(gray, np.ndarray) H, W = (gray.shape if isinstance(gray, np.ndarray)
else (gray.get().shape[0], gray.get().shape[1])) else (gray.get().shape[0], gray.get().shape[1]))
@@ -755,7 +799,9 @@ class LineShapeMatcher:
if not bin_present[b]: if not bin_present[b]:
continue # XX: nessun pixel di questo bin sopra weak_grad continue # XX: nessun pixel di questo bin sopra weak_grad
mask_b = ((bins == b) & valid).astype(np.uint8) mask_b = ((bins == b) & valid).astype(np.uint8)
if self.use_gpu: if r == 0:
d_np = mask_b
elif self.use_gpu:
d = cv2.dilate(cv2.UMat(mask_b), kernel) d = cv2.dilate(cv2.UMat(mask_b), kernel)
d_np = d.get() d_np = d.get()
else: else:
@@ -828,111 +874,9 @@ class LineShapeMatcher:
oy = float(np.clip(oy, -0.5, 0.5)) oy = float(np.clip(oy, -0.5, 0.5))
return x + ox, y + oy return x + ox, y + oy
def _refine_pose_joint(
self,
spread0: np.ndarray,
template_gray: np.ndarray,
cx: float, cy: float,
angle_deg: float, scale: float,
mask_full: np.ndarray,
max_iter: int = 24,
tol: float = 1e-3,
) -> tuple[float, float, float, float]:
"""Refine congiunto (cx, cy, angle) via Nelder-Mead 3D.
Ottimizza simultaneamente posizione e angolo (vs golden search 1D
sull'angolo poi quadratico 2D su xy che alterna assi). Halcon-style:
un singolo iter LM stila il match a precisione sub-pixel + sub-step.
Ritorna (angle, score, cx, cy) dove score e quello calcolato sulla
scena spread (no template gray).
"""
h, w = template_gray.shape
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE)
mask_p = cv2.copyMakeBorder(mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0)
center = (diag / 2.0, diag / 2.0)
H, W = spread0.shape
def _score(params: tuple[float, float, float]) -> float:
ddx, ddy, dang = params
ang = angle_deg + dang
M = cv2.getRotationMatrix2D(center, ang, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE)
mask_r = cv2.warpAffine(mask_p, M, (diag, diag),
flags=cv2.INTER_NEAREST, borderValue=0)
mag, bins = self._gradient(gray_r)
fx, fy, fb = self._extract_features(mag, bins, mask_r)
if len(fx) < 8:
return 0.0
cxe = cx + ddx; cye = cy + ddy
ix = int(round(cxe)); iy = int(round(cye))
tot = 0
valid = 0
for i in range(len(fx)):
xs = ix + int(fx[i] - center[0])
ys = iy + int(fy[i] - center[1])
if 0 <= xs < W and 0 <= ys < H:
bit = np.uint8(1 << int(fb[i]))
if spread0[ys, xs] & bit:
tot += 1
valid += 1
return -float(tot) / max(1, valid) # minimize -score
# Nelder-Mead 3D inline (no scipy). Simplex iniziale: vertice + offset
# dx=±0.5px, dy=±0.5px, dθ=±step/2.
step_a = self.angle_step_deg / 2.0 if self.angle_step_deg > 0 else 1.0
x0 = np.array([0.0, 0.0, 0.0])
simplex = np.array([
x0,
x0 + [0.5, 0.0, 0.0],
x0 + [0.0, 0.5, 0.0],
x0 + [0.0, 0.0, step_a],
])
fvals = np.array([_score(tuple(s)) for s in simplex])
for _ in range(max_iter):
order = np.argsort(fvals)
simplex = simplex[order]; fvals = fvals[order]
if abs(fvals[-1] - fvals[0]) < tol:
break
centroid = simplex[:-1].mean(axis=0)
xr = centroid + 1.0 * (centroid - simplex[-1])
fr = _score(tuple(xr))
if fvals[0] <= fr < fvals[-2]:
simplex[-1] = xr; fvals[-1] = fr
continue
if fr < fvals[0]:
xe = centroid + 2.0 * (centroid - simplex[-1])
fe = _score(tuple(xe))
if fe < fr:
simplex[-1] = xe; fvals[-1] = fe
else:
simplex[-1] = xr; fvals[-1] = fr
continue
xc = centroid + 0.5 * (simplex[-1] - centroid)
fc = _score(tuple(xc))
if fc < fvals[-1]:
simplex[-1] = xc; fvals[-1] = fc
continue
for k in range(1, 4):
simplex[k] = simplex[0] + 0.5 * (simplex[k] - simplex[0])
fvals[k] = _score(tuple(simplex[k]))
best_i = int(np.argmin(fvals))
ddx, ddy, dang = simplex[best_i]
return (angle_deg + float(dang), -float(fvals[best_i]),
cx + float(ddx), cy + float(ddy))
def _refine_angle( def _refine_angle(
self, self,
spread0: np.ndarray, # bitmap uint8 (H, W) spread0: np.ndarray, # bitmap uint8/uint16 (H, W) - spread pieno
bit_active: int, bit_active: int,
template_gray: np.ndarray, template_gray: np.ndarray,
cx: float, cy: float, cx: float, cy: float,
@@ -941,33 +885,31 @@ class LineShapeMatcher:
angle_fine_step: float = 0.5, angle_fine_step: float = 0.5,
search_radius: float | None = None, search_radius: float | None = None,
original_score: float | None = None, original_score: float | None = None,
spread_fine: np.ndarray | None = None,
) -> tuple[float, float, float, float]: ) -> tuple[float, float, float, float]:
"""Ricerca angolare fine (sub-step) attorno al match grezzo. """Ricerca angolare fine (sub-step) attorno al match grezzo.
Genera 5 template temporanei a angle ± {0.5, 1.0} * step e sceglie Golden-section sull'angolo + argmax posizione in finestra ±3 px.
l'angolo con score massimo (parabolic fit sulle 3 score centrali).
Ritorna (angle_refined, score, cx_refined, cy_refined). Ritorna (angle_refined, score, cx_refined, cy_refined).
L'ottimizzazione gira sulla bitmap FINE (spread_fine, raggio 1):
sulla bitmap dilatata (spread0, raggio 4-5) lo score satura a 1.0
per qualunque posa entro ±spread px / ±atan(spread/R) gradi e il
refine non vede alcun gradiente (l'angolo restava quello grezzo
quantizzato e cv2.minMaxLoc sul plateau saturo spostava il centro
sull'angolo in alto a sinistra della finestra: errore misurato
3·sqrt(2) ≈ 4.24 px). Lo score RITORNATO e' ricalcolato alla posa
raffinata su spread0, per mantenere la semantica precedente
(tolleranza spread_radius) su soglie/min_score.
""" """
# NB: rimosso early-skip su score >= 0.99. Lo score linemod/shape
# satura facilmente a 1.0 (specie con pyramid_propagate o spread
# ampio) ma NON garantisce angolo preciso: l'angolo grezzo della
# variante e' quantizzato a multipli di angle_step (5 deg default).
# Refine angolare e' essenziale per orientamento sub-step.
if search_radius is None: if search_radius is None:
search_radius = self._effective_angle_step() search_radius = self._effective_angle_step()
# Bitmap su cui ottimizzare: fine se disponibile, altrimenti spread0.
opt_map = spread_fine if spread_fine is not None else spread0
h, w = template_gray.shape gray_p, mask_p, center, diag = self._prepare_padded_template(
sw = max(16, int(round(w * scale))) template_gray, mask_full, scale,
sh = max(16, int(round(h * scale))) )
gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE)
mask_p = cv2.copyMakeBorder(mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0)
center = (diag / 2.0, diag / 2.0)
H, W = spread0.shape H, W = spread0.shape
margin = 3 margin = 3
@@ -982,14 +924,11 @@ class LineShapeMatcher:
feat_cache = self._refine_feat_cache feat_cache = self._refine_feat_cache
cache_scale_key = round(scale * 1000) cache_scale_key = round(scale * 1000)
def _score_at_angle(off: float) -> tuple[float, float, float]: def _feats_at_angle(ang: float):
"""Ritorna (score, best_cx, best_cy) per angolo = angle_deg + off."""
ang = angle_deg + off
ck = (round(ang * 20), cache_scale_key) ck = (round(ang * 20), cache_scale_key)
cached = feat_cache.get(ck) cached = feat_cache.get(ck)
if cached is not None: if cached is not None:
fx, fy, fb = cached return cached
else:
M = cv2.getRotationMatrix2D(center, ang, 1.0) M = cv2.getRotationMatrix2D(center, ang, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1002,15 +941,27 @@ class LineShapeMatcher:
if len(feat_cache) > 256: if len(feat_cache) > 256:
feat_cache.pop(next(iter(feat_cache))) feat_cache.pop(next(iter(feat_cache)))
feat_cache[ck] = (fx, fy, fb) feat_cache[ck] = (fx, fy, fb)
return fx, fy, fb
def _score_at_angle(off: float) -> tuple[float, float, float]:
"""Ritorna (score, best_cx, best_cy) per angolo = angle_deg + off.
Score = max su finestra ±margin px attorno a (cx, cy) sulla
bitmap di ottimizzazione; posizione = picco sub-pixel della
finestra (centroide plateau / fit quadratico, NON minMaxLoc
che sul plateau e' biased verso l'angolo top-left).
"""
ang = angle_deg + off
fx, fy, fb = _feats_at_angle(ang)
if len(fx) < 8: if len(fx) < 8:
return (0.0, cx, cy) return (0.0, cx, cy)
dx = (fx - center[0]).astype(np.int32) dx = np.round(fx - center[0]).astype(np.int32)
dy = (fy - center[1]).astype(np.int32) dy = np.round(fy - center[1]).astype(np.int32)
y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1 y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1
x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1 x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1
sh_w = y_hi - y_lo; sw_w = x_hi - x_lo sh_w = y_hi - y_lo; sw_w = x_hi - x_lo
acc = np.zeros((sh_w, sw_w), dtype=np.float32) acc = np.zeros((sh_w, sw_w), dtype=np.float32)
spread_dtype = spread0.dtype.type spread_dtype = opt_map.dtype.type
for i in range(len(dx)): for i in range(len(dx)):
ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i]) ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i])
bit = spread_dtype(1 << b) bit = spread_dtype(1 << b)
@@ -1021,14 +972,19 @@ class LineShapeMatcher:
s_y0 = max(0, sy0); s_y1 = min(H, sy1) s_y0 = max(0, sy0); s_y1 = min(H, sy1)
s_x0 = max(0, sx0); s_x1 = min(W, sx1) s_x0 = max(0, sx0); s_x1 = min(W, sx1)
if s_y1 > s_y0 and s_x1 > s_x0: if s_y1 > s_y0 and s_x1 > s_x0:
region = spread0[s_y0:s_y1, s_x0:s_x1] region = opt_map[s_y0:s_y1, s_x0:s_x1]
acc[a_y0:a_y1, a_x0:a_x1] += ( acc[a_y0:a_y1, a_x0:a_x1] += (
(region & bit) != 0 (region & bit) != 0
).astype(np.float32) ).astype(np.float32)
acc /= len(dx) acc /= len(dx)
_, max_val, _, max_loc = cv2.minMaxLoc(acc) _, max_val, _, max_loc = cv2.minMaxLoc(acc)
return (float(max_val), if max_val <= 0.0:
float(x_lo + max_loc[0]), float(y_lo + max_loc[1])) return (0.0, cx, cy)
# Picco sub-pixel dentro la finestra (gestisce plateau e fit 3x3)
px_f, py_f = self._subpixel_peak(
acc, int(max_loc[0]), int(max_loc[1]), plateau_radius=margin,
)
return (float(max_val), float(x_lo + px_f), float(y_lo + py_f))
# Golden-section search su [-search_radius, +search_radius]: # Golden-section search su [-search_radius, +search_radius]:
# converge in log tempo a precisione ~0.1°, ~8 valutazioni vs 5 # converge in log tempo a precisione ~0.1°, ~8 valutazioni vs 5
@@ -1064,7 +1020,25 @@ class LineShapeMatcher:
x1 = x2; s1 = s2; cx1 = cx2; cy1 = cy2 x1 = x2; s1 = s2; cx1 = cx2; cy1 = cy2
x2 = a_lo + _GOLDEN * (a_hi - a_lo) x2 = a_lo + _GOLDEN * (a_hi - a_lo)
s2, cx2, cy2 = _score_at_angle(x2) s2, cx2, cy2 = _score_at_angle(x2)
ang_best, s_best, cx_best, cy_best = best
if spread_fine is None:
return best return best
# Score finale alla posa raffinata sullo spread COARSE: stessa
# semantica dello score pre-refine (tolleranza spread_radius),
# cosi' min_score/verify mantengono il significato di prima.
fx, fy, fb = _feats_at_angle(ang_best)
if len(fx) < 8:
return best
xs = np.round(fx - center[0]).astype(np.int32) + int(round(cx_best))
ys = np.round(fy - center[1]).astype(np.int32) + int(round(cy_best))
ok = (xs >= 0) & (xs < W) & (ys >= 0) & (ys < H)
if not ok.any():
return (ang_best, 0.0, cx_best, cy_best)
bits = spread0[ys[ok], xs[ok]].astype(np.int32)
hit = (bits & np.left_shift(1, fb[ok].astype(np.int32))) != 0
score_coarse = float(hit.sum()) / len(fx)
return (ang_best, score_coarse, cx_best, cy_best)
def _get_view_template( def _get_view_template(
self, view_idx: int, self, view_idx: int,
@@ -1089,26 +1063,13 @@ class LineShapeMatcher:
""" """
if self.template_gray is None: if self.template_gray is None:
return 1.0 return 1.0
h, w = self.template_gray.shape
scale = variant.scale
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = ( mask_src = (
self._train_mask if self._train_mask is not None self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255) else np.full_like(self.template_gray, 255)
) )
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST) gray_p, mask_p, center, diag = self._prepare_padded_template(
diag = int(np.ceil(np.hypot(sh, sw))) + 6 self.template_gray, mask_src, variant.scale,
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
) )
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1125,8 +1086,8 @@ class LineShapeMatcher:
ix = int(round(cx)); iy = int(round(cy)) ix = int(round(cx)); iy = int(round(cy))
hits = 0 hits = 0
for i in range(n_feat): for i in range(n_feat):
xs = ix + int(fx[i] - center[0]) xs = ix + int(round(fx[i] - center[0]))
ys = iy + int(fy[i] - center[1]) ys = iy + int(round(fy[i] - center[1]))
if 0 <= xs < W and 0 <= ys < H: if 0 <= xs < W and 0 <= ys < H:
bit = spread_dtype(1 << int(fb[i])) bit = spread_dtype(1 << int(fb[i]))
if spread0[ys, xs] & bit: if spread0[ys, xs] & bit:
@@ -1140,26 +1101,13 @@ class LineShapeMatcher:
"""Soft-margin gradient similarity (Halcon Metric='use_polarity').""" """Soft-margin gradient similarity (Halcon Metric='use_polarity')."""
if self.template_gray is None: if self.template_gray is None:
return 0.0 return 0.0
h, w = self.template_gray.shape
scale = variant.scale
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = ( mask_src = (
self._train_mask if self._train_mask is not None self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255) else np.full_like(self.template_gray, 255)
) )
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST) gray_p, mask_p, center, diag = self._prepare_padded_template(
diag = int(np.ceil(np.hypot(sh, sw))) + 6 self.template_gray, mask_src, variant.scale,
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
) )
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1179,8 +1127,8 @@ class LineShapeMatcher:
ix = int(round(cx)); iy = int(round(cy)) ix = int(round(cx)); iy = int(round(cy))
sims = []; weights = [] sims = []; weights = []
for i in range(len(fx)): for i in range(len(fx)):
xs = ix + int(fx[i] - center[0]) xs = ix + int(round(fx[i] - center[0]))
ys = iy + int(fy[i] - center[1]) ys = iy + int(round(fy[i] - center[1]))
if not (0 <= xs < W and 0 <= ys < H): if not (0 <= xs < W and 0 <= ys < H):
continue continue
tx = float(gx_t[int(fy[i]), int(fx[i])]) tx = float(gx_t[int(fy[i]), int(fx[i])])
@@ -1201,35 +1149,34 @@ class LineShapeMatcher:
def _subpixel_refine_lm( def _subpixel_refine_lm(
self, scene_gray: np.ndarray, variant: _Variant, self, scene_gray: np.ndarray, variant: _Variant,
cx: float, cy: float, angle_deg: float, cx: float, cy: float, angle_deg: float,
n_iters: int = 2, n_iters: int = 4,
) -> tuple[float, float]: scene_grad: tuple[np.ndarray, np.ndarray] | None = None,
"""Sub-pixel refinement iterativo via gradient-field least-squares. ) -> tuple[float, float, float]:
"""Refinement least-squares congiunto di posizione E angolo.
Halcon-equivalent SubPixel='least_squares_high'. Precisione attesa Halcon-equivalent SubPixel='least_squares_high'. Per ogni feature
0.05 px (vs 0.5 px del fit quadratic 2D). template cerca il picco sub-pixel del gradiente scena lungo la
normale dell'edge (snap ±2 px, fit parabolico su 5 campioni), poi
risolve ai minimi quadrati pesati il sistema 3x3 in (dx, dy, dθ):
n_i · (d + dθ·u_i) = t_i, u_i = (r_y,i, -r_x,i)
dove r_i = offset feature dal centro, n_i = normale edge template,
t_i = offset del picco lungo n_i, u_i = derivata della rotazione
nella convenzione cv2.getRotationMatrix2D (R = [[c,s],[-s,c]]).
Tra le iterazioni offset e normali vengono ruotati analiticamente
(no re-warp del template). Precisione attesa <0.1 px / <0.1°.
scene_grad: (gx, gy) Sobel della scena precomputati (evita un
Sobel full-frame per ogni match). Ritorna (cx, cy, angle_deg).
""" """
if self.template_gray is None: t, train_mask = self._get_view_template(getattr(variant, "view_idx", 0))
return cx, cy if t is None:
h, w = self.template_gray.shape return cx, cy, angle_deg
scale = variant.scale mask_src = train_mask if train_mask is not None else np.full_like(t, 255)
sw = max(16, int(round(w * scale))) gray_p, mask_p, center, diag = self._prepare_padded_template(
sh = max(16, int(round(h * scale))) t, mask_src, variant.scale,
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = (
self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255)
) )
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
)
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1241,51 +1188,98 @@ class LineShapeMatcher:
mag_t = cv2.magnitude(gx_t, gy_t) mag_t = cv2.magnitude(gx_t, gy_t)
_, bins_t = self._gradient(gray_r) _, bins_t = self._gradient(gray_r)
fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r) fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r)
if len(fx) < 4: if len(fx) < 8:
return cx, cy return cx, cy, angle_deg
n = len(fx) rx = (fx - center[0]).astype(np.float64)
ddx_t = (fx - center[0]).astype(np.float32) ry = (fy - center[1]).astype(np.float64)
ddy_t = (fy - center[1]).astype(np.float32) gxf = gx_t[fy, fx].astype(np.float64)
gx_tf = np.array([gx_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32) gyf = gy_t[fy, fx].astype(np.float64)
gy_tf = np.array([gy_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32) nm = np.hypot(gxf, gyf) + 1e-9
mag_tf = np.hypot(gx_tf, gy_tf) + 1e-6 nx = gxf / nm
nx_t = gx_tf / mag_tf ny = gyf / nm
ny_t = gy_tf / mag_tf
if scene_grad is not None:
gx_s, gy_s = scene_grad
else:
gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3) gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3)
gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3) gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3)
H, W = scene_gray.shape H, W = scene_gray.shape
cur_cx, cur_cy = float(cx), float(cy)
for _ in range(n_iters): def _bilin(g: np.ndarray, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
xs = cur_cx + ddx_t xs_c = np.clip(xs, 0.0, W - 1.001)
ys = cur_cy + ddy_t ys_c = np.clip(ys, 0.0, H - 1.001)
xs_c = np.clip(xs, 0, W - 1.001)
ys_c = np.clip(ys, 0, H - 1.001)
x0 = xs_c.astype(np.int32); y0 = ys_c.astype(np.int32) x0 = xs_c.astype(np.int32); y0 = ys_c.astype(np.int32)
ax = xs_c - x0; ay = ys_c - y0 ax = xs_c - x0; ay = ys_c - y0
def _bilin(g): return ((1 - ax) * (1 - ay) * g[y0, x0]
v00 = g[y0, x0]; v10 = g[y0, x0 + 1] + ax * (1 - ay) * g[y0, x0 + 1]
v01 = g[y0 + 1, x0]; v11 = g[y0 + 1, x0 + 1] + (1 - ax) * ay * g[y0 + 1, x0]
return ((1 - ax) * (1 - ay) * v00 + ax * ay * g[y0 + 1, x0 + 1])
+ ax * (1 - ay) * v10
+ (1 - ax) * ay * v01 t_offsets = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
+ ax * ay * v11) n_feat = len(rx)
sx_v = _bilin(gx_s) idx = np.arange(n_feat)
sy_v = _bilin(gy_s) cur_cx, cur_cy, cur_ang = float(cx), float(cy), float(angle_deg)
mag_s = np.hypot(sx_v, sy_v) + 1e-6 for _ in range(n_iters):
nx_s = sx_v / mag_s px = cur_cx + rx
ny_s = sy_v / mag_s py = cur_cy + ry
w = np.minimum(mag_s, 255.0).astype(np.float32) # |grad| scena campionato a 5 offset lungo la normale di ogni
err_x = (nx_s - nx_t) * w # feature; il picco sub-pixel lungo la normale e' la distanza
err_y = (ny_s - ny_t) * w # firmata t_i dall'edge scena piu' vicino.
step_x = -float(err_x.sum()) / (w.sum() + 1e-6) mags = np.empty((5, n_feat))
step_y = -float(err_y.sum()) / (w.sum() + 1e-6) sxs = np.empty((5, n_feat))
step_x = max(-1.0, min(1.0, step_x)) sys_ = np.empty((5, n_feat))
step_y = max(-1.0, min(1.0, step_y)) for k, t_off in enumerate(t_offsets):
cur_cx += step_x sx_v = _bilin(gx_s, px + t_off * nx, py + t_off * ny)
cur_cy += step_y sy_v = _bilin(gy_s, px + t_off * nx, py + t_off * ny)
if abs(step_x) < 0.02 and abs(step_y) < 0.02: sxs[k] = sx_v; sys_[k] = sy_v
mags[k] = np.hypot(sx_v, sy_v)
k_best = np.argmax(mags, axis=0)
m_pk = mags[k_best, idx]
t_i = t_offsets[k_best]
# Fit parabolico sui picchi interni (k in 1..3)
interior = (k_best >= 1) & (k_best <= 3)
if interior.any():
ki = k_best[interior]; ii = idx[interior]
m_m = mags[ki - 1, ii]; m_0 = mags[ki, ii]; m_p = mags[ki + 1, ii]
denom = (m_m - 2.0 * m_0 + m_p)
off = np.where(np.abs(denom) > 1e-9,
0.5 * (m_m - m_p) / (denom - 1e-12), 0.0)
t_i = t_i.astype(np.float64)
t_i[interior] += np.clip(off, -0.5, 0.5)
# Peso: |grad| al picco * allineamento direzione (mod π se no
# polarity). Feature senza edge (sotto weak_grad) escluse;
# picco sul bordo finestra = snap inaffidabile → dimezzato.
sx_pk = sxs[k_best, idx]; sy_pk = sys_[k_best, idx]
cos_al = (nx * sx_pk + ny * sy_pk) / (m_pk + 1e-9)
align = np.maximum(0.0, cos_al) if self.use_polarity else np.abs(cos_al)
wgt = np.minimum(m_pk, 255.0) * align * align
wgt[m_pk < self.weak_grad] = 0.0
wgt[~interior] *= 0.5
if float(wgt.sum()) < 1e-6:
break break
return cur_cx, cur_cy # LSQ pesato 3x3: A_i = [n_x, n_y, n_x·r_y - n_y·r_x]
a3 = nx * ry - ny * rx
A = np.stack([nx, ny, a3], axis=1)
Aw = A * wgt[:, None]
AtA = Aw.T @ A
Atb = Aw.T @ t_i.astype(np.float64)
try:
sol = np.linalg.solve(AtA + 1e-6 * np.eye(3), Atb)
except np.linalg.LinAlgError:
break
ddx = float(np.clip(sol[0], -1.5, 1.5))
ddy = float(np.clip(sol[1], -1.5, 1.5))
dth = float(np.clip(sol[2], -math.radians(1.5), math.radians(1.5)))
cur_cx += ddx
cur_cy += ddy
cur_ang += math.degrees(dth)
# Ruota offset e normali di dθ (convenzione R = [[c,s],[-s,c]])
c = math.cos(dth); s = math.sin(dth)
rx, ry = c * rx + s * ry, -s * rx + c * ry
nx, ny = c * nx + s * ny, -s * nx + c * ny
if abs(ddx) < 0.01 and abs(ddy) < 0.01 and abs(dth) < 1.7e-4:
break
return cur_cx, cur_cy, cur_ang
def _verify_ncc( def _verify_ncc(
self, scene_gray: np.ndarray, cx: float, cy: float, self, scene_gray: np.ndarray, cx: float, cy: float,
@@ -1370,15 +1364,21 @@ class LineShapeMatcher:
coarse_stride: int = 1, coarse_stride: int = 1,
scale_penalty: float = 0.0, scale_penalty: float = 0.0,
search_roi: tuple[int, int, int, int] | None = None, search_roi: tuple[int, int, int, int] | None = None,
pyramid_propagate: bool = False, # off di default: meno duplicati # ON di default: full-res valutato solo in finestre locali attorno
propagate_topk: int = 4, # ai picchi top-level (costo ∝ candidati, non varianti × W × H).
refine_pose_joint: bool = False, # I duplicati che avevano fatto disattivare questa modalita' sono
# gestiti dalla NMS IoU poligonale post-refine.
pyramid_propagate: bool = True,
propagate_topk: int = 8,
refine_pose_joint: bool = False, # deprecato: alias di refine_angle
greediness: float = 0.0, greediness: float = 0.0,
batch_top: bool = False, batch_top: bool = False,
nms_iou_threshold: float = 0.3, nms_iou_threshold: float = 0.3,
min_recall: float = 0.0, min_recall: float = 0.0,
use_soft_score: bool = False, use_soft_score: bool = False,
subpixel_lm: bool = False, # ON di default: least-squares finale (posizione + angolo) sui
# gradienti scena, precisione attesa <0.1 px / <0.1°.
subpixel_lm: bool = True,
debug: bool = False, debug: bool = False,
profile: bool = False, profile: bool = False,
) -> list[Match]: ) -> list[Match]:
@@ -1462,7 +1462,7 @@ class LineShapeMatcher:
cached = self._scene_cache_get(cache_key) if cache_key else None cached = self._scene_cache_get(cache_key) if cache_key else None
if cached is not None: if cached is not None:
grays, spread_top, bit_active_top, density_top, spread0, \ grays, spread_top, bit_active_top, density_top, spread0, \
bit_active_full, density_full, top = cached bit_active_full, density_full, top, spread_fine = cached
else: else:
grays = [gray0] grays = [gray0]
for _ in range(self.pyramid_levels - 1): for _ in range(self.pyramid_levels - 1):
@@ -1478,6 +1478,7 @@ class LineShapeMatcher:
spread0 = None spread0 = None
bit_active_full = None bit_active_full = None
density_full = None density_full = None
spread_fine = None
_checkpoint("spread_top") _checkpoint("spread_top")
if nms_radius is None: if nms_radius is None:
nms_radius = max(8, min(self.template_size) // 2) nms_radius = max(8, min(self.template_size) // 2)
@@ -1488,6 +1489,18 @@ class LineShapeMatcher:
# coarse_angle_factor (skip 1 ogni 2): con step fine non e' utile. # coarse_angle_factor (skip 1 ogni 2): con step fine non e' utile.
# Risultato osservato: precisione "veloce" 10° dava risultati # Risultato osservato: precisione "veloce" 10° dava risultati
# migliori di "preciso" 2° proprio perche evitava il pruning. # migliori di "preciso" 2° proprio perche evitava il pruning.
# Il path windowed (pyramid_propagate) assume che il picco
# top-level localizzi la posizione entro il margine finestra.
# Su template ALLUNGATI (es. lama 40x280, o ROI parziale lungo un
# asse) lo score top-level ha un plateau lungo l'asse e il picco
# puo' essere lontano decine di px dal centro vero → le finestre
# tagliano fuori la posa giusta e il match muore in verify NCC.
# In quel caso si usa il full-scan esatto (costo maggiore ma
# nessuna perdita di recall).
if pyramid_propagate and self.template_size != (0, 0):
tw_t, th_t = self.template_size
if max(tw_t, th_t) / max(1, min(tw_t, th_t)) > 2.0:
pyramid_propagate = False
eff_step = self._effective_angle_step() eff_step = self._effective_angle_step()
top_factor = self.top_score_factor top_factor = self.top_score_factor
cf_eff = max(1, coarse_angle_factor) cf_eff = max(1, coarse_angle_factor)
@@ -1598,16 +1611,25 @@ class LineShapeMatcher:
return vi, -1.0 return vi, -1.0
best = float(score.max()) best = float(score.max())
if pyramid_propagate and best > 0: if pyramid_propagate and best > 0:
flat = score.ravel() # Picchi = MASSIMI LOCALI sopra soglia, non top-K pixel:
k = min(propagate_topk, flat.size) # su template allungati lo score top-level ha plateau
idx = np.argpartition(-flat, k - 1)[:k] # estesi e i top-K pixel si concentrano tutti sulle 2-3
# istanze piu' forti, perdendo per sempre le altre.
# Soglia permissiva (0.5x): un picco scartato qui =
# istanza persa, un picco in piu' = solo una finestra
# extra di costo marginale (dedup via mark).
thr = top_thresh * 0.5
dil = cv2.dilate(score, np.ones((5, 5), np.uint8))
ys_l, xs_l = np.nonzero((score >= dil) & (score >= thr))
peaks: list[tuple[int, int, float]] = [] peaks: list[tuple[int, int, float]] = []
for i in idx: if len(ys_l):
s = float(flat[i]) vals = score[ys_l, xs_l]
if s < top_thresh * 0.7: k = min(max(propagate_topk, 2 * max_matches), len(vals))
continue sel = np.argpartition(-vals, k - 1)[:k]
yt, xt = int(i // score.shape[1]), int(i % score.shape[1]) peaks = [
peaks.append((xt, yt, s)) (int(xs_l[i]), int(ys_l[i]), float(vals[i]))
for i in sel
]
peaks_by_vi[vi] = peaks peaks_by_vi[vi] = peaks
return vi, best return vi, best
@@ -1664,6 +1686,13 @@ class LineShapeMatcher:
expanded.add(vi_n) expanded.add(vi_n)
# Usa lo score del coarse come stima per il sort successivo # Usa lo score del coarse come stima per il sort successivo
score_by_vi[vi_n] = max(score_by_vi.get(vi_n, 0.0), s_top) score_by_vi[vi_n] = max(score_by_vi.get(vi_n, 0.0), s_top)
# Propaga i picchi top-level del coarse anche ai vicini:
# l'oggetto e' nella stessa posizione (angolo ±step), quindi
# anche i vicini possono usare il path windowed invece del
# full-scan dell'intera scena (che dominava il costo full-res).
if (pyramid_propagate and vi_n != vi_c
and peaks_by_vi.get(vi_c)):
peaks_by_vi.setdefault(vi_n, []).extend(peaks_by_vi[vi_c])
kept_variants: list[tuple[int, float]] = [ kept_variants: list[tuple[int, float]] = [
(vi, score_by_vi[vi]) for vi in expanded (vi, score_by_vi[vi]) for vi in expanded
] ]
@@ -1690,54 +1719,63 @@ class LineShapeMatcher:
if (spread0 & (spread0.dtype.type(1) << b)).any()) if (spread0 & (spread0.dtype.type(1) << b)).any())
) )
density_full = _jit_popcount(spread0) density_full = _jit_popcount(spread0)
# Bitmap fine (raggio 1) per il refine: sulla bitmap dilatata
# lo score satura e il refine angolare/posizionale non vede
# alcun gradiente (vedi _refine_angle).
spread_fine = self._spread_bitmap(gray0, radius=1)
# Salva cache scena complete # Salva cache scena complete
if cache_key is not None: if cache_key is not None:
self._scene_cache_put(cache_key, ( self._scene_cache_put(cache_key, (
grays, spread_top, bit_active_top, density_top, grays, spread_top, bit_active_top, density_top,
spread0, bit_active_full, density_full, top, spread0, bit_active_full, density_full, top, spread_fine,
)) ))
for sc in unique_scales: for sc in unique_scales:
bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1) bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1)
# Margine in full-res attorno ad ogni peak top: copre incertezza # Margine in full-res attorno ad ogni peak top: copre incertezza
# downsampling (sf_top px) + spread_radius + slack per NMS. # downsampling (sf_top px) + plateau radius del subpixel (10) +
propagate_margin = sf_top + self.spread_radius + max(8, nms_radius // 2) # slack. NON serve includere nms_radius: la NMS lavora sui candidati
# estratti, non richiede score validi oltre il plateau del picco.
propagate_margin = 2 * sf_top + max(10, self.spread_radius) + 6
H_full, W_full = spread0.shape H_full, W_full = spread0.shape
def _full_score(vi: int) -> tuple[int, np.ndarray]: def _full_score(vi: int) -> tuple[int, np.ndarray]:
var = self.variants[vi] var = self.variants[vi]
lvl0 = var.levels[0] lvl0 = var.levels[0]
if not pyramid_propagate or vi not in peaks_by_vi or not peaks_by_vi[vi]: peaks = peaks_by_vi.get(vi) if pyramid_propagate else None
margin = propagate_margin
if not peaks:
# Path legacy: scansiona intera scena # Path legacy: scansiona intera scena
return vi, _jit_score_bitmap_rescored( return vi, _jit_score_bitmap_rescored(
spread0, lvl0.dx, lvl0.dy, lvl0.bin, bit_active_full, spread0, lvl0.dx, lvl0.dy, lvl0.bin, bit_active_full,
bg_cache_full[var.scale], bg_cache_full[var.scale],
) )
# Path piramide propagata: valuta solo crop locali attorno # Path piramide propagata: valuta solo finestre locali attorno
# alle posizioni dei picchi top-level (riproiettati a full-res). # ai picchi top-level (riproiettati a full-res). Il kernel
# windowed campiona lo spread dell'INTERA scena: chiamare il
# kernel su un crop trattava le feature fuori-crop come miss
# (template raggio > finestra → score ~0 ovunque, 0 match).
score_full = np.zeros((H_full, W_full), dtype=np.float32) score_full = np.zeros((H_full, W_full), dtype=np.float32)
mark = np.zeros((H_full, W_full), dtype=bool) mark = np.zeros((H_full, W_full), dtype=bool)
bg = bg_cache_full[var.scale] bg = bg_cache_full[var.scale]
for xt, yt, _s in peaks_by_vi[vi]: for xt, yt, _s in peaks:
cx0 = xt * sf_top cx0 = xt * sf_top
cy0 = yt * sf_top cy0 = yt * sf_top
x_lo = max(0, cx0 - propagate_margin) x_lo = max(0, cx0 - margin)
x_hi = min(W_full, cx0 + propagate_margin + 1) x_hi = min(W_full, cx0 + margin + 1)
y_lo = max(0, cy0 - propagate_margin) y_lo = max(0, cy0 - margin)
y_hi = min(H_full, cy0 + propagate_margin + 1) y_hi = min(H_full, cy0 + margin + 1)
if x_hi <= x_lo or y_hi <= y_lo: if x_hi <= x_lo or y_hi <= y_lo:
continue continue
if mark[y_lo:y_hi, x_lo:x_hi].all(): if mark[y_lo:y_hi, x_lo:x_hi].all():
continue continue
# Crop spread + bg, valuta kernel sul crop score_win = _jit_score_bitmap_rescored_window(
spread_crop = np.ascontiguousarray(spread0[y_lo:y_hi, x_lo:x_hi]) spread0, lvl0.dx, lvl0.dy, lvl0.bin,
bg_crop = np.ascontiguousarray(bg[y_lo:y_hi, x_lo:x_hi]) bit_active_full, bg,
score_crop = _jit_score_bitmap_rescored( y_lo, x_lo, y_hi - y_lo, x_hi - x_lo,
spread_crop, lvl0.dx, lvl0.dy, lvl0.bin,
bit_active_full, bg_crop,
) )
score_full[y_lo:y_hi, x_lo:x_hi] = np.maximum( score_full[y_lo:y_hi, x_lo:x_hi] = np.maximum(
score_full[y_lo:y_hi, x_lo:x_hi], score_crop, score_full[y_lo:y_hi, x_lo:x_hi], score_win,
) )
mark[y_lo:y_hi, x_lo:x_hi] = True mark[y_lo:y_hi, x_lo:x_hi] = True
return vi, score_full return vi, score_full
@@ -1811,6 +1849,14 @@ class LineShapeMatcher:
# Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap) # Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap)
kept: list[Match] = [] kept: list[Match] = []
tw, th = self.template_size tw, th = self.template_size
# Sobel scena precomputato una volta per il refine LM (prima era
# un Sobel full-frame per OGNI match).
scene_grad = None
if subpixel_lm and self.template_gray is not None and preliminary_int:
scene_grad = (
cv2.Sobel(gray0, cv2.CV_32F, 1, 0, ksize=3),
cv2.Sobel(gray0, cv2.CV_32F, 0, 1, ksize=3),
)
for score, xi, yi, vi in preliminary_int: for score, xi, yi, vi in preliminary_int:
if subpixel and vi in score_maps: if subpixel and vi in score_maps:
cx_f, cy_f = self._subpixel_peak( cx_f, cy_f = self._subpixel_peak(
@@ -1821,12 +1867,10 @@ class LineShapeMatcher:
var = self.variants[vi] var = self.variants[vi]
ang_f = var.angle_deg ang_f = var.angle_deg
score_f = score score_f = score
if refine_pose_joint and self.template_gray is not None: # refine_pose_joint (Nelder-Mead) rimosso: valutava lo score a
ang_f, score_f, cx_f, cy_f = self._refine_pose_joint( # posizioni intere su bitmap satura (funzione a gradini piatta,
spread0, self.template_gray, cx_f, cy_f, # il simplex terminava subito). Ora e' alias del refine standard.
var.angle_deg, var.scale, mask_full, if (refine_angle or refine_pose_joint) and self.template_gray is not None:
)
elif refine_angle and self.template_gray is not None:
ang_f, score_f, cx_f, cy_f = self._refine_angle( ang_f, score_f, cx_f, cy_f = self._refine_angle(
spread0, bit_active_full, self.template_gray, cx_f, cy_f, spread0, bit_active_full, self.template_gray, cx_f, cy_f,
var.angle_deg, var.scale, mask_full, var.angle_deg, var.scale, mask_full,
@@ -1835,14 +1879,15 @@ class LineShapeMatcher:
# del bin angolare della variante grezza. # del bin angolare della variante grezza.
search_radius=self._effective_angle_step(), search_radius=self._effective_angle_step(),
original_score=score, original_score=score,
spread_fine=spread_fine,
) )
# Halcon SubPixel='least_squares_high': refinement iterativo # Halcon SubPixel='least_squares_high': least-squares finale
# gradient-field per precisione 0.05 px (vs 0.5 quadratic 2D). # (posizione + angolo) sui gradienti scena, <0.1 px / <0.1°.
if subpixel_lm and self.template_gray is not None: if subpixel_lm and self.template_gray is not None:
cx_lm, cy_lm = self._subpixel_refine_lm( cx_lm, cy_lm, ang_lm = self._subpixel_refine_lm(
gray0, var, cx_f, cy_f, ang_f, gray0, var, cx_f, cy_f, ang_f, scene_grad=scene_grad,
) )
cx_f, cy_f = float(cx_lm), float(cy_lm) cx_f, cy_f, ang_f = float(cx_lm), float(cy_lm), float(ang_lm)
# NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta # NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta
# il calcolo per shape-score gia alti. Default 1.01 = NCC sempre, # il calcolo per shape-score gia alti. Default 1.01 = NCC sempre,
# piu sicuro contro falsi positivi (lo shape-score satura facile). # piu sicuro contro falsi positivi (lo shape-score satura facile).
+26 -2
View File
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
a0, a1 = self.angle_range_deg a0, a1 = self.angle_range_deg
if self.angle_step_deg <= 0 or a0 >= a1: if self.angle_step_deg <= 0 or a0 >= a1:
return [float(a0)] return [float(a0)]
n = int(np.floor((a1 - a0) / self.angle_step_deg)) # n+1 valori per includere l'estremo superiore del range: con il
return [float(a0 + i * self.angle_step_deg) for i in range(n)] # solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one).
n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1
angles = [float(a0 + i * self.angle_step_deg) for i in range(n)]
if a1 - a0 >= 360.0:
# Range che copre il giro completo: a0+360° è la stessa pose di
# a0, escludi il duplicato (variante inutile in train/find).
eps = 1e-6
angles = [a for a in angles if a < a0 + 360.0 - eps]
return angles
def train(self, template_bgr: np.ndarray) -> int: def train(self, template_bgr: np.ndarray) -> int:
"""Genera varianti per tutte le combinazioni (angolo, scala).""" """Genera varianti per tutte le combinazioni (angolo, scala)."""
@@ -222,6 +230,14 @@ class EdgeShapeMatcher:
for y, x in zip(ys, xs): for y, x in zip(ys, xs):
candidates.append((float(res[y, x]), int(x), int(y), ti)) candidates.append((float(res[y, x]), int(x), int(y), ti))
# Cap candidati top-level: senza limite np.where con soglia bassa
# può generare migliaia di candidati, ognuno con un matchTemplate
# full-res nel refinement. Tieni solo i migliori per score.
max_candidates = max(1, max_matches * 10)
if len(candidates) > max_candidates:
candidates.sort(key=lambda c: -c[0])
candidates = candidates[:max_candidates]
# Refinement a risoluzione piena: per ogni candidato top, finestra locale # Refinement a risoluzione piena: per ogni candidato top, finestra locale
refined: list[tuple[float, int, int, int]] = [] refined: list[tuple[float, int, int, int]] = []
margin = sf + 4 margin = sf + 4
@@ -294,6 +310,10 @@ class EdgeShapeMatcher:
) )
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)} arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)}
arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)}) arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)})
# Persisti anche il grayscale originale: senza, l'overlay edge
# spariva dopo load() (template_gray restava None).
if self.template_gray is not None:
arrays["template_gray"] = self.template_gray
np.savez_compressed(path, params=params, meta=meta, **arrays) np.savez_compressed(path, params=params, meta=meta, **arrays)
@classmethod @classmethod
@@ -312,6 +332,10 @@ class EdgeShapeMatcher:
top_score_factor=float(p[12]) if len(p) > 12 else 0.6, top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
) )
m.template_size = (int(p[8]), int(p[9])) m.template_size = (int(p[8]), int(p[9]))
# Retrocompatibilità: modelli salvati prima non hanno template_gray
# (resta None: overlay edge non disponibile ma find() funziona).
if "template_gray" in z.files:
m.template_gray = z["template_gray"]
meta = z["meta"] meta = z["meta"]
for i in range(len(meta)): for i in range(len(meta)):
m.templates.append( m.templates.append(
+135 -32
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
import tempfile import tempfile
import threading
import time import time
import uuid import uuid
from collections import OrderedDict from collections import OrderedDict
@@ -64,14 +65,23 @@ STATIC_DIR.mkdir(exist_ok=True)
CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache" CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache"
CACHE_DIR.mkdir(exist_ok=True) CACHE_DIR.mkdir(exist_ok=True)
# Cache in-memory (soft, ricaricata da disco se mancante) # Cache in-memory (soft, ricaricata da disco se mancante).
_IMG_CACHE: dict[str, np.ndarray] = {} # LRU con capacità limitata: senza eviction le immagini si accumulavano
# senza limite (leak di memoria su server long-running).
_IMG_CACHE: OrderedDict[str, np.ndarray] = OrderedDict()
_IMG_CACHE_SIZE = 64
# Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher # Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher
# LRU con capacità limitata # LRU con capacità limitata
_MATCHER_CACHE: OrderedDict = OrderedDict() _MATCHER_CACHE: OrderedDict = OrderedDict()
_MATCHER_CACHE_SIZE = 8 _MATCHER_CACHE_SIZE = 8
# Lock globale matcher: gli endpoint girano nel threadpool FastAPI ma i
# matcher condivisi (_MATCHER_CACHE, _RECIPE_MATCHERS) mutano stato interno
# durante train()/find(). Serializzare il matching è la soluzione semplice
# e corretta (un lock per-ricetta sarebbe over-engineering).
_MATCHER_LOCK = threading.Lock()
def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str: def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h = hashlib.md5() h = hashlib.md5()
@@ -102,23 +112,32 @@ def _cache_put_matcher(key: str, matcher) -> None:
_MATCHER_CACHE.popitem(last=False) _MATCHER_CACHE.popitem(last=False)
def _img_cache_put(key: str, value: np.ndarray) -> None:
"""Inserisce in _IMG_CACHE con eviction LRU (cap _IMG_CACHE_SIZE)."""
_IMG_CACHE[key] = value
_IMG_CACHE.move_to_end(key)
while len(_IMG_CACHE) > _IMG_CACHE_SIZE:
_IMG_CACHE.popitem(last=False)
def _store_image(img: np.ndarray) -> str: def _store_image(img: np.ndarray) -> str:
iid = uuid.uuid4().hex[:12] iid = uuid.uuid4().hex[:12]
cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img) cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img)
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return iid return iid
def _load_image(iid: str) -> np.ndarray | None: def _load_image(iid: str) -> np.ndarray | None:
cached = _IMG_CACHE.get(iid) cached = _IMG_CACHE.get(iid)
if cached is not None: if cached is not None:
_IMG_CACHE.move_to_end(iid) # LRU touch
return cached return cached
p = CACHE_DIR / f"{iid}.png" p = CACHE_DIR / f"{iid}.png"
if not p.exists(): if not p.exists():
return None return None
img = cv2.imread(str(p)) img = cv2.imread(str(p))
if img is not None: if img is not None:
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return img return img
app = FastAPI(title="PM2D Webapp", version="1.0.0") app = FastAPI(title="PM2D Webapp", version="1.0.0")
@@ -131,6 +150,39 @@ def _encode_png(img: np.ndarray) -> bytes:
return buf.tobytes() return buf.tobytes()
def _clamp_roi(x: int, y: int, w: int, h: int,
img_w: int, img_h: int) -> tuple[int, int, int, int]:
"""Clampa la ROI dentro i limiti immagine.
Una ROI fuori immagine causava slice vuote → crash 500 negli endpoint
che non clampavano. Solleva 400 se la ROI risultante è degenere
(lato < 16 px: sotto questa soglia il train non estrae abbastanza
edge feature e produce 0 varianti → find() esplode con 500).
"""
x = max(0, min(int(x), img_w - 1))
y = max(0, min(int(y), img_h - 1))
w = min(int(w), img_w - x)
h = min(int(h), img_h - y)
if w < 16 or h < 16:
raise HTTPException(
400, f"ROI fuori immagine o degenere: [{x}, {y}, {w}, {h}] "
f"su immagine {img_w}x{img_h} (lato minimo 16 px)")
return x, y, w, h
def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
"""Solleva 422 se il train non ha prodotto varianti.
Succede con ROI senza contrasto (sfondo uniforme) o troppo piccola:
senza questo check il find() successivo esplode con RuntimeError → 500.
"""
if n_variants <= 0 or not m.variants:
raise HTTPException(
422, "La ROI non contiene abbastanza edge feature per il "
"training (zona troppo uniforme o piccola): scegliere "
"una regione con contorni netti")
def _draw_matches(scene: np.ndarray, matches: list[Match], def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None, template_gray: np.ndarray | None,
matcher: "LineShapeMatcher | None" = None) -> np.ndarray: matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
@@ -172,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 # `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
# 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 = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
M[0, 2] += m.cx - cx_t # Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop
M[1, 2] += m.cy - cy_t M[0, 2] += (m.cx - gx0) - cx_t
M[1, 2] += (m.cy - gy0) - cy_t
warped_gray = cv2.warpAffine( warped_gray = cv2.warpAffine(
t, M, (W_scene, H_scene), t, M, (cw, ch_),
flags=cv2.INTER_LINEAR, borderValue=0) flags=cv2.INTER_LINEAR, borderValue=0)
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno # Maschera: train_mask se disponibile, altrimenti rettangolo pieno
mask_src = (matcher._train_mask if matcher._train_mask is not None mask_src = (matcher._train_mask if matcher._train_mask is not None
else np.full((th, tw), 255, dtype=np.uint8)) else np.full((th, tw), 255, dtype=np.uint8))
warped_mask = cv2.warpAffine( warped_mask = cv2.warpAffine(
mask_src, M, (W_scene, H_scene), mask_src, M, (cw, ch_),
flags=cv2.INTER_NEAREST, borderValue=0) flags=cv2.INTER_NEAREST, borderValue=0)
# Erode minimo (3x3) per togliere SOLO artefatti border-padding # Erode minimo (3x3) per togliere SOLO artefatti border-padding
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu' # (~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 = mag >= matcher.strong_grad
edge_mask = edge_mask & (warped_mask > 0) edge_mask = edge_mask & (warped_mask > 0)
if edge_mask.any(): 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 # Ciano (cambiato da verde): non collide col verde dell'asse
# Y dell'UCS che altrimenti scompariva nell'overlay edge. # Y dell'UCS che altrimenti scompariva nell'overlay edge.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR) 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)) 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,6 +632,8 @@ 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)
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key) m = _cache_get_matcher(key)
if m is None: if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
@@ -570,6 +647,7 @@ def match(p: MatchParams):
pyramid_levels=p.pyramid_levels, pyramid_levels=p.pyramid_levels,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
n = len(m.variants); t_train = 0.0 n = len(m.variants); t_train = 0.0
@@ -578,6 +656,8 @@ def match(p: MatchParams):
matches = m.find( matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches, scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold, 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 t_find = time.time() - t0
@@ -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,6 +699,8 @@ 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
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key) m = _cache_get_matcher(key)
if m is None: if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
@@ -637,6 +717,7 @@ def match_simple(p: SimpleMatchParams):
use_gpu=p.use_gpu, use_gpu=p.use_gpu,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
n = len(m.variants); t_train = 0.0 n = len(m.variants); t_train = 0.0
@@ -646,6 +727,8 @@ def match_simple(p: SimpleMatchParams):
matches = m.find( matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"], scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"], 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), scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags # Halcon-mode flags
min_recall=p.min_recall, min_recall=p.min_recall,
@@ -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,23 +1009,30 @@ 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
# 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() t0 = time.time()
matches = m.find( matches = m.find(
scene, scene,
min_score=p.min_score, max_matches=p.max_matches, min_score=p.min_score, max_matches=p.max_matches,
verify_threshold=p.verify_threshold, 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, scale_penalty=p.scale_penalty,
min_recall=p.min_recall, min_recall=p.min_recall,
use_soft_score=p.use_soft_score, use_soft_score=p.use_soft_score,