Compare commits

...

5 Commits

Author SHA1 Message Date
Adriano 46e9941488 deploy: PORT/HOST configurabili in .env + .env.example versionato
- .env: aggiunte vars PORT=8080, HOST=127.0.0.1, REGISTRY, TAG
- docker-compose.yml: usa ${PORT:-8080} sia per container env che per
  traefik loadbalancer.server.port (coerenza)
- .env.example: template versionato con valori default sicuri
  (.env resta in .gitignore, non committato)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:24:42 +02:00
Adriano 71a364a1fd deploy: Dockerfile + docker-compose Traefik per VPS pm.tielogic.xyz
Dockerfile (multi-arch, python 3.13-slim):
- uv copiato da ghcr.io/astral-sh/uv per install deps
- System deps: libgl1 libglib2.0-0 (cv2) + libgomp1 (numba)
- uv sync --frozen --no-dev da uv.lock
- ENV: IMAGES_DIR=/data/images, HOST=0.0.0.0, PORT=8080
- HEALTHCHECK su GET /images ogni 30s

docker-compose.yml:
- Service pm2d con image ${REGISTRY}/pm2d:${TAG}
- Volume ./images:/data/images (persistenza upload/UI)
- Network esterna 'traefik' (adattare se diverso)
- Labels Traefik:
  - Router HTTPS Host(pm.tielogic.xyz) entrypoint websecure TLS letsencrypt
  - Middleware bodysize 50MB (upload multipart)
  - Redirect HTTP->HTTPS automatico

main.py: HOST/PORT da env (default 127.0.0.1:8080 per dev locale).

README: sezione Deploy con build/push/run su VPS.

.dockerignore: esclude .venv, Test/, benchmarks/, md files.

Build + smoke test container: OK su port 18080.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:55:16 +02:00
Adriano 3e4c20ecf5 feat: upload file nella cartella IMAGES_DIR
POST /upload_to_folder: sanitizza nome, valida estensione e contenuto
via cv2.imdecode, auto-rename su collisione.
Toolbar UI: bottone 'Carica file', dopo upload ricarica picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:45:16 +02:00
Adriano cc7d035f66 feat: scale_penalty - score riflette dimensione oltre a forma
Shape matching e invariante scala per design: 3 ruote dentate di dim
diverse avevano tutte score 1.00 confondendo l operatore.

Parametro scale_penalty [0..1]: score_final = score * max(0, 1 - penalty * |scale - 1|)
UI dropdown 'Peso dimensione nel score' con preset 0 / 0.3 / 0.5 / 0.8.

Test rings con penalty 0.5: 1.00 -> 1.00, 0.95 -> 0.97, 0.80 -> 0.90.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:37:36 +02:00
Adriano 37b718e45e perf: Fase 1 speed+precision (V1 V11 P1 P5)
V1 Coarse-to-fine angolare:
  - Al top-level valuta solo 1 variante ogni coarse_angle_factor (default 2)
  - Espande ai vicini nel full-res per preservare accuracy
  - Safe anche per template allungati (factor=2 non perde match)

V11 Cache matcher in-memory (LRU, capacita 8):
  - Key = md5(ROI bytes + params tecnici che influenzano il training)
  - Re-match con stessi parametri: train_time = 0s (era 0.5-1.5s)
  - OrderedDict LRU con _cache_get_matcher / _cache_put_matcher

P1 Fit parabolico 2D bivariato:
  - In _subpixel_peak ora usa stencil 3x3 completo: f(dx,dy) = a + b*dx
    + c*dy + d*dx^2 + e*dy^2 + f*dx*dy
  - Argmax analytic solve di sistema 2x2; fallback separabile se det~0
  - Precisione attesa: 0.1-0.3 px (era 0.5 px separabile)

P5 Golden-section angle search:
  - Sostituisce 5 sample equispaziati con convergenza log(n)
  - Tol 0.1 gradi, 8 iterazioni max
  - Helper _score_at_angle interno per valutare score a offset arbitrario

P2 Weighted centroid plateau:
  - Peso = (score - (max-0.01))^2 per enfatizzare top del plateau

Benchmark suite 16 casi (4 immagini x full/part x fast/preciso):
  prima Fase 1: totale find 27.3s
  dopo  Fase 1: totale find 25.1s
  nessuna regressione match count, alcuni casi miglioramenti precisione.

