Compare commits

..

18 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
Adriano 8c46a6ca9b fix: rimossa traslazione fissa edge overlay match
Causa principale: erode di (2*spread_radius+1) sulla maschera warpata
toglieva troppo bordo. Per spread_radius=8 → kernel 17x17 = -8px da
ogni lato. L'edge map applicata sopra mostrava i bordi spostati di ~8px
verso l'interno del pezzo, creando apparente "traslazione fissa".

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:25:15 +02:00
Adriano ee1c4a8f92 merge: fix edge bordi spuri overlay match 2026-05-05 12:13:07 +02:00
54 changed files with 1975 additions and 494 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)
+179
View File
@@ -0,0 +1,179 @@
"""Benchmark suite per LineShapeMatcher.
Usage:
python -m pm2d.bench [--quick]
Misura tempi find() su 3 template-tipo × 3 scene-tipo × N config:
- Template: rettangolo 80×80, L-shape 120×120, cerchio 150×150
- Scene: pulita 800×600, cluttered 1080×1920, multi-pezzo 1080×1920
- Config: baseline, polarity, gpu, pyramid_propagate, greediness=0.7
Per ogni config stampa: ms/find, ms per fase (profile), n. match.
Output tabellare per detectare regressioni in CI.
"""
from __future__ import annotations
import argparse
import time
import cv2
import numpy as np
from pm2d.line_matcher import LineShapeMatcher, opencl_available
# ---------- Sintetizzatori template/scena ----------
def _tpl_rect() -> np.ndarray:
t = np.zeros((80, 80, 3), np.uint8)
cv2.rectangle(t, (15, 15), (65, 65), (255, 255, 255), 3)
return t
def _tpl_lshape() -> np.ndarray:
t = np.zeros((120, 120, 3), np.uint8)
cv2.rectangle(t, (20, 20), (50, 100), (255, 255, 255), -1)
cv2.rectangle(t, (20, 70), (100, 100), (255, 255, 255), -1)
return t
def _tpl_circle() -> np.ndarray:
t = np.zeros((150, 150, 3), np.uint8)
cv2.circle(t, (75, 75), 60, (255, 255, 255), 4)
return t
def _scene_clean(W: int, H: int, n_pieces: int = 1) -> np.ndarray:
np.random.seed(0)
s = np.zeros((H, W, 3), np.uint8)
for _ in range(n_pieces):
cx = np.random.randint(80, W - 80)
cy = np.random.randint(80, H - 80)
cv2.rectangle(s, (cx - 25, cy - 25), (cx + 25, cy + 25), (255, 255, 255), 3)
return s
def _scene_cluttered(W: int, H: int) -> np.ndarray:
np.random.seed(0)
s = np.random.randint(50, 200, (H, W, 3), np.uint8)
cv2.rectangle(s, (300, 200), (350, 250), (255, 255, 255), 3)
cv2.rectangle(s, (1500, 800), (1550, 850), (255, 255, 255), 3)
return s
# ---------- Single benchmark ----------
def _bench_config(template, scene, config_name: str,
init_kw: dict, find_kw: dict,
n_iter: int = 5) -> dict:
m = LineShapeMatcher(**init_kw)
t0 = time.perf_counter()
n_var = m.train(template)
t_train = time.perf_counter() - t0
# Warmup (Numba JIT)
m.find(scene, **find_kw)
m.find(scene, **find_kw)
# Run
times_ms = []
for _ in range(n_iter):
t0 = time.perf_counter()
matches = m.find(scene, **find_kw)
times_ms.append((time.perf_counter() - t0) * 1000.0)
# Profile (1 iter)
m.find(scene, profile=True, **find_kw)
prof = m.get_last_profile() or {}
return {
"config": config_name,
"n_variants": n_var,
"t_train_s": round(t_train, 3),
"ms_avg": round(float(np.mean(times_ms)), 1),
"ms_min": round(float(np.min(times_ms)), 1),
"ms_max": round(float(np.max(times_ms)), 1),
"n_matches": len(matches),
"profile_ms": {k: round(v, 1) for k, v in prof.items()},
}
# ---------- Suite ----------
CONFIGS = [
("baseline",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2}),
("polarity",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_polarity": True},
{"min_score": 0.4, "verify_threshold": 0.2}),
("propagate",
{"angle_step_deg": 10, "pyramid_levels": 3},
{"min_score": 0.4, "verify_threshold": 0.2,
"pyramid_propagate": True, "propagate_topk": 4}),
("greedy_07",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "greediness": 0.7}),
("stride2",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "coarse_stride": 2}),
]
if opencl_available():
CONFIGS.append(
("gpu_umat",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_gpu": True},
{"min_score": 0.4, "verify_threshold": 0.2})
)
SCENARIOS = [
("rect_80 vs scene_800x600", _tpl_rect, lambda: _scene_clean(800, 600, 1)),
("lshape_120 vs scene_1080x1920_clutter",
_tpl_lshape, lambda: _scene_cluttered(1920, 1080)),
("circle_150 vs scene_clean_3pieces",
_tpl_circle, lambda: _scene_clean(1920, 1080, 3)),
]
def run(quick: bool = False) -> int:
n_iter = 2 if quick else 5
print(f"=== PM2D Benchmark Suite ({len(SCENARIOS)} scenarios x "
f"{len(CONFIGS)} configs, n_iter={n_iter}) ===\n")
rows = []
for sc_name, tpl_fn, scn_fn in SCENARIOS:
template = tpl_fn()
scene = scn_fn()
print(f"--- Scenario: {sc_name} (tpl={template.shape}, "
f"scn={scene.shape}) ---")
for cfg_name, init_kw, find_kw in CONFIGS:
r = _bench_config(template, scene, cfg_name, init_kw, find_kw,
n_iter=n_iter)
r["scenario"] = sc_name
rows.append(r)
prof_str = " ".join(
f"{k}={v:.1f}" for k, v in r["profile_ms"].items()
)
print(f" {cfg_name:14s} {r['ms_avg']:6.1f}ms "
f"(min {r['ms_min']:.1f} max {r['ms_max']:.1f}) "
f"vars={r['n_variants']:3d} "
f"matches={r['n_matches']:2d}")
if prof_str:
print(f" profile: {prof_str}")
print()
print("=== Done ===")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="PM2D benchmark suite")
p.add_argument("--quick", action="store_true",
help="2 iterazioni per config invece di 5 (smoke test)")
args = p.parse_args(argv)
return run(quick=args.quick)
if __name__ == "__main__":
import sys
sys.exit(main())
+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,
+478 -337
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
a0, a1 = self.angle_range_deg 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(
+339 -141
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:
@@ -168,34 +257,57 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
if template_gray is not None and matcher is not None: if template_gray is not None and matcher is not None:
t = template_gray t = template_gray
th, tw = t.shape th, tw = t.shape
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0 # Centro template coerente col training: in train si usa
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) # `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
M[0, 2] += m.cx - cx_t # introduceva uno shift di 0.5px per template di lato pari.
M[1, 2] += m.cy - cy_t cx_t = tw / 2.0; cy_t = th / 2.0
warped_gray = cv2.warpAffine( # Lavora su un CROP locale della scena di lato = diagonale del
t, M, (W_scene, H_scene), # template ruotato+scalato (+margine), come _verify_ncc: warp
flags=cv2.INTER_LINEAR, borderValue=0) # + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno # (costosissimo su scene grandi con molti match).
mask_src = (matcher._train_mask if matcher._train_mask is not None diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8
else np.full((th, tw), 255, dtype=np.uint8)) x0 = int(round(m.cx)) - diag // 2
warped_mask = cv2.warpAffine( y0 = int(round(m.cy)) - diag // 2
mask_src, M, (W_scene, H_scene), gx0 = max(0, x0); gy0 = max(0, y0)
flags=cv2.INTER_NEAREST, borderValue=0) gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag)
# Erode di spread_radius per scartare la fascia di transizione cw, ch_ = gx1 - gx0, gy1 - gy0
# bordo che produce gradient spurio if cw >= 3 and ch_ >= 3:
er_k = max(3, 2 * matcher.spread_radius + 1) M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
kernel_er = np.ones((er_k, er_k), np.uint8) # Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop
warped_mask = cv2.erode(warped_mask, kernel_er) M[0, 2] += (m.cx - gx0) - cx_t
mag, _ = matcher._gradient(warped_gray) M[1, 2] += (m.cy - gy0) - cy_t
if matcher.weak_grad < matcher.strong_grad: warped_gray = cv2.warpAffine(
edge_mask = matcher._hysteresis_mask(mag) t, M, (cw, ch_),
else: flags=cv2.INTER_LINEAR, borderValue=0)
edge_mask = mag >= matcher.strong_grad # Maschera: train_mask se disponibile, altrimenti rettangolo pieno
edge_mask = edge_mask & (warped_mask > 0) mask_src = (matcher._train_mask if matcher._train_mask is not None
if edge_mask.any(): else np.full((th, tw), 255, dtype=np.uint8))
edge_overlay = np.zeros_like(out) warped_mask = cv2.warpAffine(
edge_overlay[edge_mask] = (0, 220, 0) # verde brillante mask_src, M, (cw, ch_),
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0) flags=cv2.INTER_NEAREST, borderValue=0)
# Erode minimo (3x3) per togliere SOLO artefatti border-padding
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
# grande spostava visualmente l'edge verso l'interno e creava
# apparente "traslazione fissa" rispetto al bordo del pezzo.
kernel_er = np.ones((3, 3), np.uint8)
warped_mask = cv2.erode(warped_mask, kernel_er)
mag, _ = matcher._gradient(warped_gray)
if matcher.weak_grad < matcher.strong_grad:
edge_mask = matcher._hysteresis_mask(mag)
else:
edge_mask = mag >= matcher.strong_grad
edge_mask = edge_mask & (warped_mask > 0)
if edge_mask.any():
# Edge ritraslati nel sistema scena: blend solo sul crop
# (addWeighted lascia invariati i pixel con overlay nullo,
# quindi l'output visivo è identico al full-frame).
sub = out[gy0:gy1, gx0:gx1]
edge_overlay = np.zeros_like(sub)
# Ciano (cambiato da verde): non collide col verde dell'asse
# Y dell'UCS che altrimenti scompariva nell'overlay edge.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR)
out[gy0:gy1, gx0:gx1] = cv2.addWeighted(
sub, 1.0, edge_overlay, 0.6, 0)
L = max(20, int(L_base * m.scale)) 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))
@@ -228,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
@@ -240,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
@@ -307,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
@@ -401,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,
} }
@@ -520,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)
@@ -534,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")
x, y, w, h = p.roi # ROI poligonale: bbox derivato dal poligono + mask per il training
x = max(0, x); y = max(0, y) train_mask = None
w = max(1, min(w, model.shape[1] - x)) if p.roi_poly is not None:
h = max(1, min(h, model.shape[0] - y)) 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 = _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]
tech_for_cache = { tech_for_cache = {
@@ -549,35 +699,43 @@ 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)
m = _cache_get_matcher(key) # Lock globale: matcher condivisi tra thread del pool FastAPI
if m is None: with _MATCHER_LOCK:
m = LineShapeMatcher( m = _cache_get_matcher(key)
num_features=p.num_features, if m is None:
weak_grad=p.weak_grad, strong_grad=p.strong_grad, m = LineShapeMatcher(
angle_range_deg=(p.angle_min, p.angle_max), num_features=p.num_features,
angle_step_deg=p.angle_step, weak_grad=p.weak_grad, strong_grad=p.strong_grad,
scale_range=(p.scale_min, p.scale_max), angle_range_deg=(p.angle_min, p.angle_max),
scale_step=p.scale_step, angle_step_deg=p.angle_step,
spread_radius=p.spread_radius, scale_range=(p.scale_min, p.scale_max),
pyramid_levels=p.pyramid_levels, scale_step=p.scale_step,
spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels,
)
t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = p.nms_radius if p.nms_radius > 0 else None
t0 = time.time()
matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold,
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t_find = time.time() - t0
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = p.nms_radius if p.nms_radius > 0 else None
t0 = time.time()
matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold,
)
t_find = time.time() - t0
# Render annotated image # Render annotated image
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m) annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
@@ -603,59 +761,71 @@ 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")
x, y, w, h = p.roi # ROI poligonale: bbox derivato dal poligono + mask per il training
x = max(0, x); y = max(0, y) train_mask = None
w = max(1, min(w, model.shape[1] - x)) if p.roi_poly is not None:
h = max(1, min(h, model.shape[0] - y)) 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 = _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]
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
m = _cache_get_matcher(key) # Lock globale: matcher condivisi tra thread del pool FastAPI
if m is None: with _MATCHER_LOCK:
m = LineShapeMatcher( m = _cache_get_matcher(key)
num_features=tech["num_features"], if m is None:
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], m = LineShapeMatcher(
angle_range_deg=(tech["angle_min"], tech["angle_max"]), num_features=tech["num_features"],
angle_step_deg=tech["angle_step"], weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
scale_range=(tech["scale_min"], tech["scale_max"]), angle_range_deg=(tech["angle_min"], tech["angle_max"]),
scale_step=tech["scale_step"], angle_step_deg=tech["angle_step"],
spread_radius=tech["spread_radius"], scale_range=(tech["scale_min"], tech["scale_max"]),
min_feature_spacing=tech.get("min_feature_spacing", 3), scale_step=tech["scale_step"],
pyramid_levels=tech["pyramid_levels"], spread_radius=tech["spread_radius"],
use_polarity=p.use_polarity, min_feature_spacing=tech.get("min_feature_spacing", 3),
use_gpu=p.use_gpu, pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
)
t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"],
# filtro_fp="off" → verify NCC davvero disabilitato
verify_ncc=tech.get("verify_ncc", True),
scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t_find = time.time() - t0
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"],
scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg, matcher=m) annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
@@ -675,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,
@@ -688,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"
@@ -807,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")
x, y, w, h = p.roi # 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 = _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,
@@ -832,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")
@@ -858,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.
@@ -872,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),
@@ -899,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
@@ -907,37 +1098,44 @@ 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
t0 = time.time() # Lock globale: matcher condivisi tra thread del pool FastAPI
matches = m.find( with _MATCHER_LOCK:
scene, m = _RECIPE_MATCHERS.get(safe_name)
min_score=p.min_score, max_matches=p.max_matches, if m is not None:
verify_threshold=p.verify_threshold, _RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch
scale_penalty=p.scale_penalty, else:
min_recall=p.min_recall, # Auto-load on demand: stessa eviction LRU di load_recipe
use_soft_score=p.use_soft_score, # (senza cap la cache cresceva senza limite)
subpixel_lm=p.subpixel_lm, path = RECIPES_DIR / safe_name
nms_iou_threshold=p.nms_iou_threshold, if not path.is_file():
coarse_stride=p.coarse_stride, raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
pyramid_propagate=p.pyramid_propagate, m = LineShapeMatcher.load_model(str(path))
greediness=p.greediness, _recipe_matchers_put(safe_name, m)
refine_pose_joint=p.refine_pose_joint, t0 = time.time()
search_roi=search_roi_t, matches = m.find(
) scene,
t_find = time.time() - t0 min_score=p.min_score, max_matches=p.max_matches,
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) verify_threshold=p.verify_threshold,
annotated = _draw_matches(scene, matches, tg, matcher=m) # Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
scale_penalty=p.scale_penalty,
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
matches=[MatchResult( matches=[MatchResult(
+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",
+17 -3
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">
@@ -102,8 +111,8 @@
<div class="field"> <div class="field">
<label>Simmetria</label> <label>Simmetria</label>
<select id="p-simmetria"> <select id="p-simmetria">
<option value="nessuna" selected>Nessuna (0..360°)</option>
<option value="invariante">Invariante (cerchi — no rotazione)</option> <option value="invariante">Invariante (cerchi — no rotazione)</option>
<option value="nessuna">Nessuna (0..360°)</option>
<option value="bilaterale">Bilaterale (speculare 180°)</option> <option value="bilaterale">Bilaterale (speculare 180°)</option>
<option value="rot_3">Rotazionale 3× (120°)</option> <option value="rot_3">Rotazionale 3× (120°)</option>
<option value="rot_4">Rotazionale 4× (90°)</option> <option value="rot_4">Rotazionale 4× (90°)</option>
@@ -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>
+12
View File
@@ -10,12 +10,24 @@ 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]
pm2d-eval = "pm2d.eval:main" pm2d-eval = "pm2d.eval:main"
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"