Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4356a47d06 | |||
| 9458173ad0 | |||
| cc811fdc94 | |||
| 452810b67a | |||
| 8c46a6ca9b | |||
| d335f866a3 | |||
| 88f80a2cad | |||
| d52d0d0489 | |||
| 9451a418a6 | |||
| 2c9160e4be | |||
| 6d6dcc3b7a | |||
| ee1c4a8f92 | |||
| 5002515b41 | |||
| 8029a1e12b | |||
| d37833076e | |||
| e1ed9206a3 | |||
| e84ae199ac | |||
| 5f0c4542d3 | |||
| 29c034fb05 | |||
| 6fb1efcab8 | |||
| 35df4c473c | |||
| 64f2c8b5dc | |||
| 7e076deb80 | |||
| 852597ed51 |
+39
-4
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+179
@@ -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
@@ -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,
|
||||||
|
|||||||
+446
-311
File diff suppressed because it is too large
Load Diff
+26
-2
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
|
|||||||
a0, a1 = self.angle_range_deg
|
a0, a1 = self.angle_range_deg
|
||||||
if self.angle_step_deg <= 0 or a0 >= a1:
|
if self.angle_step_deg <= 0 or a0 >= a1:
|
||||||
return [float(a0)]
|
return [float(a0)]
|
||||||
n = int(np.floor((a1 - a0) / self.angle_step_deg))
|
# n+1 valori per includere l'estremo superiore del range: con il
|
||||||
return [float(a0 + i * self.angle_step_deg) for i in range(n)]
|
# solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one).
|
||||||
|
n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1
|
||||||
|
angles = [float(a0 + i * self.angle_step_deg) for i in range(n)]
|
||||||
|
if a1 - a0 >= 360.0:
|
||||||
|
# Range che copre il giro completo: a0+360° è la stessa pose di
|
||||||
|
# a0, escludi il duplicato (variante inutile in train/find).
|
||||||
|
eps = 1e-6
|
||||||
|
angles = [a for a in angles if a < a0 + 360.0 - eps]
|
||||||
|
return angles
|
||||||
|
|
||||||
def train(self, template_bgr: np.ndarray) -> int:
|
def train(self, template_bgr: np.ndarray) -> int:
|
||||||
"""Genera varianti per tutte le combinazioni (angolo, scala)."""
|
"""Genera varianti per tutte le combinazioni (angolo, scala)."""
|
||||||
@@ -222,6 +230,14 @@ class EdgeShapeMatcher:
|
|||||||
for y, x in zip(ys, xs):
|
for y, x in zip(ys, xs):
|
||||||
candidates.append((float(res[y, x]), int(x), int(y), ti))
|
candidates.append((float(res[y, x]), int(x), int(y), ti))
|
||||||
|
|
||||||
|
# Cap candidati top-level: senza limite np.where con soglia bassa
|
||||||
|
# può generare migliaia di candidati, ognuno con un matchTemplate
|
||||||
|
# full-res nel refinement. Tieni solo i migliori per score.
|
||||||
|
max_candidates = max(1, max_matches * 10)
|
||||||
|
if len(candidates) > max_candidates:
|
||||||
|
candidates.sort(key=lambda c: -c[0])
|
||||||
|
candidates = candidates[:max_candidates]
|
||||||
|
|
||||||
# Refinement a risoluzione piena: per ogni candidato top, finestra locale
|
# Refinement a risoluzione piena: per ogni candidato top, finestra locale
|
||||||
refined: list[tuple[float, int, int, int]] = []
|
refined: list[tuple[float, int, int, int]] = []
|
||||||
margin = sf + 4
|
margin = sf + 4
|
||||||
@@ -294,6 +310,10 @@ class EdgeShapeMatcher:
|
|||||||
)
|
)
|
||||||
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)}
|
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)}
|
||||||
arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)})
|
arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)})
|
||||||
|
# Persisti anche il grayscale originale: senza, l'overlay edge
|
||||||
|
# spariva dopo load() (template_gray restava None).
|
||||||
|
if self.template_gray is not None:
|
||||||
|
arrays["template_gray"] = self.template_gray
|
||||||
np.savez_compressed(path, params=params, meta=meta, **arrays)
|
np.savez_compressed(path, params=params, meta=meta, **arrays)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -312,6 +332,10 @@ class EdgeShapeMatcher:
|
|||||||
top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
|
top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
|
||||||
)
|
)
|
||||||
m.template_size = (int(p[8]), int(p[9]))
|
m.template_size = (int(p[8]), int(p[9]))
|
||||||
|
# Retrocompatibilità: modelli salvati prima non hanno template_gray
|
||||||
|
# (resta None: overlay edge non disponibile ma find() funziona).
|
||||||
|
if "template_gray" in z.files:
|
||||||
|
m.template_gray = z["template_gray"]
|
||||||
meta = z["meta"]
|
meta = z["meta"]
|
||||||
for i in range(len(meta)):
|
for i in range(len(meta)):
|
||||||
m.templates.append(
|
m.templates.append(
|
||||||
|
|||||||
+257
-75
@@ -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,20 +65,30 @@ 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()
|
||||||
h.update(roi.tobytes())
|
h.update(roi.tobytes())
|
||||||
# Solo parametri che influenzano il training
|
# Solo parametri che influenzano il training
|
||||||
relevant = ("num_features", "weak_grad", "strong_grad",
|
relevant = ("num_features", "weak_grad", "strong_grad",
|
||||||
|
"min_feature_spacing",
|
||||||
"angle_min", "angle_max", "angle_step",
|
"angle_min", "angle_max", "angle_step",
|
||||||
"scale_min", "scale_max", "scale_step",
|
"scale_min", "scale_max", "scale_step",
|
||||||
"spread_radius", "pyramid_levels")
|
"spread_radius", "pyramid_levels")
|
||||||
@@ -101,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")
|
||||||
@@ -130,46 +150,144 @@ 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) -> np.ndarray:
|
template_gray: np.ndarray | None,
|
||||||
|
matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
|
||||||
|
"""Disegna SOLO UCS (richiesta utente) per ogni match trovato.
|
||||||
|
|
||||||
|
UCS = sistema di coordinate (X rosso, Y verde) posizionato sul
|
||||||
|
baricentro feature del modello, ruotato secondo l'angolo del match.
|
||||||
|
Niente edge, niente cerchietti feature, niente bbox: i match sulla
|
||||||
|
scena reale devono essere puliti, gli edge filtrati si vedono solo
|
||||||
|
nell'anteprima modello.
|
||||||
|
"""
|
||||||
out = scene.copy()
|
out = scene.copy()
|
||||||
H, W = scene.shape[:2]
|
# Lunghezza assi UCS: stessa formula dell'anteprima modello
|
||||||
palette = [
|
# (0.15 * max lato template) scalata per m.scale → coerenza dimensionale.
|
||||||
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0),
|
if matcher is not None and matcher.template_size != (0, 0):
|
||||||
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255),
|
L_base = int(0.15 * max(matcher.template_size))
|
||||||
]
|
else:
|
||||||
|
L_base = 30
|
||||||
|
H_scene, W_scene = scene.shape[:2]
|
||||||
|
|
||||||
for i, m in enumerate(matches):
|
for i, m in enumerate(matches):
|
||||||
color = palette[i % len(palette)]
|
# UCS posizionato esattamente sul CENTRO POSE del match (m.cx, m.cy):
|
||||||
if template_gray is not None:
|
# 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)
|
||||||
|
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. 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
|
t = template_gray
|
||||||
th, tw = t.shape
|
th, tw = t.shape
|
||||||
edge = cv2.Canny(t, 50, 150)
|
# Centro template coerente col training: in train si usa
|
||||||
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
|
# `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
|
||||||
|
# introduceva uno shift di 0.5px per template di lato pari.
|
||||||
|
cx_t = tw / 2.0; cy_t = th / 2.0
|
||||||
|
# Lavora su un CROP locale della scena di lato = diagonale del
|
||||||
|
# template ruotato+scalato (+margine), come _verify_ncc: warp
|
||||||
|
# + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno
|
||||||
|
# (costosissimo su scene grandi con molti match).
|
||||||
|
diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8
|
||||||
|
x0 = int(round(m.cx)) - diag // 2
|
||||||
|
y0 = int(round(m.cy)) - diag // 2
|
||||||
|
gx0 = max(0, x0); gy0 = max(0, y0)
|
||||||
|
gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag)
|
||||||
|
cw, ch_ = gx1 - gx0, gy1 - gy0
|
||||||
|
if cw >= 3 and ch_ >= 3:
|
||||||
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
|
M = 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
|
||||||
warped = cv2.warpAffine(edge, M, (W, H),
|
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)
|
flags=cv2.INTER_NEAREST, borderValue=0)
|
||||||
mask = warped > 0
|
# Erode minimo (3x3) per togliere SOLO artefatti border-padding
|
||||||
if mask.any():
|
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
|
||||||
overlay = np.zeros_like(out)
|
# grande spostava visualmente l'edge verso l'interno e creava
|
||||||
overlay[mask] = color
|
# apparente "traslazione fissa" rispetto al bordo del pezzo.
|
||||||
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8)
|
kernel_er = np.ones((3, 3), np.uint8)
|
||||||
poly = m.bbox_poly.astype(np.int32).reshape(-1, 1, 2)
|
warped_mask = cv2.erode(warped_mask, kernel_er)
|
||||||
cv2.polylines(out, [poly], True, color, 2, cv2.LINE_AA)
|
mag, _ = matcher._gradient(warped_gray)
|
||||||
p0 = tuple(m.bbox_poly[0].astype(int))
|
if matcher.weak_grad < matcher.strong_grad:
|
||||||
p1 = tuple(m.bbox_poly[1].astype(int))
|
edge_mask = matcher._hysteresis_mask(mag)
|
||||||
cv2.line(out, p0, p1, color, 4, cv2.LINE_AA)
|
else:
|
||||||
cx, cy = int(round(m.cx)), int(round(m.cy))
|
edge_mask = mag >= matcher.strong_grad
|
||||||
cv2.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA)
|
edge_mask = edge_mask & (warped_mask > 0)
|
||||||
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
|
if edge_mask.any():
|
||||||
a = np.deg2rad(m.angle_deg)
|
# Edge ritraslati nel sistema scena: blend solo sul crop
|
||||||
cv2.arrowedLine(out, (cx, cy),
|
# (addWeighted lascia invariati i pixel con overlay nullo,
|
||||||
(int(cx + L * np.cos(a)), int(cy - L * np.sin(a))),
|
# quindi l'output visivo è identico al full-frame).
|
||||||
color, 2, cv2.LINE_AA, tipLength=0.2)
|
sub = out[gy0:gy1, gx0:gx1]
|
||||||
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
|
edge_overlay = np.zeros_like(sub)
|
||||||
cv2.putText(out, label, (cx + 8, cy - 8),
|
# Ciano (cambiato da verde): non collide col verde dell'asse
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
|
# 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))
|
||||||
|
# Y axis = rotazione di (0, 1) con cv2 matrix → (sin, cos)
|
||||||
|
# A m.angle_deg=0 deve puntare GIU' (image y-down convenzione modello)
|
||||||
|
y_end = (int(cx + L * sa), int(cy + L * ca))
|
||||||
|
cv2.arrowedLine(out, (cx, cy), x_end,
|
||||||
|
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
|
cv2.putText(out, "X", (x_end[0] + 4, x_end[1] + 5),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
cv2.arrowedLine(out, (cx, cy), y_end,
|
||||||
|
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
|
cv2.putText(out, "Y", (y_end[0] + 4, y_end[1] + 12),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
# Origine UCS: cerchio bianco con bordo nero
|
||||||
|
cv2.circle(out, (cx, cy), 4, (0, 0, 0), -1, cv2.LINE_AA)
|
||||||
|
cv2.circle(out, (cx, cy), 3, (255, 255, 255), -1, cv2.LINE_AA)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -197,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
|
||||||
|
|
||||||
@@ -272,6 +392,15 @@ class SimpleMatchParams(BaseModel):
|
|||||||
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
|
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
|
||||||
min_score: float = 0.65
|
min_score: float = 0.65
|
||||||
max_matches: int = 25
|
max_matches: int = 25
|
||||||
|
# --- Override edge da pannello "Anteprima edge" (None = auto_tune) ---
|
||||||
|
# Quando settati, sovrascrivono i valori derivati da auto_tune e
|
||||||
|
# vengono usati identici sia nel training del matcher sia nel find.
|
||||||
|
# Salvati nella ricetta cosi' la stessa pulizia rumore e' replicata
|
||||||
|
# quando la ricetta viene caricata.
|
||||||
|
edge_weak_grad: float | None = None
|
||||||
|
edge_strong_grad: float | None = None
|
||||||
|
edge_num_features: int | None = None
|
||||||
|
edge_min_feature_spacing: int | None = None
|
||||||
# --- Halcon-mode flags (default off = backward compat) ---
|
# --- Halcon-mode flags (default off = backward compat) ---
|
||||||
# Init-time (richiede ri-train se cambiato)
|
# Init-time (richiede ri-train se cambiato)
|
||||||
use_polarity: bool = False # F: 16 bin orientation mod 2pi
|
use_polarity: bool = False # F: 16 bin orientation mod 2pi
|
||||||
@@ -320,10 +449,24 @@ def _simple_to_technical(
|
|||||||
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
|
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
|
||||||
ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0)
|
ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0)
|
||||||
|
|
||||||
|
# Override edge dal pannello "Anteprima edge" se utente li ha settati.
|
||||||
|
# Questi sostituiscono i valori auto_tune nel training del matcher,
|
||||||
|
# garantendo che la selezione edge identica a quella del preview
|
||||||
|
# venga usata sia in training sia in find.
|
||||||
|
weak_g = (p.edge_weak_grad if p.edge_weak_grad is not None
|
||||||
|
else tune["weak_grad"])
|
||||||
|
strong_g = (p.edge_strong_grad if p.edge_strong_grad is not None
|
||||||
|
else tune["strong_grad"])
|
||||||
|
n_feat = (p.edge_num_features if p.edge_num_features is not None
|
||||||
|
else nf)
|
||||||
|
min_sp = (p.edge_min_feature_spacing if p.edge_min_feature_spacing is not None
|
||||||
|
else 3)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"num_features": nf,
|
"num_features": n_feat,
|
||||||
"weak_grad": tune["weak_grad"],
|
"weak_grad": weak_g,
|
||||||
"strong_grad": tune["strong_grad"],
|
"strong_grad": strong_g,
|
||||||
|
"min_feature_spacing": min_sp,
|
||||||
"spread_radius": spread,
|
"spread_radius": spread,
|
||||||
"pyramid_levels": pyr,
|
"pyramid_levels": pyr,
|
||||||
"angle_min": 0.0,
|
"angle_min": 0.0,
|
||||||
@@ -335,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,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 = {
|
||||||
@@ -485,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(
|
||||||
@@ -498,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
|
||||||
@@ -506,12 +656,14 @@ 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
|
||||||
|
|
||||||
# Render annotated image
|
# Render annotated image
|
||||||
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
||||||
annotated = _draw_matches(scene, matches, tg)
|
annotated = _draw_matches(scene, matches, tg, matcher=m)
|
||||||
ann_id = _store_image(annotated)
|
ann_id = _store_image(annotated)
|
||||||
|
|
||||||
return MatchResp(
|
return MatchResp(
|
||||||
@@ -538,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)
|
||||||
@@ -549,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(
|
||||||
@@ -559,11 +711,13 @@ def match_simple(p: SimpleMatchParams):
|
|||||||
scale_range=(tech["scale_min"], tech["scale_max"]),
|
scale_range=(tech["scale_min"], tech["scale_max"]),
|
||||||
scale_step=tech["scale_step"],
|
scale_step=tech["scale_step"],
|
||||||
spread_radius=tech["spread_radius"],
|
spread_radius=tech["spread_radius"],
|
||||||
|
min_feature_spacing=tech.get("min_feature_spacing", 3),
|
||||||
pyramid_levels=tech["pyramid_levels"],
|
pyramid_levels=tech["pyramid_levels"],
|
||||||
use_polarity=p.use_polarity,
|
use_polarity=p.use_polarity,
|
||||||
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
|
||||||
@@ -573,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,
|
||||||
@@ -588,7 +744,7 @@ def match_simple(p: SimpleMatchParams):
|
|||||||
t_find = time.time() - t0
|
t_find = time.time() - t0
|
||||||
|
|
||||||
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
||||||
annotated = _draw_matches(scene, matches, tg)
|
annotated = _draw_matches(scene, matches, tg, matcher=m)
|
||||||
ann_id = _store_image(annotated)
|
ann_id = _store_image(annotated)
|
||||||
|
|
||||||
return MatchResp(
|
return MatchResp(
|
||||||
@@ -608,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,
|
||||||
@@ -628,6 +785,11 @@ class SaveRecipeParams(BaseModel):
|
|||||||
precisione: str = "normale"
|
precisione: str = "normale"
|
||||||
use_polarity: bool = False
|
use_polarity: bool = False
|
||||||
use_gpu: bool = False
|
use_gpu: bool = False
|
||||||
|
# Override edge dal pannello "Anteprima edge" (None = auto_tune)
|
||||||
|
edge_weak_grad: float | None = None
|
||||||
|
edge_strong_grad: float | None = None
|
||||||
|
edge_num_features: int | None = None
|
||||||
|
edge_min_feature_spacing: int | None = None
|
||||||
name: str # nome file ricetta (no path)
|
name: str # nome file ricetta (no path)
|
||||||
|
|
||||||
|
|
||||||
@@ -694,26 +856,23 @@ def preview_edges(p: EdgePreviewParams):
|
|||||||
b = int(fb[i])
|
b = int(fb[i])
|
||||||
col = bin_colors[b % len(bin_colors)]
|
col = bin_colors[b % len(bin_colors)]
|
||||||
cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA)
|
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
|
# UCS sul CENTRO ROI (coerente con _draw_matches che usa centro pose).
|
||||||
bary_cx = bary_cy = None
|
# In questo modo l'UCS visualizzato nel modello = UCS del match (modulo
|
||||||
if len(fx) > 0:
|
# rotazione/traslazione data dalla pose del pezzo trovato).
|
||||||
bary_cx = float(np.mean(fx))
|
rh, rw = roi_img.shape[:2]
|
||||||
bary_cy = float(np.mean(fy))
|
bx, by = (rw - 1) // 2, (rh - 1) // 2
|
||||||
bx, by = int(round(bary_cx)), int(round(bary_cy))
|
axis_len = max(20, int(0.15 * max(rw, rh)))
|
||||||
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),
|
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
|
||||||
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
|
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
|
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
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),
|
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
|
||||||
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
|
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
|
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
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), 4, (0, 0, 0), -1, cv2.LINE_AA)
|
||||||
cv2.circle(out, (bx, by), 3, (255, 255, 255), -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)
|
img_id = _store_image(out)
|
||||||
n_edge_strong = int((mag >= m.strong_grad).sum())
|
n_edge_strong = int((mag >= m.strong_grad).sum())
|
||||||
n_edge_total = int(edge_mask.sum() / 255)
|
n_edge_total = int(edge_mask.sum() / 255)
|
||||||
@@ -739,12 +898,17 @@ 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,
|
||||||
tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
|
tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
|
||||||
precisione=p.precisione,
|
precisione=p.precisione,
|
||||||
use_polarity=p.use_polarity, use_gpu=p.use_gpu,
|
use_polarity=p.use_polarity, use_gpu=p.use_gpu,
|
||||||
|
edge_weak_grad=p.edge_weak_grad,
|
||||||
|
edge_strong_grad=p.edge_strong_grad,
|
||||||
|
edge_num_features=p.edge_num_features,
|
||||||
|
edge_min_feature_spacing=p.edge_min_feature_spacing,
|
||||||
)
|
)
|
||||||
tech = _simple_to_technical(sp, roi_img)
|
tech = _simple_to_technical(sp, roi_img)
|
||||||
m = LineShapeMatcher(
|
m = LineShapeMatcher(
|
||||||
@@ -759,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")
|
||||||
@@ -785,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.
|
||||||
@@ -799,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),
|
||||||
@@ -826,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
|
||||||
|
|
||||||
|
|
||||||
@@ -834,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,
|
||||||
@@ -864,7 +1046,7 @@ def match_recipe(p: RecipeMatchParams):
|
|||||||
)
|
)
|
||||||
t_find = time.time() - t0
|
t_find = time.time() - t0
|
||||||
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
|
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
|
||||||
annotated = _draw_matches(scene, matches, tg)
|
annotated = _draw_matches(scene, matches, tg, matcher=m)
|
||||||
ann_id = _store_image(annotated)
|
ann_id = _store_image(annotated)
|
||||||
return MatchResp(
|
return MatchResp(
|
||||||
matches=[MatchResult(
|
matches=[MatchResult(
|
||||||
|
|||||||
@@ -53,10 +53,34 @@ function readUserParams() {
|
|||||||
document.getElementById("p-penalita-scala").value),
|
document.getElementById("p-penalita-scala").value),
|
||||||
min_score: parseFloat(document.getElementById("p-min-score").value),
|
min_score: parseFloat(document.getElementById("p-min-score").value),
|
||||||
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
|
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
|
||||||
|
...readEdgeOverrides(),
|
||||||
...readHalconFlags(),
|
...readHalconFlags(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readEdgeOverrides() {
|
||||||
|
// Override edge dal pannello "Anteprima edge". Settati = utente li ha
|
||||||
|
// toccati (anche se uguali al default attuale). Vengono propagati a
|
||||||
|
// _simple_to_technical e usati identici sia in training sia in find.
|
||||||
|
// Inoltre salvati nella ricetta cosi' si replicano al load.
|
||||||
|
const _v = (id, parser) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return null;
|
||||||
|
const v = parser(el.value);
|
||||||
|
return Number.isFinite(v) ? v : null;
|
||||||
|
};
|
||||||
|
// Sempre passa i valori correnti degli slider: e' la richiesta utente
|
||||||
|
// che i param di pulizia rumore vengano usati anche nel find/ricetta.
|
||||||
|
const polCb = document.getElementById("hc-use-polarity");
|
||||||
|
return {
|
||||||
|
edge_weak_grad: _v("ep-weak", parseFloat),
|
||||||
|
edge_strong_grad: _v("ep-strong", parseFloat),
|
||||||
|
edge_num_features: _v("ep-nf", parseInt),
|
||||||
|
edge_min_feature_spacing: _v("ep-sp", parseInt),
|
||||||
|
use_polarity: polCb?.checked || document.getElementById("ep-pol")?.checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readHalconFlags() {
|
function readHalconFlags() {
|
||||||
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
|
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
|
||||||
const $cb = (id) => document.getElementById(id)?.checked ?? false;
|
const $cb = (id) => document.getElementById(id)?.checked ?? false;
|
||||||
@@ -716,6 +740,10 @@ async function saveRecipe() {
|
|||||||
precisione: user.precisione,
|
precisione: user.precisione,
|
||||||
use_polarity: user.use_polarity,
|
use_polarity: user.use_polarity,
|
||||||
use_gpu: user.use_gpu,
|
use_gpu: user.use_gpu,
|
||||||
|
edge_weak_grad: user.edge_weak_grad,
|
||||||
|
edge_strong_grad: user.edge_strong_grad,
|
||||||
|
edge_num_features: user.edge_num_features,
|
||||||
|
edge_min_feature_spacing: user.edge_min_feature_spacing,
|
||||||
name: name,
|
name: name,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -102,8 +102,8 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Simmetria</label>
|
<label>Simmetria</label>
|
||||||
<select id="p-simmetria">
|
<select id="p-simmetria">
|
||||||
|
<option value="nessuna" selected>Nessuna (0..360°)</option>
|
||||||
<option value="invariante">Invariante (cerchi — no rotazione)</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="bilaterale">Bilaterale (speculare 180°)</option>
|
||||||
<option value="rot_3">Rotazionale 3× (120°)</option>
|
<option value="rot_3">Rotazionale 3× (120°)</option>
|
||||||
<option value="rot_4">Rotazionale 4× (90°)</option>
|
<option value="rot_4">Rotazionale 4× (90°)</option>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
pm2d-eval = "pm2d.eval:main"
|
pm2d-eval = "pm2d.eval:main"
|
||||||
|
pm2d-bench = "pm2d.bench:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
Reference in New Issue
Block a user