Compare commits

...

17 Commits

Author SHA1 Message Date
Adriano 2f15a37358 merge: precisione rotazione + perf propagate + robustezza server
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:48 +00:00
Adriano 4356a47d06 docs: roadmap Fase 2 (precisione misurata, valutazione C++ vs algoritmico)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:40 +00:00
Adriano 9458173ad0 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>
2026-06-12 11:54:40 +00:00
Adriano cc811fdc94 fix: precisione rotazione sub-0.1° + refine least-squares + propagate windowed
Root cause rotazione imprecisa: score saturo sulla spread bitmap dilatata
(raggio 4-5) -> refine senza gradiente (angolo restava quantizzato allo
step) e minMaxLoc sul plateau spostava il centro sull'angolo finestra
(errore sistematico 3*sqrt(2) px).

- _refine_angle: ottimizza su bitmap fine raggio 1 (spread_fine, in cache
  scena), picco sub-pixel con centroide plateau, score finale ricalcolato
  su spread coarse (semantica soglie invariata)
- _subpixel_refine_lm riscritto: snap edge sub-pixel lungo la normale +
  LSQ 3x3 (dx, dy, dtheta), ON di default, Sobel scena precomputato
- _prepare_padded_template: centro rotazione coerente col padding
- round invece di truncation sugli offset feature (bias 0.25px)
- _angle_list include estremo superiore del range
- _refine_pose_joint rimosso (NM su funzione a gradini, terminava subito)
- pyramid_propagate default ON con kernel windowed (le feature campionano
  l'intera scena: il crop precedente le troncava -> score 0), picchi =
  massimi locali, auto-off per template elongati >2:1
- piramide 3 livelli default con clamp su dimensione template
- cache scena: hash dell'intera immagine (64KB collidevano)

GT sintetica 7 pose: errore angolo 2.3->0.05 deg, posizione 4.2->0.04 px.
Suite 16 scenari: match >= baseline, totale find -13%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:24 +00:00
Adriano 452810b67a merge: fix overlay shift 2026-05-05 12:45:11 +02:00
Adriano 8c46a6ca9b fix: rimossa traslazione fissa edge overlay match
Causa principale: erode di (2*spread_radius+1) sulla maschera warpata
toglieva troppo bordo. Per spread_radius=8 → kernel 17x17 = -8px da
ogni lato. L'edge map applicata sopra mostrava i bordi spostati di ~8px
verso l'interno del pezzo, creando apparente "traslazione fissa".

Soluzione: erode 3x3 solo per rimuovere ~1px di bordo nero residuo
da warpAffine borderValue=0 (artefatto di padding). Bordi del pezzo
ora visualizzati nelle posizioni corrette.

Bonus fix: cx_t calcolato come w/2 invece di (w-1)/2, coerente con
center=diag/2.0 usato in training (era 0.5px di shift residuo per
template di lato pari).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:45:11 +02:00
Adriano d335f866a3 merge: refine veloce + UCS Y visibile 2026-05-05 12:38:47 +02:00
Adriano 88f80a2cad fix: refine angolo piu' veloce + edge overlay ciano (no clash con asse Y)
Bug visibili dallo screenshot:
1. Rallentamento sostanziale: il fix precedente aggiungeva 16 iter golden
   (era 8) + 3 chiamate parabolic fit = ~19 _score_at_angle vs 11 prima.
2. Asse Y dell'UCS invisibile sul match: edge overlay era verde brillante
   (0,220,0) e si sovrapponeva esattamente al verde dell'asse Y dell'UCS.
3. Angolo non corretto: il parabolic fit finale era instabile su template
   simmetrici (multiple local max ravvicinati lo facevano divergere fuori
   dal vero picco trovato dal golden).

Fix:
- _refine_angle: 10 iter golden con tol 0.05 (compromesso tra precisione
  e velocita'). Rimosso parabolic fit finale instabile. search_radius
  resta a step pieno (utile per recuperare estremi del bin).
- Edge overlay color: ciano (BGR 255,200,0) invece di verde brillante.
  L'asse Y verde dell'UCS ora ben visibile sopra l'overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:38:47 +02:00
Adriano d52d0d0489 merge: precisione rotazione + default Nessuna 2026-05-05 12:32:17 +02:00
Adriano 9451a418a6 fix: precisione rotazione +UI simmetria default Nessuna
Precisione rotazione:
- _refine_angle: tol 0.1 -> 0.02 deg, 8 -> 16 iter golden-section
- search_radius default = step pieno (era step/2): copre il caso peggiore
  in cui il picco vero e' all'estremo del bin angolare grezzo
- Aggiunto parabolic fit finale sui 3 punti vicini al best (precisione
  <0.01 deg quando lo score map e' smooth attorno al picco)

Default UI:
- Simmetria "Nessuna" come default (era "Invariante" che limitava
  matching a una singola pose - confondente per l'operatore tipico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:32:17 +02:00
Adriano 2c9160e4be merge: perf profile/bench/prune 2026-05-05 12:25:15 +02:00
Adriano 6d6dcc3b7a feat: profile mode + bench suite + skip-bin-vuoti + variant pruning histogram
4 ottimizzazioni performance + visibilita':

GGG. find(profile=True) → timing per fase
- _checkpoint() registra ms tra: to_gray, spread_top, top_pruning,
  full_kernel, refine_verify_nms
- get_last_profile() ritorna dict ms per identificare bottleneck
- Costo runtime trascurabile (~5 us per call)

HHH. pm2d.bench - benchmark suite eseguibile
- 3 scenarios (rect/L/circle x scene clean/cluttered)
- 5 configs (baseline, polarity, propagate, greedy, stride)
- Auto-aggiunge gpu_umat se opencl_available()
- Tabella ms/find + profile per ogni combo
- Entry-point pm2d-bench (--quick per smoke test 2 iter)

XX. Skip dilate per bin vuoti in _spread_bitmap
- Pre-calcolo bin presenti via np.unique sui pixel valid
- Su scene a bassa varianza orientation skip 50-70% delle dilate
- Misurato benchmark: spread_top da ~0.3ms a ~0.1ms in molti casi

VV. Variant pruning preliminare via histogramma orientation
- Per ogni variante calcolo overlap (feature bins ∩ scene bins) /
  total feature bins
- Se overlap < 0.5 * min_score → skip variante (no kernel call)
- Counter n_variants_pruned_histogram nel diag
- Vantaggio: scene focalizzate (poche direzioni dominanti) skippano
  varianti template con bin assenti dalla scena

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:25:15 +02:00
Adriano ee1c4a8f92 merge: fix edge bordi spuri overlay match 2026-05-05 12:13:07 +02:00
Adriano 5002515b41 fix: rimuove edge spuri sui bordi template warpato (apparivano come ROI)
Bug: per ogni match l'overlay edge del modello includeva anche il
PERIMETRO del template warpato (transizione bordo nero borderValue=0
→ scena = forte gradient artefatto). Con N match si vedevano N
rettangoli verdi attorno ai pezzi, simili a "ROI ripetute".

Fix:
- Warpa anche _train_mask alla pose
- Erode di (2*spread_radius+1) per scartare la fascia di transizione
  bordo che produce gradient spurio
- Maschera edge_mask con warped_mask: solo edge interni al pezzo
  vengono visualizzati

Risultato: overlay edge pulito che mostra solo i veri edge del
modello allineati al pezzo trovato, niente cornici fasulle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:13:07 +02:00
Adriano 8029a1e12b merge: UCS coerente centro pose 2026-05-05 12:04:24 +02:00
Adriano d37833076e fix: UCS coerente sul centro pose, no traslazione fissata sbagliata
L'UCS del match precedentemente proiettava il baricentro feature
template alla pose, ma:
- Il baricentro veniva calcolato da una variante a 0° (v0) i cui dx/dy
  sono offsets relativi al centro PADDED (non al centro template puro)
- _extract_features dipende dai parametri matcher che possono differire
  da quelli del preview se la ricetta e' caricata
- Risultato: UCS appariva con offset costante errato rispetto al centro
  visibile del pezzo

Fix: UCS sul centro POSE del match (m.cx, m.cy) = posizione del centro
template originale nella scena (questo e' esattamente cio' che
_subpixel_peak ritorna). Coerente, prevedibile, "fissato" sul centro
del pezzo.

Per coerenza visiva, anche preview_edges sposta UCS dal baricentro al
CENTRO ROI (rh/2, rw/2). Cosi' il modello mostra UCS nello stesso
identico punto relativo dove apparira' nel match dopo
traslazione+rotazione della pose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:04:24 +02:00
Adriano e1ed9206a3 merge: fix UCS match + edge modello overlay 2026-05-05 11:58:21 +02:00
11 changed files with 1136 additions and 501 deletions
+39 -4
View File
@@ -2,6 +2,36 @@
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`)
| ID | Voce | Status | Note |
@@ -84,9 +114,14 @@ Benchmark suite 16 scenari (4 immagini × full/part × fast/preciso):
## Target performance produzione
Obiettivi da documento tecnico Vision Suite (Fase Beta):
- [ ] **Precisione posizionale mediana**: <0.5 px → **raggiunto con subpixel (attualmente ~0.1-0.3 px atteso)**
- [ ] **Precisione angolare mediana**: <1.0° → **raggiunto con refinement (~0.5°)**
- [ ] **Latency mediana**: <50 ms su 1920×1080 → **attuale ~1.7s su 830×822 (serve GPU o ulteriore CPU)**
- [x] **Precisione posizionale mediana**: <0.5 px → **0.04 px misurato su GT sintetica (Fase 2)**
- [x] **Precisione angolare mediana**: <1.0° → **0.05° misurato su GT sintetica (Fase 2)**
- [ ] **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**
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,
cfg_name: str, cfg: dict) -> dict:
scene = cv2.imread(str(TEST_DIR / img_path))
if scene is None:
# cv2.imread ritorna None silenzioso: senza check il crash arriva
# dopo, sullo slice, con un errore criptico.
raise FileNotFoundError(
f"Immagine di test non trovata o non leggibile: {TEST_DIR / img_path}")
y0, y1, x0, x1 = roi_box
roi = scene[y0:y1, x0:x1].copy()
m = LineShapeMatcher(
+144
View File
@@ -271,6 +271,108 @@ if HAS_NUMBA:
acc[y, x] = 0.0
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)
def _jit_top_max_per_variant(
spread: np.ndarray, # uint8 (H, W)
@@ -426,6 +528,9 @@ if HAS_NUMBA:
_jit_top_max_per_variant(
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)
spread16 = np.zeros((32, 32), dtype=np.uint16)
_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):
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):
raise RuntimeError("numba non disponibile")
@@ -524,6 +635,39 @@ def score_bitmap_rescored(
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(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
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)
ref = mag
# ref è costante nel loop sugli angoli: centra una volta sola
rm = ref - ref.mean()
correlations: list[tuple[float, float]] = []
for ang in np.arange(step_deg, 360.0, step_deg):
@@ -68,7 +70,6 @@ def detect_rotational_symmetry(
rot = cv2.warpAffine(
mag, M, (w, h), borderValue=0.0,
)
rm = ref - ref.mean()
rs = rot - rot.mean()
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
c = float((rm * rs).sum() / denom)
+179
View File
@@ -0,0 +1,179 @@
"""Benchmark suite per LineShapeMatcher.
Usage:
python -m pm2d.bench [--quick]
Misura tempi find() su 3 template-tipo × 3 scene-tipo × N config:
- Template: rettangolo 80×80, L-shape 120×120, cerchio 150×150
- Scene: pulita 800×600, cluttered 1080×1920, multi-pezzo 1080×1920
- Config: baseline, polarity, gpu, pyramid_propagate, greediness=0.7
Per ogni config stampa: ms/find, ms per fase (profile), n. match.
Output tabellare per detectare regressioni in CI.
"""
from __future__ import annotations
import argparse
import time
import cv2
import numpy as np
from pm2d.line_matcher import LineShapeMatcher, opencl_available
# ---------- Sintetizzatori template/scena ----------
def _tpl_rect() -> np.ndarray:
t = np.zeros((80, 80, 3), np.uint8)
cv2.rectangle(t, (15, 15), (65, 65), (255, 255, 255), 3)
return t
def _tpl_lshape() -> np.ndarray:
t = np.zeros((120, 120, 3), np.uint8)
cv2.rectangle(t, (20, 20), (50, 100), (255, 255, 255), -1)
cv2.rectangle(t, (20, 70), (100, 100), (255, 255, 255), -1)
return t
def _tpl_circle() -> np.ndarray:
t = np.zeros((150, 150, 3), np.uint8)
cv2.circle(t, (75, 75), 60, (255, 255, 255), 4)
return t
def _scene_clean(W: int, H: int, n_pieces: int = 1) -> np.ndarray:
np.random.seed(0)
s = np.zeros((H, W, 3), np.uint8)
for _ in range(n_pieces):
cx = np.random.randint(80, W - 80)
cy = np.random.randint(80, H - 80)
cv2.rectangle(s, (cx - 25, cy - 25), (cx + 25, cy + 25), (255, 255, 255), 3)
return s
def _scene_cluttered(W: int, H: int) -> np.ndarray:
np.random.seed(0)
s = np.random.randint(50, 200, (H, W, 3), np.uint8)
cv2.rectangle(s, (300, 200), (350, 250), (255, 255, 255), 3)
cv2.rectangle(s, (1500, 800), (1550, 850), (255, 255, 255), 3)
return s
# ---------- Single benchmark ----------
def _bench_config(template, scene, config_name: str,
init_kw: dict, find_kw: dict,
n_iter: int = 5) -> dict:
m = LineShapeMatcher(**init_kw)
t0 = time.perf_counter()
n_var = m.train(template)
t_train = time.perf_counter() - t0
# Warmup (Numba JIT)
m.find(scene, **find_kw)
m.find(scene, **find_kw)
# Run
times_ms = []
for _ in range(n_iter):
t0 = time.perf_counter()
matches = m.find(scene, **find_kw)
times_ms.append((time.perf_counter() - t0) * 1000.0)
# Profile (1 iter)
m.find(scene, profile=True, **find_kw)
prof = m.get_last_profile() or {}
return {
"config": config_name,
"n_variants": n_var,
"t_train_s": round(t_train, 3),
"ms_avg": round(float(np.mean(times_ms)), 1),
"ms_min": round(float(np.min(times_ms)), 1),
"ms_max": round(float(np.max(times_ms)), 1),
"n_matches": len(matches),
"profile_ms": {k: round(v, 1) for k, v in prof.items()},
}
# ---------- Suite ----------
CONFIGS = [
("baseline",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2}),
("polarity",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_polarity": True},
{"min_score": 0.4, "verify_threshold": 0.2}),
("propagate",
{"angle_step_deg": 10, "pyramid_levels": 3},
{"min_score": 0.4, "verify_threshold": 0.2,
"pyramid_propagate": True, "propagate_topk": 4}),
("greedy_07",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "greediness": 0.7}),
("stride2",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "coarse_stride": 2}),
]
if opencl_available():
CONFIGS.append(
("gpu_umat",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_gpu": True},
{"min_score": 0.4, "verify_threshold": 0.2})
)
SCENARIOS = [
("rect_80 vs scene_800x600", _tpl_rect, lambda: _scene_clean(800, 600, 1)),
("lshape_120 vs scene_1080x1920_clutter",
_tpl_lshape, lambda: _scene_cluttered(1920, 1080)),
("circle_150 vs scene_clean_3pieces",
_tpl_circle, lambda: _scene_clean(1920, 1080, 3)),
]
def run(quick: bool = False) -> int:
n_iter = 2 if quick else 5
print(f"=== PM2D Benchmark Suite ({len(SCENARIOS)} scenarios x "
f"{len(CONFIGS)} configs, n_iter={n_iter}) ===\n")
rows = []
for sc_name, tpl_fn, scn_fn in SCENARIOS:
template = tpl_fn()
scene = scn_fn()
print(f"--- Scenario: {sc_name} (tpl={template.shape}, "
f"scn={scene.shape}) ---")
for cfg_name, init_kw, find_kw in CONFIGS:
r = _bench_config(template, scene, cfg_name, init_kw, find_kw,
n_iter=n_iter)
r["scenario"] = sc_name
rows.append(r)
prof_str = " ".join(
f"{k}={v:.1f}" for k, v in r["profile_ms"].items()
)
print(f" {cfg_name:14s} {r['ms_avg']:6.1f}ms "
f"(min {r['ms_min']:.1f} max {r['ms_max']:.1f}) "
f"vars={r['n_variants']:3d} "
f"matches={r['n_matches']:2d}")
if prof_str:
print(f" profile: {prof_str}")
print()
print("=== Done ===")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="PM2D benchmark suite")
p.add_argument("--quick", action="store_true",
help="2 iterazioni per config invece di 5 (smoke test)")
args = p.parse_args(argv)
return run(quick=args.quick)
if __name__ == "__main__":
import sys
sys.exit(main())
+7 -3
View File
@@ -196,8 +196,10 @@ def _warp_template_edges_to_scene(
edge = cv2.Canny(template_gray, canny_low, canny_high)
# Matrice affine: scala + rotazione attorno al centro template, poi traslazione
Ht, Wt = h, w
cx_t = (Wt - 1) / 2.0
cy_t = (Ht - 1) / 2.0
# Centro coerente con la convenzione train (center = w / 2.0, no -1):
# (Wt-1)/2 introduceva uno shift di 0.5px per template di lato pari.
cx_t = Wt / 2.0
cy_t = Ht / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
# Traslazione per portare centro template a (cx, cy) della scena
M[0, 2] += cx - cx_t
@@ -492,7 +494,9 @@ def run(
num_features: int = 96,
weak_grad: float = 30.0,
strong_grad: float = 60.0,
spread_radius: int = 5,
# 4 allineato col default del matcher: raggio 5 peggiora la precisione
# di rotazione (spread troppo largo appiattisce il picco angolare).
spread_radius: int = 4,
pyramid_levels: int = 3,
min_score: float = 0.55,
max_matches: int = 25,
+459 -326
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
a0, a1 = self.angle_range_deg
if self.angle_step_deg <= 0 or a0 >= a1:
return [float(a0)]
n = int(np.floor((a1 - a0) / self.angle_step_deg))
return [float(a0 + i * self.angle_step_deg) for i in range(n)]
# n+1 valori per includere l'estremo superiore del range: con il
# solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one).
n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1
angles = [float(a0 + i * self.angle_step_deg) for i in range(n)]
if a1 - a0 >= 360.0:
# Range che copre il giro completo: a0+360° è la stessa pose di
# a0, escludi il duplicato (variante inutile in train/find).
eps = 1e-6
angles = [a for a in angles if a < a0 + 360.0 - eps]
return angles
def train(self, template_bgr: np.ndarray) -> int:
"""Genera varianti per tutte le combinazioni (angolo, scala)."""
@@ -222,6 +230,14 @@ class EdgeShapeMatcher:
for y, x in zip(ys, xs):
candidates.append((float(res[y, x]), int(x), int(y), ti))
# Cap candidati top-level: senza limite np.where con soglia bassa
# può generare migliaia di candidati, ognuno con un matchTemplate
# full-res nel refinement. Tieni solo i migliori per score.
max_candidates = max(1, max_matches * 10)
if len(candidates) > max_candidates:
candidates.sort(key=lambda c: -c[0])
candidates = candidates[:max_candidates]
# Refinement a risoluzione piena: per ogni candidato top, finestra locale
refined: list[tuple[float, int, int, int]] = []
margin = sf + 4
@@ -294,6 +310,10 @@ class EdgeShapeMatcher:
)
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)}
arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)})
# Persisti anche il grayscale originale: senza, l'overlay edge
# spariva dopo load() (template_gray restava None).
if self.template_gray is not None:
arrays["template_gray"] = self.template_gray
np.savez_compressed(path, params=params, meta=meta, **arrays)
@classmethod
@@ -312,6 +332,10 @@ class EdgeShapeMatcher:
top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
)
m.template_size = (int(p[8]), int(p[9]))
# Retrocompatibilità: modelli salvati prima non hanno template_gray
# (resta None: overlay edge non disponibile ma find() funziona).
if "template_gray" in z.files:
m.template_gray = z["template_gray"]
meta = z["meta"]
for i in range(len(meta)):
m.templates.append(
+273 -164
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib
import os
import tempfile
import threading
import time
import uuid
from collections import OrderedDict
@@ -64,14 +65,23 @@ STATIC_DIR.mkdir(exist_ok=True)
CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache"
CACHE_DIR.mkdir(exist_ok=True)
# Cache in-memory (soft, ricaricata da disco se mancante)
_IMG_CACHE: dict[str, np.ndarray] = {}
# Cache in-memory (soft, ricaricata da disco se mancante).
# LRU con capacità limitata: senza eviction le immagini si accumulavano
# senza limite (leak di memoria su server long-running).
_IMG_CACHE: OrderedDict[str, np.ndarray] = OrderedDict()
_IMG_CACHE_SIZE = 64
# Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher
# LRU con capacità limitata
_MATCHER_CACHE: OrderedDict = OrderedDict()
_MATCHER_CACHE_SIZE = 8
# Lock globale matcher: gli endpoint girano nel threadpool FastAPI ma i
# matcher condivisi (_MATCHER_CACHE, _RECIPE_MATCHERS) mutano stato interno
# durante train()/find(). Serializzare il matching è la soluzione semplice
# e corretta (un lock per-ricetta sarebbe over-engineering).
_MATCHER_LOCK = threading.Lock()
def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h = hashlib.md5()
@@ -102,23 +112,32 @@ def _cache_put_matcher(key: str, matcher) -> None:
_MATCHER_CACHE.popitem(last=False)
def _img_cache_put(key: str, value: np.ndarray) -> None:
"""Inserisce in _IMG_CACHE con eviction LRU (cap _IMG_CACHE_SIZE)."""
_IMG_CACHE[key] = value
_IMG_CACHE.move_to_end(key)
while len(_IMG_CACHE) > _IMG_CACHE_SIZE:
_IMG_CACHE.popitem(last=False)
def _store_image(img: np.ndarray) -> str:
iid = uuid.uuid4().hex[:12]
cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img)
_IMG_CACHE[iid] = img
_img_cache_put(iid, img)
return iid
def _load_image(iid: str) -> np.ndarray | None:
cached = _IMG_CACHE.get(iid)
if cached is not None:
_IMG_CACHE.move_to_end(iid) # LRU touch
return cached
p = CACHE_DIR / f"{iid}.png"
if not p.exists():
return None
img = cv2.imread(str(p))
if img is not None:
_IMG_CACHE[iid] = img
_img_cache_put(iid, img)
return img
app = FastAPI(title="PM2D Webapp", version="1.0.0")
@@ -131,6 +150,39 @@ def _encode_png(img: np.ndarray) -> bytes:
return buf.tobytes()
def _clamp_roi(x: int, y: int, w: int, h: int,
img_w: int, img_h: int) -> tuple[int, int, int, int]:
"""Clampa la ROI dentro i limiti immagine.
Una ROI fuori immagine causava slice vuote → crash 500 negli endpoint
che non clampavano. Solleva 400 se la ROI risultante è degenere
(lato < 16 px: sotto questa soglia il train non estrae abbastanza
edge feature e produce 0 varianti → find() esplode con 500).
"""
x = max(0, min(int(x), img_w - 1))
y = max(0, min(int(y), img_h - 1))
w = min(int(w), img_w - x)
h = min(int(h), img_h - y)
if w < 16 or h < 16:
raise HTTPException(
400, f"ROI fuori immagine o degenere: [{x}, {y}, {w}, {h}] "
f"su immagine {img_w}x{img_h} (lato minimo 16 px)")
return x, y, w, h
def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
"""Solleva 422 se il train non ha prodotto varianti.
Succede con ROI senza contrasto (sfondo uniforme) o troppo piccola:
senza questo check il find() successivo esplode con RuntimeError → 500.
"""
if n_variants <= 0 or not m.variants:
raise HTTPException(
422, "La ROI non contiene abbastanza edge feature per il "
"training (zona troppo uniforme o piccola): scegliere "
"una regione con contorni netti")
def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None,
matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
@@ -143,17 +195,6 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
nell'anteprima modello.
"""
out = scene.copy()
# Baricentro UCS in coord template (calcolato una volta dal matcher
# se disponibile): mean delle feature di una variante a 0°. Questo e'
# lo stesso baricentro mostrato nell'anteprima modello.
bary_dx = bary_dy = 0.0
if matcher is not None and matcher.variants:
# Trova variante con angle_deg piu vicino a 0
v0 = min(matcher.variants, key=lambda v: abs(v.angle_deg))
if len(v0.levels[0].dx) > 0:
bary_dx = float(np.mean(v0.levels[0].dx))
bary_dy = float(np.mean(v0.levels[0].dy))
# Lunghezza assi UCS: stessa formula dell'anteprima modello
# (0.15 * max lato template) scalata per m.scale → coerenza dimensionale.
if matcher is not None and matcher.template_size != (0, 0):
@@ -161,38 +202,75 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
else:
L_base = 30
H_scene, W_scene = scene.shape[:2]
for i, m in enumerate(matches):
# Proietta baricentro template alla pose del match.
# cv2.getRotationMatrix2D con angle positivo applica:
# new_x = cos*x + sin*y new_y = -sin*x + cos*y
# Visivamente in image y-down e' rotazione anti-clockwise.
# UCS posizionato esattamente sul CENTRO POSE del match (m.cx, m.cy):
# equivale al centro template traslato alla scena, ruotato con
# m.angle_deg. Coerente con UCS dell'anteprima modello che ora
# e' anche sul centro ROI (vedi preview_edges).
ax = np.deg2rad(m.angle_deg)
ca, sa = np.cos(ax), np.sin(ax)
bx_scene = m.cx + (bary_dx * ca + bary_dy * sa) * m.scale
by_scene = m.cy + (-bary_dx * sa + bary_dy * ca) * m.scale
cx, cy = int(round(bx_scene)), int(round(by_scene))
cx, cy = int(round(m.cx)), int(round(m.cy))
# Overlay edge del modello orientato (richiesta utente):
# warpa template alla pose, applica hysteresis identica al matcher,
# disegna pixel edge come overlay verde tenue.
# disegna pixel edge come overlay verde tenue. Maschera col
# _train_mask warpato + erode per rimuovere edge sui BORDI del
# rettangolo template (transizione bordo nero → scena = falso edge
# che appariva come "ROI" attorno a ogni match).
if template_gray is not None and matcher is not None:
t = template_gray
th, tw = t.shape
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
M[0, 2] += m.cx - cx_t
M[1, 2] += m.cy - cy_t
warped_gray = cv2.warpAffine(
t, M, (W_scene, H_scene),
flags=cv2.INTER_LINEAR, borderValue=0)
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
if edge_mask.any():
edge_overlay = np.zeros_like(out)
edge_overlay[edge_mask] = (0, 220, 0) # verde brillante
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0)
# Centro template coerente col training: in train si usa
# `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
# introduceva uno shift di 0.5px per template di lato pari.
cx_t = tw / 2.0; cy_t = th / 2.0
# Lavora su un CROP locale della scena di lato = diagonale del
# template ruotato+scalato (+margine), come _verify_ncc: warp
# + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno
# (costosissimo su scene grandi con molti match).
diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8
x0 = int(round(m.cx)) - diag // 2
y0 = int(round(m.cy)) - diag // 2
gx0 = max(0, x0); gy0 = max(0, y0)
gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag)
cw, ch_ = gx1 - gx0, gy1 - gy0
if cw >= 3 and ch_ >= 3:
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
# Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop
M[0, 2] += (m.cx - gx0) - cx_t
M[1, 2] += (m.cy - gy0) - cy_t
warped_gray = cv2.warpAffine(
t, M, (cw, ch_),
flags=cv2.INTER_LINEAR, borderValue=0)
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno
mask_src = (matcher._train_mask if matcher._train_mask is not None
else np.full((th, tw), 255, dtype=np.uint8))
warped_mask = cv2.warpAffine(
mask_src, M, (cw, ch_),
flags=cv2.INTER_NEAREST, borderValue=0)
# Erode minimo (3x3) per togliere SOLO artefatti border-padding
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
# grande spostava visualmente l'edge verso l'interno e creava
# apparente "traslazione fissa" rispetto al bordo del pezzo.
kernel_er = np.ones((3, 3), np.uint8)
warped_mask = cv2.erode(warped_mask, kernel_er)
mag, _ = matcher._gradient(warped_gray)
if matcher.weak_grad < matcher.strong_grad:
edge_mask = matcher._hysteresis_mask(mag)
else:
edge_mask = mag >= matcher.strong_grad
edge_mask = edge_mask & (warped_mask > 0)
if edge_mask.any():
# Edge ritraslati nel sistema scena: blend solo sul crop
# (addWeighted lascia invariati i pixel con overlay nullo,
# quindi l'output visivo è identico al full-frame).
sub = out[gy0:gy1, gx0:gx1]
edge_overlay = np.zeros_like(sub)
# Ciano (cambiato da verde): non collide col verde dell'asse
# Y dell'UCS che altrimenti scompariva nell'overlay edge.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR)
out[gy0:gy1, gx0:gx1] = cv2.addWeighted(
sub, 1.0, edge_overlay, 0.6, 0)
L = max(20, int(L_base * m.scale))
# X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin)
x_end = (int(cx + L * ca), int(cy - L * sa))
@@ -237,7 +315,9 @@ class MatchParams(BaseModel):
num_features: int = 96
weak_grad: float = 30.0
strong_grad: float = 60.0
spread_radius: int = 5
# 4 allineato col default del matcher: raggio 5 peggiora la precisione
# di rotazione (spread troppo largo appiattisce il picco angolare).
spread_radius: int = 4
pyramid_levels: int = 3
verify_threshold: float = 0.4
@@ -398,7 +478,13 @@ def _simple_to_technical(
"min_score": p.min_score,
"max_matches": p.max_matches,
"nms_radius": 0,
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35),
# Fallback = livello "medio" della mappa (no valore hardcoded
# che divergerebbe se la mappa cambia).
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, FILTRO_FP_MAP["medio"]),
# "off" deve disabilitare DAVVERO il verify NCC: passare solo
# verify_threshold=0.0 lascerebbe attivo il calcolo NCC (che può
# comunque scartare match con score negativo / patch uniformi).
"verify_ncc": p.filtro_fp != "off",
"scale_penalty": p.penalita_scala,
}
@@ -532,9 +618,7 @@ def match(p: MatchParams):
if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi
x = max(0, x); y = max(0, y)
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w]
tech_for_cache = {
@@ -548,33 +632,38 @@ def match(p: MatchParams):
"pyramid_levels": p.pyramid_levels,
}
key = _matcher_cache_key(roi_img, tech_for_cache)
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher(
num_features=p.num_features,
weak_grad=p.weak_grad, strong_grad=p.strong_grad,
angle_range_deg=(p.angle_min, p.angle_max),
angle_step_deg=p.angle_step,
scale_range=(p.scale_min, p.scale_max),
scale_step=p.scale_step,
spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels,
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher(
num_features=p.num_features,
weak_grad=p.weak_grad, strong_grad=p.strong_grad,
angle_range_deg=(p.angle_min, p.angle_max),
angle_step_deg=p.angle_step,
scale_range=(p.scale_min, p.scale_max),
scale_step=p.scale_step,
spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = p.nms_radius if p.nms_radius > 0 else None
t0 = time.time()
matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold,
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = p.nms_radius if p.nms_radius > 0 else None
t0 = time.time()
matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold,
)
t_find = time.time() - t0
t_find = time.time() - t0
# Render annotated image
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m)
# Render annotated image
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated)
return MatchResp(
@@ -601,9 +690,7 @@ def match_simple(p: SimpleMatchParams):
if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi
x = max(0, x); y = max(0, y)
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w]
tech = _simple_to_technical(p, roi_img)
@@ -612,47 +699,52 @@ def match_simple(p: SimpleMatchParams):
# Halcon-mode init params: incidono sul training, includere in cache key
halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}"
key = key + halcon_init_key
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher(
num_features=tech["num_features"],
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
angle_range_deg=(tech["angle_min"], tech["angle_max"]),
angle_step_deg=tech["angle_step"],
scale_range=(tech["scale_min"], tech["scale_max"]),
scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"],
min_feature_spacing=tech.get("min_feature_spacing", 3),
pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher(
num_features=tech["num_features"],
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
angle_range_deg=(tech["angle_min"], tech["angle_max"]),
angle_step_deg=tech["angle_step"],
scale_range=(tech["scale_min"], tech["scale_max"]),
scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"],
min_feature_spacing=tech.get("min_feature_spacing", 3),
pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"],
# filtro_fp="off" → verify NCC davvero disabilitato
verify_ncc=tech.get("verify_ncc", True),
scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"],
scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
t_find = time.time() - t0
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m)
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated)
return MatchResp(
@@ -672,6 +764,7 @@ def tune(p: TuneParams):
if model is None:
raise HTTPException(404, "Immagine non trovata")
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img)
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
@@ -763,26 +856,23 @@ def preview_edges(p: EdgePreviewParams):
b = int(fb[i])
col = bin_colors[b % len(bin_colors)]
cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA)
# UCS sul baricentro feature (richiesta utente): assi X rosso, Y verde
bary_cx = bary_cy = None
if len(fx) > 0:
bary_cx = float(np.mean(fx))
bary_cy = float(np.mean(fy))
bx, by = int(round(bary_cx)), int(round(bary_cy))
axis_len = max(20, int(0.15 * max(out.shape[:2])))
# X axis (rosso, verso destra)
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
# Y axis (verde, verso il basso = convenzione image y-down)
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# Origine: cerchio bianco con bordo nero
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA)
# UCS sul CENTRO ROI (coerente con _draw_matches che usa centro pose).
# In questo modo l'UCS visualizzato nel modello = UCS del match (modulo
# rotazione/traslazione data dalla pose del pezzo trovato).
rh, rw = roi_img.shape[:2]
bx, by = (rw - 1) // 2, (rh - 1) // 2
axis_len = max(20, int(0.15 * max(rw, rh)))
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA)
bary_cx, bary_cy = float(bx), float(by)
img_id = _store_image(out)
n_edge_strong = int((mag >= m.strong_grad).sum())
n_edge_total = int(edge_mask.sum() / 255)
@@ -808,6 +898,7 @@ def save_recipe(p: SaveRecipeParams):
if model is None:
raise HTTPException(404, "Modello non trovato")
x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w]
sp = SimpleMatchParams(
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
@@ -832,7 +923,10 @@ def save_recipe(p: SaveRecipeParams):
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
)
m.train(roi_img)
# Lock globale: serializza il training pesante col matching in corso
with _MATCHER_LOCK:
n_var = m.train(roi_img)
_check_trained(m, n_var)
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
if not safe_name:
raise HTTPException(400, "Nome ricetta non valido")
@@ -858,6 +952,14 @@ _RECIPE_MATCHERS: OrderedDict = OrderedDict()
_RECIPE_MATCHERS_SIZE = 4
def _recipe_matchers_put(name: str, matcher: LineShapeMatcher) -> None:
"""Inserisce in _RECIPE_MATCHERS con eviction LRU (cap _RECIPE_MATCHERS_SIZE)."""
_RECIPE_MATCHERS[name] = matcher
_RECIPE_MATCHERS.move_to_end(name)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
@app.post("/recipes/{name}/load")
def load_recipe(name: str):
"""Carica ricetta .npz e popola cache matcher in memoria.
@@ -872,10 +974,8 @@ def load_recipe(name: str):
if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m
_RECIPE_MATCHERS.move_to_end(safe_name)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
with _MATCHER_LOCK:
_recipe_matchers_put(safe_name, m)
return {
"name": safe_name,
"n_variants": len(m.variants),
@@ -899,7 +999,9 @@ class RecipeMatchParams(BaseModel):
greediness: float = 0.0
refine_pose_joint: bool = False
search_roi: list[int] | None = None
verify_threshold: float = 0.5
# Allineato a MatchParams.verify_threshold (0.4): valori divergenti
# davano risultati diversi tra /match e /match_recipe a parità di scena.
verify_threshold: float = 0.4
scale_penalty: float = 0.0
@@ -907,37 +1009,44 @@ class RecipeMatchParams(BaseModel):
def match_recipe(p: RecipeMatchParams):
"""Match con ricetta pre-trained: zero training, solo find."""
safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz"
m = _RECIPE_MATCHERS.get(safe_name)
if m is None:
# Auto-load on demand
path = RECIPES_DIR / safe_name
if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m
scene = _load_image(p.scene_id)
if scene is None:
raise HTTPException(404, "Scena non trovata")
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene,
min_score=p.min_score, max_matches=p.max_matches,
verify_threshold=p.verify_threshold,
scale_penalty=p.scale_penalty,
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
annotated = _draw_matches(scene, matches, tg, matcher=m)
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _RECIPE_MATCHERS.get(safe_name)
if m is not None:
_RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch
else:
# Auto-load on demand: stessa eviction LRU di load_recipe
# (senza cap la cache cresceva senza limite)
path = RECIPES_DIR / safe_name
if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path))
_recipe_matchers_put(safe_name, m)
t0 = time.time()
matches = m.find(
scene,
min_score=p.min_score, max_matches=p.max_matches,
verify_threshold=p.verify_threshold,
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
scale_penalty=p.scale_penalty,
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated)
return MatchResp(
matches=[MatchResult(
+1 -1
View File
@@ -102,8 +102,8 @@
<div class="field">
<label>Simmetria</label>
<select id="p-simmetria">
<option value="nessuna" selected>Nessuna (0..360°)</option>
<option value="invariante">Invariante (cerchi — no rotazione)</option>
<option value="nessuna">Nessuna (0..360°)</option>
<option value="bilaterale">Bilaterale (speculare 180°)</option>
<option value="rot_3">Rotazionale 3× (120°)</option>
<option value="rot_4">Rotazionale 4× (90°)</option>
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
[project.scripts]
pm2d-eval = "pm2d.eval:main"
pm2d-bench = "pm2d.bench:main"
[dependency-groups]
dev = [