Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f15a37358 | |||
| 4356a47d06 | |||
| 9458173ad0 | |||
| cc811fdc94 | |||
| 452810b67a | |||
| 8c46a6ca9b | |||
| d335f866a3 | |||
| 88f80a2cad | |||
| d52d0d0489 | |||
| 9451a418a6 | |||
| 2c9160e4be | |||
| 6d6dcc3b7a | |||
| ee1c4a8f92 | |||
| 5002515b41 | |||
| 8029a1e12b | |||
| d37833076e | |||
| e1ed9206a3 | |||
| e84ae199ac | |||
| 5f0c4542d3 | |||
| 29c034fb05 | |||
| 6fb1efcab8 | |||
| 35df4c473c | |||
| 64f2c8b5dc | |||
| 7e076deb80 | |||
| 852597ed51 | |||
| a78884f950 | |||
| 543ae0f643 | |||
| a12574f3c5 | |||
| 110dc87b08 | |||
| 2bb2cf63cc | |||
| ea6a9163ad | |||
| 1cc7881a51 | |||
| 74a332a2dd | |||
| dae49eb4a3 | |||
| 9218cb2741 | |||
| 159f9089a5 | |||
| b718e81ccf | |||
| d46197a81a | |||
| 37c645984f | |||
| 0e148667ec | |||
| b5bbca0e85 | |||
| ca3882c59c | |||
| 7f6571bdd1 | |||
| 7cb1ae2df7 | |||
| 6ebb08e7a2 | |||
| eba9d478a7 | |||
| 0df0d98aa5 | |||
| b2b959e801 | |||
| b05246b492 | |||
| aeaa7fb5f7 | |||
| f347a10fad | |||
| 0b24be4d94 | |||
| 0296083e3c | |||
| 39208aadab | |||
| 2b7ee6799c | |||
| 5059ce1d89 | |||
| f05dec5183 | |||
| f8f6a15166 | |||
| 5bd8fca248 | |||
| 796ccb8052 | |||
| 0a8a9365bb | |||
| 9ed779637e | |||
| 077d44c3c8 | |||
| e038ee3a1d | |||
| 041b26e791 | |||
| 84b73dc651 | |||
| 8d8a89ac35 | |||
| 41976f574d | |||
| 4ef7a4a85f | |||
| 7de7f35b7c | |||
| 7b014b7f69 | |||
| 367ee9aaac | |||
| 74e5a45a39 | |||
| 11c5160385 | |||
| 07bab87cb9 | |||
| a247484f36 | |||
| e188df0adb | |||
| b35d47669c | |||
| fc3b0dbc3a | |||
| 6da4dd5329 | |||
| b143c6607a | |||
| 6704d66cd5 | |||
| f00cf9b621 | |||
| 4b7271094b | |||
| 746d1668c6 | |||
| d9a40952c4 | |||
| 6db2086ead | |||
| 27a0ef1a45 | |||
| ba4024d252 |
@@ -8,3 +8,5 @@ __pycache__/
|
||||
.DS_Store
|
||||
*.log
|
||||
models/
|
||||
# Ricette pre-trained (generate da utente, non versionare)
|
||||
recipes/*.npz
|
||||
|
||||
+39
-4
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
+439
-12
@@ -110,6 +110,63 @@ if HAS_NUMBA:
|
||||
acc[y, x] *= inv
|
||||
return acc
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_score_bitmap_rescored_strided(
|
||||
spread: np.ndarray,
|
||||
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: np.uint8,
|
||||
bg: np.ndarray,
|
||||
stride: nb.int32,
|
||||
) -> np.ndarray:
|
||||
"""Variante con sub-sampling: valuta solo pixel su griglia stride×stride.
|
||||
Score restituito ha stessa shape (H, W); celle non valutate = 0.
|
||||
|
||||
4× speed-up con stride=2 (NMS recupera precisione in full-res).
|
||||
Numba prange richiede step costante: itero su indici griglia e
|
||||
moltiplico per stride dentro il body.
|
||||
"""
|
||||
H, W = spread.shape
|
||||
N = dx.shape[0]
|
||||
acc = np.zeros((H, W), dtype=np.float32)
|
||||
ny = (H + stride - 1) // stride
|
||||
nx = (W + stride - 1) // stride
|
||||
for yi in nb.prange(ny):
|
||||
y = yi * stride
|
||||
for i in range(N):
|
||||
b = bins[i]
|
||||
mask = np.uint8(1) << b
|
||||
if (bit_active & mask) == 0:
|
||||
continue
|
||||
ddy = dy[i]
|
||||
yy = y + ddy
|
||||
if yy < 0 or yy >= H:
|
||||
continue
|
||||
ddx = dx[i]
|
||||
x_lo = 0 if ddx >= 0 else -ddx
|
||||
x_hi = W if ddx <= 0 else W - ddx
|
||||
rem = x_lo % stride
|
||||
if rem != 0:
|
||||
x_lo += stride - rem
|
||||
x = x_lo
|
||||
while x < x_hi:
|
||||
if spread[yy, x + ddx] & mask:
|
||||
acc[y, x] += 1.0
|
||||
x += stride
|
||||
if N > 0:
|
||||
inv = 1.0 / N
|
||||
for yi in nb.prange(ny):
|
||||
y = yi * stride
|
||||
for xi in range(nx):
|
||||
x = xi * stride
|
||||
v = acc[y, x] * inv
|
||||
bgv = bg[y, x]
|
||||
if bgv < 1.0:
|
||||
r = (v - bgv) / (1.0 - bgv + 1e-6)
|
||||
acc[y, x] = r if r > 0.0 else 0.0
|
||||
else:
|
||||
acc[y, x] = 0.0
|
||||
return acc
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_score_bitmap_greedy(
|
||||
spread: np.ndarray,
|
||||
@@ -142,7 +199,6 @@ if HAS_NUMBA:
|
||||
b = bins[i]
|
||||
mask = np.uint8(1) << b
|
||||
if (bit_active & mask) == 0:
|
||||
# Nessun chance per questa feature
|
||||
if hits + (N - i - 1) < min_req:
|
||||
break
|
||||
continue
|
||||
@@ -215,6 +271,224 @@ 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)
|
||||
dx_flat: np.ndarray, # int32 (sum_N,)
|
||||
dy_flat: np.ndarray, # int32 (sum_N,)
|
||||
bins_flat: np.ndarray, # int8 (sum_N,)
|
||||
offsets: np.ndarray, # int32 (n_vars+1,) prefix sum
|
||||
bit_active: np.uint8,
|
||||
bg_per_variant: np.ndarray, # float32 (n_vars, H, W) - 1 per scala
|
||||
scale_idx: np.ndarray, # int32 (n_vars,) idx in bg_per_variant
|
||||
) -> np.ndarray:
|
||||
"""Batch: per ogni variante calcola max score (rescored bg), ritorna
|
||||
array float32 (n_vars,). Parallelismo prange ESTERNO sulle varianti
|
||||
elimina overhead di n_vars chiamate JIT separate (avg ~20us per
|
||||
chiamata su template piccoli) + pool thread Python.
|
||||
|
||||
Pensato per fase TOP del pruning quando n_vars >> n_threads.
|
||||
"""
|
||||
n_vars = offsets.shape[0] - 1
|
||||
H, W = spread.shape
|
||||
out = np.zeros(n_vars, dtype=np.float32)
|
||||
for vi in nb.prange(n_vars):
|
||||
i0 = offsets[vi]; i1 = offsets[vi + 1]
|
||||
N = i1 - i0
|
||||
if N == 0:
|
||||
out[vi] = -1.0
|
||||
continue
|
||||
si = scale_idx[vi]
|
||||
inv = nb.float32(1.0 / N)
|
||||
best = nb.float32(-1.0)
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
s = nb.float32(0.0)
|
||||
for k in range(N):
|
||||
b = bins_flat[i0 + k]
|
||||
mask = np.uint8(1) << b
|
||||
if (bit_active & mask) == 0:
|
||||
continue
|
||||
ddy = dy_flat[i0 + k]
|
||||
yy = y + ddy
|
||||
if yy < 0 or yy >= H:
|
||||
continue
|
||||
ddx = dx_flat[i0 + k]
|
||||
xx = x + ddx
|
||||
if xx < 0 or xx >= W:
|
||||
continue
|
||||
if spread[yy, xx] & mask:
|
||||
s += nb.float32(1.0)
|
||||
s *= inv
|
||||
bgv = bg_per_variant[si, y, x]
|
||||
if bgv < 1.0:
|
||||
r = (s - bgv) / (1.0 - bgv + 1e-6)
|
||||
if r > best:
|
||||
best = r
|
||||
out[vi] = best if best > 0.0 else 0.0
|
||||
return out
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_score_bitmap_rescored_u16(
|
||||
spread: np.ndarray, # uint16 (H, W) - 16 bit di polarity-aware
|
||||
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: np.uint16,
|
||||
bg: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Versione uint16 di _jit_score_bitmap_rescored per polarity 16-bin.
|
||||
|
||||
Identica logica ma mask = uint16(1) << b dove b in [0..15]
|
||||
(orientamento mod 2π invece di mod π).
|
||||
"""
|
||||
H, W = spread.shape
|
||||
N = dx.shape[0]
|
||||
acc = np.zeros((H, W), dtype=np.float32)
|
||||
for y in nb.prange(H):
|
||||
for i in range(N):
|
||||
b = bins[i]
|
||||
mask = np.uint16(1) << b
|
||||
if (bit_active & mask) == 0:
|
||||
continue
|
||||
ddy = dy[i]
|
||||
yy = y + ddy
|
||||
if yy < 0 or yy >= H:
|
||||
continue
|
||||
ddx = dx[i]
|
||||
x_lo = 0 if ddx >= 0 else -ddx
|
||||
x_hi = W if ddx <= 0 else W - ddx
|
||||
for x in range(x_lo, x_hi):
|
||||
if spread[yy, x + ddx] & mask:
|
||||
acc[y, x] += 1.0
|
||||
if N > 0:
|
||||
inv = 1.0 / N
|
||||
for y in nb.prange(H):
|
||||
for x in range(W):
|
||||
v = acc[y, x] * inv
|
||||
bgv = bg[y, x]
|
||||
if bgv < 1.0:
|
||||
r = (v - bgv) / (1.0 - bgv + 1e-6)
|
||||
acc[y, x] = r if r > 0.0 else 0.0
|
||||
else:
|
||||
acc[y, x] = 0.0
|
||||
return acc
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_popcount_density_u16(spread: np.ndarray) -> np.ndarray:
|
||||
"""Popcount per uint16 (16 bin polarity)."""
|
||||
H, W = spread.shape
|
||||
out = np.zeros((H, W), dtype=np.float32)
|
||||
for y in nb.prange(H):
|
||||
for x in range(W):
|
||||
v = spread[y, x]
|
||||
cnt = 0
|
||||
for b in range(16):
|
||||
if v & (np.uint16(1) << b):
|
||||
cnt += 1
|
||||
out[y, x] = float(cnt)
|
||||
return out
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_popcount_density(spread: np.ndarray) -> np.ndarray:
|
||||
"""Conta bit set per pixel: ritorna (H, W) float32 in [0..8]."""
|
||||
@@ -241,11 +515,28 @@ if HAS_NUMBA:
|
||||
_jit_score_bitmap(spread, dx, dy, b, np.uint8(0xFF))
|
||||
bg = np.zeros((32, 32), dtype=np.float32)
|
||||
_jit_score_bitmap_rescored(spread, dx, dy, b, np.uint8(0xFF), bg)
|
||||
_jit_score_bitmap_rescored_strided(
|
||||
spread, dx, dy, b, np.uint8(0xFF), bg, np.int32(2),
|
||||
)
|
||||
_jit_score_bitmap_greedy(
|
||||
spread, dx, dy, b, np.uint8(0xFF),
|
||||
np.float32(0.5), np.float32(0.8),
|
||||
)
|
||||
offsets = np.array([0, 1], dtype=np.int32)
|
||||
scale_idx = np.zeros(1, dtype=np.int32)
|
||||
bg_pv = np.zeros((1, 32, 32), dtype=np.float32)
|
||||
_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(
|
||||
spread16, dx, dy, b, np.uint16(0xFFFF), bg,
|
||||
)
|
||||
_jit_popcount_density_u16(spread16)
|
||||
|
||||
else: # pragma: no cover
|
||||
|
||||
@@ -258,9 +549,30 @@ else: # pragma: no cover
|
||||
def _jit_score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
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")
|
||||
|
||||
def _jit_top_max_per_variant(
|
||||
spread, dx_flat, dy_flat, bins_flat, offsets, bit_active,
|
||||
bg_per_variant, scale_idx,
|
||||
):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_score_bitmap_rescored_u16(spread, dx, dy, bins, bit_active, bg):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_popcount_density_u16(spread):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_popcount_density(spread):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
@@ -291,24 +603,71 @@ def score_bitmap(
|
||||
|
||||
def score_bitmap_rescored(
|
||||
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: int, bg: np.ndarray,
|
||||
bit_active: int, bg: np.ndarray, stride: int = 1,
|
||||
) -> np.ndarray:
|
||||
"""Score bitmap + rescore fusi in un solo pass (JIT)."""
|
||||
"""Score bitmap + rescore fusi in un solo pass (JIT).
|
||||
|
||||
Dispatch per dtype: uint16 → kernel polarity 16-bin, uint8 → kernel
|
||||
standard 8-bin (con eventuale stride > 1 per coarse top-level).
|
||||
"""
|
||||
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:
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
|
||||
return _jit_score_bitmap_rescored_u16(
|
||||
spread_c, dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
|
||||
)
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
|
||||
if stride > 1:
|
||||
return _jit_score_bitmap_rescored_strided(
|
||||
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
|
||||
np.int32(stride),
|
||||
)
|
||||
return _jit_score_bitmap_rescored(
|
||||
np.ascontiguousarray(spread, dtype=np.uint8),
|
||||
np.ascontiguousarray(dx, dtype=np.int32),
|
||||
np.ascontiguousarray(dy, dtype=np.int32),
|
||||
np.ascontiguousarray(bins, dtype=np.int8),
|
||||
np.uint8(bit_active),
|
||||
np.ascontiguousarray(bg, dtype=np.float32),
|
||||
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
|
||||
)
|
||||
# Fallback: chiamate separate
|
||||
# Fallback: chiamate separate (stride ignorato in fallback)
|
||||
score = score_bitmap(spread, dx, dy, bins, bit_active)
|
||||
out = (score - bg) / (1.0 - bg + 1e-6)
|
||||
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,
|
||||
@@ -331,10 +690,78 @@ def score_bitmap_greedy(
|
||||
return score_bitmap(spread, dx, dy, bins, bit_active)
|
||||
|
||||
|
||||
def top_max_per_variant(
|
||||
spread: np.ndarray,
|
||||
dx_list: list, dy_list: list, bin_list: list,
|
||||
bg_per_scale: dict,
|
||||
variant_scales: list,
|
||||
bit_active: int,
|
||||
) -> np.ndarray:
|
||||
"""Wrapper: prepara buffer flat e chiama kernel batch su tutte le varianti.
|
||||
|
||||
Parallelismo Numba prange-esterno sulle varianti (n_vars >> n_threads
|
||||
tipicamente per top-pruning) → meglio del thread-pool Python che paga
|
||||
overhead di n_vars chiamate JIT separate.
|
||||
"""
|
||||
if not HAS_NUMBA or len(dx_list) == 0:
|
||||
return np.array([], dtype=np.float32)
|
||||
n_vars = len(dx_list)
|
||||
sizes = [len(d) for d in dx_list]
|
||||
offsets = np.zeros(n_vars + 1, dtype=np.int32)
|
||||
offsets[1:] = np.cumsum(sizes)
|
||||
total = int(offsets[-1])
|
||||
dx_flat = np.empty(total, dtype=np.int32)
|
||||
dy_flat = np.empty(total, dtype=np.int32)
|
||||
bins_flat = np.empty(total, dtype=np.int8)
|
||||
for vi, (dx, dy, bn) in enumerate(zip(dx_list, dy_list, bin_list)):
|
||||
i0 = int(offsets[vi]); i1 = int(offsets[vi + 1])
|
||||
dx_flat[i0:i1] = dx
|
||||
dy_flat[i0:i1] = dy
|
||||
bins_flat[i0:i1] = bn
|
||||
# bg per variante: indicizzato per scala
|
||||
scales_unique = sorted(bg_per_scale.keys())
|
||||
scale_to_idx = {s: i for i, s in enumerate(scales_unique)}
|
||||
H, W = spread.shape
|
||||
bg_pv = np.empty((len(scales_unique), H, W), dtype=np.float32)
|
||||
for s, idx in scale_to_idx.items():
|
||||
bg_pv[idx] = bg_per_scale[s]
|
||||
scale_idx = np.array(
|
||||
[scale_to_idx[s] for s in variant_scales], dtype=np.int32,
|
||||
)
|
||||
return _jit_top_max_per_variant(
|
||||
np.ascontiguousarray(spread, dtype=np.uint8),
|
||||
dx_flat, dy_flat, bins_flat, offsets, np.uint8(bit_active),
|
||||
bg_pv, scale_idx,
|
||||
)
|
||||
|
||||
|
||||
_HAS_NP_BITCOUNT = hasattr(np, "bitwise_count")
|
||||
|
||||
|
||||
def popcount_density(spread: np.ndarray) -> np.ndarray:
|
||||
"""Conta bit set per pixel.
|
||||
|
||||
Order:
|
||||
1) Numba JIT parallel (preferito: piu veloce su 1080p, 0.5ms vs 1.6ms)
|
||||
2) numpy.bitwise_count (NumPy 2.0+, SIMD ma single-thread)
|
||||
3) Fallback numpy bit-shift puro
|
||||
"""
|
||||
if spread.dtype == np.uint16:
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
|
||||
if HAS_NUMBA:
|
||||
return _jit_popcount_density_u16(spread_c)
|
||||
if _HAS_NP_BITCOUNT:
|
||||
return np.bitwise_count(spread_c).astype(np.float32, copy=False)
|
||||
H, W = spread_c.shape
|
||||
out = np.zeros((H, W), dtype=np.float32)
|
||||
for b in range(16):
|
||||
out += ((spread_c >> b) & 1).astype(np.float32)
|
||||
return out
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
|
||||
if HAS_NUMBA:
|
||||
return _jit_popcount_density(np.ascontiguousarray(spread, dtype=np.uint8))
|
||||
# Fallback
|
||||
return _jit_popcount_density(spread_c)
|
||||
if _HAS_NP_BITCOUNT:
|
||||
return np.bitwise_count(spread_c).astype(np.float32, copy=False)
|
||||
H, W = spread.shape
|
||||
out = np.zeros((H, W), dtype=np.float32)
|
||||
for b in range(8):
|
||||
|
||||
+134
-7
@@ -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)
|
||||
@@ -152,14 +153,124 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
|
||||
def _self_validate(template_bgr: np.ndarray, params: dict,
|
||||
mask: np.ndarray | None = None) -> dict:
|
||||
"""Halcon-style self-validation: train il matcher coi parametri tentativi
|
||||
e verifica che il template stesso sia trovato con recall ≥ 1.0.
|
||||
|
||||
Se recall < target o score basso, regola i parametri:
|
||||
- alza weak_grad se troppi edge spuri (recall solido ma molti picchi falsi)
|
||||
- abbassa strong_grad se troppe feature scartate (low feature count)
|
||||
- riduce pyramid_levels se variants[0].levels[top] ha <8 feature
|
||||
|
||||
Halcon usa internamente questo loop in inspect_shape_model. Costo: 1
|
||||
train + 1 find sul template (~50ms su template 100x100). Ne vale la
|
||||
pena se evita match-time errors su scene reali.
|
||||
|
||||
Mutates `params` in place e ritorna lo stesso dict per chaining.
|
||||
"""
|
||||
# Import lazy: evita ciclo (line_matcher importa nulla da auto_tune)
|
||||
from pm2d.line_matcher import LineShapeMatcher
|
||||
|
||||
# Caso degenerato: troppe poche feature pre-validation → riduci soglia
|
||||
if params.get("_n_strong_pixels", 0) < 30:
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.6)
|
||||
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.6)
|
||||
|
||||
# Train minimale: 1 sola pose orientazione 0 (range degenerato che
|
||||
# produce comunque 1 variante via fallback in _angle_list).
|
||||
m = LineShapeMatcher(
|
||||
num_features=params["num_features"],
|
||||
weak_grad=params["weak_grad"],
|
||||
strong_grad=params["strong_grad"],
|
||||
angle_range_deg=(0.0, 0.0), # fallback _angle_list = [0.0]
|
||||
angle_step_deg=10.0,
|
||||
scale_range=(1.0, 1.0),
|
||||
spread_radius=params["spread_radius"],
|
||||
pyramid_levels=params["pyramid_levels"],
|
||||
)
|
||||
n_var = m.train(template_bgr, mask=mask)
|
||||
if n_var == 0:
|
||||
# Soglie troppo alte: nessuna variante generata → dimezza
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.5)
|
||||
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.5)
|
||||
params["_validation"] = "fallback: soglie dimezzate (no variants)"
|
||||
return params
|
||||
|
||||
# Verifica densita' feature al top-level (rischio collasso)
|
||||
top_lvl = m.variants[0].levels[-1]
|
||||
if top_lvl.n < 8 and params["pyramid_levels"] > 1:
|
||||
params["pyramid_levels"] = max(1, params["pyramid_levels"] - 1)
|
||||
params["_validation"] = (
|
||||
f"pyramid_levels ridotto a {params['pyramid_levels']} "
|
||||
f"(top aveva {top_lvl.n} feature)"
|
||||
)
|
||||
return params
|
||||
|
||||
# Self-find: cerca il template stesso nella propria immagine
|
||||
h, w = template_bgr.shape[:2]
|
||||
# Embed template in scena leggermente più grande per evitare bordo
|
||||
pad = 20
|
||||
canvas = np.full(
|
||||
(h + 2 * pad, w + 2 * pad, 3 if template_bgr.ndim == 3 else 1),
|
||||
128, dtype=np.uint8,
|
||||
)
|
||||
canvas[pad:pad + h, pad:pad + w] = template_bgr
|
||||
matches = m.find(
|
||||
canvas, min_score=0.3, max_matches=5,
|
||||
verify_ncc=False, # template stesso → NCC = 1 sempre, skip per velocita'
|
||||
refine_angle=False, subpixel=False,
|
||||
nms_iou_threshold=0.3,
|
||||
)
|
||||
if not matches:
|
||||
# Nessun match sul proprio template: parametri troppo restrittivi
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.7)
|
||||
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.7)
|
||||
params["num_features"] = max(48, int(params["num_features"] * 0.8))
|
||||
params["_validation"] = "soglie/feature ridotte (no self-match)"
|
||||
return params
|
||||
|
||||
# Misura score top match
|
||||
top_score = float(matches[0].score)
|
||||
params["_self_score"] = round(top_score, 3)
|
||||
if top_score < 0.7:
|
||||
# Score basso sul template stesso = parametri davvero subottimali
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.85)
|
||||
params["_validation"] = (
|
||||
f"weak_grad ridotto (self-score era {top_score:.2f})"
|
||||
)
|
||||
else:
|
||||
params["_validation"] = f"OK (self-score {top_score:.2f})"
|
||||
return params
|
||||
|
||||
|
||||
def auto_tune(
|
||||
template_bgr: np.ndarray,
|
||||
mask: np.ndarray | None = None,
|
||||
angle_tolerance_deg: float | None = None,
|
||||
angle_center_deg: float = 0.0,
|
||||
self_validate: bool = True,
|
||||
) -> dict:
|
||||
"""Analizza template e ritorna dict parametri suggeriti.
|
||||
|
||||
Chiavi compatibili con edit_params PARAM_SCHEMA.
|
||||
|
||||
angle_tolerance_deg: se != None, restringe angle_range a
|
||||
(center - tol, center + tol). Usare quando l'orientamento del
|
||||
pezzo e' noto a priori (feeder con guida, posizionamento
|
||||
meccanico): training molto piu rapido (24x meno varianti per
|
||||
tol=15° vs 360° pieno).
|
||||
|
||||
self_validate: se True (default), dopo la stima dei parametri
|
||||
esegue un dry-run del matching sul template stesso e regola
|
||||
weak_grad/strong_grad/pyramid_levels se i parametri tentativi
|
||||
non garantiscono auto-match (Halcon-style inspect_shape_model).
|
||||
|
||||
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
|
||||
"""
|
||||
ck = _cache_key(template_bgr, mask)
|
||||
if angle_tolerance_deg is not None:
|
||||
ck = f"{ck}|tol={angle_tolerance_deg}|c={angle_center_deg}"
|
||||
cached = _TUNE_CACHE.get(ck)
|
||||
if cached is not None:
|
||||
_TUNE_CACHE.move_to_end(ck)
|
||||
@@ -208,8 +319,13 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
|
||||
# spread_radius proporzionale a risoluzione + pyramid (tolleranza ~1% dim)
|
||||
spread_radius = int(np.clip(max(3, min_side * 0.02), 3, 8))
|
||||
|
||||
# angle range ridotto se simmetria rotazionale
|
||||
angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0
|
||||
# angle range: priorita' a tolerance hint utente, poi simmetria rotazionale.
|
||||
if angle_tolerance_deg is not None:
|
||||
angle_min = float(angle_center_deg - angle_tolerance_deg)
|
||||
angle_max = float(angle_center_deg + angle_tolerance_deg)
|
||||
else:
|
||||
angle_min = 0.0
|
||||
angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0
|
||||
|
||||
# min_score: se entropia orient alta → template distintivo → soglia alta ok
|
||||
# se entropia bassa → template ambiguo → soglia più permissiva
|
||||
@@ -220,12 +336,15 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
|
||||
else:
|
||||
min_score = 0.45
|
||||
|
||||
# angle step: 5° default; se simmetria, mantengo step ma range ridotto
|
||||
angle_step = 5.0
|
||||
# angle step adattivo (Halcon-style): atan(2/max_side) deg, clampato.
|
||||
# Template grande → step fine (rotazione minima visibile su perimetro).
|
||||
# Template piccolo → step grosso (over-sampling = sprecato).
|
||||
max_side = max(h, w)
|
||||
angle_step = float(np.clip(np.degrees(np.arctan2(2.0, max_side)), 1.0, 8.0))
|
||||
|
||||
result = {
|
||||
"backend": "line",
|
||||
"angle_min": 0.0,
|
||||
"angle_min": angle_min,
|
||||
"angle_max": angle_max,
|
||||
"angle_step": angle_step,
|
||||
"scale_min": 1.0,
|
||||
@@ -244,7 +363,15 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
|
||||
"_symmetry_order": sym["order"],
|
||||
"_symmetry_conf": round(sym["confidence"], 2),
|
||||
"_orient_entropy": round(stats["orient_entropy"], 2),
|
||||
"_n_strong_pixels": stats["n_strong"],
|
||||
}
|
||||
# Halcon-style self-validation: dry-run training+find sul template per
|
||||
# auto-correggere parametri tentativi che non garantirebbero match.
|
||||
if self_validate:
|
||||
result = _self_validate(template_bgr, result, mask=mask)
|
||||
# Round numerici dopo eventuali aggiustamenti
|
||||
result["weak_grad"] = round(result["weak_grad"], 1)
|
||||
result["strong_grad"] = round(result["strong_grad"], 1)
|
||||
# Store in LRU cache
|
||||
_TUNE_CACHE[ck] = dict(result)
|
||||
_TUNE_CACHE.move_to_end(ck)
|
||||
|
||||
+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())
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
"""CLI validation harness per LineShapeMatcher.
|
||||
|
||||
Usage:
|
||||
python -m pm2d.eval dataset.json [opzioni]
|
||||
|
||||
Formato dataset (JSON):
|
||||
{
|
||||
"template": "path/to/template.png",
|
||||
"mask": "path/to/mask.png", # opzionale
|
||||
"params": { # opzionali, override su matcher init
|
||||
"use_polarity": true,
|
||||
"angle_step_deg": 5,
|
||||
...
|
||||
},
|
||||
"find_params": { # opzionali, passati a find()
|
||||
"min_score": 0.6,
|
||||
"use_soft_score": true,
|
||||
...
|
||||
},
|
||||
"scenes": [
|
||||
{
|
||||
"image": "path/to/scene1.png",
|
||||
"ground_truth": [
|
||||
{"cx": 320.0, "cy": 240.0, "angle_deg": 12.0,
|
||||
"scale": 1.0, "tolerance_px": 5.0,
|
||||
"tolerance_deg": 3.0}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Output: report precision/recall/IoU/timing per ogni scena + aggregati.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from pm2d.line_matcher import LineShapeMatcher, _poly_iou, _oriented_bbox_polygon
|
||||
|
||||
|
||||
def _load_image(path: str | Path) -> np.ndarray:
|
||||
img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Immagine non trovata: {path}")
|
||||
if img.ndim == 2:
|
||||
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||
return img
|
||||
|
||||
|
||||
def _gt_to_poly(gt: dict, tw: int, th: int) -> np.ndarray:
|
||||
"""Costruisce bbox poligonale per un ground truth."""
|
||||
s = float(gt.get("scale", 1.0))
|
||||
return _oriented_bbox_polygon(
|
||||
float(gt["cx"]), float(gt["cy"]),
|
||||
tw * s, th * s, float(gt["angle_deg"]),
|
||||
)
|
||||
|
||||
|
||||
def _match_to_gt(match, gt: dict, tw: int, th: int,
|
||||
iou_thr: float = 0.3) -> bool:
|
||||
"""True se il match corrisponde al ground truth.
|
||||
|
||||
Criterio: distanza centro <= tolerance_px AND |angle_deg - gt| <= tolerance_deg
|
||||
OR IoU bbox >= iou_thr (fallback per pose con tolerance ampie).
|
||||
"""
|
||||
tol_px = float(gt.get("tolerance_px", 5.0))
|
||||
tol_deg = float(gt.get("tolerance_deg", 3.0))
|
||||
dx = match.cx - float(gt["cx"])
|
||||
dy = match.cy - float(gt["cy"])
|
||||
dist = math.hypot(dx, dy)
|
||||
da = abs((match.angle_deg - float(gt["angle_deg"]) + 180) % 360 - 180)
|
||||
if dist <= tol_px and da <= tol_deg:
|
||||
return True
|
||||
# Fallback IoU
|
||||
poly_gt = _gt_to_poly(gt, tw, th)
|
||||
poly_m = match.bbox_poly
|
||||
if _poly_iou(poly_m, poly_gt) >= iou_thr:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_scene(matcher: LineShapeMatcher, scene_bgr: np.ndarray,
|
||||
gt_list: list[dict], find_params: dict,
|
||||
tw: int, th: int) -> dict:
|
||||
"""Esegue match e calcola TP/FP/FN per una scena."""
|
||||
t0 = time.time()
|
||||
matches = matcher.find(scene_bgr, **find_params)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
gt_matched = [False] * len(gt_list)
|
||||
match_is_tp = [False] * len(matches)
|
||||
iou_per_match = [0.0] * len(matches)
|
||||
for i, m in enumerate(matches):
|
||||
for j, gt in enumerate(gt_list):
|
||||
if gt_matched[j]:
|
||||
continue
|
||||
if _match_to_gt(m, gt, tw, th):
|
||||
gt_matched[j] = True
|
||||
match_is_tp[i] = True
|
||||
# Calcolo IoU per metrica
|
||||
poly_gt = _gt_to_poly(gt, tw, th)
|
||||
iou_per_match[i] = _poly_iou(m.bbox_poly, poly_gt)
|
||||
break
|
||||
tp = sum(match_is_tp)
|
||||
fp = len(matches) - tp
|
||||
fn = len(gt_list) - sum(gt_matched)
|
||||
return {
|
||||
"n_matches": len(matches),
|
||||
"n_gt": len(gt_list),
|
||||
"tp": tp, "fp": fp, "fn": fn,
|
||||
"find_time_s": elapsed,
|
||||
"iou_mean": float(np.mean([i for i, t in zip(iou_per_match, match_is_tp) if t])
|
||||
if tp > 0 else 0.0),
|
||||
"diag": (matcher.get_last_diag()
|
||||
if hasattr(matcher, "get_last_diag") else None),
|
||||
}
|
||||
|
||||
|
||||
def run(dataset_path: str, scene_filter: str | None = None,
|
||||
verbose: bool = False) -> dict:
|
||||
"""Esegue eval su dataset, ritorna report aggregato."""
|
||||
dataset_path = Path(dataset_path)
|
||||
base = dataset_path.parent
|
||||
with open(dataset_path) as f:
|
||||
ds = json.load(f)
|
||||
|
||||
template = _load_image(base / ds["template"])
|
||||
mask = None
|
||||
if ds.get("mask"):
|
||||
mask_img = cv2.imread(str(base / ds["mask"]), cv2.IMREAD_GRAYSCALE)
|
||||
if mask_img is not None:
|
||||
mask = (mask_img > 128).astype(np.uint8) * 255
|
||||
init_params = ds.get("params", {})
|
||||
find_params = ds.get("find_params", {})
|
||||
|
||||
matcher = LineShapeMatcher(**init_params)
|
||||
n_var = matcher.train(template, mask=mask)
|
||||
tw, th = matcher.template_size
|
||||
print(f"Template: {ds['template']} ({tw}x{th}), {n_var} varianti")
|
||||
print(f"Param matcher: {init_params}")
|
||||
print(f"Param find: {find_params}")
|
||||
print()
|
||||
|
||||
scenes = ds["scenes"]
|
||||
if scene_filter:
|
||||
scenes = [s for s in scenes if scene_filter in s["image"]]
|
||||
|
||||
rows = []
|
||||
tot_tp = tot_fp = tot_fn = 0
|
||||
tot_time = 0.0
|
||||
for sc in scenes:
|
||||
scene = _load_image(base / sc["image"])
|
||||
gt = sc.get("ground_truth", [])
|
||||
result = evaluate_scene(matcher, scene, gt, find_params, tw, th)
|
||||
rows.append({"scene": sc["image"], **result})
|
||||
tot_tp += result["tp"]; tot_fp += result["fp"]; tot_fn += result["fn"]
|
||||
tot_time += result["find_time_s"]
|
||||
prec = result["tp"] / max(1, result["tp"] + result["fp"])
|
||||
rec = result["tp"] / max(1, result["tp"] + result["fn"])
|
||||
line = (f" {sc['image']:30s} "
|
||||
f"TP={result['tp']} FP={result['fp']} FN={result['fn']} "
|
||||
f"P={prec:.2f} R={rec:.2f} "
|
||||
f"IoU={result['iou_mean']:.2f} "
|
||||
f"t={result['find_time_s']*1000:.0f}ms")
|
||||
print(line)
|
||||
if verbose and result["diag"] and hasattr(matcher, "_format_diag"):
|
||||
print(f" diag: {matcher._format_diag(result['diag'])}")
|
||||
|
||||
# Aggregati
|
||||
precision = tot_tp / max(1, tot_tp + tot_fp)
|
||||
recall = tot_tp / max(1, tot_tp + tot_fn)
|
||||
f1 = 2 * precision * recall / max(1e-9, precision + recall)
|
||||
print()
|
||||
print(f"AGGREGATO: precision={precision:.3f} recall={recall:.3f} "
|
||||
f"F1={f1:.3f} TP={tot_tp} FP={tot_fp} FN={tot_fn}")
|
||||
print(f"TIME: total={tot_time:.2f}s avg={tot_time / max(1, len(scenes)) * 1000:.0f}ms/scene")
|
||||
|
||||
return {
|
||||
"precision": precision, "recall": recall, "f1": f1,
|
||||
"tp": tot_tp, "fp": tot_fp, "fn": tot_fn,
|
||||
"total_time_s": tot_time, "n_scenes": len(scenes),
|
||||
"per_scene": rows,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="pm2d-eval: validation harness per LineShapeMatcher"
|
||||
)
|
||||
p.add_argument("dataset", help="JSON dataset (template + scenes + GT)")
|
||||
p.add_argument("--scene-filter", default=None,
|
||||
help="Filtro substring sui nomi scena (debug)")
|
||||
p.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Stampa diag dict per ogni scena")
|
||||
p.add_argument("--out", default=None,
|
||||
help="Salva report JSON su file")
|
||||
args = p.parse_args(argv)
|
||||
report = run(args.dataset, scene_filter=args.scene_filter,
|
||||
verbose=args.verbose)
|
||||
if args.out:
|
||||
with open(args.out, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f"Report salvato: {args.out}")
|
||||
return 0 if report["f1"] > 0.5 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+1316
-123
File diff suppressed because it is too large
Load Diff
+26
-2
@@ -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(
|
||||
|
||||
+587
-104
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
@@ -48,6 +49,10 @@ IMAGES_DIR = Path(_images_dir_raw)
|
||||
if not IMAGES_DIR.is_absolute():
|
||||
IMAGES_DIR = PROJECT_ROOT / IMAGES_DIR
|
||||
|
||||
# Cartella ricette pre-trained (V feature: save/load matcher)
|
||||
RECIPES_DIR = PROJECT_ROOT / "recipes"
|
||||
RECIPES_DIR.mkdir(exist_ok=True)
|
||||
|
||||
from pm2d.line_matcher import LineShapeMatcher, Match
|
||||
from pm2d.auto_tune import auto_tune
|
||||
|
||||
@@ -60,20 +65,30 @@ 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()
|
||||
h.update(roi.tobytes())
|
||||
# Solo parametri che influenzano il training
|
||||
relevant = ("num_features", "weak_grad", "strong_grad",
|
||||
"min_feature_spacing",
|
||||
"angle_min", "angle_max", "angle_step",
|
||||
"scale_min", "scale_max", "scale_step",
|
||||
"spread_radius", "pyramid_levels")
|
||||
@@ -97,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")
|
||||
@@ -126,46 +150,144 @@ 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) -> 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()
|
||||
H, W = scene.shape[:2]
|
||||
palette = [
|
||||
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0),
|
||||
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255),
|
||||
]
|
||||
# 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):
|
||||
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):
|
||||
color = palette[i % len(palette)]
|
||||
if template_gray is not None:
|
||||
# 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)
|
||||
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
|
||||
th, tw = t.shape
|
||||
edge = cv2.Canny(t, 50, 150)
|
||||
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 = cv2.warpAffine(edge, M, (W, H),
|
||||
flags=cv2.INTER_NEAREST, borderValue=0)
|
||||
mask = warped > 0
|
||||
if mask.any():
|
||||
overlay = np.zeros_like(out)
|
||||
overlay[mask] = color
|
||||
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8)
|
||||
poly = m.bbox_poly.astype(np.int32).reshape(-1, 1, 2)
|
||||
cv2.polylines(out, [poly], True, color, 2, cv2.LINE_AA)
|
||||
p0 = tuple(m.bbox_poly[0].astype(int))
|
||||
p1 = tuple(m.bbox_poly[1].astype(int))
|
||||
cv2.line(out, p0, p1, color, 4, cv2.LINE_AA)
|
||||
cx, cy = int(round(m.cx)), int(round(m.cy))
|
||||
cv2.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA)
|
||||
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
|
||||
a = np.deg2rad(m.angle_deg)
|
||||
cv2.arrowedLine(out, (cx, cy),
|
||||
(int(cx + L * np.cos(a)), int(cy - L * np.sin(a))),
|
||||
color, 2, cv2.LINE_AA, tipLength=0.2)
|
||||
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
|
||||
cv2.putText(out, label, (cx + 8, cy - 8),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
|
||||
# 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))
|
||||
# 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
|
||||
|
||||
|
||||
@@ -193,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
|
||||
|
||||
@@ -213,6 +337,7 @@ class MatchResp(BaseModel):
|
||||
find_time: float
|
||||
num_variants: int
|
||||
annotated_id: str
|
||||
diag: dict | None = None # CC: diagnostica pipeline (drop reasons)
|
||||
|
||||
|
||||
class TuneParams(BaseModel):
|
||||
@@ -249,9 +374,9 @@ PRECISION_ANGLE_STEP = {
|
||||
# Un operatore sceglie il livello di rigore, non un numero astratto.
|
||||
FILTRO_FP_MAP = {
|
||||
"off": 0.0, # disabilitato: mantieni tutti i match shape-based
|
||||
"leggero": 0.20, # tollera variazioni intensità/illuminazione forti
|
||||
"medio": 0.35, # default bilanciato (consigliato)
|
||||
"forte": 0.50, # scarta match con intensità molto diversa dal template
|
||||
"leggero": 0.30, # tollera variazioni intensità/illuminazione forti
|
||||
"medio": 0.50, # default bilanciato (consigliato)
|
||||
"forte": 0.70, # scarta match con intensità molto diversa dal template
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +392,29 @@ class SimpleMatchParams(BaseModel):
|
||||
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
|
||||
min_score: float = 0.65
|
||||
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) ---
|
||||
# Init-time (richiede ri-train se cambiato)
|
||||
use_polarity: bool = False # F: 16 bin orientation mod 2pi
|
||||
use_gpu: bool = False # R: OpenCL UMat (silent fallback)
|
||||
# Find-time (no retrain)
|
||||
min_recall: float = 0.0 # M: filtra match con poche feature combaciate
|
||||
use_soft_score: bool = False # Y: cosine sim continua dei gradients
|
||||
subpixel_lm: bool = False # Z: precisione 0.05 px
|
||||
nms_iou_threshold: float = 0.3 # A: IoU bbox poligonale
|
||||
coarse_stride: int = 1 # sub-sampling top-level (>=1)
|
||||
pyramid_propagate: bool = False # propagazione candidati top->full
|
||||
greediness: float = 0.0 # early-exit kernel (0..1)
|
||||
refine_pose_joint: bool = False # Nelder-Mead 3D (cx, cy, angle)
|
||||
search_roi: list[int] | None = None # [x, y, w, h] limita area
|
||||
|
||||
|
||||
def _simple_to_technical(
|
||||
@@ -301,10 +449,24 @@ def _simple_to_technical(
|
||||
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
|
||||
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 {
|
||||
"num_features": nf,
|
||||
"weak_grad": tune["weak_grad"],
|
||||
"strong_grad": tune["strong_grad"],
|
||||
"num_features": n_feat,
|
||||
"weak_grad": weak_g,
|
||||
"strong_grad": strong_g,
|
||||
"min_feature_spacing": min_sp,
|
||||
"spread_radius": spread,
|
||||
"pyramid_levels": pyr,
|
||||
"angle_min": 0.0,
|
||||
@@ -316,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,
|
||||
}
|
||||
|
||||
@@ -450,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 = {
|
||||
@@ -466,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)
|
||||
# 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(
|
||||
@@ -503,6 +674,7 @@ def match(p: MatchParams):
|
||||
) for m_ in matches],
|
||||
train_time=t_train, find_time=t_find,
|
||||
num_variants=n, annotated_id=ann_id,
|
||||
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -518,41 +690,61 @@ 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)
|
||||
|
||||
key = _matcher_cache_key(roi_img, tech)
|
||||
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"],
|
||||
pyramid_levels=tech["pyramid_levels"],
|
||||
# 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
|
||||
# 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
|
||||
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),
|
||||
)
|
||||
t_find = time.time() - t0
|
||||
t_find = time.time() - t0
|
||||
|
||||
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
||||
annotated = _draw_matches(scene, matches, tg)
|
||||
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
||||
annotated = _draw_matches(scene, matches, tg, matcher=m)
|
||||
ann_id = _store_image(annotated)
|
||||
|
||||
return MatchResp(
|
||||
@@ -562,6 +754,7 @@ def match_simple(p: SimpleMatchParams):
|
||||
) for mt in matches],
|
||||
train_time=t_train, find_time=t_find,
|
||||
num_variants=n, annotated_id=ann_id,
|
||||
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -571,9 +764,299 @@ 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)
|
||||
return {k: v for k, v in t.items() if not k.startswith("_")}
|
||||
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
|
||||
# _symmetry_order, _orient_entropy) per feedback UI.
|
||||
return t
|
||||
|
||||
|
||||
# --- V: Save/Load ricette pre-trained ---
|
||||
|
||||
class SaveRecipeParams(BaseModel):
|
||||
model_id: str
|
||||
scene_id: str | None = None
|
||||
roi: list[int]
|
||||
# Riusa stessi param simple per training equivalente
|
||||
tipo: str = "intero"
|
||||
simmetria: str = "nessuna"
|
||||
scala: str = "fissa"
|
||||
precisione: str = "normale"
|
||||
use_polarity: 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)
|
||||
|
||||
|
||||
class EdgePreviewParams(BaseModel):
|
||||
model_id: str
|
||||
roi: list[int]
|
||||
weak_grad: float = 30.0
|
||||
strong_grad: float = 60.0
|
||||
num_features: int = 96
|
||||
min_feature_spacing: int = 3
|
||||
use_polarity: bool = False
|
||||
|
||||
|
||||
@app.post("/preview_edges")
|
||||
def preview_edges(p: EdgePreviewParams):
|
||||
"""Estrae edge feature dalla ROI con i parametri dati e ritorna
|
||||
immagine annotata con i pixel selezionati come overlay.
|
||||
|
||||
Permette tuning interattivo delle soglie weak/strong_grad e
|
||||
num_features per "togliere le sporcizie" (rumore di sfondo,
|
||||
edge spuri) prima di trainare il matcher vero.
|
||||
"""
|
||||
model = _load_image(p.model_id)
|
||||
if model is None:
|
||||
raise HTTPException(404, "Modello non trovato")
|
||||
x, y, w, h = p.roi
|
||||
H_m, W_m = model.shape[:2]
|
||||
x = max(0, min(int(x), W_m - 1)); y = max(0, min(int(y), H_m - 1))
|
||||
w = max(1, min(int(w), W_m - x)); h = max(1, min(int(h), H_m - y))
|
||||
roi_img = model[y:y + h, x:x + w]
|
||||
# Matcher temporaneo solo per estrazione feature (no train completo)
|
||||
m = LineShapeMatcher(
|
||||
weak_grad=p.weak_grad,
|
||||
strong_grad=p.strong_grad,
|
||||
num_features=p.num_features,
|
||||
min_feature_spacing=p.min_feature_spacing,
|
||||
use_polarity=p.use_polarity,
|
||||
)
|
||||
gray = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) if roi_img.ndim == 3 else roi_img
|
||||
mag, bins = m._gradient(gray)
|
||||
fx, fy, fb = m._extract_features(mag, bins, None)
|
||||
# Mostra anche i pixel "weak/strong" come heatmap di sfondo
|
||||
out = roi_img.copy() if roi_img.ndim == 3 else cv2.cvtColor(roi_img, cv2.COLOR_GRAY2BGR)
|
||||
# Overlay magnitude leggera
|
||||
mag_norm = np.clip(mag / max(1.0, mag.max()) * 255, 0, 255).astype(np.uint8)
|
||||
mag_color = cv2.applyColorMap(mag_norm, cv2.COLORMAP_BONE)
|
||||
out = cv2.addWeighted(out, 0.6, mag_color, 0.4, 0)
|
||||
# Pixel "strong" con hysteresis: contorno verde scuro tenue
|
||||
if m.weak_grad < m.strong_grad:
|
||||
edge_mask = m._hysteresis_mask(mag).astype(np.uint8) * 255
|
||||
else:
|
||||
edge_mask = (mag >= m.strong_grad).astype(np.uint8) * 255
|
||||
edge_overlay = np.zeros_like(out)
|
||||
edge_overlay[edge_mask > 0] = (0, 80, 0) # verde scuro
|
||||
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.5, 0)
|
||||
# Feature scelte: cerchietti colorati per bin
|
||||
bin_colors = [
|
||||
(255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0),
|
||||
(0, 255, 255), (0, 128, 255), (0, 0, 255), (255, 0, 255),
|
||||
(255, 100, 100), (255, 180, 100), (255, 230, 100), (180, 255, 100),
|
||||
(100, 255, 200), (100, 180, 255), (180, 100, 255), (255, 100, 200),
|
||||
]
|
||||
for i in range(len(fx)):
|
||||
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 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)
|
||||
return {
|
||||
"preview_id": img_id,
|
||||
"n_features": len(fx),
|
||||
"n_edge_strong": n_edge_strong,
|
||||
"n_edge_after_hysteresis": n_edge_total,
|
||||
"mag_max": float(mag.max()),
|
||||
"mag_p50": float(np.percentile(mag, 50)),
|
||||
"mag_p85": float(np.percentile(mag, 85)),
|
||||
"ucs_baricentro": (
|
||||
{"cx": round(bary_cx, 2), "cy": round(bary_cy, 2)}
|
||||
if bary_cx is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/recipes")
|
||||
def save_recipe(p: SaveRecipeParams):
|
||||
"""Allena matcher e salva su disco come ricetta riutilizzabile."""
|
||||
model = _load_image(p.model_id)
|
||||
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,
|
||||
tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
|
||||
precisione=p.precisione,
|
||||
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)
|
||||
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"],
|
||||
pyramid_levels=tech["pyramid_levels"],
|
||||
use_polarity=p.use_polarity,
|
||||
use_gpu=p.use_gpu,
|
||||
)
|
||||
# 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")
|
||||
if not safe_name.endswith(".npz"):
|
||||
safe_name += ".npz"
|
||||
target = RECIPES_DIR / safe_name
|
||||
m.save_model(str(target))
|
||||
return {"name": safe_name, "size": target.stat().st_size,
|
||||
"n_variants": len(m.variants)}
|
||||
|
||||
|
||||
@app.get("/recipes")
|
||||
def list_recipes():
|
||||
files = []
|
||||
if RECIPES_DIR.is_dir():
|
||||
for f in sorted(RECIPES_DIR.glob("*.npz")):
|
||||
files.append({"name": f.name, "size": f.stat().st_size})
|
||||
return {"files": files, "dir": str(RECIPES_DIR)}
|
||||
|
||||
|
||||
# Cache di matcher caricati da .npz (V feature). Key: nome ricetta.
|
||||
_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.
|
||||
|
||||
Una volta caricata, /match_recipe la usa direttamente senza
|
||||
re-train. Halcon-equivalent read_shape_model + handle.
|
||||
"""
|
||||
safe_name = "".join(c for c in name if c.isalnum() or c in "._-")
|
||||
if not safe_name.endswith(".npz"):
|
||||
safe_name += ".npz"
|
||||
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))
|
||||
with _MATCHER_LOCK:
|
||||
_recipe_matchers_put(safe_name, m)
|
||||
return {
|
||||
"name": safe_name,
|
||||
"n_variants": len(m.variants),
|
||||
"template_size": list(m.template_size),
|
||||
"use_polarity": m.use_polarity,
|
||||
}
|
||||
|
||||
|
||||
class RecipeMatchParams(BaseModel):
|
||||
recipe: str
|
||||
scene_id: str
|
||||
# Solo find-time params (training gia' fatto offline)
|
||||
min_score: float = 0.65
|
||||
max_matches: int = 25
|
||||
min_recall: float = 0.0
|
||||
use_soft_score: bool = False
|
||||
subpixel_lm: bool = False
|
||||
nms_iou_threshold: float = 0.3
|
||||
coarse_stride: int = 1
|
||||
pyramid_propagate: bool = False
|
||||
greediness: float = 0.0
|
||||
refine_pose_joint: bool = False
|
||||
search_roi: list[int] | None = None
|
||||
# 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
|
||||
|
||||
|
||||
@app.post("/match_recipe", response_model=MatchResp)
|
||||
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"
|
||||
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
|
||||
# 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(
|
||||
cx=mt.cx, cy=mt.cy, angle_deg=mt.angle_deg, scale=mt.scale,
|
||||
score=mt.score, bbox_poly=mt.bbox_poly.tolist(),
|
||||
) for mt in matches],
|
||||
train_time=0.0, find_time=t_find,
|
||||
num_variants=len(m.variants), annotated_id=ann_id,
|
||||
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
|
||||
)
|
||||
|
||||
|
||||
# Mount static
|
||||
|
||||
+412
-4
@@ -19,6 +19,7 @@ const PALETTE = [
|
||||
const state = {
|
||||
model: null, scene: null, roi: null, drag: null,
|
||||
matches: [], annotatedImg: null,
|
||||
active_recipe: null, // V: ricetta caricata (string nome) o null
|
||||
};
|
||||
|
||||
// ---------- Forms ----------
|
||||
@@ -52,6 +53,63 @@ function readUserParams() {
|
||||
document.getElementById("p-penalita-scala").value),
|
||||
min_score: parseFloat(document.getElementById("p-min-score").value),
|
||||
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
|
||||
...readEdgeOverrides(),
|
||||
...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() {
|
||||
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
|
||||
const $cb = (id) => document.getElementById(id)?.checked ?? false;
|
||||
const $num = (id, def) => {
|
||||
const v = parseFloat(document.getElementById(id)?.value);
|
||||
return Number.isFinite(v) ? v : def;
|
||||
};
|
||||
const $int = (id, def) => {
|
||||
const v = parseInt(document.getElementById(id)?.value, 10);
|
||||
return Number.isFinite(v) ? v : def;
|
||||
};
|
||||
const roiStr = document.getElementById("hc-search-roi")?.value.trim() ?? "";
|
||||
let search_roi = null;
|
||||
if (roiStr) {
|
||||
const p = roiStr.split(/[ ,;]+/).map((x) => parseInt(x, 10));
|
||||
if (p.length === 4 && p.every((v) => Number.isFinite(v))) search_roi = p;
|
||||
}
|
||||
return {
|
||||
use_polarity: $cb("hc-use-polarity"),
|
||||
use_gpu: $cb("hc-use-gpu"),
|
||||
use_soft_score: $cb("hc-soft-score"),
|
||||
subpixel_lm: $cb("hc-subpixel-lm"),
|
||||
refine_pose_joint: $cb("hc-refine-joint"),
|
||||
pyramid_propagate: $cb("hc-pyr-propagate"),
|
||||
min_recall: $num("hc-min-recall", 0),
|
||||
nms_iou_threshold: $num("hc-nms-iou", 0.3),
|
||||
greediness: $num("hc-greediness", 0),
|
||||
coarse_stride: $int("hc-coarse-stride", 1),
|
||||
search_roi: search_roi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,7 +332,43 @@ function setupROI() {
|
||||
}
|
||||
|
||||
// ---------- Match action ----------
|
||||
async function doMatchRecipe() {
|
||||
if (!state.scene) { setStatus("Carica scena"); return; }
|
||||
setStatus(`Match ricetta ${state.active_recipe}...`);
|
||||
const hc = readHalconFlags();
|
||||
const body = {
|
||||
recipe: state.active_recipe,
|
||||
scene_id: state.scene.id,
|
||||
min_score: parseFloat(document.getElementById("p-min-score").value),
|
||||
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
|
||||
verify_threshold: 0.50,
|
||||
...hc,
|
||||
};
|
||||
const r = await fetch("/match_recipe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
|
||||
const data = await r.json();
|
||||
state.matches = data.matches;
|
||||
state.annotatedImg = await loadImage(
|
||||
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
||||
renderScene();
|
||||
renderLegend();
|
||||
document.getElementById("t-train").textContent = "—";
|
||||
document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`;
|
||||
document.getElementById("t-var").textContent = data.num_variants;
|
||||
document.getElementById("t-match").textContent = data.matches.length;
|
||||
renderDiag(data.diag, data.matches.length);
|
||||
setStatus(`${data.matches.length} match trovati (ricetta ${state.active_recipe})`);
|
||||
}
|
||||
|
||||
async function doMatch() {
|
||||
// Path V: ricetta caricata → bypass training, solo find su scena
|
||||
if (state.active_recipe) {
|
||||
return doMatchRecipe();
|
||||
}
|
||||
if (!state.model) { setStatus("Carica modello"); return; }
|
||||
if (!state.scene) { setStatus("Carica scena"); return; }
|
||||
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
|
||||
@@ -294,12 +388,17 @@ async function doMatch() {
|
||||
const SCALE_MAP = {fissa:[1,1,0.1], mini:[0.9,1.1,0.05],
|
||||
medio:[0.75,1.25,0.05], max:[0.5,1.5,0.05]};
|
||||
const PREC_MAP = {veloce:10, normale:5, preciso:2};
|
||||
const FP_MAP = {off:0, leggero:0.20, medio:0.35, forte:0.50};
|
||||
// Allineato a FILTRO_FP_MAP server-side (server.py)
|
||||
const FP_MAP = {off:0, leggero:0.30, medio:0.50, forte:0.70};
|
||||
const [smin, smax, sstep] = SCALE_MAP[user.scala];
|
||||
// NB: SYM_MAP[invariante]=0 e' valido (zero rotazioni). Uso ?? per
|
||||
// distinguere "chiave mancante" da "valore zero": altrimenti 0 || 360
|
||||
// collassa invariante a 360 = bug "simmetria non ha effetto".
|
||||
const angMax = SYM_MAP[user.simmetria] ?? 360;
|
||||
body = {
|
||||
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
||||
angle_min: 0, angle_max: SYM_MAP[user.simmetria] || 360,
|
||||
angle_step: PREC_MAP[user.precisione] || 5,
|
||||
angle_min: 0, angle_max: angMax,
|
||||
angle_step: PREC_MAP[user.precisione] ?? 5,
|
||||
scale_min: smin, scale_max: smax, scale_step: sstep,
|
||||
min_score: user.min_score, max_matches: user.max_matches,
|
||||
num_features: adv.num_features ?? 96,
|
||||
@@ -307,7 +406,7 @@ async function doMatch() {
|
||||
strong_grad: adv.strong_grad ?? 60,
|
||||
spread_radius: adv.spread_radius ?? 5,
|
||||
pyramid_levels: adv.pyramid_levels ?? 3,
|
||||
verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.35),
|
||||
verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.50),
|
||||
nms_radius: adv.nms_radius ?? 0,
|
||||
};
|
||||
} else {
|
||||
@@ -335,6 +434,7 @@ async function doMatch() {
|
||||
document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`;
|
||||
document.getElementById("t-var").textContent = data.num_variants;
|
||||
document.getElementById("t-match").textContent = data.matches.length;
|
||||
renderDiag(data.diag, data.matches.length);
|
||||
setStatus(`${data.matches.length} match trovati${hasAdv ? " (avanzato)" : ""}`);
|
||||
}
|
||||
|
||||
@@ -362,6 +462,305 @@ function setStatus(s) {
|
||||
}
|
||||
|
||||
// ---------- Init ----------
|
||||
// ---------- Edge preview (clean rumore) ----------
|
||||
let _epDebounce = null;
|
||||
let _epLastImg = null;
|
||||
|
||||
async function fetchEdgePreview() {
|
||||
if (!state.model || !state.roi) {
|
||||
document.getElementById("edge-preview-info").textContent =
|
||||
"Disegna prima la ROI sul modello";
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
model_id: state.model.id,
|
||||
roi: state.roi,
|
||||
weak_grad: parseFloat(document.getElementById("ep-weak").value),
|
||||
strong_grad: parseFloat(document.getElementById("ep-strong").value),
|
||||
num_features: parseInt(document.getElementById("ep-nf").value, 10),
|
||||
min_feature_spacing: parseInt(document.getElementById("ep-sp").value, 10),
|
||||
use_polarity: document.getElementById("ep-pol").checked,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/preview_edges", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const j = await r.json();
|
||||
_epLastImg = await loadImage(`/image/${j.preview_id}/raw?t=${Date.now()}`);
|
||||
drawEdgePreview();
|
||||
const ucs = j.ucs_baricentro
|
||||
? ` | UCS=(${j.ucs_baricentro.cx},${j.ucs_baricentro.cy})`
|
||||
: "";
|
||||
document.getElementById("edge-preview-info").innerHTML =
|
||||
`<b>${j.n_features}</b> feature scelte (di ${j.n_edge_after_hysteresis} edge totali)<br>` +
|
||||
`mag: max=${j.mag_max.toFixed(0)} p50=${j.mag_p50.toFixed(0)} ` +
|
||||
`p85=${j.mag_p85.toFixed(0)}${ucs}`;
|
||||
} catch (e) {
|
||||
document.getElementById("edge-preview-info").textContent =
|
||||
`Errore preview: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function drawEdgePreview() {
|
||||
const cnv = document.getElementById("c-edge-preview");
|
||||
if (!_epLastImg) return;
|
||||
const ctx = cnv.getContext("2d");
|
||||
// Fit-contain
|
||||
const r = Math.min(cnv.width / _epLastImg.width,
|
||||
cnv.height / _epLastImg.height);
|
||||
const w = _epLastImg.width * r;
|
||||
const h = _epLastImg.height * r;
|
||||
const ox = (cnv.width - w) / 2;
|
||||
const oy = (cnv.height - h) / 2;
|
||||
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cnv.width, cnv.height);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(_epLastImg, ox, oy, w, h);
|
||||
}
|
||||
|
||||
function scheduleEdgePreview() {
|
||||
if (_epDebounce) clearTimeout(_epDebounce);
|
||||
_epDebounce = setTimeout(fetchEdgePreview, 200);
|
||||
}
|
||||
|
||||
function bindEdgePreviewControls() {
|
||||
const slid = (id, valEl) => {
|
||||
const el = document.getElementById(id);
|
||||
const v = document.getElementById(valEl);
|
||||
el.addEventListener("input", () => {
|
||||
v.textContent = el.value;
|
||||
scheduleEdgePreview();
|
||||
});
|
||||
};
|
||||
slid("ep-weak", "ep-weak-v");
|
||||
slid("ep-strong", "ep-strong-v");
|
||||
slid("ep-nf", "ep-nf-v");
|
||||
slid("ep-sp", "ep-sp-v");
|
||||
document.getElementById("ep-pol").addEventListener("change",
|
||||
scheduleEdgePreview);
|
||||
// Auto-refresh quando il pannello viene aperto
|
||||
document.getElementById("edge-preview-panel").addEventListener("toggle",
|
||||
(e) => { if (e.target.open) fetchEdgePreview(); });
|
||||
document.getElementById("btn-edge-apply").addEventListener("click", () => {
|
||||
// Copia i valori correnti nei campi avanzati
|
||||
const map = {
|
||||
"ep-weak": "adv-weak_grad",
|
||||
"ep-strong": "adv-strong_grad",
|
||||
"ep-nf": "adv-num_features",
|
||||
"ep-sp": "adv-min_feature_spacing",
|
||||
};
|
||||
for (const [src, dst] of Object.entries(map)) {
|
||||
const dstEl = document.getElementById(dst);
|
||||
if (dstEl) dstEl.value = document.getElementById(src).value;
|
||||
}
|
||||
// use_polarity: alla checkbox della modalita Halcon
|
||||
const polCb = document.getElementById("hc-use-polarity");
|
||||
if (polCb) polCb.checked = document.getElementById("ep-pol").checked;
|
||||
// Apri pannello Avanzate per feedback
|
||||
const advDetails = document.querySelectorAll("#col-params details");
|
||||
advDetails.forEach((d) => { d.open = true; });
|
||||
alert("Parametri edge applicati. Esegui MATCH per usare i valori scelti.");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- CC: Diagnostica match ----------
|
||||
function renderDiag(diag, n_matches) {
|
||||
const el = document.getElementById("diag-content");
|
||||
if (!diag) {
|
||||
el.innerHTML = '<em style="color:#888">Diagnostica non disponibile</em>';
|
||||
return;
|
||||
}
|
||||
const dropTotal = (diag.drop_ncc_low || 0) + (diag.drop_min_score_post_avg || 0)
|
||||
+ (diag.drop_recall_low || 0) + (diag.drop_bbox_out_of_scene || 0)
|
||||
+ (diag.drop_nms_iou || 0);
|
||||
// Hint contestuali se 0 match
|
||||
let hint = "";
|
||||
if (n_matches === 0) {
|
||||
if (diag.n_after_pre_nms === 0) {
|
||||
hint = `<div style="color:#f88; margin-top:6px">⚠ Nessun candidato sopra soglia.
|
||||
Prova: ↓ <b>min_score</b> o ↓ <b>top_thresh</b> (currently ${diag.top_thresh_used.toFixed(2)})</div>`;
|
||||
} else if (diag.drop_ncc_low > 0 && dropTotal === diag.drop_ncc_low) {
|
||||
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_ncc_low} candidati droppati da NCC.
|
||||
Prova: ↓ <b>verify_threshold</b> (filtro_fp più leggero)</div>`;
|
||||
} else if (diag.drop_min_score_post_avg > 0) {
|
||||
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_min_score_post_avg} match sotto min_score post-NCC.
|
||||
Prova: ↓ <b>min_score</b></div>`;
|
||||
} else if (diag.drop_recall_low > 0) {
|
||||
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_recall_low} match con recall < ${diag.min_recall_used}.
|
||||
Prova: ↓ <b>min_recall</b></div>`;
|
||||
} else if (diag.drop_bbox_out_of_scene > 0) {
|
||||
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_bbox_out_of_scene} match con bbox fuori scena.
|
||||
Centro derivato male: aumenta <b>min_score</b> o restringi <b>search_roi</b></div>`;
|
||||
}
|
||||
}
|
||||
const flags = [];
|
||||
if (diag.use_polarity) flags.push("polarity");
|
||||
if (diag.use_soft_score) flags.push("soft");
|
||||
if (diag.subpixel_lm) flags.push("subpix-LM");
|
||||
el.innerHTML = `
|
||||
<div><b>Pipeline pruning:</b></div>
|
||||
<div>varianti: ${diag.n_variants_total} → top_eval=${diag.n_variants_top_evaluated}
|
||||
→ top_pass=${diag.n_variants_top_passed} → full_eval=${diag.n_variants_full_evaluated}</div>
|
||||
<div><b>Candidati:</b> raw=${diag.n_raw_candidates}
|
||||
→ pre_nms=${diag.n_after_pre_nms} → final=${diag.n_final}</div>
|
||||
<div><b>Drop reasons:</b> NCC=${diag.drop_ncc_low}, score=${diag.drop_min_score_post_avg},
|
||||
recall=${diag.drop_recall_low}, bbox=${diag.drop_bbox_out_of_scene}, NMS=${diag.drop_nms_iou}</div>
|
||||
<div><b>Soglie:</b> top=${diag.top_thresh_used.toFixed(2)},
|
||||
min_score=${diag.min_score_used.toFixed(2)},
|
||||
NCC=${diag.verify_threshold_used.toFixed(2)},
|
||||
recall=${diag.min_recall_used.toFixed(2)}</div>
|
||||
${flags.length ? `<div><b>Flag attivi:</b> ${flags.join(", ")}</div>` : ""}
|
||||
${hint}
|
||||
`;
|
||||
// Auto-apri pannello se 0 match (segnala problema)
|
||||
if (n_matches === 0) {
|
||||
document.getElementById("diag-panel").open = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Auto-tune (Halcon-style) ----------
|
||||
async function doAutoTune() {
|
||||
if (!state.model || !state.roi) {
|
||||
alert("Seleziona modello e disegna ROI prima di Auto-tune.");
|
||||
return;
|
||||
}
|
||||
const status = document.getElementById("status");
|
||||
status.textContent = "Analisi ROI in corso...";
|
||||
try {
|
||||
const r = await fetch("/auto_tune", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model_id: state.model.id,
|
||||
roi: state.roi,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const t = await r.json();
|
||||
// Applica ai campi avanzati (override automatico)
|
||||
for (const [key] of ADV_PARAMS) {
|
||||
const el = document.getElementById(`adv-${key}`);
|
||||
if (el && t[key] !== undefined) el.value = String(t[key]);
|
||||
}
|
||||
// Espandi la sezione Avanzate per mostrare i valori applicati
|
||||
const advDetails = document.querySelector("#col-params details:last-of-type");
|
||||
if (advDetails) advDetails.open = true;
|
||||
// Feedback diagnostico
|
||||
const lines = [
|
||||
`weak/strong: ${t.weak_grad} / ${t.strong_grad}`,
|
||||
`feature: ${t.num_features}, piramide: ${t.pyramid_levels}`,
|
||||
`angle: [${t.angle_min}..${t.angle_max}]@${t.angle_step}°`,
|
||||
];
|
||||
if (t._symmetry_order > 1) {
|
||||
lines.push(`simmetria rotaz. ${t._symmetry_order}x (conf ${t._symmetry_conf})`);
|
||||
}
|
||||
if (t._self_score !== undefined) {
|
||||
lines.push(`self-validation: ${t._validation}`);
|
||||
}
|
||||
status.textContent = `Auto-tune OK — ${lines[0]}`;
|
||||
alert("Auto-tune completato:\n\n" + lines.join("\n"));
|
||||
} catch (e) {
|
||||
status.textContent = `Auto-tune errore: ${e.message}`;
|
||||
alert(`Errore auto-tune: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- V: Recipe load/list/unload ----------
|
||||
async function refreshRecipeList() {
|
||||
try {
|
||||
const r = await fetch("/recipes");
|
||||
if (!r.ok) return;
|
||||
const j = await r.json();
|
||||
const sel = document.getElementById("hc-recipe-list");
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">— ricette disponibili —</option>';
|
||||
for (const f of j.files) {
|
||||
const o = document.createElement("option");
|
||||
o.value = f.name;
|
||||
o.textContent = `${f.name} (${(f.size / 1024).toFixed(1)} KB)`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
if (cur) sel.value = cur;
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadRecipe() {
|
||||
const sel = document.getElementById("hc-recipe-list");
|
||||
const name = sel.value;
|
||||
if (!name) {
|
||||
alert("Seleziona una ricetta dalla lista.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/recipes/${encodeURIComponent(name)}/load`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const j = await r.json();
|
||||
state.active_recipe = j.name;
|
||||
document.getElementById("recipe-status").textContent =
|
||||
`Caricata: ${j.name} — ${j.n_variants} varianti, ` +
|
||||
`${j.template_size[0]}x${j.template_size[1]} px` +
|
||||
(j.use_polarity ? " (polarity)" : "");
|
||||
document.getElementById("recipe-status").style.color = "#0c0";
|
||||
document.getElementById("btn-unload-recipe").disabled = false;
|
||||
} catch (e) {
|
||||
alert(`Errore caricamento: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function unloadRecipe() {
|
||||
state.active_recipe = null;
|
||||
document.getElementById("recipe-status").textContent = "Nessuna ricetta caricata";
|
||||
document.getElementById("recipe-status").style.color = "#888";
|
||||
document.getElementById("btn-unload-recipe").disabled = true;
|
||||
}
|
||||
|
||||
// ---------- V: Save recipe ----------
|
||||
async function saveRecipe() {
|
||||
if (!state.model || !state.roi) {
|
||||
alert("Seleziona modello e disegna ROI prima di salvare la ricetta.");
|
||||
return;
|
||||
}
|
||||
const name = document.getElementById("hc-recipe-name").value.trim();
|
||||
if (!name) {
|
||||
alert("Inserisci un nome per la ricetta.");
|
||||
return;
|
||||
}
|
||||
const user = readUserParams();
|
||||
const body = {
|
||||
model_id: state.model.id,
|
||||
scene_id: state.scene?.id || state.model.id,
|
||||
roi: state.roi,
|
||||
tipo: user.tipo,
|
||||
simmetria: user.simmetria,
|
||||
scala: user.scala,
|
||||
precisione: user.precisione,
|
||||
use_polarity: user.use_polarity,
|
||||
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,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/recipes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const j = await r.json();
|
||||
alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`);
|
||||
refreshRecipeList();
|
||||
} catch (e) {
|
||||
alert(`Errore salvataggio: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async () => {
|
||||
buildAdvancedForm();
|
||||
setupROI();
|
||||
@@ -389,6 +788,15 @@ window.addEventListener("DOMContentLoaded", async () => {
|
||||
e.target.value = ""; // consente re-upload stesso file
|
||||
});
|
||||
document.getElementById("btn-match").addEventListener("click", doMatch);
|
||||
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
|
||||
document.getElementById("btn-save-recipe").addEventListener("click",
|
||||
saveRecipe);
|
||||
document.getElementById("btn-load-recipe").addEventListener("click",
|
||||
loadRecipe);
|
||||
document.getElementById("btn-unload-recipe").addEventListener("click",
|
||||
unloadRecipe);
|
||||
refreshRecipeList();
|
||||
bindEdgePreviewControls();
|
||||
const slider = document.getElementById("p-min-score");
|
||||
slider.addEventListener("input", (e) => {
|
||||
document.getElementById("v-score").textContent =
|
||||
|
||||
+120
-1
@@ -26,6 +26,10 @@
|
||||
<div class="picker-list"></div>
|
||||
</div>
|
||||
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
|
||||
<button class="btn" id="btn-autotune"
|
||||
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
|
||||
⚙ Auto-tune
|
||||
</button>
|
||||
<label class="btn" title="Carica nuovo file nella cartella immagini">
|
||||
⬆ Carica file
|
||||
<input type="file" id="file-upload" accept="image/*" hidden>
|
||||
@@ -41,6 +45,40 @@
|
||||
<canvas id="c-model" width="380" height="420"></canvas>
|
||||
</div>
|
||||
<div id="roi-info">ROI: (nessuna)</div>
|
||||
<details id="edge-preview-panel" style="margin-top:10px">
|
||||
<summary>🔬 Anteprima edge / pulizia rumore</summary>
|
||||
<div style="font-size:11px; color:#aaa; margin:4px 0">
|
||||
Regola le soglie per togliere edge spuri (sporcizie). UCS rosso/verde
|
||||
sul baricentro feature.
|
||||
</div>
|
||||
<div class="ep-grid">
|
||||
<label class="ep-row">weak_grad <span id="ep-weak-v">30</span>
|
||||
<input type="range" id="ep-weak" min="5" max="200" value="30" step="1">
|
||||
</label>
|
||||
<label class="ep-row">strong_grad <span id="ep-strong-v">60</span>
|
||||
<input type="range" id="ep-strong" min="10" max="400" value="60" step="1">
|
||||
</label>
|
||||
<label class="ep-row">num_features <span id="ep-nf-v">96</span>
|
||||
<input type="range" id="ep-nf" min="16" max="300" value="96" step="1">
|
||||
</label>
|
||||
<label class="ep-row">spacing <span id="ep-sp-v">3</span>
|
||||
<input type="range" id="ep-sp" min="1" max="15" value="3" step="1">
|
||||
</label>
|
||||
<label class="ep-row" style="flex-direction:row; gap:6px">
|
||||
<input type="checkbox" id="ep-pol"> polarity
|
||||
</label>
|
||||
<button class="btn" id="btn-edge-apply" type="button"
|
||||
style="grid-column:1/-1">
|
||||
✓ Applica ai parametri Avanzate
|
||||
</button>
|
||||
</div>
|
||||
<div class="canvas-wrap" style="margin-top:6px">
|
||||
<canvas id="c-edge-preview" width="380" height="380"></canvas>
|
||||
</div>
|
||||
<div id="edge-preview-info" style="font-size:11px; color:#888; margin-top:4px">
|
||||
Disegna ROI e apri questo pannello per generare anteprima
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="col" id="col-scene">
|
||||
@@ -64,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>
|
||||
@@ -129,6 +167,77 @@
|
||||
<input type="number" id="p-max-matches" value="25" min="1" max="200">
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Modalità Halcon</summary>
|
||||
<div class="halcon-grid">
|
||||
<label class="hc-row" title="16-bin orientation polarity-aware (mod 2π)">
|
||||
<input type="checkbox" id="hc-use-polarity">
|
||||
<span>Polarity 16-bin (F)</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Score continuo cos(θ_t-θ_s) invece di bin">
|
||||
<input type="checkbox" id="hc-soft-score">
|
||||
<span>Soft-margin score (Y)</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Sub-pixel refinement gradient field LM">
|
||||
<input type="checkbox" id="hc-subpixel-lm">
|
||||
<span>Sub-pixel LM 0.05 px (Z)</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Refine congiunto Nelder-Mead (cx,cy,θ)">
|
||||
<input type="checkbox" id="hc-refine-joint">
|
||||
<span>Refine pose joint</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Pyramid candidates propagation">
|
||||
<input type="checkbox" id="hc-pyr-propagate">
|
||||
<span>Pyramid propagate</span>
|
||||
</label>
|
||||
<label class="hc-row" title="OpenCL GPU offload (silent fallback CPU)">
|
||||
<input type="checkbox" id="hc-use-gpu">
|
||||
<span>GPU OpenCL (R)</span>
|
||||
</label>
|
||||
|
||||
<div class="hc-row hc-num">
|
||||
<label>Min recall (M)</label>
|
||||
<input type="number" id="hc-min-recall" value="0.0" min="0" max="1" step="0.05">
|
||||
</div>
|
||||
<div class="hc-row hc-num">
|
||||
<label>NMS IoU thr (A)</label>
|
||||
<input type="number" id="hc-nms-iou" value="0.3" min="0" max="1" step="0.05">
|
||||
</div>
|
||||
<div class="hc-row hc-num">
|
||||
<label>Greediness</label>
|
||||
<input type="number" id="hc-greediness" value="0.0" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
<div class="hc-row hc-num">
|
||||
<label>Coarse stride</label>
|
||||
<input type="number" id="hc-coarse-stride" value="1" min="1" max="4" step="1">
|
||||
</div>
|
||||
<div class="hc-row hc-num" style="grid-column:1/-1">
|
||||
<label title="Limita area di ricerca scena: x,y,w,h (vuoto = tutta scena)">
|
||||
Search ROI (x,y,w,h)
|
||||
</label>
|
||||
<input type="text" id="hc-search-roi" placeholder="es. 100,50,800,400">
|
||||
</div>
|
||||
|
||||
<div class="hc-row" style="grid-column:1/-1; border-top:1px solid #444; padding-top:8px">
|
||||
<label>Ricetta pre-trained (V)</label>
|
||||
<div style="display:flex; gap:6px; margin-top:4px">
|
||||
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
|
||||
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; margin-top:6px; align-items:center">
|
||||
<select id="hc-recipe-list" style="flex:1">
|
||||
<option value="">— ricette disponibili —</option>
|
||||
</select>
|
||||
<button class="btn" id="btn-load-recipe" type="button">📂 Carica</button>
|
||||
<button class="btn" id="btn-unload-recipe" type="button" disabled>✖ Stacca</button>
|
||||
</div>
|
||||
<div id="recipe-status" style="margin-top:4px; font-size:11px; color:#888">
|
||||
Nessuna ricetta caricata
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Avanzate</summary>
|
||||
<div id="adv-form"></div>
|
||||
@@ -139,6 +248,16 @@
|
||||
<div class="kv"><span>find:</span><span id="t-find">-</span></div>
|
||||
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
|
||||
<div class="kv"><span>match:</span><span id="t-match">-</span></div>
|
||||
|
||||
<details id="diag-panel" style="margin-top:10px">
|
||||
<summary>🔍 Diagnostica (CC)</summary>
|
||||
<div id="diag-content" style="font-family:monospace; font-size:11px;
|
||||
background:#1a1a1a; padding:8px;
|
||||
border-radius:3px; margin-top:6px;
|
||||
line-height:1.5">
|
||||
<em style="color:#888">Esegui un MATCH per vedere la diagnostica</em>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -156,3 +156,35 @@ footer h2 {
|
||||
}
|
||||
|
||||
#col-model, #col-scene { min-width: 0; }
|
||||
|
||||
/* Halcon-mode panel */
|
||||
.halcon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 12px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.hc-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.hc-row.hc-num {
|
||||
flex-direction: column; align-items: flex-start;
|
||||
}
|
||||
.hc-row.hc-num label { font-size: 11px; color: #aaa; }
|
||||
.hc-row.hc-num input { width: 100%; }
|
||||
|
||||
/* Edge preview panel */
|
||||
.ep-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 12px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ep-row {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
font-size: 11px; color: #aaa;
|
||||
}
|
||||
.ep-row input[type="range"] { width: 100%; }
|
||||
.ep-row span { color: #fff; font-weight: bold; font-family: monospace; }
|
||||
|
||||
@@ -12,6 +12,10 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.34",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pm2d-eval = "pm2d.eval:main"
|
||||
pm2d-bench = "pm2d.bench:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
|
||||
Reference in New Issue
Block a user