Compare commits

..

8 Commits

Author SHA1 Message Date
Adriano 27fa5986bf docs: README allineato allo stato attuale
CI / test (push) Failing after 18s
Kernel Numba JIT, precisione misurata (0.05 deg / 0.04 px), pipeline
refine (bitmap fine + LSQ pos+angolo), propagate windowed, webapp con
endpoint DXF/roi_poly/ricette, test pytest + CI, Test/ non versionate,
deploy compose build sulla VPS, parametri aggiornati ai default reali.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:47:41 +00:00
Adriano 4652202dae fix: CMD docker con --frozen --no-dev (no sync dev deps a ogni avvio)
CI / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:18:08 +00:00
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
50 changed files with 1007 additions and 130 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/
+3 -1
View File
@@ -35,4 +35,6 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/images').read()" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/images').read()" || exit 1
CMD ["uv", "run", "python", "main.py"] # --frozen --no-dev: senza, uv run ri-sincronizza il gruppo dev
# (pytest/ruff) a OGNI avvio del container, scaricando pacchetti a runtime
CMD ["uv", "run", "--frozen", "--no-dev", "python", "main.py"]
+131 -102
View File
@@ -1,32 +1,55 @@
# Shape Model 2D — Standalone PM 2D # Shape Model 2D — Standalone PM 2D
Programma standalone Pattern Matching 2D shape-based. Pattern Matching 2D shape-based (stile Halcon `find_shape_model`), standalone:
libreria Python + GUI desktop + webapp FastAPI.
Due backend algoritmici: Due backend algoritmici:
| Backend | Modulo | Algoritmo | Tempo clip.png (13 istanze) | | Backend | Modulo | Algoritmo |
|---|---|---|---| |---|---|---|
| `line` (default) | `pm2d.line_matcher.LineShapeMatcher` | Linemod-style: gradient orient quantizzata + spread + response map + feature sparse | **3.5 s, 12/13 score 1.0** | | `line` (default) | `pm2d.line_matcher.LineShapeMatcher` | Linemod-style: orientazione gradiente quantizzata + spread bitmap + kernel Numba JIT + refine least-squares |
| `edge` | `pm2d.matcher.EdgeShapeMatcher` | Edge Canny + `matchTemplate` multi-rotazione | 84 s, 6/13 score ~0.3 | | `edge` (legacy) | `pm2d.matcher.EdgeShapeMatcher` | Edge Canny + `matchTemplate` multi-rotazione (fallback semplice, lento) |
Porting algoritmico (non SIMD) di `meiqua/shape_based_matching/line2Dup`. MIPP (wrapper SIMD C++) non ha senso in Python — la vettorizzazione la fa già NumPy. Porting algoritmico di `meiqua/shape_based_matching/line2Dup`; gli hot-path
sono kernel **Numba JIT** (`pm2d/_jit_kernels.py`, parallelo, ≈ velocità C)
con fallback NumPy automatico se numba non è disponibile.
## Precisione (misurata su ground-truth sintetica, 7 pose note)
| Metrica | Valore |
|---|---|
| Errore angolare mediano | **~0.05°** (step 5°), ~0.03° (step 2°) |
| Errore posizione mediano | **~0.04 px** |
| Recall | 7/7 a min_score 0.5 |
Pipeline di refine: golden-section sull'angolo su bitmap fine (raggio 1,
non satura) → least-squares 3×3 congiunto (dx, dy, dθ) sui gradienti scena
(`subpixel_lm`, ON di default). I test in `tests/` fanno da guardia di
non-regressione su queste soglie.
## Struttura ## Struttura
``` ```
Shape_model_2d/ shape_model_2d/
├── pm2d/ ├── pm2d/
│ ├── __init__.py │ ├── line_matcher.py # LineShapeMatcher (default)
│ ├── matcher.py # EdgeShapeMatcher (fallback, semplice) │ ├── _jit_kernels.py # kernel Numba JIT (score bitmap, windowed, popcount)
│ ├── line_matcher.py # LineShapeMatcher (default, ottimizzato) │ ├── matcher.py # EdgeShapeMatcher (legacy)
── gui.py # GUI OpenCV + tk file dialog ── dxf.py # rasterizzazione DXF → template (ezdxf)
├── main.py # entry point │ ├── auto_tune.py # stima automatica parametri (simmetria, soglie)
├── Test/ # immagini di test │ ├── gui.py # GUI OpenCV + tk file dialog
├── pyproject.toml │ ├── bench.py, eval.py # CLI benchmark / valutazione
└── README.md │ └── web/ # webapp FastAPI (server.py + static/)
├── benchmarks/test_suite.py # suite 16 scenari su immagini reali (Test/)
├── tests/ # pytest sintetici (precisione + unit, no Test/)
├── .gitea/workflows/ci.yml # CI: uv sync + ruff + pytest
├── main.py # entry point GUI
├── Test/ # immagini di test LOCALI (non versionate)
├── Dockerfile, docker-compose.yml
└── pyproject.toml
``` ```
GUI e algoritmo separati: i matcher sono riusabili da qualsiasi script/backend. GUI/web e algoritmo separati: i matcher sono riusabili da qualsiasi script.
## Setup ## Setup
@@ -37,12 +60,16 @@ uv sync
## Esecuzione ## Esecuzione
```bash ```bash
uv run python main.py uv run python main.py # GUI desktop
uv run python -m uvicorn pm2d.web.server:app --port 8080 # webapp
uv run pytest tests/ # test (sintetici, ~1 min)
uv run python benchmarks/test_suite.py # benchmark (richiede Test/)
``` ```
Flusso: file dialog modello → ROI → file dialog scena → risultati. Le immagini in `Test/` **non sono versionate** (vedi `.gitignore`): la suite
benchmark le richiede in locale, i test pytest no.
## API algoritmo (backend `line`, raccomandato) ## API algoritmo (backend `line`)
```python ```python
import cv2 import cv2
@@ -53,122 +80,129 @@ scene = cv2.imread("scene.png")
m = LineShapeMatcher( m = LineShapeMatcher(
num_features=96, # feature sparse per variante num_features=96, # feature sparse per variante
weak_grad=30, # soglia gradiente per spread weak_grad=30, # soglia gradiente debole (spread/hysteresis)
strong_grad=60, # soglia gradiente per estrazione feature strong_grad=60, # soglia gradiente forte (estrazione feature)
angle_range_deg=(0, 360), angle_range_deg=(0, 360),
angle_step_deg=5.0, angle_step_deg=5.0, # <=0 → step auto dal lato template
scale_range=(0.9, 1.1), # invarianza a scala scale_range=(0.9, 1.1), # invarianza a scala
scale_step=0.05, scale_step=0.05,
spread_radius=5, # raggio dilate per robustezza spread_radius=4, # tolleranza posizionale matching coarse
pyramid_levels=3, # velocità via pruning top-level pyramid_levels=3, # clampato auto alla dimensione template
top_score_factor=0.5, # soglia top = min_score * factor
) )
m.train(template) # ~0.2 s m.train(template) # opzionale: train(template, mask=...) per ROI parziale
matches = m.find(scene, min_score=0.55, max_matches=25) matches = m.find(scene, min_score=0.55, max_matches=25)
for x in matches: for x in matches:
print(x.cx, x.cy, x.angle_deg, x.scale, x.score) print(x.cx, x.cy, x.angle_deg, x.scale, x.score) # pose sub-pixel/sub-grado
``` ```
### Modello su regione parziale (non blob distinto) Opzioni `find()` utili: `search_roi=(x, y, w, h)`, `min_recall`,
`scale_penalty`, `use_soft_score`, `debug=True` (diagnostica drop),
`profile=True` (timing per fase via `get_last_profile()`).
`train()` accetta una **maschera binaria opzionale** per limitare le feature Persistenza modello (Halcon write/read_shape_model):
a una porzione della ROI (es. parte interna di un oggetto complesso, dettaglio
distintivo, ecc.):
```python ```python
mask = np.zeros_like(template[:, :, 0]) m.save_model("ricetta.npz")
cv2.fillPoly(mask, [poligono_utente], 255) m2 = LineShapeMatcher.load_model("ricetta.npz") # deploy senza re-train
m.train(template, mask=mask)
``` ```
Solo i gradienti dentro la maschera contribuiscono alle feature.
## Come funziona il backend `line` ## Come funziona il backend `line`
### Training (costoso, ~0.2 s / 72 varianti) ### Training
Per ogni coppia (angolo, scala) del template: Per ogni coppia (angolo, scala) del template:
1. Rotazione + scala su canvas con padding diagonale 1. Rotazione + scala su canvas con padding diagonale (centro di rotazione
2. Sobel → `magnitude` + `orientation` (atan2) = centro reale del template, coerente in tutta la pipeline)
3. Quantizzazione orientazione in **8 bin modulo π** (edge simmetrici) 2. Sobel → magnitude + orientation, quantizzata in **8 bin modulo π**
4. Estrazione **N feature sparse**: top-magnitude sopra `strong_grad`, con spacing minimo per evitare cluster (16 bin mod 2π con `use_polarity=True`)
5. Feature salvate come `(dx, dy, bin)` relative al centro-modello 3. Edge selection con **hysteresis** weak/strong (Halcon Contrast auto)
4. **N feature sparse** top-magnitude con spacing minimo, salvate come
`(dx, dy, bin)` arrotondate rispetto al centro-modello
5. Piramide feature per livello + dedup varianti identiche (simmetrie)
### Matching (veloce) ### Matching
Scena processata **una volta per livello piramide**: Scena processata una volta per livello (e cachata per scene ripetute):
- Sobel → mag → orient quantizzato → bin invalidato dove `mag < weak_grad` Sobel → quantizzazione → **spread bitmap** uint8/uint16 (bit b = bin b
- **Spread**: dilate morfologica per bin (tolleranza localizzazione) presente nel raggio) → kernel JIT:
- **Response map** `(8, H, W)`: response[b][y,x] = 1 dove orient b è presente
Per ogni variante:
``` ```
score[y, x] = Σ_i resp[bin_i][y + dy_i, x + dx_i] / N_features score[y, x] = popcount-AND feature/bitmap / N_features (rescored vs background)
``` ```
Implementato con **shift+add vettorizzato NumPy** (O(N_features · H · W) invece di O(kh·kw·H·W) come `matchTemplate`). 1. **Top-level**: valuta 1 variante ogni `cf_auto` (step angolare auto:
al livello top lo spread tollera ~atan(spread/(lato_top/2)) gradi),
### Piramide multi-risoluzione pruning per soglia + istogramma orientazioni.
2. **Full-res windowed** (`pyramid_propagate`, default ON): score calcolato
- **Top-level** (risoluzione /4 di default con `pyramid_levels=3`): score ridotto per pruning varianti. Se nessun pixel raggiunge `min_score * top_score_factor`, la variante è scartata. solo in finestre attorno ai massimi locali del top-level — costo
- **Full-res**: calcolato solo per le varianti sopravvissute → nel benchmark clip: ~5-10 varianti su 72 = 7-14× speed-up rispetto a full-res per tutte. proporzionale ai candidati, non a varianti × W × H. Per template
elongati (>2:1) si torna automaticamente al full-scan esatto.
3. **Refine per candidato**: subpixel 2D sul picco → golden-section
sull'angolo su bitmap fine → least-squares (dx, dy, dθ) sui gradienti.
4. **Verify**: NCC su crop locale (anti falsi-positivi, mediato nello
score) → NMS IoU su bbox poligonali orientati.
## Parametri principali ## Parametri principali
| Parametro | Default | Significato | | Parametro | Default | Significato |
|---|---|---| |---|---|---|
| `num_features` | 96 | feature sparse per variante | | `num_features` | 96 | feature sparse per variante |
| `weak_grad` | 30 | threshold debole (per spread) | | `weak_grad` | 30 | soglia debole (spread + hysteresis) |
| `strong_grad` | 60 | threshold forte (per estrazione feature) | | `strong_grad` | 60 | soglia forte (estrazione feature) |
| `spread_radius` | 5 | raggio dilate spread (tolleranza posizionale) | | `spread_radius` | 4 | tolleranza posizionale matching coarse |
| `min_feature_spacing` | 3 | spacing minimo tra feature per evitare cluster |
| `angle_range_deg` | `(0,360)` | range rotazioni | | `angle_range_deg` | `(0,360)` | range rotazioni |
| `angle_step_deg` | 5.0 | passo angolare | | `angle_step_deg` | 5.0 | passo angolare (<=0 = auto) |
| `scale_range` | `(1,1)` | range scale | | `scale_range` | `(1,1)` | range scale |
| `scale_step` | 0.1 | passo scala | | `pyramid_levels` | 3 | livelli piramide (clamp auto su template piccoli) |
| `pyramid_levels` | 3 | livelli piramide (più = pruning più aggressivo) | | `min_score` | 0.6 | soglia score finale [0..1] |
| `top_score_factor`| 0.5 | soglia top-level = min_score * factor | | `max_matches` | 20 | numero max di match |
| `min_score` | 0.55 | soglia score finale [0..1] | | `verify_threshold`| 0.4 | soglia NCC anti falso-positivo |
| `max_matches` | 25 | numero max di match | | `subpixel_lm` | True | least-squares finale pos+angolo |
| `nms_radius` | `min(w,h)/2` | raggio NMS baricentri | | `pyramid_propagate` | True | full-res solo in finestre sui picchi top |
## Webapp (pm2d/web)
UI single-page (canvas ROI rettangolare o **poligonale**, slider parametri,
anteprima edge, ricette) + API JSON:
| Endpoint | Funzione |
|---|---|
| `POST /upload` | carica immagine (multipart) |
| `POST /upload_dxf` | carica **DXF** → rasterizzato a template (`?size=128..2048`) |
| `POST /match` | match con parametri tecnici (`roi`, opzionale `roi_poly`) |
| `POST /match_simple` | match con profili semplificati (precisione/filtro_fp/simmetria) |
| `POST /auto_tune` | stima automatica parametri dalla ROI |
| `POST /recipes`, `GET /recipes`, `/match_recipe` | salva/carica/usa ricette `.npz` |
| `GET /image/{id}/annotated` | PNG con overlay match (UCS) |
Dalla UI: bottone **Esporta JSON** per scaricare i risultati completi
(pose, score, bbox, parametri, tempi) per integrazione.
## Test e CI
- `tests/`: pytest **sintetici** (template/scene generati, GT nota) —
precisione angolo/posizione, recall, cache, save/load, mask poligonale.
- CI Gitea Actions (`.gitea/workflows/ci.yml`): ruff + pytest su ogni push.
- `benchmarks/test_suite.py`: 16 scenari su immagini reali per confronto
manuale prestazioni/recall (richiede `Test/` in locale).
## Roadmap ## Roadmap
- Subpixel refinement (interpolazione parabolic sui picchi) Vedi [ROADMAP.md](ROADMAP.md) — Fase 1 (speed) e Fase 2 (precisione
- ICP locale per raffinamento pose rotazione + robustezza) completate; prossimo target: latency <50 ms su
- Vincoli di orientamento: clustering delle pose per eliminare duplicati cross-variante 1920×1080 (auto step per livello fatto; restano greediness default, GPU,
- Numba JIT per il ciclo shift+add (eventuale 3-5× su scene grandi) eventuale SIMD C++).
## Deploy VPS con Docker + Traefik ## Deploy VPS con Docker + Traefik
Assume che sulla VPS siano già attivi: Assume che sulla VPS siano già attivi:
- **Traefik** come reverse proxy su network Docker esterna `traefik` - **Traefik** come reverse proxy su network Docker esterna `traefik`
- Entrypoints `web` (:80) e `websecure` (:443) - Entrypoint `websecure` (:443) e cert resolver configurato
- Cert resolver `letsencrypt` configurato
### Build e push al registry
```bash ```bash
# Build locale cd /opt/docker/visionsuite/shape_model_2d
docker build -t vps-ip:5000/pm2d:latest . docker compose build
docker push vps-ip:5000/pm2d:latest
```
### Sulla VPS
```bash
# Cartella deploy (immagini persistenti qui)
mkdir -p /opt/docker/pm2d/images
cd /opt/docker/pm2d
# Copia docker-compose.yml
# Imposta REGISTRY / TAG se necessario via .env
echo "REGISTRY=vps-ip:5000" > .env
echo "TAG=latest" >> .env
docker compose pull
docker compose up -d docker compose up -d
``` ```
@@ -179,13 +213,8 @@ Servizio raggiungibile: **https://pm.tielogic.xyz**
- **Volume `./images`**: persistenza delle immagini caricate tramite UI - **Volume `./images`**: persistenza delle immagini caricate tramite UI
(`IMAGES_DIR=/data/images` nel container). Sopravvive a restart. (`IMAGES_DIR=/data/images` nel container). Sopravvive a restart.
- **Upload max 50MB**: middleware Traefik `pm2d-bodysize`. Adattare se serve. - **Upload max 50MB**: middleware Traefik `pm2d-bodysize`. Adattare se serve.
- **Cache matcher in-memory**: si svuota a restart container (no problema, - **Cache matcher in-memory**: si svuota a restart container (ri-popolata
viene ri-popolata al primo match). al primo match). Le ricette `.npz` invece persistono in `recipes/`.
- **Healthcheck**: HTTP `GET /images` ogni 30s. - **Healthcheck**: HTTP `GET /images` ogni 30s.
- Se nome network Traefik diverso da `traefik`, modifica - Se nome network Traefik o cert resolver diversi, modifica i labels in
`docker-compose.yml` sezione `networks`. `docker-compose.yml`.
### Adattamenti config Traefik non-standard
Se la VPS ha convenzioni diverse (es. cert resolver chiamato `le`,
entrypoint `https`), modifica i labels nel `docker-compose.yml`.
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

