From 27fa5986bfc45772816cc398c2bde8ef9d76ceac Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 12 Jun 2026 14:47:41 +0000 Subject: [PATCH] docs: README allineato allo stato attuale 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 --- README.md | 233 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 131 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 2cd4ed8..ceea3c5 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,55 @@ # 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: -| Backend | Modulo | Algoritmo | Tempo clip.png (13 istanze) | -|---|---|---|---| -| `line` (default) | `pm2d.line_matcher.LineShapeMatcher` | Linemod-style: gradient orient quantizzata + spread + response map + feature sparse | **3.5 s, 12/13 score 1.0** | -| `edge` | `pm2d.matcher.EdgeShapeMatcher` | Edge Canny + `matchTemplate` multi-rotazione | 84 s, 6/13 score ~0.3 | +| Backend | Modulo | Algoritmo | +|---|---|---| +| `line` (default) | `pm2d.line_matcher.LineShapeMatcher` | Linemod-style: orientazione gradiente quantizzata + spread bitmap + kernel Numba JIT + refine least-squares | +| `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 ``` -Shape_model_2d/ +shape_model_2d/ ├── pm2d/ -│ ├── __init__.py -│ ├── matcher.py # EdgeShapeMatcher (fallback, semplice) -│ ├── line_matcher.py # LineShapeMatcher (default, ottimizzato) -│ └── gui.py # GUI OpenCV + tk file dialog -├── main.py # entry point -├── Test/ # immagini di test -├── pyproject.toml -└── README.md +│ ├── line_matcher.py # LineShapeMatcher (default) +│ ├── _jit_kernels.py # kernel Numba JIT (score bitmap, windowed, popcount) +│ ├── matcher.py # EdgeShapeMatcher (legacy) +│ ├── dxf.py # rasterizzazione DXF → template (ezdxf) +│ ├── auto_tune.py # stima automatica parametri (simmetria, soglie) +│ ├── gui.py # GUI OpenCV + tk file dialog +│ ├── bench.py, eval.py # CLI benchmark / valutazione +│ └── 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 @@ -37,12 +60,16 @@ uv sync ## Esecuzione ```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 import cv2 @@ -53,122 +80,129 @@ scene = cv2.imread("scene.png") m = LineShapeMatcher( num_features=96, # feature sparse per variante - weak_grad=30, # soglia gradiente per spread - strong_grad=60, # soglia gradiente per estrazione feature + weak_grad=30, # soglia gradiente debole (spread/hysteresis) + strong_grad=60, # soglia gradiente forte (estrazione feature) 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_step=0.05, - spread_radius=5, # raggio dilate per robustezza - pyramid_levels=3, # velocità via pruning top-level - top_score_factor=0.5, # soglia top = min_score * factor + spread_radius=4, # tolleranza posizionale matching coarse + pyramid_levels=3, # clampato auto alla dimensione template ) -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) 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 -a una porzione della ROI (es. parte interna di un oggetto complesso, dettaglio -distintivo, ecc.): +Persistenza modello (Halcon write/read_shape_model): ```python -mask = np.zeros_like(template[:, :, 0]) -cv2.fillPoly(mask, [poligono_utente], 255) -m.train(template, mask=mask) +m.save_model("ricetta.npz") +m2 = LineShapeMatcher.load_model("ricetta.npz") # deploy senza re-train ``` -Solo i gradienti dentro la maschera contribuiscono alle feature. - ## Come funziona il backend `line` -### Training (costoso, ~0.2 s / 72 varianti) +### Training Per ogni coppia (angolo, scala) del template: -1. Rotazione + scala su canvas con padding diagonale -2. Sobel → `magnitude` + `orientation` (atan2) -3. Quantizzazione orientazione in **8 bin modulo π** (edge simmetrici) -4. Estrazione **N feature sparse**: top-magnitude sopra `strong_grad`, con spacing minimo per evitare cluster -5. Feature salvate come `(dx, dy, bin)` relative al centro-modello +1. Rotazione + scala su canvas con padding diagonale (centro di rotazione + = centro reale del template, coerente in tutta la pipeline) +2. Sobel → magnitude + orientation, quantizzata in **8 bin modulo π** + (16 bin mod 2π con `use_polarity=True`) +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**: -- Sobel → mag → orient quantizzato → bin invalidato dove `mag < weak_grad` -- **Spread**: dilate morfologica per bin (tolleranza localizzazione) -- **Response map** `(8, H, W)`: response[b][y,x] = 1 dove orient b è presente - -Per ogni variante: +Scena processata una volta per livello (e cachata per scene ripetute): +Sobel → quantizzazione → **spread bitmap** uint8/uint16 (bit b = bin b +presente nel raggio) → kernel JIT: ``` -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`). - -### Piramide multi-risoluzione - -- **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. -- **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. +1. **Top-level**: valuta 1 variante ogni `cf_auto` (step angolare auto: + al livello top lo spread tollera ~atan(spread/(lato_top/2)) gradi), + pruning per soglia + istogramma orientazioni. +2. **Full-res windowed** (`pyramid_propagate`, default ON): score calcolato + solo in finestre attorno ai massimi locali del top-level — costo + 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 | Parametro | Default | Significato | |---|---|---| | `num_features` | 96 | feature sparse per variante | -| `weak_grad` | 30 | threshold debole (per spread) | -| `strong_grad` | 60 | threshold forte (per estrazione feature) | -| `spread_radius` | 5 | raggio dilate spread (tolleranza posizionale) | -| `min_feature_spacing` | 3 | spacing minimo tra feature per evitare cluster | +| `weak_grad` | 30 | soglia debole (spread + hysteresis) | +| `strong_grad` | 60 | soglia forte (estrazione feature) | +| `spread_radius` | 4 | tolleranza posizionale matching coarse | | `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_step` | 0.1 | passo scala | -| `pyramid_levels` | 3 | livelli piramide (più = pruning più aggressivo) | -| `top_score_factor`| 0.5 | soglia top-level = min_score * factor | -| `min_score` | 0.55 | soglia score finale [0..1] | -| `max_matches` | 25 | numero max di match | -| `nms_radius` | `min(w,h)/2` | raggio NMS baricentri | +| `pyramid_levels` | 3 | livelli piramide (clamp auto su template piccoli) | +| `min_score` | 0.6 | soglia score finale [0..1] | +| `max_matches` | 20 | numero max di match | +| `verify_threshold`| 0.4 | soglia NCC anti falso-positivo | +| `subpixel_lm` | True | least-squares finale pos+angolo | +| `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 -- Subpixel refinement (interpolazione parabolic sui picchi) -- ICP locale per raffinamento pose -- Vincoli di orientamento: clustering delle pose per eliminare duplicati cross-variante -- Numba JIT per il ciclo shift+add (eventuale 3-5× su scene grandi) +Vedi [ROADMAP.md](ROADMAP.md) — Fase 1 (speed) e Fase 2 (precisione +rotazione + robustezza) completate; prossimo target: latency <50 ms su +1920×1080 (auto step per livello fatto; restano greediness default, GPU, +eventuale SIMD C++). ## Deploy VPS con Docker + Traefik Assume che sulla VPS siano già attivi: - **Traefik** come reverse proxy su network Docker esterna `traefik` -- Entrypoints `web` (:80) e `websecure` (:443) -- Cert resolver `letsencrypt` configurato - -### Build e push al registry +- Entrypoint `websecure` (:443) e cert resolver configurato ```bash -# Build locale -docker build -t vps-ip:5000/pm2d:latest . -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 +cd /opt/docker/visionsuite/shape_model_2d +docker compose build docker compose up -d ``` @@ -179,13 +213,8 @@ Servizio raggiungibile: **https://pm.tielogic.xyz** - **Volume `./images`**: persistenza delle immagini caricate tramite UI (`IMAGES_DIR=/data/images` nel container). Sopravvive a restart. - **Upload max 50MB**: middleware Traefik `pm2d-bodysize`. Adattare se serve. -- **Cache matcher in-memory**: si svuota a restart container (no problema, - viene ri-popolata al primo match). +- **Cache matcher in-memory**: si svuota a restart container (ri-popolata + al primo match). Le ricette `.npz` invece persistono in `recipes/`. - **Healthcheck**: HTTP `GET /images` ogni 30s. -- Se nome network Traefik diverso da `traefik`, modifica - `docker-compose.yml` sezione `networks`. - -### 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`. +- Se nome network Traefik o cert resolver diversi, modifica i labels in + `docker-compose.yml`.