ROADMAP.md aggiornato con checklist Fase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:35:40 +02:00
24 changed files with 500 additions and 87 deletions
+22
View File
@@ -0,0 +1,22 @@
.venv
.git
.gitignore
.github
__pycache__
*.pyc
*.pyo
*.pyd
.DS_Store
.idea
.vscode
*.log
# Test images non necessarie nel container (caricate via volume/UI)
Test
benchmarks
ROADMAP.md
shape_model_2d_technical_doc.md
*.md
!README.md
Dockerfile
docker-compose*.yml
.env
+14
View File
@@ -0,0 +1,14 @@
# Copia questo file in .env e adatta i valori.
# .env NON è versionato (contiene config locale/secrets).
# Cartella immagini (relativa al progetto in dev locale,
# assoluta dentro container es. /data/images)
IMAGES_DIR=Test
# Web server
HOST=127.0.0.1
PORT=8080
# Registry + tag per docker-compose (deploy VPS)
REGISTRY=localhost:5000
TAG=latest
+38
View File
@@ -0,0 +1,38 @@
FROM python:3.13-slim AS base
# uv package manager (copia binario ufficiale)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# System deps per opencv (libgl/glib), numba (libgomp)
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install deps da lockfile (layer cachato finché pyproject/uv.lock non cambiano)
COPY pyproject.toml uv.lock ./
COPY .python-version* ./
RUN uv sync --frozen --no-dev
# Copia sorgenti applicazione
COPY pm2d ./pm2d
COPY main.py ./
# Defaults (override via docker-compose env)
ENV IMAGES_DIR=/data/images \
HOST=0.0.0.0 \
PORT=8080 \
PYTHONUNBUFFERED=1
# Cartella dati (montata come volume in compose)
RUN mkdir -p /data/images
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/images').read()" || exit 1
CMD ["uv", "run", "python", "main.py"]
+49
View File
@@ -140,3 +140,52 @@ Implementato con **shift+add vettorizzato NumPy** (O(N_features · H · W) invec
- ICP locale per raffinamento pose - ICP locale per raffinamento pose
- Vincoli di orientamento: clustering delle pose per eliminare duplicati cross-variante - Vincoli di orientamento: clustering delle pose per eliminare duplicati cross-variante
- Numba JIT per il ciclo shift+add (eventuale 3-5× su scene grandi) - Numba JIT per il ciclo shift+add (eventuale 3-5× su scene grandi)
## 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
```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
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 (no problema,
viene ri-popolata al primo match).
- **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`.
+16
View File
@@ -2,6 +2,22 @@
Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale. Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale.
## Fase 1 COMPLETATA (branch `speedFase1`)
| ID | Voce | Status | Note |
|---|---|---|---|
| V1 | Coarse-to-fine angolare (step coarse al top-level) | ✅ | `coarse_angle_factor=2` default, safe anche su template allungati |
| V11 | Cache matcher in-memory LRU (capacità 8) | ✅ | Key = hash(ROI bytes + params). Re-match stesse params = train 0s |
| P1 | Fit parabolico 2D bivariato sul peak | ✅ | `_subpixel_peak` con coefficienti a, b, c, d, e, f dalla stencil 3×3; fallback separabile |
| P5 | Golden-section angle search | ✅ | Sostituisce 5 sample equispaziati con log(n) convergenza a tol=0.1° |
| P2 | Weighted centroid del plateau | ✅ | Integrato in `_subpixel_peak` con peso = (score - soglia)² |
Benchmark suite 16 scenari (4 immagini × full/part × fast/preciso):
- Prima Fase 1: totale find 27.3s
- Dopo Fase 1: totale find 25.1s (~8% speedup)
- Regressione match count: nessuna (alcuni casi +1 match grazie a subpixel migliore)
- Match auto-referenziale: offset 0.00 px, angolo 0.000° (era -3.5 px, -2.5°)
## Performance CPU ## Performance CPU
| Sviluppo | Effort | Speed-up atteso | Dipendenze | Priorità | | Sviluppo | Effort | Speed-up atteso | Dipendenze | Priorità |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

+46
View File
@@ -0,0 +1,46 @@
# docker-compose per deploy VPS con Traefik.
# Assume che Traefik sia già attivo sulla VPS con:
# - network esterna "traefik" (adatta nome se diverso)
# - entrypoint "websecure" su :443
# - certresolver "letsencrypt" configurato
#
# Adattare eventualmente: nome network, entrypoint, certresolver.
services:
pm2d:
image: ${REGISTRY:-localhost:5000}/pm2d:${TAG:-latest}
container_name: pm2d
restart: unless-stopped
environment:
IMAGES_DIR: /data/images
HOST: 0.0.0.0
PORT: ${PORT:-8080}
volumes:
# Persistenza immagini tra restart (upload/selezione)
- ./images:/data/images
networks:
- traefik
labels:
- "traefik.enable=true"
# Router HTTPS principale
- "traefik.http.routers.pm2d.rule=Host(`pm.tielogic.xyz`)"
- "traefik.http.routers.pm2d.entrypoints=websecure"
- "traefik.http.routers.pm2d.tls=true"
- "traefik.http.routers.pm2d.tls.certresolver=letsencrypt"
- "traefik.http.services.pm2d.loadbalancer.server.port=${PORT:-8080}"
# Middleware: upload fino a 50MB (default Traefik bufferizza a 4MB)
- "traefik.http.middlewares.pm2d-bodysize.buffering.maxRequestBodyBytes=52428800"
- "traefik.http.routers.pm2d.middlewares=pm2d-bodysize"
# Redirect HTTP → HTTPS
- "traefik.http.routers.pm2d-http.rule=Host(`pm.tielogic.xyz`)"
- "traefik.http.routers.pm2d-http.entrypoints=web"
- "traefik.http.routers.pm2d-http.middlewares=pm2d-redirect-https"
- "traefik.http.middlewares.pm2d-redirect-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.pm2d-redirect-https.redirectscheme.permanent=true"
networks:
traefik:
external: true
+7 -3
View File
@@ -1,10 +1,14 @@
"""Entry-point PM2D — webapp HTML. """Entry-point PM2D — webapp HTML.
Esegui: uv run python main.py Esegui locale: uv run python main.py (default 127.0.0.1:8080)
Apri: http://127.0.0.1:8080/ Container: HOST=0.0.0.0 PORT=8080 python main.py
""" """
import os
from pm2d.web.server import serve from pm2d.web.server import serve
if __name__ == "__main__": if __name__ == "__main__":
serve(host="127.0.0.1", port=8080) host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8080"))
serve(host=host, port=port)
+131 -57
View File
@@ -26,6 +26,7 @@ della ROI (modello non-rettangolare).
from __future__ import annotations from __future__ import annotations
import math
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
@@ -33,6 +34,8 @@ from dataclasses import dataclass
import cv2 import cv2
import numpy as np import numpy as np
_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 as _jit_score_bitmap,
@@ -338,9 +341,10 @@ class LineShapeMatcher:
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Posizione sub-pixel del picco. """Posizione sub-pixel del picco.
Se c'è un plateau di valori ~massimi (spread_radius satura il peak 1. Plateau saturo → centroide pesato del plateau (peso = score).
su un'area) ritorna il CENTROIDE del plateau. Altrimenti fit 2. Altrimenti → fit quadratico 2D bivariato sui 9 vicini
parabolico 2D ±0.5 px. (z = a + b·dx + c·dy + d·dx² + e·dy² + f·dx·dy), argmax risolto
analiticamente con clamping ±0.5 px.
""" """
H, W = acc.shape H, W = acc.shape
val = float(acc[y, x]) val = float(acc[y, x])
@@ -350,18 +354,37 @@ class LineShapeMatcher:
patch = acc[y0:y1, x0:x1] patch = acc[y0:y1, x0:x1]
plateau = patch >= val - 0.01 plateau = patch >= val - 0.01
if plateau.sum() > 1: if plateau.sum() > 1:
# Centroide pesato per (score - (max-0.01))² per enfatizzare i top
weights = np.where(plateau, patch - (val - 0.01), 0.0).astype(np.float64)
weights = weights * weights
total = weights.sum()
if total > 1e-9:
ys_idx, xs_idx = np.indices(patch.shape)
cx_w = (xs_idx * weights).sum() / total
cy_w = (ys_idx * weights).sum() / total
return float(x0 + cx_w), float(y0 + cy_w)
ys_m, xs_m = np.where(plateau) ys_m, xs_m = np.where(plateau)
return float(x0 + xs_m.mean()), float(y0 + ys_m.mean()) return float(x0 + xs_m.mean()), float(y0 + ys_m.mean())
# Fallback parabolico # Fit quadratico 2D bivariato su 3x3 intorno
if x <= 0 or x >= W - 1 or y <= 0 or y >= H - 1: if x <= 0 or x >= W - 1 or y <= 0 or y >= H - 1:
return float(x), float(y) return float(x), float(y)
c = acc[y, x] # Stencil 3x3: Z[i, j] con i,j ∈ {-1, 0, +1}
dx2 = acc[y, x + 1] - 2 * c + acc[y, x - 1] Z = acc[y - 1:y + 2, x - 1:x + 2].astype(np.float64)
dy2 = acc[y + 1, x] - 2 * c + acc[y - 1, x] # Coefficienti da finite differences
dx1 = (acc[y, x + 1] - acc[y, x - 1]) / 2.0 b_c = (Z[1, 2] - Z[1, 0]) / 2.0
dy1 = (acc[y + 1, x] - acc[y - 1, x]) / 2.0 c_c = (Z[2, 1] - Z[0, 1]) / 2.0
ox = -dx1 / dx2 if abs(dx2) > 1e-6 else 0.0 d_c = (Z[1, 2] + Z[1, 0] - 2.0 * Z[1, 1]) / 2.0
oy = -dy1 / dy2 if abs(dy2) > 1e-6 else 0.0 e_c = (Z[2, 1] + Z[0, 1] - 2.0 * Z[1, 1]) / 2.0
f_c = (Z[2, 2] - Z[0, 2] - Z[2, 0] + Z[0, 0]) / 4.0
# Max: risolve [2d f; f 2e][dx;dy] = [-b;-c]
det = 4.0 * d_c * e_c - f_c * f_c
if abs(det) > 1e-9:
ox = (-2.0 * e_c * b_c + f_c * c_c) / det
oy = (-2.0 * d_c * c_c + f_c * b_c) / det
else:
# Fallback separabile
ox = -b_c / (2.0 * d_c) if abs(d_c) > 1e-6 else 0.0
oy = -c_c / (2.0 * e_c) if abs(e_c) > 1e-6 else 0.0
ox = float(np.clip(ox, -0.5, 0.5)) ox = float(np.clip(ox, -0.5, 0.5))
oy = float(np.clip(oy, -0.5, 0.5)) oy = float(np.clip(oy, -0.5, 0.5))
return x + ox, y + oy return x + ox, y + oy
@@ -384,16 +407,11 @@ class LineShapeMatcher:
l'angolo con score massimo (parabolic fit sulle 3 score centrali). l'angolo con score massimo (parabolic fit sulle 3 score centrali).
Ritorna (angle_refined, score, cx_refined, cy_refined). Ritorna (angle_refined, score, cx_refined, cy_refined).
""" """
# Se il match grezzo è già quasi perfetto, NON refinare: il parabolic # Se il match grezzo è già quasi perfetto, NON refinare
# fit su picco saturo produce spostamenti spurious di posizione e
# angolo (esempio: modello==scena deve dare ang=0, pos=centro ROI)
if original_score is not None and original_score >= 0.99: if original_score is not None and original_score >= 0.99:
return (angle_deg, original_score, cx, cy) return (angle_deg, original_score, cx, cy)
if search_radius is None: if search_radius is None:
search_radius = self.angle_step_deg / 2.0 search_radius = self.angle_step_deg / 2.0
offsets = np.linspace(-search_radius, search_radius, 5)
best = (angle_deg, -1.0, cx, cy)
scores_by_off: dict[float, float] = {}
h, w = template_gray.shape h, w = template_gray.shape
sw = max(16, int(round(w * scale))) sw = max(16, int(round(w * scale)))
@@ -409,10 +427,10 @@ class LineShapeMatcher:
center = (diag / 2.0, diag / 2.0) center = (diag / 2.0, diag / 2.0)
H, W = spread0.shape H, W = spread0.shape
# Ricerca locale posizione con margine ±2 px sulla (cx, cy)
margin = 3 margin = 3
for off in offsets: def _score_at_angle(off: float) -> tuple[float, float, float]:
"""Ritorna (score, best_cx, best_cy) per angolo = angle_deg + off."""
ang = angle_deg + off ang = angle_deg + off
M = cv2.getRotationMatrix2D(center, ang, 1.0) M = cv2.getRotationMatrix2D(center, ang, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag), gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
@@ -423,22 +441,20 @@ class LineShapeMatcher:
mag, bins = self._gradient(gray_r) mag, bins = self._gradient(gray_r)
fx, fy, fb = self._extract_features(mag, bins, mask_r) fx, fy, fb = self._extract_features(mag, bins, mask_r)
if len(fx) < 8: if len(fx) < 8:
scores_by_off[float(off)] = 0.0 return (0.0, cx, cy)
continue
dx = (fx - center[0]).astype(np.int32) dx = (fx - center[0]).astype(np.int32)
dy = (fy - center[1]).astype(np.int32) dy = (fy - center[1]).astype(np.int32)
# Finestra locale ±margin attorno a (cx, cy) via slicing su bitmap
y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1 y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1
x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1 x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1
sh = y_hi - y_lo; sw = x_hi - x_lo sh_w = y_hi - y_lo; sw_w = x_hi - x_lo
acc = np.zeros((sh, sw), dtype=np.float32) acc = np.zeros((sh_w, sw_w), dtype=np.float32)
for i in range(len(dx)): for i in range(len(dx)):
ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i]) ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i])
bit = np.uint8(1 << b) bit = np.uint8(1 << b)
sy0 = y_lo + ddy; sy1 = y_hi + ddy sy0 = y_lo + ddy; sy1 = y_hi + ddy
sx0 = x_lo + ddx; sx1 = x_hi + ddx sx0 = x_lo + ddx; sx1 = x_hi + ddx
a_y0 = max(0, -sy0); a_y1 = sh - max(0, sy1 - H) a_y0 = max(0, -sy0); a_y1 = sh_w - max(0, sy1 - H)
a_x0 = max(0, -sx0); a_x1 = sw - max(0, sx1 - W) a_x0 = max(0, -sx0); a_x1 = sw_w - max(0, sx1 - W)
s_y0 = max(0, sy0); s_y1 = min(H, sy1) s_y0 = max(0, sy0); s_y1 = min(H, sy1)
s_x0 = max(0, sx0); s_x1 = min(W, sx1) s_x0 = max(0, sx0); s_x1 = min(W, sx1)
if s_y1 > s_y0 and s_x1 > s_x0: if s_y1 > s_y0 and s_x1 > s_x0:
@@ -448,31 +464,39 @@ class LineShapeMatcher:
).astype(np.float32) ).astype(np.float32)
acc /= len(dx) acc /= len(dx)
_, max_val, _, max_loc = cv2.minMaxLoc(acc) _, max_val, _, max_loc = cv2.minMaxLoc(acc)
scores_by_off[float(off)] = float(max_val) return (float(max_val),
if max_val > best[1]: float(x_lo + max_loc[0]), float(y_lo + max_loc[1]))
new_cx = x_lo + float(max_loc[0])
new_cy = y_lo + float(max_loc[1])
best = (ang, float(max_val), new_cx, new_cy)
# Parabolic fit su 3 angoli attorno al massimo # Golden-section search su [-search_radius, +search_radius]:
sorted_offs = sorted(scores_by_off.keys()) # converge in log tempo a precisione ~0.1°, ~8 valutazioni vs 5
best_off = best[0] - angle_deg # ma centrate su picco reale (non sample equispaziati).
try: a_lo = -search_radius
i = sorted_offs.index( a_hi = +search_radius
min(sorted_offs, key=lambda x: abs(x - best_off)) x1 = a_hi - _GOLDEN * (a_hi - a_lo)
) x2 = a_lo + _GOLDEN * (a_hi - a_lo)
if 0 < i < len(sorted_offs) - 1: s1, cx1, cy1 = _score_at_angle(x1)
s0 = scores_by_off[sorted_offs[i - 1]] s2, cx2, cy2 = _score_at_angle(x2)
s1 = scores_by_off[sorted_offs[i]] # Score all'origine come riferimento (ang offset 0)
s2 = scores_by_off[sorted_offs[i + 1]] s0, cx0_s, cy0_s = _score_at_angle(0.0)
denom = (s0 - 2 * s1 + s2) best = (angle_deg, s0, cx0_s, cy0_s)
if abs(denom) > 1e-6: tol = 0.1 # gradi
delta = 0.5 * (s0 - s2) / denom for _ in range(8):
step = sorted_offs[i + 1] - sorted_offs[i] if s1 > best[1]:
refined_off = sorted_offs[i] + delta * step best = (angle_deg + x1, s1, cx1, cy1)
return (angle_deg + refined_off, best[1], best[2], best[3]) if s2 > best[1]:
except ValueError: best = (angle_deg + x2, s2, cx2, cy2)
pass if abs(a_hi - a_lo) < tol:
break
if s1 > s2:
a_hi = x2
x2 = x1; s2 = s1; cx2 = cx1; cy2 = cy1
x1 = a_hi - _GOLDEN * (a_hi - a_lo)
s1, cx1, cy1 = _score_at_angle(x1)
else:
a_lo = x1
x1 = x2; s1 = s2; cx1 = cx2; cy1 = cy2
x2 = a_lo + _GOLDEN * (a_hi - a_lo)
s2, cx2, cy2 = _score_at_angle(x2)
return best return best
def _verify_ncc( def _verify_ncc(
@@ -523,7 +547,16 @@ class LineShapeMatcher:
subpixel: bool = True, subpixel: bool = True,
verify_ncc: bool = True, verify_ncc: bool = True,
verify_threshold: float = 0.4, verify_threshold: float = 0.4,
coarse_angle_factor: int = 2,
scale_penalty: float = 0.0,
) -> list[Match]: ) -> list[Match]:
"""
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
score_final = score_shape * max(0, 1 - scale_penalty * |scale - 1|)
Utile se l'operatore vuole che match "identico al template anche per
dimensione" abbia score più alto di match "stessa forma, dimensione
diversa". scale_penalty=0 (default) = comportamento shape puro.
"""
if not self.variants: if not self.variants:
raise RuntimeError("Matcher non addestrato: chiamare train() prima.") raise RuntimeError("Matcher non addestrato: chiamare train() prima.")
@@ -564,7 +597,30 @@ class LineShapeMatcher:
def _rescore(score: np.ndarray, bg: np.ndarray) -> np.ndarray: def _rescore(score: np.ndarray, bg: np.ndarray) -> np.ndarray:
return np.maximum(0.0, (score - bg) / (1.0 - bg + 1e-6)) return np.maximum(0.0, (score - bg) / (1.0 - bg + 1e-6))
# Pruning varianti via top-level (parallelizzato) # Coarse-to-fine angolare:
# 1) Raggruppa varianti per scala, ordina per angolo
# 2) Top-level: valuta solo 1 ogni coarse_angle_factor varianti
# 3) Espandi ai vicini nel full-res
variants_by_scale: dict[float, list[int]] = {}
for vi, var in enumerate(self.variants):
variants_by_scale.setdefault(var.scale, []).append(vi)
coarse_idx_list: list[int] = [] # varianti da valutare al top
neighbor_map: dict[int, list[int]] = {} # vi_coarse -> indici vicini
cf = max(1, coarse_angle_factor)
for scale_key, vi_list in variants_by_scale.items():
vi_sorted = sorted(vi_list, key=lambda i: self.variants[i].angle_deg)
n = len(vi_sorted)
for i in range(0, n, cf):
vi_c = vi_sorted[i]
coarse_idx_list.append(vi_c)
# Vicini: ±cf/2 attorno a i (stessa scala)
half = cf // 2
start = max(0, i - half)
end = min(n, i + half + 1)
neighbor_map[vi_c] = vi_sorted[start:end]
# Pruning varianti via top-level (parallelizzato) - solo coarse
def _top_score(vi: int) -> tuple[int, float]: def _top_score(vi: int) -> tuple[int, float]:
var = self.variants[vi] var = self.variants[vi]
lvl = var.levels[min(top, len(var.levels) - 1)] lvl = var.levels[min(top, len(var.levels) - 1)]
@@ -574,17 +630,30 @@ class LineShapeMatcher:
score = _rescore(score, bg_cache_top[var.scale]) score = _rescore(score, bg_cache_top[var.scale])
return vi, float(score.max()) if score.size else -1.0 return vi, float(score.max()) if score.size else -1.0
kept_variants: list[tuple[int, float]] = [] kept_coarse: list[tuple[int, float]] = []
if self.n_threads > 1: if self.n_threads > 1 and len(coarse_idx_list) > 1:
with ThreadPoolExecutor(max_workers=self.n_threads) as ex: with ThreadPoolExecutor(max_workers=self.n_threads) as ex:
for vi, best in ex.map(_top_score, range(len(self.variants))): for vi, best in ex.map(_top_score, coarse_idx_list):
if best >= top_thresh: if best >= top_thresh:
kept_variants.append((vi, best)) kept_coarse.append((vi, best))
else: else:
for vi in range(len(self.variants)): for vi in coarse_idx_list:
vi2, best = _top_score(vi) vi2, best = _top_score(vi)
if best >= top_thresh: if best >= top_thresh:
kept_variants.append((vi2, best)) kept_coarse.append((vi2, best))
# Espandi ogni coarse promosso con i suoi vicini (stessa scala,
# angoli intermedi non valutati al top)
expanded: set[int] = set()
score_by_vi: dict[int, float] = {}
for vi_c, s_top in kept_coarse:
for vi_n in neighbor_map.get(vi_c, [vi_c]):
expanded.add(vi_n)
# Usa lo score del coarse come stima per il sort successivo
score_by_vi[vi_n] = max(score_by_vi.get(vi_n, 0.0), s_top)
kept_variants: list[tuple[int, float]] = [
(vi, score_by_vi[vi]) for vi in expanded
]
if not kept_variants: if not kept_variants:
return [] return []
@@ -685,6 +754,11 @@ class LineShapeMatcher:
poly = _oriented_bbox_polygon( poly = _oriented_bbox_polygon(
cx_f, cy_f, tw * var.scale, th * var.scale, ang_f, cx_f, cy_f, tw * var.scale, th * var.scale, ang_f,
) )
# Penalità scala opzionale: score degrada con distanza da 1.0
if scale_penalty > 0.0 and var.scale != 1.0:
score_f = float(score_f) * max(
0.0, 1.0 - scale_penalty * abs(var.scale - 1.0)
)
kept.append(Match( kept.append(Match(
cx=cx_f, cy=cy_f, cx=cx_f, cy=cy_f,
angle_deg=ang_f, angle_deg=ang_f,
+125 -22
View File
@@ -9,10 +9,12 @@ Endpoint:
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import os import os
import tempfile import tempfile
import time import time
import uuid import uuid
from collections import OrderedDict
from pathlib import Path from pathlib import Path
import cv2 import cv2
@@ -61,6 +63,39 @@ CACHE_DIR.mkdir(exist_ok=True)
# Cache in-memory (soft, ricaricata da disco se mancante) # Cache in-memory (soft, ricaricata da disco se mancante)
_IMG_CACHE: dict[str, np.ndarray] = {} _IMG_CACHE: dict[str, np.ndarray] = {}
# Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher
# LRU con capacità limitata
_MATCHER_CACHE: OrderedDict = OrderedDict()
_MATCHER_CACHE_SIZE = 8
def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h = hashlib.md5()
h.update(roi.tobytes())
# Solo parametri che influenzano il training
relevant = ("num_features", "weak_grad", "strong_grad",
"angle_min", "angle_max", "angle_step",
"scale_min", "scale_max", "scale_step",
"spread_radius", "pyramid_levels")
for k in relevant:
h.update(f"{k}={tech.get(k)}".encode())
h.update(f"shape={roi.shape}".encode())
return h.hexdigest()
def _cache_get_matcher(key: str):
m = _MATCHER_CACHE.get(key)
if m is not None:
_MATCHER_CACHE.move_to_end(key) # LRU touch
return m
def _cache_put_matcher(key: str, matcher) -> None:
_MATCHER_CACHE[key] = matcher
_MATCHER_CACHE.move_to_end(key)
while len(_MATCHER_CACHE) > _MATCHER_CACHE_SIZE:
_MATCHER_CACHE.popitem(last=False)
def _store_image(img: np.ndarray) -> str: def _store_image(img: np.ndarray) -> str:
iid = uuid.uuid4().hex[:12] iid = uuid.uuid4().hex[:12]
@@ -229,6 +264,7 @@ class SimpleMatchParams(BaseModel):
scala: str = "fissa" # chiave SCALE_PRESETS scala: str = "fissa" # chiave SCALE_PRESETS
precisione: str = "normale" # chiave PRECISION_ANGLE_STEP precisione: str = "normale" # chiave PRECISION_ANGLE_STEP
filtro_fp: str = "medio" # chiave FILTRO_FP_MAP filtro_fp: str = "medio" # chiave FILTRO_FP_MAP
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
min_score: float = 0.65 min_score: float = 0.65
max_matches: int = 25 max_matches: int = 25
@@ -281,6 +317,7 @@ def _simple_to_technical(
"max_matches": p.max_matches, "max_matches": p.max_matches,
"nms_radius": 0, "nms_radius": 0,
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35), "verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35),
"scale_penalty": p.penalita_scala,
} }
@@ -292,6 +329,49 @@ def index():
return HTMLResponse(html_path.read_text(encoding="utf-8")) return HTMLResponse(html_path.read_text(encoding="utf-8"))
@app.post("/upload_to_folder")
async def upload_to_folder(file: UploadFile = File(...)):
"""Salva file caricato nella cartella IMAGES_DIR. Ritorna lista aggiornata."""
if not IMAGES_DIR.is_dir():
raise HTTPException(500, f"IMAGES_DIR non esiste: {IMAGES_DIR}")
# Sanitizza nome file (no traversal)
name = Path(file.filename or "upload.png").name
if not name:
raise HTTPException(400, "nome file vuoto")
ext = Path(name).suffix.lower()
allowed = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
if ext not in allowed:
raise HTTPException(400, f"estensione non supportata: {ext}")
# Leggi contenuto e valida come immagine
data = await file.read()
arr = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
raise HTTPException(400, "file non è un'immagine valida")
# Evita overwrite: se esiste, aggiungi suffisso numerico
target = IMAGES_DIR / name
if target.exists():
stem = target.stem; suffix = target.suffix
i = 1
while True:
alt = IMAGES_DIR / f"{stem}_{i}{suffix}"
if not alt.exists():
target = alt; break
i += 1
# Scrivi su disco
with open(target, "wb") as f:
f.write(data)
# Ritorna lista aggiornata
return {
"saved_as": target.name,
"dir": str(IMAGES_DIR),
"files": sorted(
p.name for p in IMAGES_DIR.iterdir()
if p.is_file() and p.suffix.lower() in allowed
),
}
@app.get("/folder_image/{filename}") @app.get("/folder_image/{filename}")
def folder_image(filename: str, w: int = 120): def folder_image(filename: str, w: int = 120):
"""Serve thumbnail PNG dell'immagine IMAGES_DIR (scalata a width w).""" """Serve thumbnail PNG dell'immagine IMAGES_DIR (scalata a width w)."""
@@ -375,17 +455,33 @@ def match(p: MatchParams):
h = max(1, min(h, model.shape[0] - y)) h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
m = LineShapeMatcher( tech_for_cache = {
num_features=p.num_features, "num_features": p.num_features,
weak_grad=p.weak_grad, strong_grad=p.strong_grad, "weak_grad": p.weak_grad, "strong_grad": p.strong_grad,
angle_range_deg=(p.angle_min, p.angle_max), "angle_min": p.angle_min, "angle_max": p.angle_max,
angle_step_deg=p.angle_step, "angle_step": p.angle_step,
scale_range=(p.scale_min, p.scale_max), "scale_min": p.scale_min, "scale_max": p.scale_max,
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,
) }
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 key = _matcher_cache_key(roi_img, tech_for_cache)
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher(
num_features=p.num_features,
weak_grad=p.weak_grad, strong_grad=p.strong_grad,
angle_range_deg=(p.angle_min, p.angle_max),
angle_step_deg=p.angle_step,
scale_range=(p.scale_min, p.scale_max),
scale_step=p.scale_step,
spread_radius=p.spread_radius,
pyramid_levels=p.pyramid_levels,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = p.nms_radius if p.nms_radius > 0 else None nms = p.nms_radius if p.nms_radius > 0 else None
t0 = time.time() t0 = time.time()
matches = m.find( matches = m.find(
@@ -429,22 +525,29 @@ def match_simple(p: SimpleMatchParams):
tech = _simple_to_technical(p, roi_img) tech = _simple_to_technical(p, roi_img)
m = LineShapeMatcher( key = _matcher_cache_key(roi_img, tech)
num_features=tech["num_features"], m = _cache_get_matcher(key)
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], if m is None:
angle_range_deg=(tech["angle_min"], tech["angle_max"]), m = LineShapeMatcher(
angle_step_deg=tech["angle_step"], num_features=tech["num_features"],
scale_range=(tech["scale_min"], tech["scale_max"]), weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
scale_step=tech["scale_step"], angle_range_deg=(tech["angle_min"], tech["angle_max"]),
spread_radius=tech["spread_radius"], angle_step_deg=tech["angle_step"],
pyramid_levels=tech["pyramid_levels"], scale_range=(tech["scale_min"], tech["scale_max"]),
) scale_step=tech["scale_step"],
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 spread_radius=tech["spread_radius"],
pyramid_levels=tech["pyramid_levels"],
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
t0 = time.time() t0 = time.time()
matches = m.find( matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"], scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"], nms_radius=nms, verify_threshold=tech["verify_threshold"],
scale_penalty=tech.get("scale_penalty", 0.0),
) )
t_find = time.time() - t0 t_find = time.time() - t0
+36 -5
View File
@@ -48,6 +48,8 @@ function readUserParams() {
scala: document.getElementById("p-scala").value, scala: document.getElementById("p-scala").value,
precisione: document.getElementById("p-precisione").value, precisione: document.getElementById("p-precisione").value,
filtro_fp: document.getElementById("p-filtro-fp").value, filtro_fp: document.getElementById("p-filtro-fp").value,
penalita_scala: parseFloat(
document.getElementById("p-penalita-scala").value),
min_score: parseFloat(document.getElementById("p-min-score").value), min_score: parseFloat(document.getElementById("p-min-score").value),
max_matches: parseInt(document.getElementById("p-max-matches").value, 10), max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
}; };
@@ -80,6 +82,21 @@ async function fetchImagesList() {
return await r.json(); return await r.json();
} }
async function uploadToFolder(file) {
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/upload_to_folder", { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
return await r.json();
}
async function refreshPickers() {
const {files, dir} = await fetchImagesList();
buildThumbPicker("picker-model", files, onSelectModel);
buildThumbPicker("picker-scene", files, onSelectScene);
return {files, dir};
}
function buildThumbPicker(pickerId, files, onSelect) { function buildThumbPicker(pickerId, files, onSelect) {
const picker = document.getElementById(pickerId); const picker = document.getElementById(pickerId);
const current = picker.querySelector(".picker-current"); const current = picker.querySelector(".picker-current");
@@ -349,14 +366,28 @@ window.addEventListener("DOMContentLoaded", async () => {
buildAdvancedForm(); buildAdvancedForm();
setupROI(); setupROI();
// Popola picker immagini da IMAGES_DIR (con thumbnail) // Popola picker immagini da IMAGES_DIR (con thumbnail)
const {files, dir} = await fetchImagesList(); const {files, dir} = await refreshPickers();
buildThumbPicker("picker-model", files, onSelectModel);
buildThumbPicker("picker-scene", files, onSelectScene);
if (files.length === 0) { if (files.length === 0) {
setStatus(`Nessuna immagine in ${dir} (configura IMAGES_DIR in .env)`); setStatus(`Nessuna immagine in ${dir} (carica file o configura IMAGES_DIR)`);
} else { } else {
setStatus(`${files.length} immagini disponibili in ${dir}`); setStatus(`${files.length} immagini in ${dir}`);
} }
// Upload file nella folder
const upEl = document.getElementById("file-upload");
upEl.addEventListener("change", async (e) => {
const f = e.target.files[0];
if (!f) return;
setStatus(`Caricamento ${f.name} nella cartella...`);
try {
const res = await uploadToFolder(f);
await refreshPickers();
setStatus(`Salvato come ${res.saved_as} (${res.files.length} file totali)`);
} catch (err) {
setStatus(`Errore upload: ${err.message}`);
}
e.target.value = ""; // consente re-upload stesso file
});
document.getElementById("btn-match").addEventListener("click", doMatch); document.getElementById("btn-match").addEventListener("click", doMatch);
const slider = document.getElementById("p-min-score"); const slider = document.getElementById("p-min-score");
slider.addEventListener("input", (e) => { slider.addEventListener("input", (e) => {
+16
View File
@@ -26,6 +26,10 @@
<div class="picker-list"></div> <div class="picker-list"></div>
</div> </div>
<button class="btn btn-go" id="btn-match">▶ MATCH</button> <button class="btn btn-go" id="btn-match">▶ MATCH</button>
<label class="btn" title="Carica nuovo file nella cartella immagini">
⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden>
</label>
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span> <span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
</div> </div>
</header> </header>
@@ -101,6 +105,18 @@
</select> </select>
</div> </div>
<div class="field">
<label>Peso dimensione nel score
<span class="hint">(penalizza scala ≠ 1.0)</span>
</label>
<select id="p-penalita-scala">
<option value="0" selected>Nessuno (score shape puro)</option>
<option value="0.3">Leggero (30% max)</option>
<option value="0.5">Medio (50% max)</option>
<option value="0.8">Forte (80% max)</option>
</select>
</div>
<div class="field"> <div class="field">
<label>Score minimo <span id="v-score">0.65</span> <label>Score minimo <span id="v-score">0.65</span>
<span class="hint">(più basso = più match anche incerti)</span> <span class="hint">(più basso = più match anche incerti)</span>