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>
This commit is contained in:
@@ -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`.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user