+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
-1
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
+20 -12
View File
@@ -38,7 +38,6 @@ _GOLDEN = (math.sqrt(5.0) - 1.0) / 2.0 # ≈ 0.618
from pm2d._jit_kernels import ( from pm2d._jit_kernels import (
score_by_shift as _jit_score_by_shift, score_by_shift as _jit_score_by_shift,
score_bitmap as _jit_score_bitmap,
score_bitmap_rescored as _jit_score_bitmap_rescored, score_bitmap_rescored as _jit_score_bitmap_rescored,
score_bitmap_rescored_window as _jit_score_bitmap_rescored_window, score_bitmap_rescored_window as _jit_score_bitmap_rescored_window,
score_bitmap_greedy as _jit_score_bitmap_greedy, score_bitmap_greedy as _jit_score_bitmap_greedy,
@@ -326,8 +325,6 @@ class LineShapeMatcher:
n_vars = len(self.variants) n_vars = len(self.variants)
n_levels = len(self.variants[0].levels) n_levels = len(self.variants[0].levels)
var_meta = np.zeros((n_vars, 6), dtype=np.float32) # ang, scale, kh, kw, cxl, cyl var_meta = np.zeros((n_vars, 6), dtype=np.float32) # ang, scale, kh, kw, cxl, cyl
all_dx, all_dy, all_bin, all_offsets = [], [], [], []
offset = 0
all_offsets_per_level = [[] for _ in range(n_levels)] all_offsets_per_level = [[] for _ in range(n_levels)]
all_dx_per_level = [[] for _ in range(n_levels)] all_dx_per_level = [[] for _ in range(n_levels)]
all_dy_per_level = [[] for _ in range(n_levels)] all_dy_per_level = [[] for _ in range(n_levels)]
@@ -1483,12 +1480,9 @@ class LineShapeMatcher:
if nms_radius is None: if nms_radius is None:
nms_radius = max(8, min(self.template_size) // 2) nms_radius = max(8, min(self.template_size) // 2)
# Pruning adattivo allo step angolare: con step piccolo (<= 3 deg) # Pruning adattivo allo step angolare: con step piccolo (<= 3 deg)
# ci sono molte varianti vicine, gli score top-level sono ravvicinati # ci sono molte varianti vicine e gli score top-level sono
# e top_thresh*0.5 e' troppo aggressivo: scarta varianti valide che # ravvicinati: top_thresh*0.5 e' troppo aggressivo, scarta varianti
# sarebbero state riprese al full-res. Stessa cosa per # valide che sarebbero state riprese al full-res.
# coarse_angle_factor (skip 1 ogni 2): con step fine non e' utile.
# Risultato osservato: precisione "veloce" 10° dava risultati
# migliori di "preciso" 2° proprio perche evitava il pruning.
# Il path windowed (pyramid_propagate) assume che il picco # Il path windowed (pyramid_propagate) assume che il picco
# top-level localizzi la posizione entro il margine finestra. # top-level localizzi la posizione entro il margine finestra.
# Su template ALLUNGATI (es. lama 40x280, o ROI parziale lungo un # Su template ALLUNGATI (es. lama 40x280, o ROI parziale lungo un
@@ -1503,10 +1497,25 @@ class LineShapeMatcher:
pyramid_propagate = False pyramid_propagate = False
eff_step = self._effective_angle_step() eff_step = self._effective_angle_step()
top_factor = self.top_score_factor top_factor = self.top_score_factor
cf_eff = max(1, coarse_angle_factor)
if eff_step <= 3.0: if eff_step <= 3.0:
top_factor = max(top_factor, 0.7) top_factor = max(top_factor, 0.7)
cf_eff = 1 # Coarse step angolare AUTO al top-level (Halcon-style): al livello
# top le feature distano R/2^top dal centro, quindi lo spread
# (raggio in px, costante per livello) tollera una rotazione
# ~atan(spread / (max_side_top/2)) — molto piu' ampia dello step
# richiesto a full-res. Si valuta al top 1 variante ogni cf_eff;
# le intermedie vengono riprese dall'espansione ai vicini.
# Es: template 160 px, 3 livelli, step 2° → tolleranza top ~11°
# → cf 6 → top-pruning ~6x piu' veloce a parita' di recall.
if self.template_size != (0, 0):
max_side_top = max(self.template_size) / (2 ** top)
else:
max_side_top = 64.0
step_top_tol = math.degrees(
math.atan2(float(self.spread_radius), max(8.0, max_side_top / 2.0))
)
cf_auto = int(np.clip(round(step_top_tol / max(eff_step, 1e-6)), 1, 8))
cf_eff = max(1, coarse_angle_factor, cf_auto)
top_thresh = min_score * top_factor top_thresh = min_score * top_factor
diag["top_thresh_used"] = float(top_thresh) diag["top_thresh_used"] = float(top_thresh)
@@ -1562,7 +1571,6 @@ class LineShapeMatcher:
dtype=bool, dtype=bool,
) )
if scene_bins.any(): if scene_bins.any():
n_scene_active = int(scene_bins.sum())
# Soglia: variante deve avere >= 50% delle sue feature in bin # Soglia: variante deve avere >= 50% delle sue feature in bin
# presenti nella scena. Sotto = score certamente < 0.5. # presenti nella scena. Sotto = score certamente < 0.5.
pruned_idx_list = [] pruned_idx_list = []
+93 -4
View File
@@ -55,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
@@ -91,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())
@@ -170,6 +174,39 @@ def _clamp_roi(x: int, y: int, w: int, h: int,
return x, y, w, h 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: def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
"""Solleva 422 se il train non ha prodotto varianti. """Solleva 422 se il train non ha prodotto varianti.
@@ -303,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
@@ -384,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
@@ -603,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)
@@ -617,6 +680,12 @@ def match(p: MatchParams):
scene = _load_image(p.scene_id) scene = _load_image(p.scene_id)
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) 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]
@@ -630,6 +699,9 @@ def match(p: MatchParams):
"scale_step": p.scale_step, "scale_step": p.scale_step,
"spread_radius": p.spread_radius, "spread_radius": p.spread_radius,
"pyramid_levels": p.pyramid_levels, "pyramid_levels": p.pyramid_levels,
# Tuple per repr stabile nella cache key (None = rettangolare)
"roi_poly": (tuple(map(tuple, p.roi_poly))
if p.roi_poly is not None else None),
} }
key = _matcher_cache_key(roi_img, tech_for_cache) key = _matcher_cache_key(roi_img, tech_for_cache)
# Lock globale: matcher condivisi tra thread del pool FastAPI # Lock globale: matcher condivisi tra thread del pool FastAPI
@@ -646,7 +718,7 @@ def match(p: MatchParams):
spread_radius=p.spread_radius, spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels, pyramid_levels=p.pyramid_levels,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n) _check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
@@ -689,11 +761,20 @@ def match_simple(p: SimpleMatchParams):
scene = _load_image(p.scene_id) scene = _load_image(p.scene_id)
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) 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
@@ -716,7 +797,7 @@ def match_simple(p: SimpleMatchParams):
use_polarity=p.use_polarity, use_polarity=p.use_polarity,
use_gpu=p.use_gpu, use_gpu=p.use_gpu,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
_check_trained(m, n) _check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
@@ -778,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"
@@ -897,6 +980,12 @@ def save_recipe(p: SaveRecipeParams):
model = _load_image(p.model_id) model = _load_image(p.model_id)
if model is None: if model is None:
raise HTTPException(404, "Modello non trovato") raise HTTPException(404, "Modello non trovato")
# ROI poligonale: bbox derivato dal poligono + mask per il training
train_mask = None
if p.roi_poly is not None:
x, y, w, h, train_mask = _poly_bbox_mask(
p.roi_poly, model.shape[1], model.shape[0])
else:
x, y, w, h = p.roi x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) 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]
@@ -925,7 +1014,7 @@ def save_recipe(p: SaveRecipeParams):
) )
# Lock globale: serializza il training pesante col matching in corso # Lock globale: serializza il training pesante col matching in corso
with _MATCHER_LOCK: with _MATCHER_LOCK:
n_var = m.train(roi_img) n_var = m.train(roi_img, train_mask)
_check_trained(m, n_var) _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:
+180 -1
View File
@@ -20,6 +20,10 @@ const state = {
model: null, scene: null, roi: null, drag: null, model: null, scene: null, roi: null, drag: null,
matches: [], annotatedImg: null, matches: [], annotatedImg: null,
active_recipe: null, // V: ricetta caricata (string nome) o null active_recipe: null, // V: ricetta caricata (string nome) o null
// ROI poligonale: vertici [x, y] in coordinate immagine modello
polyMode: false, polyPts: [], polyClosed: false,
// Export JSON: ultimo match completo (params + risposta)
lastMatch: null,
}; };
// ---------- Forms ---------- // ---------- Forms ----------
@@ -148,6 +152,15 @@ async function uploadToFolder(file) {
return await r.json(); return await r.json();
} }
async function uploadDxf(file) {
// DXF: rasterizzato server-side in template grayscale (vedi pm2d/dxf.py)
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/upload_dxf", { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
return await r.json();
}
async function refreshPickers() { async function refreshPickers() {
const {files, dir} = await fetchImagesList(); const {files, dir} = await fetchImagesList();
buildThumbPicker("picker-model", files, onSelectModel); buildThumbPicker("picker-model", files, onSelectModel);
@@ -222,6 +235,7 @@ async function onSelectModel(filename) {
const img = await loadImage(`/image/${meta.id}/raw`); const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img }; state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null; state.roi = null;
state.polyPts = []; state.polyClosed = false; // B: scarta poligono stale
document.getElementById("roi-info").textContent = "ROI: (nessuna)"; document.getElementById("roi-info").textContent = "ROI: (nessuna)";
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`); setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
renderModel(); renderModel();
@@ -262,12 +276,36 @@ function renderModel() {
state.model.scale = fit.sc; state.model.scale = fit.sc;
state.model.ox = fit.ox; state.model.oy = fit.oy; state.model.ox = fit.ox; state.model.oy = fit.oy;
ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh); ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh);
if (state.roi) { if (state.roi && !state.polyMode) {
const [x, y, w, h] = state.roi; const [x, y, w, h] = state.roi;
ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2; ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2;
ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc, ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc,
w * fit.sc, h * fit.sc); w * fit.sc, h * fit.sc);
} }
// ROI poligonale: path aperto giallo, chiuso verde con fill semitrasparente
if (state.polyMode && state.polyPts.length > 0) {
ctx.beginPath();
state.polyPts.forEach(([px, py], i) => {
const cx = fit.ox + px * fit.sc;
const cy = fit.oy + py * fit.sc;
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
});
if (state.polyClosed) {
ctx.closePath();
ctx.fillStyle = "rgba(0, 255, 128, 0.18)";
ctx.fill();
ctx.strokeStyle = "#00ff80";
} else {
ctx.strokeStyle = "#ffff00";
}
ctx.lineWidth = 2;
ctx.stroke();
// Vertici come quadratini
ctx.fillStyle = state.polyClosed ? "#00ff80" : "#ffff00";
for (const [px, py] of state.polyPts) {
ctx.fillRect(fit.ox + px * fit.sc - 2, fit.oy + py * fit.sc - 2, 4, 4);
}
}
if (state.drag) { if (state.drag) {
ctx.strokeStyle = "#ffff00"; ctx.strokeStyle = "#ffff00";
ctx.setLineDash([4, 2]); ctx.lineWidth = 2; ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
@@ -301,10 +339,35 @@ function setupROI() {
const cnv = document.getElementById("c-model"); const cnv = document.getElementById("c-model");
cnv.addEventListener("mousedown", (e) => { cnv.addEventListener("mousedown", (e) => {
if (!state.model) return; if (!state.model) return;
if (state.polyMode) return; // poly mode: gestito da click/dblclick
const p = canvasPos(cnv, e); const p = canvasPos(cnv, e);
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
renderModel(); renderModel();
}); });
// ROI poligonale: click aggiunge vertice, doppio click chiude
cnv.addEventListener("click", (e) => {
if (!state.model || !state.polyMode || state.polyClosed) return;
const m = state.model;
const p = canvasPos(cnv, e);
const ix = (p.x - m.ox) / m.scale;
const iy = (p.y - m.oy) / m.scale;
if (ix < 0 || iy < 0 || ix > m.w || iy > m.h) return; // fuori immagine
const last = state.polyPts[state.polyPts.length - 1];
// Dedup: il dblclick genera anche 2 click ravvicinati
if (last && Math.hypot(ix - last[0], iy - last[1]) < 3) return;
state.polyPts.push([
Math.max(0, Math.min(Math.round(ix), m.w - 1)),
Math.max(0, Math.min(Math.round(iy), m.h - 1)),
]);
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici (doppio click o "Chiudi" per chiudere)`;
renderModel();
});
cnv.addEventListener("dblclick", (e) => {
if (!state.polyMode) return;
e.preventDefault();
closePoly();
});
cnv.addEventListener("mousemove", (e) => { cnv.addEventListener("mousemove", (e) => {
if (!state.drag) return; if (!state.drag) return;
const p = canvasPos(cnv, e); const p = canvasPos(cnv, e);
@@ -331,6 +394,41 @@ function setupROI() {
}); });
} }
// ---------- ROI poligonale ----------
function closePoly() {
if (!state.polyMode || state.polyClosed) return;
if (state.polyPts.length < 3) {
setStatus("Servono almeno 3 vertici per chiudere il poligono");
return;
}
state.polyClosed = true;
// ROI = bounding box del poligono (il server riceve anche roi_poly)
const xs = state.polyPts.map((p) => p[0]);
const ys = state.polyPts.map((p) => p[1]);
const x0 = Math.min(...xs), y0 = Math.min(...ys);
const w = Math.max(...xs) - x0, h = Math.max(...ys) - y0;
state.roi = [x0, y0, Math.max(1, w), Math.max(1, h)];
document.getElementById("roi-info").textContent =
`Poligono: ${state.polyPts.length} vertici, bbox ${w}x${h} @ (${x0}, ${y0})`;
renderModel();
}
function resetPoly() {
state.polyPts = [];
state.polyClosed = false;
state.roi = null;
document.getElementById("roi-info").textContent = state.polyMode
? "Poligono: clicca sul modello per aggiungere vertici"
: "ROI: (nessuna)";
renderModel();
}
function getRoiPoly() {
// Poligono valido solo se in modalità poly e chiuso
return (state.polyMode && state.polyClosed && state.polyPts.length >= 3)
? state.polyPts : null;
}
// ---------- Match action ---------- // ---------- Match action ----------
async function doMatchRecipe() { async function doMatchRecipe() {
if (!state.scene) { setStatus("Carica scena"); return; } if (!state.scene) { setStatus("Carica scena"); return; }
@@ -352,6 +450,12 @@ async function doMatchRecipe() {
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; } if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
const data = await r.json(); const data = await r.json();
state.matches = data.matches; state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: "/match_recipe", params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage( state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`); `/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene(); renderScene();
@@ -371,7 +475,11 @@ async function doMatch() {
} }
if (!state.model) { setStatus("Carica modello"); return; } if (!state.model) { setStatus("Carica modello"); return; }
if (!state.scene) { setStatus("Carica scena"); return; } if (!state.scene) { setStatus("Carica scena"); return; }
if (state.polyMode && !state.polyClosed) {
setStatus("Chiudi il poligono (doppio click o bottone Chiudi)"); return;
}
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; } if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
const roiPoly = getRoiPoly();
const user = readUserParams(); const user = readUserParams();
const adv = readAdvancedOverrides(); const adv = readAdvancedOverrides();
setStatus("Match in corso..."); setStatus("Match in corso...");
@@ -397,6 +505,7 @@ async function doMatch() {
const angMax = SYM_MAP[user.simmetria] ?? 360; const angMax = SYM_MAP[user.simmetria] ?? 360;
body = { body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
angle_min: 0, angle_max: angMax, angle_min: 0, angle_max: angMax,
angle_step: PREC_MAP[user.precisione] ?? 5, angle_step: PREC_MAP[user.precisione] ?? 5,
scale_min: smin, scale_max: smax, scale_step: sstep, scale_min: smin, scale_max: smax, scale_step: sstep,
@@ -412,6 +521,7 @@ async function doMatch() {
} else { } else {
body = { body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
roi_poly: roiPoly,
...user, ...user,
}; };
} }
@@ -426,6 +536,12 @@ async function doMatch() {
} }
const data = await r.json(); const data = await r.json();
state.matches = data.matches; state.matches = data.matches;
// C: salva tutto per "Esporta JSON"
state.lastMatch = {
endpoint: url, params: body, response: data,
image_id: state.scene.id,
};
document.getElementById("btn-export-json").disabled = false;
state.annotatedImg = await loadImage( state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`); `/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene(); renderScene();
@@ -461,6 +577,38 @@ function setStatus(s) {
document.getElementById("status").textContent = s; document.getElementById("status").textContent = s;
} }
// ---------- C: Export JSON risultati ----------
function exportMatchJSON() {
if (!state.lastMatch) {
alert("Nessun match da esportare: esegui prima un MATCH.");
return;
}
const lm = state.lastMatch;
const payload = {
timestamp: new Date().toISOString(),
image_id: lm.image_id,
endpoint: lm.endpoint,
params: lm.params,
matches: lm.response.matches.map((m) => ({
cx: m.cx, cy: m.cy, angle_deg: m.angle_deg,
scale: m.scale, score: m.score, bbox: m.bbox_poly,
})),
train_time: lm.response.train_time,
find_time: lm.response.find_time,
num_variants: lm.response.num_variants,
};
const blob = new Blob([JSON.stringify(payload, null, 2)],
{ type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
a.download = `pm2d_match_${ts}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}
// ---------- Init ---------- // ---------- Init ----------
// ---------- Edge preview (clean rumore) ---------- // ---------- Edge preview (clean rumore) ----------
let _epDebounce = null; let _epDebounce = null;
@@ -734,6 +882,7 @@ async function saveRecipe() {
model_id: state.model.id, model_id: state.model.id,
scene_id: state.scene?.id || state.model.id, scene_id: state.scene?.id || state.model.id,
roi: state.roi, roi: state.roi,
roi_poly: getRoiPoly(),
tipo: user.tipo, tipo: user.tipo,
simmetria: user.simmetria, simmetria: user.simmetria,
scala: user.scala, scala: user.scala,
@@ -777,6 +926,24 @@ window.addEventListener("DOMContentLoaded", async () => {
upEl.addEventListener("change", async (e) => { upEl.addEventListener("change", async (e) => {
const f = e.target.files[0]; const f = e.target.files[0];
if (!f) return; if (!f) return;
// A: file DXF → rasterizza server-side e usa direttamente come modello
if (f.name.toLowerCase().endsWith(".dxf")) {
setStatus(`Rasterizzazione DXF ${f.name}...`);
try {
const meta = await uploadDxf(f);
const img = await loadImage(`/image/${meta.id}/raw`);
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
state.roi = null;
resetPoly();
setStatus(`DXF ${f.name} rasterizzato ` +
`${meta.width}x${meta.height} — disegna ROI sul modello`);
renderModel();
} catch (err) {
setStatus(`Errore DXF: ${err.message}`);
}
e.target.value = "";
return;
}
setStatus(`Caricamento ${f.name} nella cartella...`); setStatus(`Caricamento ${f.name} nella cartella...`);
try { try {
const res = await uploadToFolder(f); const res = await uploadToFolder(f);
@@ -789,6 +956,18 @@ window.addEventListener("DOMContentLoaded", async () => {
}); });
document.getElementById("btn-match").addEventListener("click", doMatch); document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-autotune").addEventListener("click", doAutoTune); document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
// B: ROI poligonale (toggle + chiudi + reset)
document.getElementById("roi-poly-toggle").addEventListener("change", (e) => {
state.polyMode = e.target.checked;
document.getElementById("btn-poly-close").disabled = !state.polyMode;
document.getElementById("btn-poly-reset").disabled = !state.polyMode;
resetPoly();
});
document.getElementById("btn-poly-close").addEventListener("click", closePoly);
document.getElementById("btn-poly-reset").addEventListener("click", resetPoly);
// C: export JSON ultimo match
document.getElementById("btn-export-json").addEventListener("click",
exportMatchJSON);
document.getElementById("btn-save-recipe").addEventListener("click", document.getElementById("btn-save-recipe").addEventListener("click",
saveRecipe); saveRecipe);
document.getElementById("btn-load-recipe").addEventListener("click", document.getElementById("btn-load-recipe").addEventListener("click",
+16 -2
View File
@@ -30,9 +30,9 @@
title="Analizza ROI e derivata parametri ottimali (Halcon-style)"> title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
⚙ Auto-tune ⚙ Auto-tune
</button> </button>
<label class="btn" title="Carica nuovo file nella cartella immagini"> <label class="btn" title="Carica nuovo file nella cartella immagini (immagine o DXF)">
⬆ Carica file ⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden> <input type="file" id="file-upload" accept="image/*,.dxf" hidden>
</label> </label>
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span> <span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
</div> </div>
@@ -45,6 +45,15 @@
<canvas id="c-model" width="380" height="420"></canvas> <canvas id="c-model" width="380" height="420"></canvas>
</div> </div>
<div id="roi-info">ROI: (nessuna)</div> <div id="roi-info">ROI: (nessuna)</div>
<div id="roi-poly-bar" style="display:flex; gap:6px; align-items:center; margin-top:6px">
<label style="display:flex; gap:4px; align-items:center; font-size:12px; cursor:pointer">
<input type="checkbox" id="roi-poly-toggle"> ROI poligonale
</label>
<button class="btn" id="btn-poly-close" type="button" disabled
title="Chiude il poligono (equivale al doppio click)">Chiudi</button>
<button class="btn" id="btn-poly-reset" type="button" disabled
title="Cancella i vertici del poligono">Reset</button>
</div>
<details id="edge-preview-panel" style="margin-top:10px"> <details id="edge-preview-panel" style="margin-top:10px">
<summary>🔬 Anteprima edge / pulizia rumore</summary> <summary>🔬 Anteprima edge / pulizia rumore</summary>
<div style="font-size:11px; color:#aaa; margin:4px 0"> <div style="font-size:11px; color:#aaa; margin:4px 0">
@@ -248,6 +257,11 @@
<div class="kv"><span>find:</span><span id="t-find">-</span></div> <div class="kv"><span>find:</span><span id="t-find">-</span></div>
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div> <div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
<div class="kv"><span>match:</span><span id="t-match">-</span></div> <div class="kv"><span>match:</span><span id="t-match">-</span></div>
<button class="btn" id="btn-export-json" type="button" disabled
style="margin-top:8px; width:100%"
title="Scarica i risultati dell'ultimo match in formato JSON">
⬇ Esporta JSON
</button>
<details id="diag-panel" style="margin-top:10px"> <details id="diag-panel" style="margin-top:10px">
<summary>🔍 Diagnostica (CC)</summary> <summary>🔍 Diagnostica (CC)</summary>
+11
View File
@@ -10,6 +10,7 @@ dependencies = [
"pillow>=12.2.0", "pillow>=12.2.0",
"python-multipart>=0.0.26", "python-multipart>=0.0.26",
"uvicorn[standard]>=0.34", "uvicorn[standard]>=0.34",
"ezdxf>=1.3",
] ]
[project.scripts] [project.scripts]
@@ -19,4 +20,14 @@ pm2d-bench = "pm2d.bench:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"pytest>=8.0",
"ruff>=0.8",
] ]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F"]
# E702 (a; b) ed E402 (import dopo codice) sono idiomi voluti del codebase
ignore = ["E501", "E741", "E702", "E731", "E402"]
View File
+99
View File
@@ -0,0 +1,99 @@
"""Fixture condivise: template e scene sintetiche con ground-truth nota.
Tutti i test sono sintetici (nessuna dipendenza dalle immagini Test/,
non versionate): generano scene con pose note e verificano recall e
precisione del matcher. Runtime totale atteso: ~2-4 min su 2 core.
"""
from __future__ import annotations
import math
import cv2
import numpy as np
import pytest
def make_template(tw: int = 160, th: int = 120) -> np.ndarray:
"""Forma a L asimmetrica con foro circolare, contrasto netto.
Asimmetrica per evitare ambiguita' rotazionali nei confronti GT.
"""
img = np.full((th, tw), 60, np.uint8)
cv2.rectangle(img, (20, 20), (60, th - 20), 200, -1)
cv2.rectangle(img, (20, th - 55), (tw - 25, th - 20), 200, -1)
cv2.circle(img, (tw - 45, 40), 16, 200, -1)
return cv2.GaussianBlur(img, (3, 3), 0)
# Pose ground-truth: (cx, cy, angle_deg) - angoli volutamente lontani
# dalla griglia di step 5/2 gradi per misurare il refine.
GT_POSES: list[tuple[float, float, float]] = [
(150.0, 150.0, 0.0),
(450.0, 140.0, 7.3),
(740.0, 170.0, 33.7),
(160.0, 420.0, 91.2),
(460.0, 430.0, 158.4),
(750.0, 480.0, 246.9),
(300.0, 590.0, 312.6),
]
def make_scene(
template: np.ndarray,
poses: list[tuple[float, float, float]],
W: int = 900, H: int = 700,
noise: float = 4.0, seed: int = 7,
) -> np.ndarray:
"""Incolla il template warpato alle pose date su sfondo rumoroso.
Convenzione di rotazione identica al matcher (cv2.getRotationMatrix2D
attorno al centro template, poi traslazione del centro su (cx, cy)).
"""
rng = np.random.default_rng(seed)
scene = np.full((H, W), 60, np.float32)
th, tw = template.shape
for (cx, cy, ang) in poses:
M = cv2.getRotationMatrix2D((tw / 2.0, th / 2.0), ang, 1.0)
M[0, 2] += cx - tw / 2.0
M[1, 2] += cy - th / 2.0
warped = cv2.warpAffine(template.astype(np.float32), M, (W, H),
flags=cv2.INTER_LINEAR, borderValue=-1)
scene = np.where(warped >= 0, warped, scene)
scene += rng.normal(0, noise, scene.shape)
return np.clip(scene, 0, 255).astype(np.uint8)
def ang_diff(a: float, b: float) -> float:
"""Differenza angolare firmata in (-180, 180]."""
d = (a - b) % 360.0
return d - 360.0 if d > 180.0 else d
def match_errors(matches, poses, radius: float = 20.0):
"""Associa match a pose GT per distanza; ritorna (err_ang, err_pos, n_miss)."""
errs_a: list[float] = []
errs_p: list[float] = []
miss = 0
for (cx, cy, ang) in poses:
cands = [
(math.hypot(m.cx - cx, m.cy - cy), m)
for m in matches
if math.hypot(m.cx - cx, m.cy - cy) < radius
]
if not cands:
miss += 1
continue
d, m = min(cands, key=lambda t: t[0])
errs_a.append(abs(ang_diff(m.angle_deg, ang)))
errs_p.append(d)
return errs_a, errs_p, miss
@pytest.fixture(scope="session")
def template() -> np.ndarray:
return make_template()
@pytest.fixture(scope="session")
def scene(template) -> np.ndarray:
return make_scene(template, GT_POSES)
+84
View File
@@ -0,0 +1,84 @@
"""Unit test rapidi su componenti del matcher (no matching pesante)."""
from __future__ import annotations
import numpy as np
import cv2
import pytest
from pm2d import LineShapeMatcher
from tests.conftest import GT_POSES, make_scene, match_errors
def test_angle_list_includes_range_end():
# Range parziale ±15: l'estremo +15 deve essere testato (era escluso).
m = LineShapeMatcher(angle_range_deg=(-15.0, 15.0), angle_step_deg=5.0)
angles = m._angle_list()
assert -15.0 in angles and 15.0 in angles
assert len(angles) == 7
def test_angle_list_full_circle_no_duplicate():
# (0, 360): 360 coincide con 0 → escluso, niente variante duplicata.
m = LineShapeMatcher(angle_range_deg=(0.0, 360.0), angle_step_deg=5.0)
angles = m._angle_list()
assert len(angles) == 72
assert 360.0 not in angles
def test_pyramid_clamp_small_template():
# Template 40px di lato minimo: al top /4 le feature collassano →
# i livelli vengono clampati (40/2=20 >= 12, 40/4=10 < 12 → 2 livelli).
m = LineShapeMatcher(pyramid_levels=4, angle_range_deg=(0.0, 10.0),
angle_step_deg=5.0)
tpl = np.full((40, 200), 60, np.uint8)
cv2.rectangle(tpl, (30, 8), (170, 32), 200, -1)
m.train(tpl)
assert m.pyramid_levels == 2
def test_save_load_roundtrip(tmp_path, template, scene):
m = LineShapeMatcher(angle_step_deg=10.0)
m.train(template)
path = str(tmp_path / "model.npz")
m.save_model(path)
m2 = LineShapeMatcher.load_model(path)
assert len(m2.variants) == len(m.variants)
matches = m2.find(scene, min_score=0.5, max_matches=10)
_, _, miss = match_errors(matches, GT_POSES)
assert miss == 0
def test_scene_cache_no_collision(template):
# Due scene IDENTICHE nella banda superiore ma diverse sotto: la cache
# (che prima hashava solo i primi 64KB) non deve restituire i risultati
# della scena sbagliata.
poses_a = [GT_POSES[0], (450.0, 560.0, 33.7)]
poses_b = [GT_POSES[0], (700.0, 560.0, 91.2)]
scene_a = make_scene(template, poses_a)
scene_b = make_scene(template, poses_b)
# Stessa banda superiore (le pose extra sono in basso, y >= 430)
assert np.array_equal(scene_a[:80], scene_b[:80])
m = LineShapeMatcher(angle_step_deg=10.0)
m.train(template)
ma = m.find(scene_a, min_score=0.5, max_matches=5)
mb = m.find(scene_b, min_score=0.5, max_matches=5)
_, _, miss_a = match_errors(ma, poses_a)
_, _, miss_b = match_errors(mb, poses_b)
assert miss_a == 0 and miss_b == 0
def test_train_mask_polygonal(template, scene):
# ROI poligonale: mask che copre solo la L verticale del template.
mask = np.zeros_like(template)
cv2.rectangle(mask, (10, 10), (70, template.shape[0] - 10), 255, -1)
m = LineShapeMatcher(angle_step_deg=10.0)
n = m.train(template, mask=mask)
assert n > 0
matches = m.find(scene, min_score=0.5, max_matches=10)
assert len(matches) >= 1
def test_untrained_find_raises():
m = LineShapeMatcher()
with pytest.raises(RuntimeError):
m.find(np.zeros((100, 100), np.uint8))
+56
View File
@@ -0,0 +1,56 @@
"""Test di non-regressione su precisione e recall (GT sintetica).
Soglie derivate dalle misure di Fase 2 (errore mediano ~0.05 deg /
~0.08 px) con margine 3-4x per assorbire rumore tra run/macchine.
Una regressione del refine (es. score saturo, minMaxLoc sul plateau)
riporterebbe gli errori a 2-4 deg / 4 px e fa fallire i test con
margine enorme.
"""
from __future__ import annotations
import numpy as np
from pm2d import LineShapeMatcher
from tests.conftest import GT_POSES, match_errors
def _find(template, scene, step, **kw):
m = LineShapeMatcher(angle_step_deg=step, num_features=96)
m.train(template)
return m.find(scene, min_score=0.5, max_matches=10, **kw)
def test_recall_and_precision_step5(template, scene):
matches = _find(template, scene, 5.0)
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
assert miss == 0, f"{miss} pose GT non trovate"
assert float(np.median(errs_a)) < 0.2, f"err angolo mediano {np.median(errs_a):.3f} deg"
assert float(np.max(errs_a)) < 0.5, f"err angolo max {np.max(errs_a):.3f} deg"
assert float(np.median(errs_p)) < 0.3, f"err posizione mediano {np.median(errs_p):.3f} px"
assert float(np.max(errs_p)) < 1.0, f"err posizione max {np.max(errs_p):.3f} px"
def test_recall_and_precision_step2(template, scene):
# Step fine: storicamente il caso peggiore (plateau con piu' varianti
# dentro la tolleranza spread → scelta variante arbitraria).
matches = _find(template, scene, 2.0)
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
assert miss == 0, f"{miss} pose GT non trovate"
assert float(np.median(errs_a)) < 0.2
assert float(np.max(errs_a)) < 0.5
assert float(np.median(errs_p)) < 0.3
def test_no_false_positives(template, scene):
# max_matches alto: non devono comparire match spuri oltre le 7 pose.
matches = _find(template, scene, 5.0)
assert len(matches) <= len(GT_POSES) + 1, (
f"{len(matches)} match per {len(GT_POSES)} oggetti reali"
)
def test_full_scan_path_equivalent(template, scene):
# Il path full-scan (propagate off) deve trovare le stesse pose.
matches = _find(template, scene, 5.0, pyramid_propagate=False)
_, _, miss = match_errors(matches, GT_POSES)
assert miss == 0
Generated
+151 -1
View File
@@ -62,6 +62,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "ezdxf"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fonttools" },
{ name = "numpy" },
{ name = "pyparsing" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" },
{ url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" },
{ url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" },
{ url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" },
{ url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" },
{ url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.1" version = "0.136.1"
@@ -78,6 +101,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
] ]
[[package]]
name = "fonttools"
version = "4.63.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" },
{ url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" },
{ url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" },
{ url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" },
{ url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" },
{ url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" },
{ url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" },
{ url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" },
{ url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" },
{ url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" },
{ url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" },
{ url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" },
{ url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" },
{ url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" },
{ url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" },
{ url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" },
{ url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" },
{ url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" },
{ url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" },
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -146,6 +202,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "llvmlite" name = "llvmlite"
version = "0.47.0" version = "0.47.0"
@@ -258,6 +323,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
] ]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.2.0" version = "12.2.0"
@@ -316,6 +390,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.3" version = "2.13.3"
@@ -387,6 +470,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" },
] ]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -441,11 +558,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
{ url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
{ url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
{ url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
{ url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
{ url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
{ url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
{ url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
{ url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
{ url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
{ url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
{ url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
]
[[package]] [[package]]
name = "shape-model-2d" name = "shape-model-2d"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "ezdxf" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "numba" }, { name = "numba" },
{ name = "numpy" }, { name = "numpy" },
@@ -458,10 +601,13 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "ezdxf", specifier = ">=1.3" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "numba", specifier = ">=0.65.0" }, { name = "numba", specifier = ">=0.65.0" },
{ name = "numpy", specifier = ">=1.24" }, { name = "numpy", specifier = ">=1.24" },
@@ -472,7 +618,11 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "httpx", specifier = ">=0.28.1" }] dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"