Compare commits

..

10 Commits

Author SHA1 Message Date
Adriano cdd9455d27 merge: test pytest + CI + auto coarse step + DXF/ROI poligonale/export JSON
CI / test (push) Failing after 1m15s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:30:53 +00:00
Adriano e3114d6255 feat: input DXF + ROI poligonale + export JSON + CI Gitea
CI / test (push) Failing after 32s
- pm2d/dxf.py: rasterizzazione DXF -> template (ezdxf, flattening
  entita', scala/centratura, render edge antialiased)
- POST /upload_dxf: carica CAD come modello (size 128..2048)
- roi_poly su /match, /match_simple e POST /recipes: train con mask
  cv2.fillPoly (validazione 400 su poligoni degeneri), cache key inclusa
- UI: upload .dxf, modalita' ROI poligonale su canvas (click=vertice,
  dblclick=chiudi, reset), bottone Esporta JSON dei risultati
- .gitea/workflows/ci.yml: uv sync + ruff + pytest su push/PR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:30:46 +00:00
Adriano 91a6beb032 perf: coarse step angolare auto al top-level (Halcon-style)
Al livello top le feature distano R/2^top dal centro: lo spread tollera
una rotazione ~atan(spread/(max_side_top/2)), molto piu' ampia dello
step full-res. Si valuta al top 1 variante ogni cf_auto (clamp 1..8),
le intermedie vengono riprese dall'espansione ai vicini. Con step 2°
e template 160px: top_eval 180 -> 30 varianti a parita' di recall
(rimosso il forzato cf=1 per step <= 3 che valutava tutto).

Inclusa pulizia lint: variabili/import inutilizzati.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:30:46 +00:00
Adriano 0420c4a863 test: suite pytest sintetica (GT pose note) + deps dev pytest/ruff
11 test senza dipendenza dalle immagini Test/ (non versionate):
- precisione/recall su 7 pose GT (soglie 0.2-0.5 deg, 0.3-1.0 px,
  margine 3-4x sulle misure Fase 2)
- unit: angle_list con estremi, clamp piramide, save/load roundtrip,
  no collisione cache scena, mask poligonale, find non addestrato
Config ruff in pyproject (E702/E402 idiomi del codebase esclusi).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:30:46 +00:00
Adriano caabb05023 chore: de-versiona Test/ + ignora images/ e .omc/
Test/ (34 png, ~8MB) resta su disco ma esce dal versioning: la suite
benchmark le richiede in locale. images/ e' il volume di persistenza
upload della webapp (dati utente), .omc/ stato locale tooling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:08:41 +00:00
Adriano 2f15a37358 merge: precisione rotazione + perf propagate + robustezza server
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:48 +00:00
Adriano 4356a47d06 docs: roadmap Fase 2 (precisione misurata, valutazione C++ vs algoritmico)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:40 +00:00
Adriano 9458173ad0 fix: robustezza web/gui/legacy (lock matcher, LRU cache, clamp ROI, overlay)
- server: lock globale matcher (race nel threadpool FastAPI), LRU su
  _IMG_CACHE e _RECIPE_MATCHERS (leak), clamp ROI in tutti gli endpoint
  (400/422 invece di crash 500, check train senza varianti),
  filtro_fp=off disabilita davvero il verify NCC, fallback FILTRO_FP_MAP
  = medio, verify_threshold ricetta allineato a 0.4, _draw_matches su
  crop locale (era warp+Sobel full-frame per ogni match), spread_radius
  default 5->4
- gui: centro overlay edge (W-1)/2 -> W/2 (coerenza col train),
  spread_radius 5->4
- matcher legacy: _angle_list include estremo, cap candidati top-level,
  save/load persiste template_gray
