# Shape Model 2D — Standalone PM 2D Pattern Matching 2D shape-based (stile Halcon `find_shape_model`), standalone: libreria Python + GUI desktop + webapp FastAPI. Due backend algoritmici: | 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 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/ ├── pm2d/ │ ├── 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/web e algoritmo separati: i matcher sono riusabili da qualsiasi script. ## Setup ```bash uv sync ``` ## Esecuzione ```bash 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/) ``` Le immagini in `Test/` **non sono versionate** (vedi `.gitignore`): la suite benchmark le richiede in locale, i test pytest no. ## API algoritmo (backend `line`) ```python import cv2 from pm2d import LineShapeMatcher template = cv2.imread("model.png") scene = cv2.imread("scene.png") m = LineShapeMatcher( num_features=96, # feature sparse per variante 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, # <=0 → step auto dal lato template scale_range=(0.9, 1.1), # invarianza a scala scale_step=0.05, spread_radius=4, # tolleranza posizionale matching coarse pyramid_levels=3, # clampato auto alla dimensione template ) 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) # pose sub-pixel/sub-grado ``` 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()`). Persistenza modello (Halcon write/read_shape_model): ```python m.save_model("ricetta.npz") m2 = LineShapeMatcher.load_model("ricetta.npz") # deploy senza re-train ``` ## Come funziona il backend `line` ### Training Per ogni coppia (angolo, scala) del template: 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 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] = popcount-AND feature/bitmap / N_features (rescored vs background) ``` 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 | 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 (<=0 = auto) | | `scale_range` | `(1,1)` | range scale | | `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 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` - Entrypoint `websecure` (:443) e cert resolver configurato ```bash cd /opt/docker/visionsuite/shape_model_2d docker compose build docker compose up -d ``` Servizio raggiungibile: **https://pm.tielogic.xyz** ### Note operative - **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 (ri-popolata al primo match). Le ricette `.npz` invece persistono in `recipes/`. - **Healthcheck**: HTTP `GET /images` ogni 30s. - Se nome network Traefik o cert resolver diversi, modifica i labels in `docker-compose.yml`.