- auto_tune: ref centrato fuori dal loop angoli
- test_suite: check imread con errore chiaro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:40 +00:00
Adriano cc811fdc94 fix: precisione rotazione sub-0.1° + refine least-squares + propagate windowed
Root cause rotazione imprecisa: score saturo sulla spread bitmap dilatata
(raggio 4-5) -> refine senza gradiente (angolo restava quantizzato allo
step) e minMaxLoc sul plateau spostava il centro sull'angolo finestra
(errore sistematico 3*sqrt(2) px).

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:24 +00:00
Adriano 452810b67a merge: fix overlay shift 2026-05-05 12:45:11 +02:00
53 changed files with 1695 additions and 488 deletions
+31
View File
@@ -0,0 +1,31 @@
# CI Gitea Actions: lint (ruff) + test sintetici (pytest).
# I test non richiedono le immagini in Test/ (sono generati a runtime).
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Installa uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync dipendenze
run: uv sync
- name: Lint (ruff)
# Ignore da CLI (pyproject.toml non va toccato): E501/E741 +
# stile pre-esistente del progetto (E702 statement con ';',
# E402 import dopo setup env, F841/F401 nei moduli legacy).
run: uv run ruff check pm2d/
- name: Test (pytest)
run: uv run pytest tests/ -v
+7
View File
@@ -10,3 +10,10 @@ __pycache__/
models/ models/
# Ricette pre-trained (generate da utente, non versionare) # Ricette pre-trained (generate da utente, non versionare)
recipes/*.npz recipes/*.npz
# Immagini di test locali (richieste da benchmarks/test_suite.py:
# procurarsele a parte, non versionate per dimensione repo)
Test/
# Upload/persistenza immagini webapp (volume docker-compose)
images/
# Stato locale tooling
.omc/
+39 -4
View File
@@ -2,6 +2,36 @@
Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale. Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale.
## Fase 2 COMPLETATA (precisione rotazione + robustezza + perf)
Root cause della rotazione imprecisa: lo score satura a 1.0 sulla spread
bitmap dilatata (raggio 4-5) → il refine non vedeva gradiente né in angolo
né in posizione, e `cv2.minMaxLoc` sul plateau saturo spostava il centro
sull'angolo della finestra (errore sistematico 3·√2 ≈ 4.24 px).
| Fix | Dettaglio |
|---|---|
| Refine su bitmap fine | `_refine_angle` ottimizza su spread raggio 1 (`spread_fine`, cached); score finale ricalcolato su spread coarse per mantenere semantica soglie |
| Picco sub-pixel nel refine | centroide plateau / fit quadratico al posto di minMaxLoc (bias top-left) |
| LM least-squares pos+angolo | `_subpixel_refine_lm` riscritto: snap edge ±2px lungo normale + LSQ 3x3 (dx, dy, dθ), ON di default |
| Round feature offsets | troncamento `astype(int32)``np.round` (bias ~0.25 px) |
| Centro rotazione coerente | `_prepare_padded_template`: rotazione attorno al centro reale del template nel padding (bias ≤0.5 px dipendente dall'angolo) |
| `_angle_list` include estremo | range parziali ±tol ora testano anche +tol |
| `_refine_pose_joint` rimosso | Nelder-Mead su funzione a gradini satura: terminava subito; param ora alias di refine_angle |
| pyramid_propagate di default | kernel windowed (feature campionano l'intera scena: prima il crop troncava le feature → score 0); picchi = massimi locali (non top-K pixel); disattivato automaticamente per template elongati (>2:1) dove il picco top-level non localizza |
| Piramide 3 livelli default | con clamp automatico sulla dimensione template (min 12 px al top) |
| Cache scena: hash completo | prima hashava solo i primi 64KB → collisioni tra scene con stessa banda superiore → risultati della scena sbagliata |
| Web server | lock matcher (race con threadpool FastAPI), LRU `_IMG_CACHE`, clamp ROI ovunque (400/422 invece di 500), `filtro_fp=off` disabilita davvero NCC, `_draw_matches` su crop locale |
| GUI/legacy | centro overlay `(W-1)/2``W/2`, spread_radius default 5→4, EdgeShapeMatcher: angle list endpoint + cap candidati + save template_gray |
Misure (GT sintetica 7 pose, scena 900x700, VPS 2 core):
- Errore angolare mediano: **2.3° → 0.05°** (step 5°); a step 2° era 4.4° → **0.03°**
- Errore posizione mediano: **4.24 px → 0.04 px**
- find GT scene: 4.7s → 1.7s; scena reale 646x482: 1.14s → 0.81s
- Benchmark suite 16 scenari: 96.5s → 84.2s, match count ≥ baseline
(eccezioni: dado_full -1 = match borderline su parte diversa;
lama_part_preciso 25→18 con baseline al cap max_matches)
## Fase 1 COMPLETATA (branch `speedFase1`) ## Fase 1 COMPLETATA (branch `speedFase1`)
| ID | Voce | Status | Note | | ID | Voce | Status | Note |
@@ -84,9 +114,14 @@ Benchmark suite 16 scenari (4 immagini × full/part × fast/preciso):
## Target performance produzione ## Target performance produzione
Obiettivi da documento tecnico Vision Suite (Fase Beta): Obiettivi da documento tecnico Vision Suite (Fase Beta):
- [ ] **Precisione posizionale mediana**: <0.5 px → **raggiunto con subpixel (attualmente ~0.1-0.3 px atteso)** - [x] **Precisione posizionale mediana**: <0.5 px → **0.04 px misurato su GT sintetica (Fase 2)**
- [ ] **Precisione angolare mediana**: <1.0° → **raggiunto con refinement (~0.5°)** - [x] **Precisione angolare mediana**: <1.0° → **0.05° misurato su GT sintetica (Fase 2)**
- [ ] **Latency mediana**: <50 ms su 1920×1080 → **attuale ~1.7s su 830×822 (serve GPU o ulteriore CPU)** - [ ] **Latency mediana**: <50 ms su 1920×1080 → **~0.8s su 646×482 con 2 core; da misurare su hardware produzione**
- [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico** - [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico**
Prossimo blocker per target: **latency**. Via più promettente: GPU (CuPy) o coarse-to-fine angolare. Prossimo blocker per target: **latency**. Nota: i kernel hot sono gia'
Numba JIT (≈ velocita' C, prange parallelo): un port C++ dei kernel vale
solo il margine SIMD esplicito (~2-4x con AVX2 su AND+popcount byte-wise).
Prima di scriverlo conviene esaurire le vie algoritmiche rimaste:
riduzione varianti al top-level (auto angle step per livello, stile
Halcon), greediness di default, e GPU (CuPy/OpenCL) per scene 1080p.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

+5
View File
@@ -36,6 +36,11 @@ CONFIGS = [
def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str, def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str,
cfg_name: str, cfg: dict) -> dict: cfg_name: str, cfg: dict) -> dict:
scene = cv2.imread(str(TEST_DIR / img_path)) scene = cv2.imread(str(TEST_DIR / img_path))
if scene is None:
# cv2.imread ritorna None silenzioso: senza check il crash arriva
# dopo, sullo slice, con un errore criptico.
raise FileNotFoundError(
f"Immagine di test non trovata o non leggibile: {TEST_DIR / img_path}")
y0, y1, x0, x1 = roi_box y0, y1, x0, x1 = roi_box
roi = scene[y0:y1, x0:x1].copy() roi = scene[y0:y1, x0:x1].copy()
m = LineShapeMatcher( m = LineShapeMatcher(
+144
View File
@@ -271,6 +271,108 @@ if HAS_NUMBA:
acc[y, x] = 0.0 acc[y, x] = 0.0
return acc return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_window(
spread: np.ndarray, # uint8 (H, W) - scena INTERA
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
bg: np.ndarray, # float32 (H, W) - scena intera
y0: nb.int64, x0: nb.int64,
wh: nb.int64, ww: nb.int64,
) -> np.ndarray:
"""Score rescored valutato SOLO nella finestra (y0, x0, wh, ww).
Le feature campionano lo spread dell'intera scena (bounds-checked
sui bordi scena): a differenza di chiamare il kernel su un crop,
le feature che escono dalla finestra NON contano come miss.
Usato dal path pyramid_propagate: costo ∝ area finestra.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((wh, ww), dtype=np.float32)
for yi in nb.prange(wh):
y = y0 + yi
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
yy = y + dy[i]
if yy < 0 or yy >= H:
continue
ddx = dx[i]
xi_lo = 0
xi_hi = ww
lo = -(x0 + ddx)
if lo > xi_lo:
xi_lo = lo
hi = W - (x0 + ddx)
if hi < xi_hi:
xi_hi = hi
for xi in range(xi_lo, xi_hi):
if spread[yy, x0 + xi + ddx] & mask:
acc[yi, xi] += 1.0
if N > 0:
inv = 1.0 / N
for yi in nb.prange(wh):
for xi in range(ww):
v = acc[yi, xi] * inv
bgv = bg[y0 + yi, x0 + xi]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[yi, xi] = r if r > 0.0 else 0.0
else:
acc[yi, xi] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_window_u16(
spread: np.ndarray, # uint16 (H, W)
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint16,
bg: np.ndarray,
y0: nb.int64, x0: nb.int64,
wh: nb.int64, ww: nb.int64,
) -> np.ndarray:
"""Versione uint16 (polarity 16-bin) del kernel windowed."""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((wh, ww), dtype=np.float32)
for yi in nb.prange(wh):
y = y0 + yi
for i in range(N):
b = bins[i]
mask = np.uint16(1) << b
if (bit_active & mask) == 0:
continue
yy = y + dy[i]
if yy < 0 or yy >= H:
continue
ddx = dx[i]
xi_lo = 0
xi_hi = ww
lo = -(x0 + ddx)
if lo > xi_lo:
xi_lo = lo
hi = W - (x0 + ddx)
if hi < xi_hi:
xi_hi = hi
for xi in range(xi_lo, xi_hi):
if spread[yy, x0 + xi + ddx] & mask:
acc[yi, xi] += 1.0
if N > 0:
inv = 1.0 / N
for yi in nb.prange(wh):
for xi in range(ww):
v = acc[yi, xi] * inv
bgv = bg[y0 + yi, x0 + xi]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[yi, xi] = r if r > 0.0 else 0.0
else:
acc[yi, xi] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False) @nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_top_max_per_variant( def _jit_top_max_per_variant(
spread: np.ndarray, # uint8 (H, W) spread: np.ndarray, # uint8 (H, W)
@@ -426,6 +528,9 @@ if HAS_NUMBA:
_jit_top_max_per_variant( _jit_top_max_per_variant(
spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx, spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx,
) )
_jit_score_bitmap_rescored_window(
spread, dx, dy, b, np.uint8(0xFF), bg, 4, 4, 8, 8,
)
_jit_popcount_density(spread) _jit_popcount_density(spread)
spread16 = np.zeros((32, 32), dtype=np.uint16) spread16 = np.zeros((32, 32), dtype=np.uint16)
_jit_score_bitmap_rescored_u16( _jit_score_bitmap_rescored_u16(
@@ -447,6 +552,12 @@ else: # pragma: no cover
def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride): def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_window(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_window_u16(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness): def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
@@ -524,6 +635,39 @@ def score_bitmap_rescored(
return np.maximum(0.0, out).astype(np.float32) return np.maximum(0.0, out).astype(np.float32)
def score_bitmap_rescored_window(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, bg: np.ndarray,
y0: int, x0: int, wh: int, ww: int,
) -> np.ndarray:
"""Score rescored solo nella finestra (y0, x0, wh, ww) della scena.
Le feature campionano l'INTERA scena: feature fuori finestra ma dentro
scena contano correttamente (chiamare il kernel su un crop le tratta
come miss e azzera lo score — il bug che rendeva inutilizzabile il
path pyramid_propagate). Fallback no-numba: kernel pieno + slice.
"""
if HAS_NUMBA and len(dx) > 0:
dx_c = np.ascontiguousarray(dx, dtype=np.int32)
dy_c = np.ascontiguousarray(dy, dtype=np.int32)
bins_c = np.ascontiguousarray(bins, dtype=np.int8)
bg_c = np.ascontiguousarray(bg, dtype=np.float32)
if spread.dtype == np.uint16:
return _jit_score_bitmap_rescored_window_u16(
np.ascontiguousarray(spread, dtype=np.uint16),
dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
int(y0), int(x0), int(wh), int(ww),
)
return _jit_score_bitmap_rescored_window(
np.ascontiguousarray(spread, dtype=np.uint8),
dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
int(y0), int(x0), int(wh), int(ww),
)
# Fallback (lento, solo senza numba): score full-frame + slice finestra
full = score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg)
return full[y0:y0 + wh, x0:x0 + ww]
def score_bitmap_greedy( def score_bitmap_greedy(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, min_score: float, greediness: float, bit_active: int, min_score: float, greediness: float,
+2 -1
View File
@@ -61,6 +61,8 @@ def detect_rotational_symmetry(
center = (w / 2.0, h / 2.0) center = (w / 2.0, h / 2.0)
ref = mag ref = mag
# ref è costante nel loop sugli angoli: centra una volta sola
rm = ref - ref.mean()
correlations: list[tuple[float, float]] = [] correlations: list[tuple[float, float]] = []
for ang in np.arange(step_deg, 360.0, step_deg): for ang in np.arange(step_deg, 360.0, step_deg):
@@ -68,7 +70,6 @@ def detect_rotational_symmetry(
rot = cv2.warpAffine( rot = cv2.warpAffine(
mag, M, (w, h), borderValue=0.0, mag, M, (w, h), borderValue=0.0,
) )
rm = ref - ref.mean()
rs = rot - rot.mean() rs = rot - rot.mean()
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9 denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
c = float((rm * rs).sum() / denom) c = float((rm * rs).sum() / denom)
+119
View File
@@ -0,0 +1,119 @@
"""Rasterizzazione DXF → immagine template per il matcher shape-based.
Il matcher lavora sui gradienti degli edge: un line-drawing pulito
(sfondo grigio scuro, tratti chiari) è un template perfettamente valido.
Questo modulo converte un file DXF (CAD 2D) in una bitmap grayscale
centrata e scalata, pronta per train().
"""
from __future__ import annotations
import io
import cv2
import numpy as np
# Valori di rendering: sfondo scuro / tratto chiaro → gradiente netto
BG_GRAY = 60
LINE_GRAY = 220
def _read_doc(data: bytes):
"""Parse DXF da bytes con gestione encoding.
Prima prova ezdxf.read su StringIO (DXF ASCII utf-8 / cp1252),
poi fallback su ezdxf.recover che auto-rileva encoding e tollera
file malformati.
"""
import ezdxf
from ezdxf import recover
for enc in ("utf-8", "cp1252"):
try:
text = data.decode(enc)
return ezdxf.read(io.StringIO(text))
except Exception:
# UnicodeDecodeError, DXFStructureError e simili: prossimo tentativo
continue
# Ultimo tentativo: recover lavora direttamente sui bytes
try:
doc, _auditor = recover.read(io.BytesIO(data))
return doc
except Exception as e:
raise ValueError(f"DXF illeggibile o corrotto: {e}") from e
def _extract_polylines(doc, flatten_dist: float = 0.05) -> tuple[list[np.ndarray], int]:
"""Converte le entità del modelspace in polilinee (liste di punti XY).
Entità non convertibili (non supportate da make_path) vengono saltate
silenziosamente ma conteggiate. Ritorna (polilinee, n_saltate).
"""
from ezdxf import path as ezpath
polylines: list[np.ndarray] = []
skipped = 0
for entity in doc.modelspace():
try:
p = ezpath.make_path(entity)
pts = np.array(
[(v.x, v.y) for v in p.flattening(distance=flatten_dist)],
dtype=np.float64,
)
if len(pts) >= 2:
polylines.append(pts)
except Exception:
skipped += 1
return polylines, skipped
def dxf_to_image(data: bytes, target_size: int = 512,
line_thickness: int = 2, margin: int = 16) -> np.ndarray:
"""Rasterizza un DXF in immagine grayscale (H, W) uint8.
- Scala uniforme: il lato lungo del disegno = target_size - 2*margin.
- Disegno centrato, asse Y CAD (su) ribaltato in convenzione immagine.
- Sfondo grigio scuro (60), tratti chiari (220), antialiased.
Solleva ValueError se il DXF è vuoto o illeggibile.
"""
doc = _read_doc(data)
# Distanza di flattening provvisoria in unità CAD: raffinata sotto
# una volta nota la scala (qui serve solo per il bounding box).
polylines, skipped = _extract_polylines(doc)
if not polylines:
raise ValueError(
"DXF vuoto: nessuna entità convertibile in polilinea nel "
f"modelspace ({skipped} entità non supportate saltate)")
all_pts = np.vstack(polylines)
min_xy = all_pts.min(axis=0)
max_xy = all_pts.max(axis=0)
extent = max_xy - min_xy
long_side = float(extent.max())
if long_side <= 0:
raise ValueError("DXF degenere: bounding box con estensione nulla")
# Ri-flattening con distanza adattiva: ~0.25 px di errore alla scala
# finale (il primo pass usava una tolleranza in unità CAD arbitraria).
avail = max(1, target_size - 2 * margin)
scale = avail / long_side
polylines, _ = _extract_polylines(doc, flatten_dist=max(1e-9, 0.25 / scale))
canvas = np.full((target_size, target_size), BG_GRAY, dtype=np.uint8)
# Offset per centrare il disegno (anche sul lato corto)
draw_w = extent[0] * scale
draw_h = extent[1] * scale
off_x = (target_size - draw_w) / 2.0
off_y = (target_size - draw_h) / 2.0
for pts in polylines:
px = (pts[:, 0] - min_xy[0]) * scale + off_x
# Y CAD verso l'alto → Y immagine verso il basso
py = (max_xy[1] - pts[:, 1]) * scale + off_y
ipts = np.stack([px, py], axis=1).round().astype(np.int32)
cv2.polylines(canvas, [ipts], isClosed=False,
color=LINE_GRAY, thickness=line_thickness,
lineType=cv2.LINE_AA)
return canvas
+7 -4
View File
@@ -12,7 +12,6 @@ Tutta la logica algoritmica vive in pm2d.matcher.EdgeShapeMatcher.
from __future__ import annotations from __future__ import annotations
import sys
from pathlib import Path from pathlib import Path
from tkinter import Tk, filedialog from tkinter import Tk, filedialog
import tkinter as tk import tkinter as tk
@@ -196,8 +195,10 @@ def _warp_template_edges_to_scene(
edge = cv2.Canny(template_gray, canny_low, canny_high) edge = cv2.Canny(template_gray, canny_low, canny_high)
# Matrice affine: scala + rotazione attorno al centro template, poi traslazione # Matrice affine: scala + rotazione attorno al centro template, poi traslazione
Ht, Wt = h, w Ht, Wt = h, w
cx_t = (Wt - 1) / 2.0 # Centro coerente con la convenzione train (center = w / 2.0, no -1):
cy_t = (Ht - 1) / 2.0 # (Wt-1)/2 introduceva uno shift di 0.5px per template di lato pari.
cx_t = Wt / 2.0
cy_t = Ht / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale) M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
# Traslazione per portare centro template a (cx, cy) della scena # Traslazione per portare centro template a (cx, cy) della scena
M[0, 2] += cx - cx_t M[0, 2] += cx - cx_t
@@ -492,7 +493,9 @@ def run(
num_features: int = 96, num_features: int = 96,
weak_grad: float = 30.0, weak_grad: float = 30.0,
strong_grad: float = 60.0, strong_grad: float = 60.0,
spread_radius: int = 5, # 4 allineato col default del matcher: raggio 5 peggiora la precisione
# di rotazione (spread troppo largo appiattisce il picco angolare).
spread_radius: int = 4,
pyramid_levels: int = 3, pyramid_levels: int = 3,
min_score: float = 0.55, min_score: float = 0.55,
max_matches: int = 25, max_matches: int = 25,
+368 -315
View File
@@ -38,8 +38,8 @@ _GOLDEN = (math.sqrt(5.0) - 1.0) / 2.0 # ≈ 0.618
from pm2d._jit_kernels import ( from pm2d._jit_kernels import (
score_by_shift as _jit_score_by_shift, score_by_shift as _jit_score_by_shift,
score_bitmap as _jit_score_bitmap,
score_bitmap_rescored as _jit_score_bitmap_rescored, score_bitmap_rescored as _jit_score_bitmap_rescored,
score_bitmap_rescored_window as _jit_score_bitmap_rescored_window,
score_bitmap_greedy as _jit_score_bitmap_greedy, score_bitmap_greedy as _jit_score_bitmap_greedy,
top_max_per_variant as _jit_top_max_per_variant, top_max_per_variant as _jit_top_max_per_variant,
popcount_density as _jit_popcount, popcount_density as _jit_popcount,
@@ -172,7 +172,7 @@ class LineShapeMatcher:
scale_step: float = 0.1, scale_step: float = 0.1,
spread_radius: int = 4, spread_radius: int = 4,
min_feature_spacing: int = 3, min_feature_spacing: int = 3,
pyramid_levels: int = 2, pyramid_levels: int = 3,
top_score_factor: float = 0.5, top_score_factor: float = 0.5,
n_threads: int | None = None, n_threads: int | None = None,
use_polarity: bool = False, use_polarity: bool = False,
@@ -325,8 +325,6 @@ class LineShapeMatcher:
n_vars = len(self.variants) n_vars = len(self.variants)
n_levels = len(self.variants[0].levels) n_levels = len(self.variants[0].levels)
var_meta = np.zeros((n_vars, 6), dtype=np.float32) # ang, scale, kh, kw, cxl, cyl var_meta = np.zeros((n_vars, 6), dtype=np.float32) # ang, scale, kh, kw, cxl, cyl
all_dx, all_dy, all_bin, all_offsets = [], [], [], []
offset = 0
all_offsets_per_level = [[] for _ in range(n_levels)] all_offsets_per_level = [[] for _ in range(n_levels)]
all_dx_per_level = [[] for _ in range(n_levels)] all_dx_per_level = [[] for _ in range(n_levels)]
all_dy_per_level = [[] for _ in range(n_levels)] all_dy_per_level = [[] for _ in range(n_levels)]
@@ -473,8 +471,46 @@ class LineShapeMatcher:
step = self._effective_angle_step() step = self._effective_angle_step()
if step <= 0 or a0 >= a1: if step <= 0 or a0 >= a1:
return [float(a0)] return [float(a0)]
n = int(np.floor((a1 - a0) / step)) # Include l'estremo superiore: con range parziali (es. ±15°) il
return [float(a0 + i * step) for i in range(n)] # +15° deve essere testato quanto il -15°. Se il range copre 360°
# interi l'estremo coincide con a0 (mod 360) e viene escluso per
# non duplicare la variante.
n = int(np.floor((a1 - a0) / step + 1e-9)) + 1
angles = [float(a0 + i * step) for i in range(n)]
if a1 - a0 >= 360.0:
angles = [a for a in angles if a - a0 < 360.0 - 1e-9]
return angles
def _prepare_padded_template(
self, template_gray: np.ndarray, mask_full: np.ndarray, scale: float,
) -> tuple[np.ndarray, np.ndarray, tuple[float, float], int]:
"""Scala + padda template e mask; ritorna (gray_p, mask_p, center, diag).
`center` e' il centro REALE del template dentro l'immagine paddata
(px + sw/2, py + sh/2): con padding floor differisce da diag/2 fino
a 0.5 px. Ruotare attorno a diag/2 (come si faceva prima) faceva
orbitare il centro-modello attorno al centro di rotazione, con un
bias di posizione dipendente dall'angolo. Tutti i percorsi che
ricostruiscono il template ruotato devono usare QUESTO helper.
"""
h, w = template_gray.shape
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2
px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE,
)
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (px + sw / 2.0, py + sh / 2.0)
return gray_p, mask_p, center, diag
# --- Training ------------------------------------------------------ # --- Training ------------------------------------------------------
@@ -504,6 +540,13 @@ class LineShapeMatcher:
h, w = gray.shape h, w = gray.shape
self.template_size = (w, h) self.template_size = (w, h)
self.template_gray = gray.copy() self.template_gray = gray.copy()
# Clamp livelli piramide alla dimensione template: al top-level il
# lato minimo deve restare >= 12 px, sotto le feature collassano
# tutte negli stessi (dx,dy) e lo score top diventa rumore.
max_lv = 1
while min(w, h) / (2 ** max_lv) >= 12 and max_lv < 4:
max_lv += 1
self.pyramid_levels = max(1, min(self.pyramid_levels, max_lv))
if mask is None: if mask is None:
mask_full = np.full((h, w), 255, dtype=np.uint8) mask_full = np.full((h, w), 255, dtype=np.uint8)
else: else:
@@ -566,24 +609,10 @@ class LineShapeMatcher:
Estrazione algorithm identica al train() originale, separato per Estrazione algorithm identica al train() originale, separato per
riuso da add_template_view (multi-template ensemble). riuso da add_template_view (multi-template ensemble).
""" """
h, w = gray.shape
for s in self._scale_list(): for s in self._scale_list():
sw = max(16, int(round(w * s))) gray_p, mask_p, center, diag = self._prepare_padded_template(
sh = max(16, int(round(h * s))) gray, mask_full, s,
gray_s = cv2.resize(gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2
px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE,
) )
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
for ang in self._angle_list(): for ang in self._angle_list():
M = cv2.getRotationMatrix2D(center, ang, 1.0) M = cv2.getRotationMatrix2D(center, ang, 1.0)
@@ -600,10 +629,10 @@ class LineShapeMatcher:
if len(fx) < 8: if len(fx) < 8:
continue continue
cx_c = diag / 2.0 # round (non truncation): astype(int32) tronca verso zero
cy_c = diag / 2.0 # e introduceva un bias sistematico ~0.25 px verso il centro.
dx = (fx - cx_c).astype(np.int32) dx = np.round(fx - center[0]).astype(np.int32)
dy = (fy - cy_c).astype(np.int32) dy = np.round(fy - center[1]).astype(np.int32)
x0 = int(dx.min()); x1 = int(dx.max()) x0 = int(dx.min()); x1 = int(dx.max())
y0 = int(dy.min()); y1 = int(dy.max()) y0 = int(dy.min()); y1 = int(dy.max())
@@ -687,8 +716,13 @@ class LineShapeMatcher:
try: try:
import hashlib import hashlib
h = hashlib.md5() h = hashlib.md5()
sample = gray.tobytes()[:65536] # Hash dell'INTERA scena: hashare solo i primi 64KB (prime
h.update(sample) # ~80 righe a 830px) faceva collidere scene con la stessa
# banda superiore (es. sfondo uniforme da camera fissa) →
# find() ritornava i risultati della scena sbagliata.
# tobytes() copiava gia' tutto il buffer, il costo extra
# dell'md5 completo e' ~1ms.
h.update(gray.tobytes())
h.update(f"|{gray.shape}|{gray.dtype}".encode()) h.update(f"|{gray.shape}|{gray.dtype}".encode())
h.update( h.update(
f"|{self.weak_grad}|{self.strong_grad}" f"|{self.weak_grad}|{self.strong_grad}"
@@ -717,11 +751,17 @@ class LineShapeMatcher:
while len(self._scene_cache) > self._SCENE_CACHE_SIZE: while len(self._scene_cache) > self._SCENE_CACHE_SIZE:
self._scene_cache.popitem(last=False) self._scene_cache.popitem(last=False)
def _spread_bitmap(self, gray: np.ndarray) -> np.ndarray: def _spread_bitmap(
self, gray: np.ndarray, radius: int | None = None,
) -> np.ndarray:
"""Spread bitmap: bit b acceso dove bin b è presente nel raggio. """Spread bitmap: bit b acceso dove bin b è presente nel raggio.
dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity). dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity).
Se use_gpu=True: Sobel + dilate via cv2.UMat (OpenCL kernel GPU). Se use_gpu=True: Sobel + dilate via cv2.UMat (OpenCL kernel GPU).
radius: override del raggio di spread (default self.spread_radius).
radius=0/1 produce una bitmap "fine" senza tolleranza, usata nel
refine finale: sulla bitmap dilatata lo score satura e il refine
non distingue pose entro ±spread_radius px / ±atan(spread/R) gradi.
""" """
if self.use_gpu and not isinstance(gray, cv2.UMat): if self.use_gpu and not isinstance(gray, cv2.UMat):
gray_in = cv2.UMat(np.ascontiguousarray(gray)) gray_in = cv2.UMat(np.ascontiguousarray(gray))
@@ -729,7 +769,8 @@ class LineShapeMatcher:
gray_in = gray gray_in = gray
mag, bins = self._gradient(gray_in) mag, bins = self._gradient(gray_in)
valid = mag >= self.weak_grad valid = mag >= self.weak_grad
k = 2 * self.spread_radius + 1 r = self.spread_radius if radius is None else max(0, int(radius))
k = 2 * r + 1
kernel = np.ones((k, k), dtype=np.uint8) kernel = np.ones((k, k), dtype=np.uint8)
H, W = (gray.shape if isinstance(gray, np.ndarray) H, W = (gray.shape if isinstance(gray, np.ndarray)
else (gray.get().shape[0], gray.get().shape[1])) else (gray.get().shape[0], gray.get().shape[1]))
@@ -755,7 +796,9 @@ class LineShapeMatcher:
if not bin_present[b]: if not bin_present[b]:
continue # XX: nessun pixel di questo bin sopra weak_grad continue # XX: nessun pixel di questo bin sopra weak_grad
mask_b = ((bins == b) & valid).astype(np.uint8) mask_b = ((bins == b) & valid).astype(np.uint8)
if self.use_gpu: if r == 0:
d_np = mask_b
elif self.use_gpu:
d = cv2.dilate(cv2.UMat(mask_b), kernel) d = cv2.dilate(cv2.UMat(mask_b), kernel)
d_np = d.get() d_np = d.get()
else: else:
@@ -828,111 +871,9 @@ class LineShapeMatcher:
oy = float(np.clip(oy, -0.5, 0.5)) oy = float(np.clip(oy, -0.5, 0.5))
return x + ox, y + oy return x + ox, y + oy
def _refine_pose_joint(
self,
spread0: np.ndarray,
template_gray: np.ndarray,
cx: float, cy: float,
angle_deg: float, scale: float,
mask_full: np.ndarray,
max_iter: int = 24,
tol: float = 1e-3,
) -> tuple[float, float, float, float]:
"""Refine congiunto (cx, cy, angle) via Nelder-Mead 3D.
Ottimizza simultaneamente posizione e angolo (vs golden search 1D
sull'angolo poi quadratico 2D su xy che alterna assi). Halcon-style:
un singolo iter LM stila il match a precisione sub-pixel + sub-step.
Ritorna (angle, score, cx, cy) dove score e quello calcolato sulla
scena spread (no template gray).
"""
h, w = template_gray.shape
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE)
mask_p = cv2.copyMakeBorder(mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0)
center = (diag / 2.0, diag / 2.0)
H, W = spread0.shape
def _score(params: tuple[float, float, float]) -> float:
ddx, ddy, dang = params
ang = angle_deg + dang
M = cv2.getRotationMatrix2D(center, ang, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE)
mask_r = cv2.warpAffine(mask_p, M, (diag, diag),
flags=cv2.INTER_NEAREST, borderValue=0)
mag, bins = self._gradient(gray_r)
fx, fy, fb = self._extract_features(mag, bins, mask_r)
if len(fx) < 8:
return 0.0
cxe = cx + ddx; cye = cy + ddy
ix = int(round(cxe)); iy = int(round(cye))
tot = 0
valid = 0
for i in range(len(fx)):
xs = ix + int(fx[i] - center[0])
ys = iy + int(fy[i] - center[1])
if 0 <= xs < W and 0 <= ys < H:
bit = np.uint8(1 << int(fb[i]))
if spread0[ys, xs] & bit:
tot += 1
valid += 1
return -float(tot) / max(1, valid) # minimize -score
# Nelder-Mead 3D inline (no scipy). Simplex iniziale: vertice + offset
# dx=±0.5px, dy=±0.5px, dθ=±step/2.
step_a = self.angle_step_deg / 2.0 if self.angle_step_deg > 0 else 1.0
x0 = np.array([0.0, 0.0, 0.0])
simplex = np.array([
x0,
x0 + [0.5, 0.0, 0.0],
x0 + [0.0, 0.5, 0.0],
x0 + [0.0, 0.0, step_a],
])
fvals = np.array([_score(tuple(s)) for s in simplex])
for _ in range(max_iter):
order = np.argsort(fvals)
simplex = simplex[order]; fvals = fvals[order]
if abs(fvals[-1] - fvals[0]) < tol:
break
centroid = simplex[:-1].mean(axis=0)
xr = centroid + 1.0 * (centroid - simplex[-1])
fr = _score(tuple(xr))
if fvals[0] <= fr < fvals[-2]:
simplex[-1] = xr; fvals[-1] = fr
continue
if fr < fvals[0]:
xe = centroid + 2.0 * (centroid - simplex[-1])
fe = _score(tuple(xe))
if fe < fr:
simplex[-1] = xe; fvals[-1] = fe
else:
simplex[-1] = xr; fvals[-1] = fr
continue
xc = centroid + 0.5 * (simplex[-1] - centroid)
fc = _score(tuple(xc))
if fc < fvals[-1]:
simplex[-1] = xc; fvals[-1] = fc
continue
for k in range(1, 4):
simplex[k] = simplex[0] + 0.5 * (simplex[k] - simplex[0])
fvals[k] = _score(tuple(simplex[k]))
best_i = int(np.argmin(fvals))
ddx, ddy, dang = simplex[best_i]
return (angle_deg + float(dang), -float(fvals[best_i]),
cx + float(ddx), cy + float(ddy))
def _refine_angle( def _refine_angle(
self, self,
spread0: np.ndarray, # bitmap uint8 (H, W) spread0: np.ndarray, # bitmap uint8/uint16 (H, W) - spread pieno
bit_active: int, bit_active: int,
template_gray: np.ndarray, template_gray: np.ndarray,
cx: float, cy: float, cx: float, cy: float,
@@ -941,33 +882,31 @@ class LineShapeMatcher:
angle_fine_step: float = 0.5, angle_fine_step: float = 0.5,
search_radius: float | None = None, search_radius: float | None = None,
original_score: float | None = None, original_score: float | None = None,
spread_fine: np.ndarray | None = None,
) -> tuple[float, float, float, float]: ) -> tuple[float, float, float, float]:
"""Ricerca angolare fine (sub-step) attorno al match grezzo. """Ricerca angolare fine (sub-step) attorno al match grezzo.
Genera 5 template temporanei a angle ± {0.5, 1.0} * step e sceglie Golden-section sull'angolo + argmax posizione in finestra ±3 px.
l'angolo con score massimo (parabolic fit sulle 3 score centrali).
Ritorna (angle_refined, score, cx_refined, cy_refined). Ritorna (angle_refined, score, cx_refined, cy_refined).
L'ottimizzazione gira sulla bitmap FINE (spread_fine, raggio 1):
sulla bitmap dilatata (spread0, raggio 4-5) lo score satura a 1.0
per qualunque posa entro ±spread px / ±atan(spread/R) gradi e il
refine non vede alcun gradiente (l'angolo restava quello grezzo
quantizzato e cv2.minMaxLoc sul plateau saturo spostava il centro
sull'angolo in alto a sinistra della finestra: errore misurato
3·sqrt(2) 4.24 px). Lo score RITORNATO e' ricalcolato alla posa
raffinata su spread0, per mantenere la semantica precedente
(tolleranza spread_radius) su soglie/min_score.
""" """
# NB: rimosso early-skip su score >= 0.99. Lo score linemod/shape
# satura facilmente a 1.0 (specie con pyramid_propagate o spread
# ampio) ma NON garantisce angolo preciso: l'angolo grezzo della
# variante e' quantizzato a multipli di angle_step (5 deg default).
# Refine angolare e' essenziale per orientamento sub-step.
if search_radius is None: if search_radius is None:
search_radius = self._effective_angle_step() search_radius = self._effective_angle_step()
# Bitmap su cui ottimizzare: fine se disponibile, altrimenti spread0.
opt_map = spread_fine if spread_fine is not None else spread0
h, w = template_gray.shape gray_p, mask_p, center, diag = self._prepare_padded_template(
sw = max(16, int(round(w * scale))) template_gray, mask_full, scale,
sh = max(16, int(round(h * scale))) )
gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE)
mask_p = cv2.copyMakeBorder(mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0)
center = (diag / 2.0, diag / 2.0)
H, W = spread0.shape H, W = spread0.shape
margin = 3 margin = 3
@@ -982,14 +921,11 @@ class LineShapeMatcher:
feat_cache = self._refine_feat_cache feat_cache = self._refine_feat_cache
cache_scale_key = round(scale * 1000) cache_scale_key = round(scale * 1000)
def _score_at_angle(off: float) -> tuple[float, float, float]: def _feats_at_angle(ang: float):
"""Ritorna (score, best_cx, best_cy) per angolo = angle_deg + off."""
ang = angle_deg + off
ck = (round(ang * 20), cache_scale_key) ck = (round(ang * 20), cache_scale_key)
cached = feat_cache.get(ck) cached = feat_cache.get(ck)
if cached is not None: if cached is not None:
fx, fy, fb = cached return cached
else:
M = cv2.getRotationMatrix2D(center, ang, 1.0) M = cv2.getRotationMatrix2D(center, ang, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1002,15 +938,27 @@ class LineShapeMatcher:
if len(feat_cache) > 256: if len(feat_cache) > 256:
feat_cache.pop(next(iter(feat_cache))) feat_cache.pop(next(iter(feat_cache)))
feat_cache[ck] = (fx, fy, fb) feat_cache[ck] = (fx, fy, fb)
return fx, fy, fb
def _score_at_angle(off: float) -> tuple[float, float, float]:
"""Ritorna (score, best_cx, best_cy) per angolo = angle_deg + off.
Score = max su finestra ±margin px attorno a (cx, cy) sulla
bitmap di ottimizzazione; posizione = picco sub-pixel della
finestra (centroide plateau / fit quadratico, NON minMaxLoc
che sul plateau e' biased verso l'angolo top-left).
"""
ang = angle_deg + off
fx, fy, fb = _feats_at_angle(ang)
if len(fx) < 8: if len(fx) < 8:
return (0.0, cx, cy) return (0.0, cx, cy)
dx = (fx - center[0]).astype(np.int32) dx = np.round(fx - center[0]).astype(np.int32)
dy = (fy - center[1]).astype(np.int32) dy = np.round(fy - center[1]).astype(np.int32)
y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1 y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1
x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1 x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1
sh_w = y_hi - y_lo; sw_w = x_hi - x_lo sh_w = y_hi - y_lo; sw_w = x_hi - x_lo
acc = np.zeros((sh_w, sw_w), dtype=np.float32) acc = np.zeros((sh_w, sw_w), dtype=np.float32)
spread_dtype = spread0.dtype.type spread_dtype = opt_map.dtype.type
for i in range(len(dx)): for i in range(len(dx)):
ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i]) ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i])
bit = spread_dtype(1 << b) bit = spread_dtype(1 << b)
@@ -1021,14 +969,19 @@ class LineShapeMatcher:
s_y0 = max(0, sy0); s_y1 = min(H, sy1) s_y0 = max(0, sy0); s_y1 = min(H, sy1)
s_x0 = max(0, sx0); s_x1 = min(W, sx1) s_x0 = max(0, sx0); s_x1 = min(W, sx1)
if s_y1 > s_y0 and s_x1 > s_x0: if s_y1 > s_y0 and s_x1 > s_x0:
region = spread0[s_y0:s_y1, s_x0:s_x1] region = opt_map[s_y0:s_y1, s_x0:s_x1]
acc[a_y0:a_y1, a_x0:a_x1] += ( acc[a_y0:a_y1, a_x0:a_x1] += (
(region & bit) != 0 (region & bit) != 0
).astype(np.float32) ).astype(np.float32)
acc /= len(dx) acc /= len(dx)
_, max_val, _, max_loc = cv2.minMaxLoc(acc) _, max_val, _, max_loc = cv2.minMaxLoc(acc)
return (float(max_val), if max_val <= 0.0:
float(x_lo + max_loc[0]), float(y_lo + max_loc[1])) return (0.0, cx, cy)
# Picco sub-pixel dentro la finestra (gestisce plateau e fit 3x3)
px_f, py_f = self._subpixel_peak(
acc, int(max_loc[0]), int(max_loc[1]), plateau_radius=margin,
)
return (float(max_val), float(x_lo + px_f), float(y_lo + py_f))
# Golden-section search su [-search_radius, +search_radius]: # Golden-section search su [-search_radius, +search_radius]:
# converge in log tempo a precisione ~0.1°, ~8 valutazioni vs 5 # converge in log tempo a precisione ~0.1°, ~8 valutazioni vs 5
@@ -1064,7 +1017,25 @@ class LineShapeMatcher:
x1 = x2; s1 = s2; cx1 = cx2; cy1 = cy2 x1 = x2; s1 = s2; cx1 = cx2; cy1 = cy2
x2 = a_lo + _GOLDEN * (a_hi - a_lo) x2 = a_lo + _GOLDEN * (a_hi - a_lo)
s2, cx2, cy2 = _score_at_angle(x2) s2, cx2, cy2 = _score_at_angle(x2)
ang_best, s_best, cx_best, cy_best = best
if spread_fine is None:
return best return best
# Score finale alla posa raffinata sullo spread COARSE: stessa
# semantica dello score pre-refine (tolleranza spread_radius),
# cosi' min_score/verify mantengono il significato di prima.
fx, fy, fb = _feats_at_angle(ang_best)
if len(fx) < 8:
return best
xs = np.round(fx - center[0]).astype(np.int32) + int(round(cx_best))
ys = np.round(fy - center[1]).astype(np.int32) + int(round(cy_best))
ok = (xs >= 0) & (xs < W) & (ys >= 0) & (ys < H)
if not ok.any():
return (ang_best, 0.0, cx_best, cy_best)
bits = spread0[ys[ok], xs[ok]].astype(np.int32)
hit = (bits & np.left_shift(1, fb[ok].astype(np.int32))) != 0
score_coarse = float(hit.sum()) / len(fx)
return (ang_best, score_coarse, cx_best, cy_best)
def _get_view_template( def _get_view_template(
self, view_idx: int, self, view_idx: int,
@@ -1089,26 +1060,13 @@ class LineShapeMatcher:
""" """
if self.template_gray is None: if self.template_gray is None:
return 1.0 return 1.0
h, w = self.template_gray.shape
scale = variant.scale
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = ( mask_src = (
self._train_mask if self._train_mask is not None self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255) else np.full_like(self.template_gray, 255)
) )
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST) gray_p, mask_p, center, diag = self._prepare_padded_template(
diag = int(np.ceil(np.hypot(sh, sw))) + 6 self.template_gray, mask_src, variant.scale,
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
) )
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1125,8 +1083,8 @@ class LineShapeMatcher:
ix = int(round(cx)); iy = int(round(cy)) ix = int(round(cx)); iy = int(round(cy))
hits = 0 hits = 0
for i in range(n_feat): for i in range(n_feat):
xs = ix + int(fx[i] - center[0]) xs = ix + int(round(fx[i] - center[0]))
ys = iy + int(fy[i] - center[1]) ys = iy + int(round(fy[i] - center[1]))
if 0 <= xs < W and 0 <= ys < H: if 0 <= xs < W and 0 <= ys < H:
bit = spread_dtype(1 << int(fb[i])) bit = spread_dtype(1 << int(fb[i]))
if spread0[ys, xs] & bit: if spread0[ys, xs] & bit:
@@ -1140,26 +1098,13 @@ class LineShapeMatcher:
"""Soft-margin gradient similarity (Halcon Metric='use_polarity').""" """Soft-margin gradient similarity (Halcon Metric='use_polarity')."""
if self.template_gray is None: if self.template_gray is None:
return 0.0 return 0.0
h, w = self.template_gray.shape
scale = variant.scale
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = ( mask_src = (
self._train_mask if self._train_mask is not None self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255) else np.full_like(self.template_gray, 255)
) )
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST) gray_p, mask_p, center, diag = self._prepare_padded_template(
diag = int(np.ceil(np.hypot(sh, sw))) + 6 self.template_gray, mask_src, variant.scale,
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
) )
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1179,8 +1124,8 @@ class LineShapeMatcher:
ix = int(round(cx)); iy = int(round(cy)) ix = int(round(cx)); iy = int(round(cy))
sims = []; weights = [] sims = []; weights = []
for i in range(len(fx)): for i in range(len(fx)):
xs = ix + int(fx[i] - center[0]) xs = ix + int(round(fx[i] - center[0]))
ys = iy + int(fy[i] - center[1]) ys = iy + int(round(fy[i] - center[1]))
if not (0 <= xs < W and 0 <= ys < H): if not (0 <= xs < W and 0 <= ys < H):
continue continue
tx = float(gx_t[int(fy[i]), int(fx[i])]) tx = float(gx_t[int(fy[i]), int(fx[i])])
@@ -1201,35 +1146,34 @@ class LineShapeMatcher:
def _subpixel_refine_lm( def _subpixel_refine_lm(
self, scene_gray: np.ndarray, variant: _Variant, self, scene_gray: np.ndarray, variant: _Variant,
cx: float, cy: float, angle_deg: float, cx: float, cy: float, angle_deg: float,
n_iters: int = 2, n_iters: int = 4,
) -> tuple[float, float]: scene_grad: tuple[np.ndarray, np.ndarray] | None = None,
"""Sub-pixel refinement iterativo via gradient-field least-squares. ) -> tuple[float, float, float]:
"""Refinement least-squares congiunto di posizione E angolo.
Halcon-equivalent SubPixel='least_squares_high'. Precisione attesa Halcon-equivalent SubPixel='least_squares_high'. Per ogni feature
0.05 px (vs 0.5 px del fit quadratic 2D). template cerca il picco sub-pixel del gradiente scena lungo la
normale dell'edge (snap ±2 px, fit parabolico su 5 campioni), poi
risolve ai minimi quadrati pesati il sistema 3x3 in (dx, dy, ):
n_i · (d + ·u_i) = t_i, u_i = (r_y,i, -r_x,i)
dove r_i = offset feature dal centro, n_i = normale edge template,
t_i = offset del picco lungo n_i, u_i = derivata della rotazione
nella convenzione cv2.getRotationMatrix2D (R = [[c,s],[-s,c]]).
Tra le iterazioni offset e normali vengono ruotati analiticamente
(no re-warp del template). Precisione attesa <0.1 px / <0.1°.
scene_grad: (gx, gy) Sobel della scena precomputati (evita un
Sobel full-frame per ogni match). Ritorna (cx, cy, angle_deg).
""" """
if self.template_gray is None: t, train_mask = self._get_view_template(getattr(variant, "view_idx", 0))
return cx, cy if t is None:
h, w = self.template_gray.shape return cx, cy, angle_deg
scale = variant.scale mask_src = train_mask if train_mask is not None else np.full_like(t, 255)
sw = max(16, int(round(w * scale))) gray_p, mask_p, center, diag = self._prepare_padded_template(
sh = max(16, int(round(h * scale))) t, mask_src, variant.scale,
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = (
self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255)
) )
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
)
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR, flags=cv2.INTER_LINEAR,
@@ -1241,51 +1185,98 @@ class LineShapeMatcher:
mag_t = cv2.magnitude(gx_t, gy_t) mag_t = cv2.magnitude(gx_t, gy_t)
_, bins_t = self._gradient(gray_r) _, bins_t = self._gradient(gray_r)
fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r) fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r)
if len(fx) < 4: if len(fx) < 8:
return cx, cy return cx, cy, angle_deg
n = len(fx) rx = (fx - center[0]).astype(np.float64)
ddx_t = (fx - center[0]).astype(np.float32) ry = (fy - center[1]).astype(np.float64)
ddy_t = (fy - center[1]).astype(np.float32) gxf = gx_t[fy, fx].astype(np.float64)
gx_tf = np.array([gx_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32) gyf = gy_t[fy, fx].astype(np.float64)
gy_tf = np.array([gy_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32) nm = np.hypot(gxf, gyf) + 1e-9
mag_tf = np.hypot(gx_tf, gy_tf) + 1e-6 nx = gxf / nm
nx_t = gx_tf / mag_tf ny = gyf / nm
ny_t = gy_tf / mag_tf
if scene_grad is not None:
gx_s, gy_s = scene_grad
else:
gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3) gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3)
gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3) gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3)
H, W = scene_gray.shape H, W = scene_gray.shape
cur_cx, cur_cy = float(cx), float(cy)
for _ in range(n_iters): def _bilin(g: np.ndarray, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
xs = cur_cx + ddx_t xs_c = np.clip(xs, 0.0, W - 1.001)
ys = cur_cy + ddy_t ys_c = np.clip(ys, 0.0, H - 1.001)
xs_c = np.clip(xs, 0, W - 1.001)
ys_c = np.clip(ys, 0, H - 1.001)
x0 = xs_c.astype(np.int32); y0 = ys_c.astype(np.int32) x0 = xs_c.astype(np.int32); y0 = ys_c.astype(np.int32)
ax = xs_c - x0; ay = ys_c - y0 ax = xs_c - x0; ay = ys_c - y0
def _bilin(g): return ((1 - ax) * (1 - ay) * g[y0, x0]
v00 = g[y0, x0]; v10 = g[y0, x0 + 1] + ax * (1 - ay) * g[y0, x0 + 1]
v01 = g[y0 + 1, x0]; v11 = g[y0 + 1, x0 + 1] + (1 - ax) * ay * g[y0 + 1, x0]
return ((1 - ax) * (1 - ay) * v00 + ax * ay * g[y0 + 1, x0 + 1])
+ ax * (1 - ay) * v10
+ (1 - ax) * ay * v01 t_offsets = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
+ ax * ay * v11) n_feat = len(rx)
sx_v = _bilin(gx_s) idx = np.arange(n_feat)
sy_v = _bilin(gy_s) cur_cx, cur_cy, cur_ang = float(cx), float(cy), float(angle_deg)
mag_s = np.hypot(sx_v, sy_v) + 1e-6 for _ in range(n_iters):
nx_s = sx_v / mag_s px = cur_cx + rx
ny_s = sy_v / mag_s py = cur_cy + ry
w = np.minimum(mag_s, 255.0).astype(np.float32) # |grad| scena campionato a 5 offset lungo la normale di ogni
err_x = (nx_s - nx_t) * w # feature; il picco sub-pixel lungo la normale e' la distanza
err_y = (ny_s - ny_t) * w # firmata t_i dall'edge scena piu' vicino.
step_x = -float(err_x.sum()) / (w.sum() + 1e-6) mags = np.empty((5, n_feat))
step_y = -float(err_y.sum()) / (w.sum() + 1e-6) sxs = np.empty((5, n_feat))
step_x = max(-1.0, min(1.0, step_x)) sys_ = np.empty((5, n_feat))
step_y = max(-1.0, min(1.0, step_y)) for k, t_off in enumerate(t_offsets):
cur_cx += step_x sx_v = _bilin(gx_s, px + t_off * nx, py + t_off * ny)
cur_cy += step_y sy_v = _bilin(gy_s, px + t_off * nx, py + t_off * ny)
if abs(step_x) < 0.02 and abs(step_y) < 0.02: sxs[k] = sx_v; sys_[k] = sy_v
mags[k] = np.hypot(sx_v, sy_v)
k_best = np.argmax(mags, axis=0)
m_pk = mags[k_best, idx]
t_i = t_offsets[k_best]
# Fit parabolico sui picchi interni (k in 1..3)
interior = (k_best >= 1) & (k_best <= 3)
if interior.any():
ki = k_best[interior]; ii = idx[interior]
m_m = mags[ki - 1, ii]; m_0 = mags[ki, ii]; m_p = mags[ki + 1, ii]
denom = (m_m - 2.0 * m_0 + m_p)
off = np.where(np.abs(denom) > 1e-9,
0.5 * (m_m - m_p) / (denom - 1e-12), 0.0)
t_i = t_i.astype(np.float64)
t_i[interior] += np.clip(off, -0.5, 0.5)
# Peso: |grad| al picco * allineamento direzione (mod π se no
# polarity). Feature senza edge (sotto weak_grad) escluse;
# picco sul bordo finestra = snap inaffidabile → dimezzato.
sx_pk = sxs[k_best, idx]; sy_pk = sys_[k_best, idx]
cos_al = (nx * sx_pk + ny * sy_pk) / (m_pk + 1e-9)
align = np.maximum(0.0, cos_al) if self.use_polarity else np.abs(cos_al)
wgt = np.minimum(m_pk, 255.0) * align * align
wgt[m_pk < self.weak_grad] = 0.0
wgt[~interior] *= 0.5
if float(wgt.sum()) < 1e-6:
break break
return cur_cx, cur_cy # LSQ pesato 3x3: A_i = [n_x, n_y, n_x·r_y - n_y·r_x]
a3 = nx * ry - ny * rx
A = np.stack([nx, ny, a3], axis=1)
Aw = A * wgt[:, None]
AtA = Aw.T @ A
Atb = Aw.T @ t_i.astype(np.float64)
try:
sol = np.linalg.solve(AtA + 1e-6 * np.eye(3), Atb)
except np.linalg.LinAlgError:
break
ddx = float(np.clip(sol[0], -1.5, 1.5))
ddy = float(np.clip(sol[1], -1.5, 1.5))
dth = float(np.clip(sol[2], -math.radians(1.5), math.radians(1.5)))
cur_cx += ddx
cur_cy += ddy
cur_ang += math.degrees(dth)
# Ruota offset e normali di dθ (convenzione R = [[c,s],[-s,c]])
c = math.cos(dth); s = math.sin(dth)
rx, ry = c * rx + s * ry, -s * rx + c * ry
nx, ny = c * nx + s * ny, -s * nx + c * ny
if abs(ddx) < 0.01 and abs(ddy) < 0.01 and abs(dth) < 1.7e-4:
break
return cur_cx, cur_cy, cur_ang
def _verify_ncc( def _verify_ncc(
self, scene_gray: np.ndarray, cx: float, cy: float, self, scene_gray: np.ndarray, cx: float, cy: float,
@@ -1370,15 +1361,21 @@ class LineShapeMatcher:
coarse_stride: int = 1, coarse_stride: int = 1,
scale_penalty: float = 0.0, scale_penalty: float = 0.0,
search_roi: tuple[int, int, int, int] | None = None, search_roi: tuple[int, int, int, int] | None = None,
pyramid_propagate: bool = False, # off di default: meno duplicati # ON di default: full-res valutato solo in finestre locali attorno
propagate_topk: int = 4, # ai picchi top-level (costo ∝ candidati, non varianti × W × H).
refine_pose_joint: bool = False, # I duplicati che avevano fatto disattivare questa modalita' sono
# gestiti dalla NMS IoU poligonale post-refine.
pyramid_propagate: bool = True,
propagate_topk: int = 8,
refine_pose_joint: bool = False, # deprecato: alias di refine_angle
greediness: float = 0.0, greediness: float = 0.0,
batch_top: bool = False, batch_top: bool = False,
nms_iou_threshold: float = 0.3, nms_iou_threshold: float = 0.3,
min_recall: float = 0.0, min_recall: float = 0.0,
use_soft_score: bool = False, use_soft_score: bool = False,
subpixel_lm: bool = False, # ON di default: least-squares finale (posizione + angolo) sui
# gradienti scena, precisione attesa <0.1 px / <0.1°.
subpixel_lm: bool = True,
debug: bool = False, debug: bool = False,
profile: bool = False, profile: bool = False,
) -> list[Match]: ) -> list[Match]:
@@ -1462,7 +1459,7 @@ class LineShapeMatcher:
cached = self._scene_cache_get(cache_key) if cache_key else None cached = self._scene_cache_get(cache_key) if cache_key else None
if cached is not None: if cached is not None:
grays, spread_top, bit_active_top, density_top, spread0, \ grays, spread_top, bit_active_top, density_top, spread0, \
bit_active_full, density_full, top = cached bit_active_full, density_full, top, spread_fine = cached
else: else:
grays = [gray0] grays = [gray0]
for _ in range(self.pyramid_levels - 1): for _ in range(self.pyramid_levels - 1):
@@ -1478,22 +1475,47 @@ class LineShapeMatcher:
spread0 = None spread0 = None
bit_active_full = None bit_active_full = None
density_full = None density_full = None
spread_fine = None
_checkpoint("spread_top") _checkpoint("spread_top")
if nms_radius is None: if nms_radius is None:
nms_radius = max(8, min(self.template_size) // 2) nms_radius = max(8, min(self.template_size) // 2)
# Pruning adattivo allo step angolare: con step piccolo (<= 3 deg) # Pruning adattivo allo step angolare: con step piccolo (<= 3 deg)
# ci sono molte varianti vicine, gli score top-level sono ravvicinati # ci sono molte varianti vicine e gli score top-level sono
# e top_thresh*0.5 e' troppo aggressivo: scarta varianti valide che # ravvicinati: top_thresh*0.5 e' troppo aggressivo, scarta varianti
# sarebbero state riprese al full-res. Stessa cosa per # valide che sarebbero state riprese al full-res.
# coarse_angle_factor (skip 1 ogni 2): con step fine non e' utile. # Il path windowed (pyramid_propagate) assume che il picco
# Risultato osservato: precisione "veloce" 10° dava risultati # top-level localizzi la posizione entro il margine finestra.
# migliori di "preciso" 2° proprio perche evitava il pruning. # Su template ALLUNGATI (es. lama 40x280, o ROI parziale lungo un
# asse) lo score top-level ha un plateau lungo l'asse e il picco
# puo' essere lontano decine di px dal centro vero → le finestre
# tagliano fuori la posa giusta e il match muore in verify NCC.
# In quel caso si usa il full-scan esatto (costo maggiore ma
# nessuna perdita di recall).
if pyramid_propagate and self.template_size != (0, 0):
tw_t, th_t = self.template_size
if max(tw_t, th_t) / max(1, min(tw_t, th_t)) > 2.0:
pyramid_propagate = False
eff_step = self._effective_angle_step() eff_step = self._effective_angle_step()
top_factor = self.top_score_factor top_factor = self.top_score_factor
cf_eff = max(1, coarse_angle_factor)
if eff_step <= 3.0: if eff_step <= 3.0:
top_factor = max(top_factor, 0.7) top_factor = max(top_factor, 0.7)
cf_eff = 1 # Coarse step angolare AUTO al top-level (Halcon-style): al livello
# top le feature distano R/2^top dal centro, quindi lo spread
# (raggio in px, costante per livello) tollera una rotazione
# ~atan(spread / (max_side_top/2)) — molto piu' ampia dello step
# richiesto a full-res. Si valuta al top 1 variante ogni cf_eff;
# le intermedie vengono riprese dall'espansione ai vicini.
# Es: template 160 px, 3 livelli, step 2° → tolleranza top ~11°
# → cf 6 → top-pruning ~6x piu' veloce a parita' di recall.
if self.template_size != (0, 0):
max_side_top = max(self.template_size) / (2 ** top)
else:
max_side_top = 64.0
step_top_tol = math.degrees(
math.atan2(float(self.spread_radius), max(8.0, max_side_top / 2.0))
)
cf_auto = int(np.clip(round(step_top_tol / max(eff_step, 1e-6)), 1, 8))
cf_eff = max(1, coarse_angle_factor, cf_auto)
top_thresh = min_score * top_factor top_thresh = min_score * top_factor
diag["top_thresh_used"] = float(top_thresh) diag["top_thresh_used"] = float(top_thresh)
@@ -1549,7 +1571,6 @@ class LineShapeMatcher:
dtype=bool, dtype=bool,
) )
if scene_bins.any(): if scene_bins.any():
n_scene_active = int(scene_bins.sum())
# Soglia: variante deve avere >= 50% delle sue feature in bin # Soglia: variante deve avere >= 50% delle sue feature in bin
# presenti nella scena. Sotto = score certamente < 0.5. # presenti nella scena. Sotto = score certamente < 0.5.
pruned_idx_list = [] pruned_idx_list = []
@@ -1598,16 +1619,25 @@ class LineShapeMatcher:
return vi, -1.0 return vi, -1.0
best = float(score.max()) best = float(score.max())
if pyramid_propagate and best > 0: if pyramid_propagate and best > 0:
flat = score.ravel() # Picchi = MASSIMI LOCALI sopra soglia, non top-K pixel:
k = min(propagate_topk, flat.size) # su template allungati lo score top-level ha plateau
idx = np.argpartition(-flat, k - 1)[:k] # estesi e i top-K pixel si concentrano tutti sulle 2-3
# istanze piu' forti, perdendo per sempre le altre.
# Soglia permissiva (0.5x): un picco scartato qui =
# istanza persa, un picco in piu' = solo una finestra
# extra di costo marginale (dedup via mark).
thr = top_thresh * 0.5
dil = cv2.dilate(score, np.ones((5, 5), np.uint8))
ys_l, xs_l = np.nonzero((score >= dil) & (score >= thr))
peaks: list[tuple[int, int, float]] = [] peaks: list[tuple[int, int, float]] = []
for i in idx: if len(ys_l):
s = float(flat[i]) vals = score[ys_l, xs_l]
if s < top_thresh * 0.7: k = min(max(propagate_topk, 2 * max_matches), len(vals))
continue sel = np.argpartition(-vals, k - 1)[:k]
yt, xt = int(i // score.shape[1]), int(i % score.shape[1]) peaks = [
peaks.append((xt, yt, s)) (int(xs_l[i]), int(ys_l[i]), float(vals[i]))
for i in sel
]
peaks_by_vi[vi] = peaks peaks_by_vi[vi] = peaks
return vi, best return vi, best
@@ -1664,6 +1694,13 @@ class LineShapeMatcher:
expanded.add(vi_n) expanded.add(vi_n)
# Usa lo score del coarse come stima per il sort successivo # Usa lo score del coarse come stima per il sort successivo
score_by_vi[vi_n] = max(score_by_vi.get(vi_n, 0.0), s_top) score_by_vi[vi_n] = max(score_by_vi.get(vi_n, 0.0), s_top)
# Propaga i picchi top-level del coarse anche ai vicini:
# l'oggetto e' nella stessa posizione (angolo ±step), quindi
# anche i vicini possono usare il path windowed invece del
# full-scan dell'intera scena (che dominava il costo full-res).
if (pyramid_propagate and vi_n != vi_c
and peaks_by_vi.get(vi_c)):
peaks_by_vi.setdefault(vi_n, []).extend(peaks_by_vi[vi_c])
kept_variants: list[tuple[int, float]] = [ kept_variants: list[tuple[int, float]] = [
(vi, score_by_vi[vi]) for vi in expanded (vi, score_by_vi[vi]) for vi in expanded
] ]
@@ -1690,54 +1727,63 @@ class LineShapeMatcher:
if (spread0 & (spread0.dtype.type(1) << b)).any()) if (spread0 & (spread0.dtype.type(1) << b)).any())
) )
density_full = _jit_popcount(spread0) density_full = _jit_popcount(spread0)
# Bitmap fine (raggio 1) per il refine: sulla bitmap dilatata
# lo score satura e il refine angolare/posizionale non vede
# alcun gradiente (vedi _refine_angle).
spread_fine = self._spread_bitmap(gray0, radius=1)
# Salva cache scena complete # Salva cache scena complete
if cache_key is not None: if cache_key is not None:
self._scene_cache_put(cache_key, ( self._scene_cache_put(cache_key, (
grays, spread_top, bit_active_top, density_top, grays, spread_top, bit_active_top, density_top,
spread0, bit_active_full, density_full, top, spread0, bit_active_full, density_full, top, spread_fine,
)) ))
for sc in unique_scales: for sc in unique_scales:
bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1) bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1)
# Margine in full-res attorno ad ogni peak top: copre incertezza # Margine in full-res attorno ad ogni peak top: copre incertezza
# downsampling (sf_top px) + spread_radius + slack per NMS. # downsampling (sf_top px) + plateau radius del subpixel (10) +
propagate_margin = sf_top + self.spread_radius + max(8, nms_radius // 2) # slack. NON serve includere nms_radius: la NMS lavora sui candidati
# estratti, non richiede score validi oltre il plateau del picco.
propagate_margin = 2 * sf_top + max(10, self.spread_radius) + 6
H_full, W_full = spread0.shape H_full, W_full = spread0.shape
def _full_score(vi: int) -> tuple[int, np.ndarray]: def _full_score(vi: int) -> tuple[int, np.ndarray]:
var = self.variants[vi] var = self.variants[vi]
lvl0 = var.levels[0] lvl0 = var.levels[0]
if not pyramid_propagate or vi not in peaks_by_vi or not peaks_by_vi[vi]: peaks = peaks_by_vi.get(vi) if pyramid_propagate else None
margin = propagate_margin
if not peaks:
# Path legacy: scansiona intera scena # Path legacy: scansiona intera scena
return vi, _jit_score_bitmap_rescored( return vi, _jit_score_bitmap_rescored(
spread0, lvl0.dx, lvl0.dy, lvl0.bin, bit_active_full, spread0, lvl0.dx, lvl0.dy, lvl0.bin, bit_active_full,
bg_cache_full[var.scale], bg_cache_full[var.scale],
) )
# Path piramide propagata: valuta solo crop locali attorno # Path piramide propagata: valuta solo finestre locali attorno
# alle posizioni dei picchi top-level (riproiettati a full-res). # ai picchi top-level (riproiettati a full-res). Il kernel
# windowed campiona lo spread dell'INTERA scena: chiamare il
# kernel su un crop trattava le feature fuori-crop come miss
# (template raggio > finestra → score ~0 ovunque, 0 match).
score_full = np.zeros((H_full, W_full), dtype=np.float32) score_full = np.zeros((H_full, W_full), dtype=np.float32)
mark = np.zeros((H_full, W_full), dtype=bool) mark = np.zeros((H_full, W_full), dtype=bool)
bg = bg_cache_full[var.scale] bg = bg_cache_full[var.scale]
for xt, yt, _s in peaks_by_vi[vi]: for xt, yt, _s in peaks:
cx0 = xt * sf_top cx0 = xt * sf_top
cy0 = yt * sf_top cy0 = yt * sf_top
x_lo = max(0, cx0 - propagate_margin) x_lo = max(0, cx0 - margin)
x_hi = min(W_full, cx0 + propagate_margin + 1) x_hi = min(W_full, cx0 + margin + 1)
y_lo = max(0, cy0 - propagate_margin) y_lo = max(0, cy0 - margin)
y_hi = min(H_full, cy0 + propagate_margin + 1) y_hi = min(H_full, cy0 + margin + 1)
if x_hi <= x_lo or y_hi <= y_lo: if x_hi <= x_lo or y_hi <= y_lo:
continue continue
if mark[y_lo:y_hi, x_lo:x_hi].all(): if mark[y_lo:y_hi, x_lo:x_hi].all():
continue continue
# Crop spread + bg, valuta kernel sul crop score_win = _jit_score_bitmap_rescored_window(
spread_crop = np.ascontiguousarray(spread0[y_lo:y_hi, x_lo:x_hi]) spread0, lvl0.dx, lvl0.dy, lvl0.bin,
bg_crop = np.ascontiguousarray(bg[y_lo:y_hi, x_lo:x_hi]) bit_active_full, bg,
score_crop = _jit_score_bitmap_rescored( y_lo, x_lo, y_hi - y_lo, x_hi - x_lo,
spread_crop, lvl0.dx, lvl0.dy, lvl0.bin,
bit_active_full, bg_crop,
) )
score_full[y_lo:y_hi, x_lo:x_hi] = np.maximum( score_full[y_lo:y_hi, x_lo:x_hi] = np.maximum(
score_full[y_lo:y_hi, x_lo:x_hi], score_crop, score_full[y_lo:y_hi, x_lo:x_hi], score_win,
) )
mark[y_lo:y_hi, x_lo:x_hi] = True mark[y_lo:y_hi, x_lo:x_hi] = True
return vi, score_full return vi, score_full
@@ -1811,6 +1857,14 @@ class LineShapeMatcher:
# Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap) # Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap)
kept: list[Match] = [] kept: list[Match] = []
tw, th = self.template_size tw, th = self.template_size
# Sobel scena precomputato una volta per il refine LM (prima era
# un Sobel full-frame per OGNI match).
scene_grad = None
if subpixel_lm and self.template_gray is not None and preliminary_int:
scene_grad = (
cv2.Sobel(gray0, cv2.CV_32F, 1, 0, ksize=3),
cv2.Sobel(gray0, cv2.CV_32F, 0, 1, ksize=3),
)
for score, xi, yi, vi in preliminary_int: for score, xi, yi, vi in preliminary_int:
if subpixel and vi in score_maps: if subpixel and vi in score_maps:
cx_f, cy_f = self._subpixel_peak( cx_f, cy_f = self._subpixel_peak(
@@ -1821,12 +1875,10 @@ class LineShapeMatcher:
var = self.variants[vi] var = self.variants[vi]
ang_f = var.angle_deg ang_f = var.angle_deg
score_f = score score_f = score
if refine_pose_joint and self.template_gray is not None: # refine_pose_joint (Nelder-Mead) rimosso: valutava lo score a
ang_f, score_f, cx_f, cy_f = self._refine_pose_joint( # posizioni intere su bitmap satura (funzione a gradini piatta,
spread0, self.template_gray, cx_f, cy_f, # il simplex terminava subito). Ora e' alias del refine standard.
var.angle_deg, var.scale, mask_full, if (refine_angle or refine_pose_joint) and self.template_gray is not None:
)
elif refine_angle and self.template_gray is not None:
ang_f, score_f, cx_f, cy_f = self._refine_angle( ang_f, score_f, cx_f, cy_f = self._refine_angle(
spread0, bit_active_full, self.template_gray, cx_f, cy_f, spread0, bit_active_full, self.template_gray, cx_f, cy_f,
var.angle_deg, var.scale, mask_full, var.angle_deg, var.scale, mask_full,
@@ -1835,14 +1887,15 @@ class LineShapeMatcher:
# del bin angolare della variante grezza. # del bin angolare della variante grezza.
search_radius=self._effective_angle_step(), search_radius=self._effective_angle_step(),
original_score=score, original_score=score,
spread_fine=spread_fine,
) )
# Halcon SubPixel='least_squares_high': refinement iterativo # Halcon SubPixel='least_squares_high': least-squares finale
# gradient-field per precisione 0.05 px (vs 0.5 quadratic 2D). # (posizione + angolo) sui gradienti scena, <0.1 px / <0.1°.
if subpixel_lm and self.template_gray is not None: if subpixel_lm and self.template_gray is not None:
cx_lm, cy_lm = self._subpixel_refine_lm( cx_lm, cy_lm, ang_lm = self._subpixel_refine_lm(
gray0, var, cx_f, cy_f, ang_f, gray0, var, cx_f, cy_f, ang_f, scene_grad=scene_grad,
) )
cx_f, cy_f = float(cx_lm), float(cy_lm) cx_f, cy_f, ang_f = float(cx_lm), float(cy_lm), float(ang_lm)
# NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta # NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta
# il calcolo per shape-score gia alti. Default 1.01 = NCC sempre, # il calcolo per shape-score gia alti. Default 1.01 = NCC sempre,
# piu sicuro contro falsi positivi (lo shape-score satura facile). # piu sicuro contro falsi positivi (lo shape-score satura facile).
+26 -2
View File
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
a0, a1 = self.angle_range_deg a0, a1 = self.angle_range_deg
if self.angle_step_deg <= 0 or a0 >= a1: if self.angle_step_deg <= 0 or a0 >= a1:
return [float(a0)] return [float(a0)]
n = int(np.floor((a1 - a0) / self.angle_step_deg)) # n+1 valori per includere l'estremo superiore del range: con il
return [float(a0 + i * self.angle_step_deg) for i in range(n)] # solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one).
n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1
angles = [float(a0 + i * self.angle_step_deg) for i in range(n)]
if a1 - a0 >= 360.0:
# Range che copre il giro completo: a0+360° è la stessa pose di
# a0, escludi il duplicato (variante inutile in train/find).
eps = 1e-6
angles = [a for a in angles if a < a0 + 360.0 - eps]
return angles
def train(self, template_bgr: np.ndarray) -> int: def train(self, template_bgr: np.ndarray) -> int:
"""Genera varianti per tutte le combinazioni (angolo, scala).""" """Genera varianti per tutte le combinazioni (angolo, scala)."""
@@ -222,6 +230,14 @@ class EdgeShapeMatcher:
for y, x in zip(ys, xs): for y, x in zip(ys, xs):
candidates.append((float(res[y, x]), int(x), int(y), ti)) candidates.append((float(res[y, x]), int(x), int(y), ti))
# Cap candidati top-level: senza limite np.where con soglia bassa
# può generare migliaia di candidati, ognuno con un matchTemplate
# full-res nel refinement. Tieni solo i migliori per score.
max_candidates = max(1, max_matches * 10)
if len(candidates) > max_candidates:
candidates.sort(key=lambda c: -c[0])
candidates = candidates[:max_candidates]
# Refinement a risoluzione piena: per ogni candidato top, finestra locale # Refinement a risoluzione piena: per ogni candidato top, finestra locale
refined: list[tuple[float, int, int, int]] = [] refined: list[tuple[float, int, int, int]] = []
margin = sf + 4 margin = sf + 4
@@ -294,6 +310,10 @@ class EdgeShapeMatcher:
) )
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)} arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)}
arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)}) arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)})
# Persisti anche il grayscale originale: senza, l'overlay edge
# spariva dopo load() (template_gray restava None).
if self.template_gray is not None:
arrays["template_gray"] = self.template_gray
np.savez_compressed(path, params=params, meta=meta, **arrays) np.savez_compressed(path, params=params, meta=meta, **arrays)
@classmethod @classmethod
@@ -312,6 +332,10 @@ class EdgeShapeMatcher:
top_score_factor=float(p[12]) if len(p) > 12 else 0.6, top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
) )
m.template_size = (int(p[8]), int(p[9])) m.template_size = (int(p[8]), int(p[9]))
# Retrocompatibilità: modelli salvati prima non hanno template_gray
# (resta None: overlay edge non disponibile ma find() funziona).
if "template_gray" in z.files:
m.template_gray = z["template_gray"]
meta = z["meta"] meta = z["meta"]
for i in range(len(meta)): for i in range(len(meta)):
m.templates.append( m.templates.append(
+227 -35
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
import tempfile import tempfile
import threading
import time import time
import uuid import uuid
from collections import OrderedDict from collections import OrderedDict
@@ -54,6 +55,7 @@ RECIPES_DIR.mkdir(exist_ok=True)
from pm2d.line_matcher import LineShapeMatcher, Match from pm2d.line_matcher import LineShapeMatcher, Match
from pm2d.auto_tune import auto_tune from pm2d.auto_tune import auto_tune
from pm2d.dxf import dxf_to_image
WEB_DIR = Path(__file__).parent WEB_DIR = Path(__file__).parent
@@ -64,14 +66,23 @@ STATIC_DIR.mkdir(exist_ok=True)
CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache" CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache"
CACHE_DIR.mkdir(exist_ok=True) CACHE_DIR.mkdir(exist_ok=True)
# Cache in-memory (soft, ricaricata da disco se mancante) # Cache in-memory (soft, ricaricata da disco se mancante).
_IMG_CACHE: dict[str, np.ndarray] = {} # LRU con capacità limitata: senza eviction le immagini si accumulavano
# senza limite (leak di memoria su server long-running).
_IMG_CACHE: OrderedDict[str, np.ndarray] = OrderedDict()
_IMG_CACHE_SIZE = 64
# Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher # Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher
# LRU con capacità limitata # LRU con capacità limitata
_MATCHER_CACHE: OrderedDict = OrderedDict() _MATCHER_CACHE: OrderedDict = OrderedDict()
_MATCHER_CACHE_SIZE = 8 _MATCHER_CACHE_SIZE = 8
# Lock globale matcher: gli endpoint girano nel threadpool FastAPI ma i
# matcher condivisi (_MATCHER_CACHE, _RECIPE_MATCHERS) mutano stato interno
# durante train()/find(). Serializzare il matching è la soluzione semplice
# e corretta (un lock per-ricetta sarebbe over-engineering).
_MATCHER_LOCK = threading.Lock()
def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str: def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h = hashlib.md5() h = hashlib.md5()
@@ -81,7 +92,10 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
"min_feature_spacing", "min_feature_spacing",
"angle_min", "angle_max", "angle_step", "angle_min", "angle_max", "angle_step",
"scale_min", "scale_max", "scale_step", "scale_min", "scale_max", "scale_step",
"spread_radius", "pyramid_levels") "spread_radius", "pyramid_levels",
# ROI poligonale: la mask cambia il training a parità di
# bbox → deve invalidare la cache (None = ROI rettangolare)
"roi_poly")
for k in relevant: for k in relevant:
h.update(f"{k}={tech.get(k)}".encode()) h.update(f"{k}={tech.get(k)}".encode())
h.update(f"shape={roi.shape}".encode()) h.update(f"shape={roi.shape}".encode())
@@ -102,23 +116,32 @@ def _cache_put_matcher(key: str, matcher) -> None:
_MATCHER_CACHE.popitem(last=False) _MATCHER_CACHE.popitem(last=False)
def _img_cache_put(key: str, value: np.ndarray) -> None:
"""Inserisce in _IMG_CACHE con eviction LRU (cap _IMG_CACHE_SIZE)."""
_IMG_CACHE[key] = value
_IMG_CACHE.move_to_end(key)
while len(_IMG_CACHE) > _IMG_CACHE_SIZE:
_IMG_CACHE.popitem(last=False)
def _store_image(img: np.ndarray) -> str: def _store_image(img: np.ndarray) -> str:
iid = uuid.uuid4().hex[:12] iid = uuid.uuid4().hex[:12]
cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img) cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img)
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return iid return iid
def _load_image(iid: str) -> np.ndarray | None: def _load_image(iid: str) -> np.ndarray | None:
cached = _IMG_CACHE.get(iid) cached = _IMG_CACHE.get(iid)
if cached is not None: if cached is not None:
_IMG_CACHE.move_to_end(iid) # LRU touch
return cached return cached
p = CACHE_DIR / f"{iid}.png" p = CACHE_DIR / f"{iid}.png"
if not p.exists(): if not p.exists():
return None return None
img = cv2.imread(str(p)) img = cv2.imread(str(p))
if img is not None: if img is not None:
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return img return img
app = FastAPI(title="PM2D Webapp", version="1.0.0") app = FastAPI(title="PM2D Webapp", version="1.0.0")
@@ -131,6 +154,72 @@ def _encode_png(img: np.ndarray) -> bytes:
return buf.tobytes() return buf.tobytes()
def _clamp_roi(x: int, y: int, w: int, h: int,
img_w: int, img_h: int) -> tuple[int, int, int, int]:
"""Clampa la ROI dentro i limiti immagine.
Una ROI fuori immagine causava slice vuote crash 500 negli endpoint
che non clampavano. Solleva 400 se la ROI risultante è degenere
(lato < 16 px: sotto questa soglia il train non estrae abbastanza
edge feature e produce 0 varianti find() esplode con 500).
"""
x = max(0, min(int(x), img_w - 1))
y = max(0, min(int(y), img_h - 1))
w = min(int(w), img_w - x)
h = min(int(h), img_h - y)
if w < 16 or h < 16:
raise HTTPException(
400, f"ROI fuori immagine o degenere: [{x}, {y}, {w}, {h}] "
f"su immagine {img_w}x{img_h} (lato minimo 16 px)")
return x, y, w, h
def _poly_bbox_mask(
roi_poly: list[list[float]], img_w: int, img_h: int,
) -> tuple[int, int, int, int, np.ndarray]:
"""Valida roi_poly (vertici [x, y] in coordinate IMMAGINE) e ritorna
(x, y, w, h, mask): bbox del poligono clampato con _clamp_roi e mask
uint8 (255 dentro il poligono) nel sistema di coordinate della ROI.
Solleva 400 se il poligono ha <3 punti o area degenere.
"""
pts = np.asarray(roi_poly, dtype=np.float64)
if pts.ndim != 2 or pts.shape[1] != 2 or pts.shape[0] < 3:
raise HTTPException(
400, "roi_poly non valido: servono almeno 3 vertici [x, y]")
# Area con formula shoelace: poligoni collineari/degeneri → 400
px_, py_ = pts[:, 0], pts[:, 1]
area = 0.5 * abs(np.dot(px_, np.roll(py_, 1)) - np.dot(py_, np.roll(px_, 1)))
if area < 16.0:
raise HTTPException(
400, f"roi_poly degenere: area {area:.1f} px² troppo piccola")
x0 = int(np.floor(px_.min())); y0 = int(np.floor(py_.min()))
bw = int(np.ceil(px_.max())) - x0
bh = int(np.ceil(py_.max())) - y0
x, y, w, h = _clamp_roi(x0, y0, bw, bh, img_w, img_h)
# Mask nel sistema ROI: vertici ritraslati di (-x, -y)
mask = np.zeros((h, w), dtype=np.uint8)
local = np.round(pts - [x, y]).astype(np.int32)
cv2.fillPoly(mask, [local], 255)
if not mask.any():
raise HTTPException(
400, "roi_poly fuori immagine: nessun pixel utile nella mask")
return x, y, w, h, mask
def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
"""Solleva 422 se il train non ha prodotto varianti.
Succede con ROI senza contrasto (sfondo uniforme) o troppo piccola:
senza questo check il find() successivo esplode con RuntimeError 500.
"""
if n_variants <= 0 or not m.variants:
raise HTTPException(
422, "La ROI non contiene abbastanza edge feature per il "
"training (zona troppo uniforme o piccola): scegliere "
"una regione con contorni netti")
def _draw_matches(scene: np.ndarray, matches: list[Match], def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None, template_gray: np.ndarray | None,
matcher: "LineShapeMatcher | None" = None) -> np.ndarray: matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
@@ -172,17 +261,29 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
# `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2 # `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
# introduceva uno shift di 0.5px per template di lato pari. # introduceva uno shift di 0.5px per template di lato pari.
cx_t = tw / 2.0; cy_t = th / 2.0 cx_t = tw / 2.0; cy_t = th / 2.0
# Lavora su un CROP locale della scena di lato = diagonale del
# template ruotato+scalato (+margine), come _verify_ncc: warp
# + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno
# (costosissimo su scene grandi con molti match).
diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8
x0 = int(round(m.cx)) - diag // 2
y0 = int(round(m.cy)) - diag // 2
gx0 = max(0, x0); gy0 = max(0, y0)
gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag)
cw, ch_ = gx1 - gx0, gy1 - gy0
if cw >= 3 and ch_ >= 3:
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
M[0, 2] += m.cx - cx_t # Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop
M[1, 2] += m.cy - cy_t M[0, 2] += (m.cx - gx0) - cx_t
M[1, 2] += (m.cy - gy0) - cy_t
warped_gray = cv2.warpAffine( warped_gray = cv2.warpAffine(
t, M, (W_scene, H_scene), t, M, (cw, ch_),
flags=cv2.INTER_LINEAR, borderValue=0) flags=cv2.INTER_LINEAR, borderValue=0)
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno # Maschera: train_mask se disponibile, altrimenti rettangolo pieno
mask_src = (matcher._train_mask if matcher._train_mask is not None mask_src = (matcher._train_mask if matcher._train_mask is not None
else np.full((th, tw), 255, dtype=np.uint8)) else np.full((th, tw), 255, dtype=np.uint8))
warped_mask = cv2.warpAffine( warped_mask = cv2.warpAffine(
mask_src, M, (W_scene, H_scene), mask_src, M, (cw, ch_),
flags=cv2.INTER_NEAREST, borderValue=0) flags=cv2.INTER_NEAREST, borderValue=0)
# Erode minimo (3x3) per togliere SOLO artefatti border-padding # Erode minimo (3x3) per togliere SOLO artefatti border-padding
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu' # (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
@@ -197,11 +298,16 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
edge_mask = mag >= matcher.strong_grad edge_mask = mag >= matcher.strong_grad
edge_mask = edge_mask & (warped_mask > 0) edge_mask = edge_mask & (warped_mask > 0)
if edge_mask.any(): if edge_mask.any():
edge_overlay = np.zeros_like(out) # Edge ritraslati nel sistema scena: blend solo sul crop
# (addWeighted lascia invariati i pixel con overlay nullo,
# quindi l'output visivo è identico al full-frame).
sub = out[gy0:gy1, gx0:gx1]
edge_overlay = np.zeros_like(sub)
# Ciano (cambiato da verde): non collide col verde dell'asse # Ciano (cambiato da verde): non collide col verde dell'asse
# Y dell'UCS che altrimenti scompariva nell'overlay edge. # Y dell'UCS che altrimenti scompariva nell'overlay edge.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR) edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR)
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0) out[gy0:gy1, gx0:gx1] = cv2.addWeighted(
sub, 1.0, edge_overlay, 0.6, 0)
L = max(20, int(L_base * m.scale)) L = max(20, int(L_base * m.scale))
# X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin) # X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin)
x_end = (int(cx + L * ca), int(cy - L * sa)) x_end = (int(cx + L * ca), int(cy - L * sa))
@@ -234,6 +340,10 @@ class MatchParams(BaseModel):
model_id: str model_id: str
scene_id: str scene_id: str
roi: list[int] # [x, y, w, h] nell'immagine modello roi: list[int] # [x, y, w, h] nell'immagine modello
# ROI poligonale opzionale: vertici [x, y] in coordinate IMMAGINE
# (min 3 punti). Se presente, il bbox del poligono sostituisce `roi`
# e il training usa la mask del poligono.
roi_poly: list[list[float]] | None = None
angle_min: float = 0.0 angle_min: float = 0.0
angle_max: float = 360.0 angle_max: float = 360.0
angle_step: float = 5.0 angle_step: float = 5.0
@@ -246,7 +356,9 @@ class MatchParams(BaseModel):
num_features: int = 96 num_features: int = 96
weak_grad: float = 30.0 weak_grad: float = 30.0
strong_grad: float = 60.0 strong_grad: float = 60.0
spread_radius: int = 5 # 4 allineato col default del matcher: raggio 5 peggiora la precisione
# di rotazione (spread troppo largo appiattisce il picco angolare).
spread_radius: int = 4
pyramid_levels: int = 3 pyramid_levels: int = 3
verify_threshold: float = 0.4 verify_threshold: float = 0.4
@@ -313,6 +425,8 @@ class SimpleMatchParams(BaseModel):
model_id: str model_id: str
scene_id: str scene_id: str
roi: list[int] roi: list[int]
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
roi_poly: list[list[float]] | None = None
tipo: str = "intero" # "intero" | "parziale" tipo: str = "intero" # "intero" | "parziale"
simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX
scala: str = "fissa" # chiave SCALE_PRESETS scala: str = "fissa" # chiave SCALE_PRESETS
@@ -407,7 +521,13 @@ def _simple_to_technical(
"min_score": p.min_score, "min_score": p.min_score,
"max_matches": p.max_matches, "max_matches": p.max_matches,
"nms_radius": 0, "nms_radius": 0,
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35), # Fallback = livello "medio" della mappa (no valore hardcoded
# che divergerebbe se la mappa cambia).
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, FILTRO_FP_MAP["medio"]),
# "off" deve disabilitare DAVVERO il verify NCC: passare solo
# verify_threshold=0.0 lascerebbe attivo il calcolo NCC (che può
# comunque scartare match con score negativo / patch uniformi).
"verify_ncc": p.filtro_fp != "off",
"scale_penalty": p.penalita_scala, "scale_penalty": p.penalita_scala,
} }
@@ -526,6 +646,26 @@ async def upload(file: UploadFile = File(...)):
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0]) return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
@app.post("/upload_dxf", response_model=UploadResp)
async def upload_dxf(file: UploadFile = File(...), size: int = 512):
"""Upload DXF: rasterizza il CAD in template grayscale e lo salva
nella cache immagini come un normale upload.
Query param `size` = lato del canvas (clamp 128..2048).
"""
size = max(128, min(2048, int(size)))
data = await file.read()
try:
gray = dxf_to_image(data, target_size=size)
except ValueError as e:
raise HTTPException(400, f"DXF non valido: {e}")
# _store_image salva PNG e gli endpoint a valle (cvtColor BGR2GRAY su
# roi_img, _load_image con IMREAD_COLOR) si aspettano 3 canali → BGR.
img = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
iid = _store_image(img)
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
@app.get("/image/{iid}/raw") @app.get("/image/{iid}/raw")
def image_raw(iid: str): def image_raw(iid: str):
img = _load_image(iid) img = _load_image(iid)
@@ -540,10 +680,14 @@ def match(p: MatchParams):
scene = _load_image(p.scene_id) scene = _load_image(p.scene_id)
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi x, y, w, h = p.roi
x = max(0, x); y = max(0, y) x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
tech_for_cache = { tech_for_cache = {
@@ -555,8 +699,13 @@ def match(p: MatchParams):
"scale_step": p.scale_step, "scale_step": p.scale_step,
"spread_radius": p.spread_radius, "spread_radius": p.spread_radius,
"pyramid_levels": p.pyramid_levels, "pyramid_levels": p.pyramid_levels,
# Tuple per repr stabile nella cache key (None = rettangolare)
"roi_poly": (tuple(map(tuple, p.roi_poly))
if p.roi_poly is not None else None),
} }
key = _matcher_cache_key(roi_img, tech_for_cache) key = _matcher_cache_key(roi_img, tech_for_cache)
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key) m = _cache_get_matcher(key)
if m is None: if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
@@ -569,7 +718,8 @@ def match(p: MatchParams):
spread_radius=p.spread_radius, spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels, pyramid_levels=p.pyramid_levels,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
n = len(m.variants); t_train = 0.0 n = len(m.variants); t_train = 0.0
@@ -578,6 +728,8 @@ def match(p: MatchParams):
matches = m.find( matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches, scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold, nms_radius=nms, verify_threshold=p.verify_threshold,
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
) )
t_find = time.time() - t0 t_find = time.time() - t0
@@ -609,18 +761,27 @@ def match_simple(p: SimpleMatchParams):
scene = _load_image(p.scene_id) scene = _load_image(p.scene_id)
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi x, y, w, h = p.roi
x = max(0, x); y = max(0, y) x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
tech = _simple_to_technical(p, roi_img) tech = _simple_to_technical(p, roi_img)
# Tuple per repr stabile nella cache key (None = rettangolare)
tech["roi_poly"] = (tuple(map(tuple, p.roi_poly))
if p.roi_poly is not None else None)
key = _matcher_cache_key(roi_img, tech) key = _matcher_cache_key(roi_img, tech)
# Halcon-mode init params: incidono sul training, includere in cache key # Halcon-mode init params: incidono sul training, includere in cache key
halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}" halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}"
key = key + halcon_init_key key = key + halcon_init_key
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key) m = _cache_get_matcher(key)
if m is None: if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
@@ -636,7 +797,8 @@ def match_simple(p: SimpleMatchParams):
use_polarity=p.use_polarity, use_polarity=p.use_polarity,
use_gpu=p.use_gpu, use_gpu=p.use_gpu,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
n = len(m.variants); t_train = 0.0 n = len(m.variants); t_train = 0.0
@@ -646,6 +808,8 @@ def match_simple(p: SimpleMatchParams):
matches = m.find( matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"], scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"], nms_radius=nms, verify_threshold=tech["verify_threshold"],
# filtro_fp="off" → verify NCC davvero disabilitato
verify_ncc=tech.get("verify_ncc", True),
scale_penalty=tech.get("scale_penalty", 0.0), scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags # Halcon-mode flags
min_recall=p.min_recall, min_recall=p.min_recall,
@@ -681,6 +845,7 @@ def tune(p: TuneParams):
if model is None: if model is None:
raise HTTPException(404, "Immagine non trovata") raise HTTPException(404, "Immagine non trovata")
x, y, w, h = p.roi x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img) t = auto_tune(roi_img)
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation, # Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
@@ -694,6 +859,8 @@ class SaveRecipeParams(BaseModel):
model_id: str model_id: str
scene_id: str | None = None scene_id: str | None = None
roi: list[int] roi: list[int]
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
roi_poly: list[list[float]] | None = None
# Riusa stessi param simple per training equivalente # Riusa stessi param simple per training equivalente
tipo: str = "intero" tipo: str = "intero"
simmetria: str = "nessuna" simmetria: str = "nessuna"
@@ -813,7 +980,14 @@ def save_recipe(p: SaveRecipeParams):
model = _load_image(p.model_id) model = _load_image(p.model_id)
if model is None: if model is None:
raise HTTPException(404, "Modello non trovato") raise HTTPException(404, "Modello non trovato")
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
sp = SimpleMatchParams( sp = SimpleMatchParams(
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi, model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
@@ -838,7 +1012,10 @@ def save_recipe(p: SaveRecipeParams):
use_polarity=p.use_polarity, use_polarity=p.use_polarity,
use_gpu=p.use_gpu, use_gpu=p.use_gpu,
) )
m.train(roi_img) # Lock globale: serializza il training pesante col matching in corso
with _MATCHER_LOCK:
n_var = m.train(roi_img, train_mask)
_check_trained(m, n_var)
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-") safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
if not safe_name: if not safe_name:
raise HTTPException(400, "Nome ricetta non valido") raise HTTPException(400, "Nome ricetta non valido")
@@ -864,6 +1041,14 @@ _RECIPE_MATCHERS: OrderedDict = OrderedDict()
_RECIPE_MATCHERS_SIZE = 4 _RECIPE_MATCHERS_SIZE = 4
def _recipe_matchers_put(name: str, matcher: LineShapeMatcher) -> None:
"""Inserisce in _RECIPE_MATCHERS con eviction LRU (cap _RECIPE_MATCHERS_SIZE)."""
_RECIPE_MATCHERS[name] = matcher
_RECIPE_MATCHERS.move_to_end(name)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
@app.post("/recipes/{name}/load") @app.post("/recipes/{name}/load")
def load_recipe(name: str): def load_recipe(name: str):
"""Carica ricetta .npz e popola cache matcher in memoria. """Carica ricetta .npz e popola cache matcher in memoria.
@@ -878,10 +1063,8 @@ def load_recipe(name: str):
if not path.is_file(): if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}") raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path)) m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m with _MATCHER_LOCK:
_RECIPE_MATCHERS.move_to_end(safe_name) _recipe_matchers_put(safe_name, m)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
return { return {
"name": safe_name, "name": safe_name,
"n_variants": len(m.variants), "n_variants": len(m.variants),
@@ -905,7 +1088,9 @@ class RecipeMatchParams(BaseModel):
greediness: float = 0.0 greediness: float = 0.0
refine_pose_joint: bool = False refine_pose_joint: bool = False
search_roi: list[int] | None = None search_roi: list[int] | None = None
verify_threshold: float = 0.5 # Allineato a MatchParams.verify_threshold (0.4): valori divergenti
# davano risultati diversi tra /match e /match_recipe a parità di scena.
verify_threshold: float = 0.4
scale_penalty: float = 0.0 scale_penalty: float = 0.0
@@ -913,23 +1098,30 @@ class RecipeMatchParams(BaseModel):
def match_recipe(p: RecipeMatchParams): def match_recipe(p: RecipeMatchParams):
"""Match con ricetta pre-trained: zero training, solo find.""" """Match con ricetta pre-trained: zero training, solo find."""
safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz" safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz"
m = _RECIPE_MATCHERS.get(safe_name)
if m is None:
# Auto-load on demand
path = RECIPES_DIR / safe_name
if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m
scene = _load_image(p.scene_id) scene = _load_image(p.scene_id)
if scene is None: if scene is None:
raise HTTPException(404, "Scena non trovata") raise HTTPException(404, "Scena non trovata")
search_roi_t = tuple(p.search_roi) if p.search_roi else None search_roi_t = tuple(p.search_roi) if p.search_roi else None
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _RECIPE_MATCHERS.get(safe_name)
if m is not None:
_RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch
else:
# Auto-load on demand: stessa eviction LRU di load_recipe
# (senza cap la cache cresceva senza limite)
path = RECIPES_DIR / safe_name
if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path))
_recipe_matchers_put(safe_name, m)
t0 = time.time() t0 = time.time()
matches = m.find( matches = m.find(
scene, scene,
min_score=p.min_score, max_matches=p.max_matches, min_score=p.min_score, max_matches=p.max_matches,
verify_threshold=p.verify_threshold, verify_threshold=p.verify_threshold,
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
scale_penalty=p.scale_penalty, scale_penalty=p.scale_penalty,
min_recall=p.min_recall, min_recall=p.min_recall,
use_soft_score=p.use_soft_score, use_soft_score=p.use_soft_score,
+180 -1
View File
@@ -20,6 +20,10 @@ const state = {
model: null, scene: null, roi: null, drag: null, model: null, scene: null, roi: null, drag: null,
matches: [], annotatedImg: null, matches: [], annotatedImg: null,
active_recipe: null, // V: ricetta caricata (string nome) o null active_recipe: null, // V: ricetta caricata (string nome) o null
// ROI poligonale: vertici [x, y] in coordinate immagine modello
polyMode: false, polyPts: [], polyClosed: false,
// Export JSON: ultimo match completo (params + risposta)
lastMatch: null,
}; };
// ---------- Forms ---------- // ---------- Forms ----------
@@ -148,6 +152,15 @@ async function uploadToFolder(file) {
return await r.json(); return await r.json();
} }
async function uploadDxf(file) {
// DXF: rasterizzato server-side in template grayscale (vedi pm2d/dxf.py)
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/upload_dxf", { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
return await r.json();
}
async function refreshPickers() { async function refreshPickers() {
const {files, dir} = await fetchImagesList(); const {files, dir} = await fetchImagesList();
buildThumbPicker("picker-model", files, onSelectModel); buildThumbPicker("picker-model", files, onSelectModel);
@@ -222,6 +235,7 @@ async function onSelectModel(filename) {
const img = await loadImage(`/image/${meta.id}/raw`); const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img }; state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null; state.roi = null;
state.polyPts = []; state.polyClosed = false; // B: scarta poligono stale
document.getElementById("roi-info").textContent = "ROI: (nessuna)"; document.getElementById("roi-info").textContent = "ROI: (nessuna)";
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`); setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
renderModel(); renderModel();
@@ -262,12 +276,36 @@ function renderModel() {
state.model.scale = fit.sc; state.model.scale = fit.sc;
state.model.ox = fit.ox; state.model.oy = fit.oy; state.model.ox = fit.ox; state.model.oy = fit.oy;
ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh); ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh);
if (state.roi) { if (state.roi && !state.polyMode) {
const [x, y, w, h] = state.roi; const [x, y, w, h] = state.roi;
ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2; ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2;
ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc, ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc,
w * fit.sc, h * fit.sc); w * fit.sc, h * fit.sc);
} }
// ROI poligonale: path aperto giallo, chiuso verde con fill semitrasparente
if (state.polyMode && state.polyPts.length > 0) {
ctx.beginPath();
state.polyPts.forEach(([px, py], i) => {
const cx = fit.ox + px * fit.sc;
const cy = fit.oy + py * fit.sc;
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
});
if (state.polyClosed) {
ctx.closePath();
ctx.fillStyle = "rgba(0, 255, 128, 0.18)";
ctx.fill();
ctx.strokeStyle = "#00ff80";
} else {
ctx.strokeStyle = "#ffff00";
}
ctx.lineWidth = 2;
ctx.stroke();
// Vertici come quadratini
ctx.fillStyle = state.polyClosed ? "#00ff80" : "#ffff00";
for (const [px, py] of state.polyPts) {
ctx.fillRect(fit.ox + px * fit.sc - 2, fit.oy + py * fit.sc - 2, 4, 4);
}
}
if (state.drag) { if (state.drag) {
ctx.strokeStyle = "#ffff00"; ctx.strokeStyle = "#ffff00";
ctx.setLineDash([4, 2]); ctx.lineWidth = 2; ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
@@ -301,10 +339,35 @@ function setupROI() {
const cnv = document.getElementById("c-model"); const cnv = document.getElementById("c-model");
cnv.addEventListener("mousedown", (e) => { cnv.addEventListener("mousedown", (e) => {
if (!state.model) return; if (!state.model) return;
if (state.polyMode) return; // poly mode: gestito da click/dblclick
const p = canvasPos(cnv, e); const p = canvasPos(cnv, e);
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
renderModel(); renderModel();
}); });
// ROI poligonale: click aggiunge vertice, doppio click chiude
cnv.addEventListener("click", (e) => {
if (!state.model || !state.polyMode || state.polyClosed) return;
const m = state.model;
const p = canvasPos(cnv, e);
const ix = (p.x - m.ox) / m.scale;
const iy = (p.y - m.oy) / m.scale;
if (ix < 0 || iy < 0 || ix > m.w || iy > m.h) return; // fuori immagine
const last = state.polyPts[state.polyPts.length - 1];
// Dedup: il dblclick genera anche 2 click ravvicinati
if (last && Math.hypot(ix - last[0], iy - last[1]) < 3) return;
state.polyPts.push([
Math.max(0, Math.min(Math.round(ix), m.w - 1)),
Math.max(0, Math.min(Math.round(iy), m.h - 1)),
]);
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici (doppio click o "Chiudi" per chiudere)`;
renderModel();
});
cnv.addEventListener("dblclick", (e) => {
if (!state.polyMode) return;
e.preventDefault();
closePoly();
});
cnv.addEventListener("mousemove", (e) => { cnv.addEventListener("mousemove", (e) => {
if (!state.drag) return; if (!state.drag) return;
const p = canvasPos(cnv, e); const p = canvasPos(cnv, e);
@@ -331,6 +394,41 @@ function setupROI() {
}); });
} }
// ---------- ROI poligonale ----------
function closePoly() {
if (!state.polyMode || state.polyClosed) return;
if (state.polyPts.length < 3) {
setStatus("Servono almeno 3 vertici per chiudere il poligono");
return;
}
state.polyClosed = true;
// ROI = bounding box del poligono (il server riceve anche roi_poly)
const xs = state.polyPts.map((p) => p[0]);
const ys = state.polyPts.map((p) => p[1]);
const x0 = Math.min(...xs), y0 = Math.min(...ys);
const w = Math.max(...xs) - x0, h = Math.max(...ys) - y0;
state.roi = [x0, y0, Math.max(1, w), Math.max(1, h)];
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici, bbox ${w}x${h} @ (${x0}, ${y0})`;
renderModel();
}
function resetPoly() {
state.polyPts = [];
state.polyClosed = false;
state.roi = null;
document.getElementById("roi-info").textContent = state.polyMode
? "Poligono: clicca sul modello per aggiungere vertici"
: "ROI: (nessuna)";
renderModel();
}
function getRoiPoly() {
// Poligono valido solo se in modalità poly e chiuso
return (state.polyMode && state.polyClosed && state.polyPts.length >= 3)
? state.polyPts : null;
}
// ---------- Match action ---------- // ---------- Match action ----------
async function doMatchRecipe() { async function doMatchRecipe() {
if (!state.scene) { setStatus("Carica scena"); return; } if (!state.scene) { setStatus("Carica scena"); return; }
@@ -352,6 +450,12 @@ async function doMatchRecipe() {
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; } if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
const data = await r.json(); const data = await r.json();
state.matches = data.matches; state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: "/match_recipe", params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage( state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`); `/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene(); renderScene();
@@ -371,7 +475,11 @@ async function doMatch() {
} }
if (!state.model) { setStatus("Carica modello"); return; } if (!state.model) { setStatus("Carica modello"); return; }
if (!state.scene) { setStatus("Carica scena"); return; } if (!state.scene) { setStatus("Carica scena"); return; }
if (state.polyMode && !state.polyClosed) {
setStatus("Chiudi il poligono (doppio click o bottone Chiudi)"); return;
}
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; } if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
const roiPoly = getRoiPoly();
const user = readUserParams(); const user = readUserParams();
const adv = readAdvancedOverrides(); const adv = readAdvancedOverrides();
setStatus("Match in corso..."); setStatus("Match in corso...");
@@ -397,6 +505,7 @@ async function doMatch() {
const angMax = SYM_MAP[user.simmetria] ?? 360; const angMax = SYM_MAP[user.simmetria] ?? 360;
body = { body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
angle_min: 0, angle_max: angMax, angle_min: 0, angle_max: angMax,
angle_step: PREC_MAP[user.precisione] ?? 5, angle_step: PREC_MAP[user.precisione] ?? 5,
scale_min: smin, scale_max: smax, scale_step: sstep, scale_min: smin, scale_max: smax, scale_step: sstep,
@@ -412,6 +521,7 @@ async function doMatch() {
} else { } else {
body = { body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
...user, ...user,
}; };
} }
@@ -426,6 +536,12 @@ async function doMatch() {
} }
const data = await r.json(); const data = await r.json();
state.matches = data.matches; state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: url, params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage( state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`); `/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene(); renderScene();
@@ -461,6 +577,38 @@ function setStatus(s) {
document.getElementById("status").textContent = s; document.getElementById("status").textContent = s;
} }
// ---------- C: Export JSON risultati ----------
function exportMatchJSON() {
if (!state.lastMatch) {
alert("Nessun match da esportare: esegui prima un MATCH.");
return;
}
const lm = state.lastMatch;
const payload = {
timestamp: new Date().toISOString(),
image_id: lm.image_id,
endpoint: lm.endpoint,
params: lm.params,
matches: lm.response.matches.map((m) => ({
cx: m.cx, cy: m.cy, angle_deg: m.angle_deg,
scale: m.scale, score: m.score, bbox: m.bbox_poly,
})),
train_time: lm.response.train_time,
find_time: lm.response.find_time,
num_variants: lm.response.num_variants,
};
const blob = new Blob([JSON.stringify(payload, null, 2)],
{ type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
a.download = `pm2d_match_${ts}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}
// ---------- Init ---------- // ---------- Init ----------
// ---------- Edge preview (clean rumore) ---------- // ---------- Edge preview (clean rumore) ----------
let _epDebounce = null; let _epDebounce = null;
@@ -734,6 +882,7 @@ async function saveRecipe() {
model_id: state.model.id, model_id: state.model.id,
scene_id: state.scene?.id || state.model.id, scene_id: state.scene?.id || state.model.id,
roi: state.roi, roi: state.roi,
roi_poly: getRoiPoly(),
tipo: user.tipo, tipo: user.tipo,
simmetria: user.simmetria, simmetria: user.simmetria,
scala: user.scala, scala: user.scala,
@@ -777,6 +926,24 @@ window.addEventListener("DOMContentLoaded", async () => {
upEl.addEventListener("change", async (e) => { upEl.addEventListener("change", async (e) => {
const f = e.target.files[0]; const f = e.target.files[0];
if (!f) return; if (!f) return;
// A: file DXF → rasterizza server-side e usa direttamente come modello
if (f.name.toLowerCase().endsWith(".dxf")) {
setStatus(`Rasterizzazione DXF ${f.name}...`);
try {
const meta = await uploadDxf(f);
const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null;
resetPoly();
setStatus(`DXF ${f.name} rasterizzato ` +
`${meta.width}x${meta.height} — disegna ROI sul modello`);
renderModel();
} catch (err) {
setStatus(`Errore DXF: ${err.message}`);
}
e.target.value = "";
return;
}
setStatus(`Caricamento ${f.name} nella cartella...`); setStatus(`Caricamento ${f.name} nella cartella...`);
try { try {
const res = await uploadToFolder(f); const res = await uploadToFolder(f);
@@ -789,6 +956,18 @@ window.addEventListener("DOMContentLoaded", async () => {
}); });
document.getElementById("btn-match").addEventListener("click", doMatch); document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-autotune").addEventListener("click", doAutoTune); document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
// B: ROI poligonale (toggle + chiudi + reset)
document.getElementById("roi-poly-toggle").addEventListener("change", (e) => {
state.polyMode = e.target.checked;
document.getElementById("btn-poly-close").disabled = !state.polyMode;
document.getElementById("btn-poly-reset").disabled = !state.polyMode;
resetPoly();
});
document.getElementById("btn-poly-close").addEventListener("click", closePoly);
document.getElementById("btn-poly-reset").addEventListener("click", resetPoly);
// C: export JSON ultimo match
document.getElementById("btn-export-json").addEventListener("click",
exportMatchJSON);
document.getElementById("btn-save-recipe").addEventListener("click", document.getElementById("btn-save-recipe").addEventListener("click",
saveRecipe); saveRecipe);
document.getElementById("btn-load-recipe").addEventListener("click", document.getElementById("btn-load-recipe").addEventListener("click",
+16 -2
View File
@@ -30,9 +30,9 @@
title="Analizza ROI e derivata parametri ottimali (Halcon-style)"> title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
⚙ Auto-tune ⚙ Auto-tune
</button> </button>
<label class="btn" title="Carica nuovo file nella cartella immagini"> <label class="btn" title="Carica nuovo file nella cartella immagini (immagine o DXF)">
⬆ Carica file ⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden> <input type="file" id="file-upload" accept="image/*,.dxf" hidden>
</label> </label>
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span> <span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
</div> </div>
@@ -45,6 +45,15 @@
<canvas id="c-model" width="380" height="420"></canvas> <canvas id="c-model" width="380" height="420"></canvas>
</div> </div>
<div id="roi-info">ROI: (nessuna)</div> <div id="roi-info">ROI: (nessuna)</div>
<div id="roi-poly-bar" style="display:flex; gap:6px; align-items:center; margin-top:6px">
<label style="display:flex; gap:4px; align-items:center; font-size:12px; cursor:pointer">
<input type="checkbox" id="roi-poly-toggle"> ROI poligonale
</label>
<button class="btn" id="btn-poly-close" type="button" disabled
title="Chiude il poligono (equivale al doppio click)">Chiudi</button>
<button class="btn" id="btn-poly-reset" type="button" disabled
title="Cancella i vertici del poligono">Reset</button>
</div>
<details id="edge-preview-panel" style="margin-top:10px"> <details id="edge-preview-panel" style="margin-top:10px">
<summary>🔬 Anteprima edge / pulizia rumore</summary> <summary>🔬 Anteprima edge / pulizia rumore</summary>
<div style="font-size:11px; color:#aaa; margin:4px 0"> <div style="font-size:11px; color:#aaa; margin:4px 0">
@@ -248,6 +257,11 @@
<div class="kv"><span>find:</span><span id="t-find">-</span></div> <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>varianti:</span><span id="t-var">-</span></div>
<div class="kv"><span>match:</span><span id="t-match">-</span></div> <div class="kv"><span>match:</span><span id="t-match">-</span></div>
<button class="btn" id="btn-export-json" type="button" disabled
style="margin-top:8px; width:100%"
title="Scarica i risultati dell'ultimo match in formato JSON">
⬇ Esporta JSON
</button>
<details id="diag-panel" style="margin-top:10px"> <details id="diag-panel" style="margin-top:10px">
<summary>🔍 Diagnostica (CC)</summary> <summary>🔍 Diagnostica (CC)</summary>
+11
View File
@@ -10,6 +10,7 @@ dependencies = [
"pillow>=12.2.0", "pillow>=12.2.0",
"python-multipart>=0.0.26", "python-multipart>=0.0.26",
"uvicorn[standard]>=0.34", "uvicorn[standard]>=0.34",
"ezdxf>=1.3",
] ]
[project.scripts] [project.scripts]
@@ -19,4 +20,14 @@ pm2d-bench = "pm2d.bench:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"pytest>=8.0",
"ruff>=0.8",
] ]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F"]
# E702 (a; b) ed E402 (import dopo codice) sono idiomi voluti del codebase
ignore = ["E501", "E741", "E702", "E731", "E402"]
View File
+99
View File
@@ -0,0 +1,99 @@
"""Fixture condivise: template e scene sintetiche con ground-truth nota.
Tutti i test sono sintetici (nessuna dipendenza dalle immagini Test/,
non versionate): generano scene con pose note e verificano recall e
precisione del matcher. Runtime totale atteso: ~2-4 min su 2 core.
"""
from __future__ import annotations
import math
import cv2
import numpy as np
import pytest
def make_template(tw: int = 160, th: int = 120) -> np.ndarray:
"""Forma a L asimmetrica con foro circolare, contrasto netto.
Asimmetrica per evitare ambiguita' rotazionali nei confronti GT.
"""
img = np.full((th, tw), 60, np.uint8)
cv2.rectangle(img, (20, 20), (60, th - 20), 200, -1)
cv2.rectangle(img, (20, th - 55), (tw - 25, th - 20), 200, -1)
cv2.circle(img, (tw - 45, 40), 16, 200, -1)
return cv2.GaussianBlur(img, (3, 3), 0)
# Pose ground-truth: (cx, cy, angle_deg) - angoli volutamente lontani
# dalla griglia di step 5/2 gradi per misurare il refine.
GT_POSES: list[tuple[float, float, float]] = [
(150.0, 150.0, 0.0),
(450.0, 140.0, 7.3),
(740.0, 170.0, 33.7),
(160.0, 420.0, 91.2),
(460.0, 430.0, 158.4),
(750.0, 480.0, 246.9),
(300.0, 590.0, 312.6),
]
def make_scene(
template: np.ndarray,
poses: list[tuple[float, float, float]],
W: int = 900, H: int = 700,
noise: float = 4.0, seed: int = 7,
) -> np.ndarray:
"""Incolla il template warpato alle pose date su sfondo rumoroso.
Convenzione di rotazione identica al matcher (cv2.getRotationMatrix2D
attorno al centro template, poi traslazione del centro su (cx, cy)).
"""
rng = np.random.default_rng(seed)
scene = np.full((H, W), 60, np.float32)
th, tw = template.shape
for (cx, cy, ang) in poses:
M = cv2.getRotationMatrix2D((tw / 2.0, th / 2.0), ang, 1.0)
M[0, 2] += cx - tw / 2.0
M[1, 2] += cy - th / 2.0
warped = cv2.warpAffine(template.astype(np.float32), M, (W, H),
flags=cv2.INTER_LINEAR, borderValue=-1)
scene = np.where(warped >= 0, warped, scene)
scene += rng.normal(0, noise, scene.shape)
return np.clip(scene, 0, 255).astype(np.uint8)
def ang_diff(a: float, b: float) -> float:
"""Differenza angolare firmata in (-180, 180]."""
d = (a - b) % 360.0
return d - 360.0 if d > 180.0 else d
def match_errors(matches, poses, radius: float = 20.0):
"""Associa match a pose GT per distanza; ritorna (err_ang, err_pos, n_miss)."""
errs_a: list[float] = []
errs_p: list[float] = []
miss = 0
for (cx, cy, ang) in poses:
cands = [
(math.hypot(m.cx - cx, m.cy - cy), m)
for m in matches
if math.hypot(m.cx - cx, m.cy - cy) < radius
]
if not cands:
miss += 1
continue
d, m = min(cands, key=lambda t: t[0])
errs_a.append(abs(ang_diff(m.angle_deg, ang)))
errs_p.append(d)
return errs_a, errs_p, miss
@pytest.fixture(scope="session")
def template() -> np.ndarray:
return make_template()
@pytest.fixture(scope="session")
def scene(template) -> np.ndarray:
return make_scene(template, GT_POSES)
+84
View File
@@ -0,0 +1,84 @@
"""Unit test rapidi su componenti del matcher (no matching pesante)."""
from __future__ import annotations
import numpy as np
import cv2
import pytest
from pm2d import LineShapeMatcher
from tests.conftest import GT_POSES, make_scene, match_errors
def test_angle_list_includes_range_end():
# Range parziale ±15: l'estremo +15 deve essere testato (era escluso).
m = LineShapeMatcher(angle_range_deg=(-15.0, 15.0), angle_step_deg=5.0)
angles = m._angle_list()
assert -15.0 in angles and 15.0 in angles
assert len(angles) == 7
def test_angle_list_full_circle_no_duplicate():
# (0, 360): 360 coincide con 0 → escluso, niente variante duplicata.
m = LineShapeMatcher(angle_range_deg=(0.0, 360.0), angle_step_deg=5.0)
angles = m._angle_list()
assert len(angles) == 72
assert 360.0 not in angles
def test_pyramid_clamp_small_template():
# Template 40px di lato minimo: al top /4 le feature collassano →
# i livelli vengono clampati (40/2=20 >= 12, 40/4=10 < 12 → 2 livelli).
m = LineShapeMatcher(pyramid_levels=4, angle_range_deg=(0.0, 10.0),
angle_step_deg=5.0)
tpl = np.full((40, 200), 60, np.uint8)
cv2.rectangle(tpl, (30, 8), (170, 32), 200, -1)
m.train(tpl)
assert m.pyramid_levels == 2
def test_save_load_roundtrip(tmp_path, template, scene):
m = LineShapeMatcher(angle_step_deg=10.0)
m.train(template)
path = str(tmp_path / "model.npz")
m.save_model(path)
m2 = LineShapeMatcher.load_model(path)
assert len(m2.variants) == len(m.variants)
matches = m2.find(scene, min_score=0.5, max_matches=10)
_, _, miss = match_errors(matches, GT_POSES)
assert miss == 0
def test_scene_cache_no_collision(template):
# Due scene IDENTICHE nella banda superiore ma diverse sotto: la cache
# (che prima hashava solo i primi 64KB) non deve restituire i risultati
# della scena sbagliata.
poses_a = [GT_POSES[0], (450.0, 560.0, 33.7)]
poses_b = [GT_POSES[0], (700.0, 560.0, 91.2)]
scene_a = make_scene(template, poses_a)
scene_b = make_scene(template, poses_b)
# Stessa banda superiore (le pose extra sono in basso, y >= 430)
assert np.array_equal(scene_a[:80], scene_b[:80])
m = LineShapeMatcher(angle_step_deg=10.0)
m.train(template)
ma = m.find(scene_a, min_score=0.5, max_matches=5)
mb = m.find(scene_b, min_score=0.5, max_matches=5)
_, _, miss_a = match_errors(ma, poses_a)
_, _, miss_b = match_errors(mb, poses_b)
assert miss_a == 0 and miss_b == 0
def test_train_mask_polygonal(template, scene):
# ROI poligonale: mask che copre solo la L verticale del template.
mask = np.zeros_like(template)
cv2.rectangle(mask, (10, 10), (70, template.shape[0] - 10), 255, -1)
m = LineShapeMatcher(angle_step_deg=10.0)
n = m.train(template, mask=mask)
assert n > 0
matches = m.find(scene, min_score=0.5, max_matches=10)
assert len(matches) >= 1
def test_untrained_find_raises():
m = LineShapeMatcher()
with pytest.raises(RuntimeError):
m.find(np.zeros((100, 100), np.uint8))
+56
View File
@@ -0,0 +1,56 @@
"""Test di non-regressione su precisione e recall (GT sintetica).
Soglie derivate dalle misure di Fase 2 (errore mediano ~0.05 deg /
~0.08 px) con margine 3-4x per assorbire rumore tra run/macchine.
Una regressione del refine (es. score saturo, minMaxLoc sul plateau)
riporterebbe gli errori a 2-4 deg / 4 px e fa fallire i test con
margine enorme.
"""
from __future__ import annotations
import numpy as np
from pm2d import LineShapeMatcher
from tests.conftest import GT_POSES, match_errors
def _find(template, scene, step, **kw):
m = LineShapeMatcher(angle_step_deg=step, num_features=96)
m.train(template)
return m.find(scene, min_score=0.5, max_matches=10, **kw)
def test_recall_and_precision_step5(template, scene):
matches = _find(template, scene, 5.0)
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
assert miss == 0, f"{miss} pose GT non trovate"
assert float(np.median(errs_a)) < 0.2, f"err angolo mediano {np.median(errs_a):.3f} deg"
assert float(np.max(errs_a)) < 0.5, f"err angolo max {np.max(errs_a):.3f} deg"
assert float(np.median(errs_p)) < 0.3, f"err posizione mediano {np.median(errs_p):.3f} px"
assert float(np.max(errs_p)) < 1.0, f"err posizione max {np.max(errs_p):.3f} px"
def test_recall_and_precision_step2(template, scene):
# Step fine: storicamente il caso peggiore (plateau con piu' varianti
# dentro la tolleranza spread → scelta variante arbitraria).
matches = _find(template, scene, 2.0)
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
assert miss == 0, f"{miss} pose GT non trovate"
assert float(np.median(errs_a)) < 0.2
assert float(np.max(errs_a)) < 0.5
assert float(np.median(errs_p)) < 0.3
def test_no_false_positives(template, scene):
# max_matches alto: non devono comparire match spuri oltre le 7 pose.
matches = _find(template, scene, 5.0)
assert len(matches) <= len(GT_POSES) + 1, (
f"{len(matches)} match per {len(GT_POSES)} oggetti reali"
)
def test_full_scan_path_equivalent(template, scene):
# Il path full-scan (propagate off) deve trovare le stesse pose.
matches = _find(template, scene, 5.0, pyramid_propagate=False)
_, _, miss = match_errors(matches, GT_POSES)
assert miss == 0
Generated
+151 -1
View File
@@ -62,6 +62,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "ezdxf"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fonttools" },
{ name = "numpy" },
{ name = "pyparsing" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" },
{ url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" },
{ url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" },
{ url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" },
{ url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" },
{ url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.1" version = "0.136.1"
@@ -78,6 +101,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
] ]
[[package]]
name = "fonttools"
version = "4.63.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" },
{ url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" },
{ url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" },
{ url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" },
{ url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" },
{ url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" },
{ url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" },
{ url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" },
{ url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" },
{ url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" },
{ url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" },
{ url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" },
{ url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" },
{ url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" },
{ url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" },
{ url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" },
{ url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" },
{ url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" },
{ url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" },
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -146,6 +202,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "llvmlite" name = "llvmlite"
version = "0.47.0" version = "0.47.0"
@@ -258,6 +323,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
] ]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.2.0" version = "12.2.0"
@@ -316,6 +390,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.3" version = "2.13.3"
@@ -387,6 +470,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" },
] ]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -441,11 +558,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
{ url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
{ url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
{ url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
{ url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
{ url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
{ url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
{ url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
{ url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
{ url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
{ url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
{ url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
]
[[package]] [[package]]
name = "shape-model-2d" name = "shape-model-2d"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "ezdxf" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "numba" }, { name = "numba" },
{ name = "numpy" }, { name = "numpy" },
@@ -458,10 +601,13 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "ezdxf", specifier = ">=1.3" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "numba", specifier = ">=0.65.0" }, { name = "numba", specifier = ">=0.65.0" },
{ name = "numpy", specifier = ">=1.24" }, { name = "numpy", specifier = ">=1.24" },
@@ -472,7 +618,11 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "httpx", specifier = ">=0.28.1" }] dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"