Compare commits

...

8 Commits

Author SHA1 Message Date
Adriano 27a0ef1a45 feat: coarse_stride per sub-sampling top-level
Nuovo kernel JIT _jit_score_bitmap_rescored_strided: valuta solo
pixel su griglia stride x stride al top della piramide. NMS + fase
full-res recuperano precisione. Speed-up ~stride^2 sulla fase coarse,
specie su scene grandi (1920x1080).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:24:44 +02:00
root 89b59b3ea3 perf: Fase 2 speed (3x baseline) - fuse JIT + LRU + sub-pixel lazy
Ottimizzazioni cumulative (225s -> 73s sul bench suite, 3.07x):

pm2d/line_matcher.py:
- Sub-pixel + plateau centroid spostati DOPO il pre-NMS (prima: 58k chiamate
  per clip_preciso anche su candidati poi scartati dalla NMS; ora solo sui
  ~75 preliminary sopravvissuti). Coordinate intere OK per la decisione
  reject, dato che nms_radius >= 8 px.
- Usa nuovo kernel fuso score+rescore (no allocazione intermedia).
- Adaptive plateau_radius + propagazione train_mask per NCC coerente.
- Local crop NCC (diag template invece di intera scena).
- Fallback adattivo se bg_rescore azzera tutti gli score top-level.

pm2d/_jit_kernels.py:
- Nuovo kernel _jit_score_bitmap_rescored: fonde scoring bitmap e rescore
  (score - bg) / (1 - bg) in un singolo pass parallelo. Evita allocazione
  e passata aggiuntiva (era ~15% del tempo find sul preciso).

pm2d/auto_tune.py:
- LRU cache in-memory sui risultati auto_tune (chiave md5 ROI + mask):
  richiamate successive con stessa ROI sono O(1).
- Downsample a 128px prima della correlazione rotazionale
  (O(n_angles * H * W) -> insensibile su sample moderati).
- Soglie weak/strong da percentili reali (p55/p85) senza clamp a 100,
  con clamp massimo 400 per evitare saturazione su template ad alto contrasto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:21:59 +00:00
root 44a3046616 deploy: build locale immagine + allineamento Traefik
- build: . invece di pull da registry (non disponibile su VPS)
- certresolver: mytlschallenge (già configurato in Traefik)
- redirect HTTP→HTTPS gestito dall'entrypoint web globale

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:46:23 +00:00
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
26 changed files with 802 additions and 129 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

+41
View File
@@ -0,0 +1,41 @@
# 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 "mytlschallenge" configurato
#
# Adattare eventualmente: nome network, entrypoint, certresolver.
services:
pm2d:
build: .
image: pm2d: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=mytlschallenge"
- "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 è gestito globalmente dall'entrypoint `web` di Traefik
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)
+147
View File
@@ -110,6 +110,112 @@ if HAS_NUMBA:
acc[y, x] *= inv acc[y, x] *= inv
return acc return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_strided(
spread: np.ndarray,
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
bg: np.ndarray,
stride: nb.int32,
) -> np.ndarray:
"""Variante con sub-sampling: valuta solo pixel su griglia stride×stride.
Score restituito ha stessa shape (H, W); celle non valutate = 0.
4× speed-up con stride=2 (NMS recupera precisione in full-res).
Numba prange richiede step costante: itero su indici griglia e
moltiplico per stride dentro il body.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
ny = (H + stride - 1) // stride
nx = (W + stride - 1) // stride
for yi in nb.prange(ny):
y = yi * stride
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx[i]
x_lo = 0 if ddx >= 0 else -ddx
x_hi = W if ddx <= 0 else W - ddx
rem = x_lo % stride
if rem != 0:
x_lo += stride - rem
x = x_lo
while x < x_hi:
if spread[yy, x + ddx] & mask:
acc[y, x] += 1.0
x += stride
if N > 0:
inv = 1.0 / N
for yi in nb.prange(ny):
y = yi * stride
for xi in range(nx):
x = xi * stride
v = acc[y, x] * inv
bgv = bg[y, x]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[y, x] = r if r > 0.0 else 0.0
else:
acc[y, x] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored(
spread: np.ndarray, # uint8 (H, W)
dx: np.ndarray, # int32 (N,)
dy: np.ndarray, # int32 (N,)
bins: np.ndarray, # int8 (N,)
bit_active: np.uint8,
bg: np.ndarray, # float32 (H, W) background density normalizzata
) -> np.ndarray:
"""score+rescore in un singolo pass: evita allocazione intermedia.
Equivalente a:
score = _jit_score_bitmap(...)
out = max(0, (score - bg) / (1 - bg + 1e-6))
ma fonde la seconda passata dentro la normalizzazione finale
(cache-friendly, risparmia ~15% sul totale find).
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
for y in nb.prange(H):
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx[i]
x_lo = 0 if ddx >= 0 else -ddx
x_hi = W if ddx <= 0 else W - ddx
for x in range(x_lo, x_hi):
if spread[yy, x + ddx] & mask:
acc[y, x] += 1.0
if N > 0:
inv = 1.0 / N
for y in nb.prange(H):
for x in range(W):
v = acc[y, x] * inv
bgv = bg[y, x]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[y, x] = r if r > 0.0 else 0.0
else:
acc[y, x] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False) @nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_popcount_density(spread: np.ndarray) -> np.ndarray: def _jit_popcount_density(spread: np.ndarray) -> np.ndarray:
"""Conta bit set per pixel: ritorna (H, W) float32 in [0..8].""" """Conta bit set per pixel: ritorna (H, W) float32 in [0..8]."""
@@ -134,6 +240,11 @@ if HAS_NUMBA:
_jit_score_by_shift(resp, dx, dy, b, ba) _jit_score_by_shift(resp, dx, dy, b, ba)
spread = np.zeros((32, 32), dtype=np.uint8) spread = np.zeros((32, 32), dtype=np.uint8)
_jit_score_bitmap(spread, dx, dy, b, np.uint8(0xFF)) _jit_score_bitmap(spread, dx, dy, b, np.uint8(0xFF))
bg = np.zeros((32, 32), dtype=np.float32)
_jit_score_bitmap_rescored(spread, dx, dy, b, np.uint8(0xFF), bg)
_jit_score_bitmap_rescored_strided(
spread, dx, dy, b, np.uint8(0xFF), bg, np.int32(2),
)
_jit_popcount_density(spread) _jit_popcount_density(spread)
else: # pragma: no cover else: # pragma: no cover
@@ -144,6 +255,12 @@ else: # pragma: no cover
def _jit_score_bitmap(spread, dx, dy, bins, bit_active): def _jit_score_bitmap(spread, dx, dy, bins, bit_active):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride):
raise RuntimeError("numba non disponibile")
def _jit_popcount_density(spread): def _jit_popcount_density(spread):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
@@ -172,6 +289,36 @@ def score_bitmap(
return _numpy_score_by_shift(resp, dx, dy, bins, None) return _numpy_score_by_shift(resp, dx, dy, bins, None)
def score_bitmap_rescored(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, bg: np.ndarray, stride: int = 1,
) -> np.ndarray:
"""Score bitmap + rescore fusi in un solo pass (JIT).
stride > 1: valuta solo pixel su griglia stride×stride. Le celle non
valutate restano 0 nello score map. Pensato per coarse-pass al top
della piramide; il refinement full-res poi recupera precisione.
"""
if HAS_NUMBA and len(dx) > 0:
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
dx_c = np.ascontiguousarray(dx, dtype=np.int32)
dy_c = np.ascontiguousarray(dy, dtype=np.int32)
bins_c = np.ascontiguousarray(bins, dtype=np.int8)
bg_c = np.ascontiguousarray(bg, dtype=np.float32)
if stride > 1:
return _jit_score_bitmap_rescored_strided(
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
np.int32(stride),
)
return _jit_score_bitmap_rescored(
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
)
# Fallback: chiamate separate (stride ignorato in fallback)
score = score_bitmap(spread, dx, dy, bins, bit_active)
out = (score - bg) / (1.0 - bg + 1e-6)
return np.maximum(0.0, out).astype(np.float32)
def popcount_density(spread: np.ndarray) -> np.ndarray: def popcount_density(spread: np.ndarray) -> np.ndarray:
if HAS_NUMBA: if HAS_NUMBA:
return _jit_popcount_density(np.ascontiguousarray(spread, dtype=np.uint8)) return _jit_popcount_density(np.ascontiguousarray(spread, dtype=np.uint8))
+66 -11
View File
@@ -14,6 +14,9 @@ Ritorna dict con i key esatti del form `edit_params`.
from __future__ import annotations from __future__ import annotations
import hashlib
from collections import OrderedDict
import cv2 import cv2
import numpy as np import numpy as np
@@ -24,17 +27,33 @@ def _to_gray(img: np.ndarray) -> np.ndarray:
return img return img
# Cache in-memory (LRU) dei risultati auto_tune per stesso input ROI.
_TUNE_CACHE: OrderedDict[str, dict] = OrderedDict()
_TUNE_CACHE_SIZE = 32
def detect_rotational_symmetry( def detect_rotational_symmetry(
gray: np.ndarray, step_deg: float = 5.0, corr_thresh: float = 0.75, gray: np.ndarray, step_deg: float = 5.0, corr_thresh: float = 0.75,
) -> dict: ) -> dict:
"""Rileva simmetria rotazionale su edge map (più robusto a sfondo uniforme). """Rileva simmetria rotazionale su edge map (più robusto a sfondo uniforme).
Downsample a max 128 px prima di correlare per abbattere il costo
O(n_angles · H · W) senza perdere precisione (la simmetria rotazionale
è invariante a subsampling moderato).
Ritorna dict con: Ritorna dict con:
- order: int, 1=nessuna, 2=180°, 3=120°, 4=90°, 6=60°, 8=45° - order: int, 1=nessuna, 2=180°, 3=120°, 4=90°, 6=60°, 8=45°
- period_deg: float, periodo minimo di simmetria (360/order) - period_deg: float, periodo minimo di simmetria (360/order)
- confidence: float [0..1], correlazione minima tra rotazioni equivalenti - confidence: float [0..1], correlazione minima tra rotazioni equivalenti
""" """
h, w = gray.shape h, w = gray.shape
target = 128
if max(h, w) > target:
sf = target / max(h, w)
new_w = max(32, int(w * sf))
new_h = max(32, int(h * sf))
gray = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_AREA)
h, w = gray.shape
# Usa magnitude gradiente (rotation-invariant rispetto a bg uniforme) # Usa magnitude gradiente (rotation-invariant rispetto a bg uniforme)
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
@@ -88,9 +107,12 @@ def analyze_gradients(gray: np.ndarray) -> dict:
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
mag = cv2.magnitude(gx, gy) mag = cv2.magnitude(gx, gy)
# Percentili magnitude # Percentili magnitude: p55/p85 usati per soglie weak/strong (più aderenti
# alla distribuzione reale rispetto a p50/p80 + clamp).
p50 = float(np.percentile(mag, 50)) p50 = float(np.percentile(mag, 50))
p55 = float(np.percentile(mag, 55))
p80 = float(np.percentile(mag, 80)) p80 = float(np.percentile(mag, 80))
p85 = float(np.percentile(mag, 85))
p95 = float(np.percentile(mag, 95)) p95 = float(np.percentile(mag, 95))
mag_max = float(mag.max()) mag_max = float(mag.max())
@@ -112,7 +134,8 @@ def analyze_gradients(gray: np.ndarray) -> dict:
ent = 0.0 ent = 0.0
return { return {
"p50": p50, "p80": p80, "p95": p95, "mag_max": mag_max, "p50": p50, "p55": p55, "p80": p80, "p85": p85, "p95": p95,
"mag_max": mag_max,
"strong_pct": strong_pct, "weak_pct": weak_pct, "strong_pct": strong_pct, "weak_pct": weak_pct,
"orient_entropy": ent, "orient_entropy": ent,
"n_pixels": mag.size, "n_pixels": mag.size,
@@ -120,11 +143,28 @@ def analyze_gradients(gray: np.ndarray) -> dict:
} }
def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
h = hashlib.md5()
h.update(np.ascontiguousarray(template_bgr).tobytes())
h.update(f"shape={template_bgr.shape}".encode())
if mask is not None:
h.update(np.ascontiguousarray(mask).tobytes())
return h.hexdigest()
def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict: def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
"""Analizza template e ritorna dict parametri suggeriti. """Analizza template e ritorna dict parametri suggeriti.
Chiavi compatibili con edit_params PARAM_SCHEMA. Chiavi compatibili con edit_params PARAM_SCHEMA.
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
""" """
ck = _cache_key(template_bgr, mask)
cached = _TUNE_CACHE.get(ck)
if cached is not None:
_TUNE_CACHE.move_to_end(ck)
return dict(cached)
gray = _to_gray(template_bgr) gray = _to_gray(template_bgr)
h, w = gray.shape h, w = gray.shape
if mask is not None: if mask is not None:
@@ -136,16 +176,22 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
stats = analyze_gradients(gray_for_stats) stats = analyze_gradients(gray_for_stats)
sym = detect_rotational_symmetry(gray_for_stats) sym = detect_rotational_symmetry(gray_for_stats)
# Soglie magnitude: usa percentili per robustezza illuminazione. # Soglie magnitude: usa percentili reali (p85/p55) senza clamp duro a 100.
# Target: strong_grad ~= valore a percentile 80-90 in assoluto, ma # Sobel ksize=3 su uint8 può arrivare a ~1020, quindi clamp massimo 400
# clamp per compatibilità uint8 (Sobel può sforare). # evita saturazione del threshold su template ad alto contrasto.
strong_grad = float(np.clip(stats["p80"], 20.0, 100.0)) strong_grad = float(np.clip(stats["p85"], 30.0, 400.0))
weak_grad = float(np.clip(strong_grad * 0.5, 10.0, 60.0)) weak_grad = float(np.clip(stats["p55"], 15.0, strong_grad * 0.7))
# num_features: 1 feature ogni ~25 px forti, clamp 48..192 # num_features: ibrido perimetro + densità. Target = min(perimeter_budget,
target_feat = int(np.clip(stats["n_strong"] / 25, 48, 192)) # density_budget) per non generare più feature di quante edge nitide siano
# disponibili, ma neanche meno di quante il perimetro possa tracciare.
perim_budget = int(2 * (h + w) * 0.4) # ~40% dei pixel di perimetro
density_budget = int(stats["n_strong"] / 20) # 1 feature ogni ~20 px forti
target_feat = int(np.clip(min(perim_budget, density_budget), 64, 192))
# pyramid_levels in base alla dimensione minima # pyramid_levels in base a dimensione minima E densità feature: un template
# grande ma povero di feature non deve scendere troppi livelli (rischio
# collasso a <16 feature al top level).
min_side = min(h, w) min_side = min(h, w)
if min_side < 60: if min_side < 60:
pyr = 1 pyr = 1
@@ -155,6 +201,9 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
pyr = 3 pyr = 3
else: else:
pyr = 4 pyr = 4
# Cap: non scendere sotto ~16 feature al top level (feature ÷ 4^(pyr-1))
max_pyr_from_feat = max(1, int(np.floor(np.log2(max(1, target_feat / 16.0)) / 2.0)) + 1)
pyr = min(pyr, max_pyr_from_feat)
# spread_radius proporzionale a risoluzione + pyramid (tolleranza ~1% dim) # spread_radius proporzionale a risoluzione + pyramid (tolleranza ~1% dim)
spread_radius = int(np.clip(max(3, min_side * 0.02), 3, 8)) spread_radius = int(np.clip(max(3, min_side * 0.02), 3, 8))
@@ -174,7 +223,7 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
# angle step: 5° default; se simmetria, mantengo step ma range ridotto # angle step: 5° default; se simmetria, mantengo step ma range ridotto
angle_step = 5.0 angle_step = 5.0
return { result = {
"backend": "line", "backend": "line",
"angle_min": 0.0, "angle_min": 0.0,
"angle_max": angle_max, "angle_max": angle_max,
@@ -196,6 +245,12 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
"_symmetry_conf": round(sym["confidence"], 2), "_symmetry_conf": round(sym["confidence"], 2),
"_orient_entropy": round(stats["orient_entropy"], 2), "_orient_entropy": round(stats["orient_entropy"], 2),
} }
# Store in LRU cache
_TUNE_CACHE[ck] = dict(result)
_TUNE_CACHE.move_to_end(ck)
while len(_TUNE_CACHE) > _TUNE_CACHE_SIZE:
_TUNE_CACHE.popitem(last=False)
return result
def summarize(tune: dict) -> str: def summarize(tune: dict) -> str:
+225 -88
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,9 +34,12 @@ 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,
score_bitmap_rescored as _jit_score_bitmap_rescored,
popcount_density as _jit_popcount, popcount_density as _jit_popcount,
HAS_NUMBA, HAS_NUMBA,
) )
@@ -133,6 +137,8 @@ class LineShapeMatcher:
self.variants: list[_Variant] = [] self.variants: list[_Variant] = []
self.template_size: tuple[int, int] = (0, 0) self.template_size: tuple[int, int] = (0, 0)
self.template_gray: np.ndarray | None = None self.template_gray: np.ndarray | None = None
# Maschera usata in training (propagata al refine per coerenza).
self._train_mask: np.ndarray | None = None
# --- Helpers ------------------------------------------------------- # --- Helpers -------------------------------------------------------
@@ -230,6 +236,7 @@ class LineShapeMatcher:
mask_full = np.full((h, w), 255, dtype=np.uint8) mask_full = np.full((h, w), 255, dtype=np.uint8)
else: else:
mask_full = (mask > 0).astype(np.uint8) * 255 mask_full = (mask > 0).astype(np.uint8) * 255
self._train_mask = mask_full.copy()
self.variants.clear() self.variants.clear()
for s in self._scale_list(): for s in self._scale_list():
@@ -338,9 +345,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 +358,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 +411,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 +431,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 +445,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 +468,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(
@@ -481,6 +509,10 @@ class LineShapeMatcher:
) -> float: ) -> float:
"""NCC tra template warpato alla pose e scena sottostante. """NCC tra template warpato alla pose e scena sottostante.
Lavora su un **crop locale** della scena di lato = diagonale del
template ruotato+scalato, non sull'intera scena. Su scene grandi
(1920×1080) taglia drasticamente il costo del warp per ogni match.
Ritorna score [-1, 1]. Usato come filtro anti-falso-positivo: Ritorna score [-1, 1]. Usato come filtro anti-falso-positivo:
il matcher linemod può dare score alto su texture generiche ma il matcher linemod può dare score alto su texture generiche ma
sovrapponendo il template gray i pixel non corrispondono. sovrapponendo il template gray i pixel non corrispondono.
@@ -491,23 +523,40 @@ class LineShapeMatcher:
h, w = t.shape h, w = t.shape
cx_t = (w - 1) / 2.0 cx_t = (w - 1) / 2.0
cy_t = (h - 1) / 2.0 cy_t = (h - 1) / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
M[0, 2] += cx - cx_t # Bounding box del template ruotato/scalato attorno a (cx, cy)
M[1, 2] += cy - cy_t diag = int(np.ceil(np.hypot(w, h) * scale)) + 8
H, W = scene_gray.shape H, W = scene_gray.shape
x0 = int(round(cx)) - diag // 2
y0 = int(round(cy)) - diag // 2
cx0 = max(0, x0); cy0 = max(0, y0)
cx1 = min(W, x0 + diag); cy1 = min(H, y0 + diag)
if cx1 - cx0 < 10 or cy1 - cy0 < 10:
return 0.0
scn_crop = scene_gray[cy0:cy1, cx0:cx1]
ch, cw = scn_crop.shape
M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
# Porta il centro del template a (cx - cx0, cy - cy0) del crop
M[0, 2] += (cx - cx0) - cx_t
M[1, 2] += (cy - cy0) - cy_t
warped = cv2.warpAffine( warped = cv2.warpAffine(
t, M, (W, H), t, M, (cw, ch),
flags=cv2.INTER_LINEAR, borderValue=0, flags=cv2.INTER_LINEAR, borderValue=0,
) )
mask = cv2.warpAffine( if self._train_mask is not None:
np.full_like(t, 255), M, (W, H), mask_src = self._train_mask
else:
mask_src = np.full_like(t, 255)
mask_w = cv2.warpAffine(
mask_src, M, (cw, ch),
flags=cv2.INTER_NEAREST, borderValue=0, flags=cv2.INTER_NEAREST, borderValue=0,
) )
valid = mask > 0 valid = mask_w > 0
if valid.sum() < 20: if valid.sum() < 20:
return 0.0 return 0.0
tpl = warped[valid].astype(np.float32) tpl = warped[valid].astype(np.float32)
scn = scene_gray[valid].astype(np.float32) scn = scn_crop[valid].astype(np.float32)
tm = tpl - tpl.mean() tm = tpl - tpl.mean()
sm = scn - scn.mean() sm = scn - scn.mean()
denom = np.sqrt((tm * tm).sum() * (sm * sm).sum()) + 1e-9 denom = np.sqrt((tm * tm).sum() * (sm * sm).sum()) + 1e-9
@@ -523,7 +572,17 @@ 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,
coarse_stride: int = 1,
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,27 +623,79 @@ 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.
# coarse_stride > 1: valuta solo 1 pixel ogni stride, ~stride² speed-up.
cs = max(1, int(coarse_stride))
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)]
score = _jit_score_bitmap( score = _jit_score_bitmap_rescored(
spread_top, lvl.dx, lvl.dy, lvl.bin, bit_active_top, spread_top, lvl.dx, lvl.dy, lvl.bin, bit_active_top,
bg_cache_top[var.scale], stride=cs,
) )
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: all_top_scores: list[tuple[int, float]] = []
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):
all_top_scores.append((vi, best))
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)
all_top_scores.append((vi2, best))
if best >= top_thresh: if best >= top_thresh:
kept_variants.append((vi2, best)) kept_coarse.append((vi2, best))
# Fallback adattivo: se il rescore background ha abbattuto tutti
# gli score sotto top_thresh (scene texturate pesanti), ripesca
# le varianti migliori al top level per dare comunque una chance
# alla fase full-res invece di ritornare 0 match.
if not kept_coarse and all_top_scores:
all_top_scores.sort(key=lambda t: -t[1])
n_keep = max(4, len(all_top_scores) // 10)
# Limita a varianti con score top > 0 (non completamente a zero)
kept_coarse = [(vi, s) for vi, s in all_top_scores[:n_keep] if s > 0]
# 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 []
@@ -609,10 +720,10 @@ class LineShapeMatcher:
def _full_score(vi: int) -> tuple[int, np.ndarray]: def _full_score(vi: int) -> tuple[int, np.ndarray]:
var = self.variants[vi] var = self.variants[vi]
lvl0 = var.levels[0] lvl0 = var.levels[0]
score = _jit_score_bitmap( score = _jit_score_bitmap_rescored(
spread0, lvl0.dx, lvl0.dy, lvl0.bin, bit_active_full, spread0, lvl0.dx, lvl0.dy, lvl0.bin, bit_active_full,
bg_cache_full[var.scale],
) )
score = _rescore(score, bg_cache_full[var.scale])
return vi, score return vi, score
candidates_per_var: list[tuple[int, np.ndarray]] = [] candidates_per_var: list[tuple[int, np.ndarray]] = []
@@ -624,14 +735,24 @@ class LineShapeMatcher:
else: else:
results = [_full_score(vi) for vi in var_indices] results = [_full_score(vi) for vi in var_indices]
def _scale_factor(s: float) -> float:
"""Penalità moltiplicativa per scala diversa da 1.0."""
if scale_penalty > 0.0 and s != 1.0:
return max(0.0, 1.0 - scale_penalty * abs(s - 1.0))
return 1.0
for vi, score in results: for vi, score in results:
ys, xs = np.where(score >= min_score) pen = _scale_factor(self.variants[vi].scale)
# Ordinare/sogliare su score penalizzato: un match a scala 1.5 con
# score 0.8 e penalty=0.3 effettivamente vale 0.56, non 0.8.
score_for_sort = score if pen == 1.0 else score * pen
ys, xs = np.where(score_for_sort >= min_score)
if len(ys) == 0: if len(ys) == 0:
continue continue
vals = score[ys, xs] vals = score_for_sort[ys, xs]
K = min(len(vals), max_matches * 5) K = min(len(vals), max_matches * 5)
ord_idx = np.argpartition(-vals, K - 1)[:K] ord_idx = np.argpartition(-vals, K - 1)[:K]
candidates_per_var.append((vi, score)) candidates_per_var.append((vi, score)) # score_map originale
for i in ord_idx: for i in ord_idx:
raw.append((float(vals[i]), int(xs[i]), int(ys[i]), vi)) raw.append((float(vals[i]), int(xs[i]), int(ys[i]), vi))
@@ -641,32 +762,43 @@ class LineShapeMatcher:
score_maps = dict(candidates_per_var) score_maps = dict(candidates_per_var)
# NMS + subpixel + refinement angolare # NMS + subpixel + refinement angolare
# Mask template per refinement (non disponibile qui: usa full) # Usa mask salvata in train() per coerenza (se ROI poligonale).
h, w = self.template_gray.shape if self.template_gray is not None else (0, 0) h, w = self.template_gray.shape if self.template_gray is not None else (0, 0)
mask_full = np.full((h, w), 255, dtype=np.uint8) mask_full = (
self._train_mask if self._train_mask is not None
else np.full((h, w), 255, dtype=np.uint8)
)
# Plateau radius adattivo al template (evita plateau troppo ampi su
# template piccoli: 8% del lato minimo, clampato [3, 10]).
plateau_r = max(3, min(10, int(min(self.template_size) * 0.08)))
# Pre-NMS rapido su raw (solo subpixel, no refine/verify): riduce # Pre-NMS rapido su raw con coordinate intere (nms_radius ≥ 8,
# i candidati a ~max_matches*3 prima di operazioni costose (refine, # la precisione sub-pixel non cambia la decisione di reject).
# verify) che erano chiamate per ogni raw causando lentezze 100x. # Subpixel viene calcolato DOPO il pre-NMS solo sui ~pre_cap
# preliminary sopravvissuti: prima era chiamato su ogni raw (~58k
# chiamate su clip_preciso) anche se la maggior parte veniva poi
# scartata dalla NMS, sprecando la parte più costosa del loop.
r2 = nms_radius * nms_radius r2 = nms_radius * nms_radius
preliminary: list[tuple[float, float, float, int]] = []
pre_cap = max(max_matches * 3, max_matches + 10) pre_cap = max(max_matches * 3, max_matches + 10)
preliminary_int: list[tuple[float, int, int, int]] = []
for score, xi, yi, vi in raw: for score, xi, yi, vi in raw:
if subpixel and vi in score_maps: if any((k[1] - xi) ** 2 + (k[2] - yi) ** 2 < r2
cx_f, cy_f = self._subpixel_peak(score_maps[vi], xi, yi) for k in preliminary_int):
else:
cx_f, cy_f = float(xi), float(yi)
if any((k[1] - cx_f) ** 2 + (k[2] - cy_f) ** 2 < r2
for k in preliminary):
continue continue
preliminary.append((score, cx_f, cy_f, vi)) preliminary_int.append((score, xi, yi, vi))
if len(preliminary) >= pre_cap: if len(preliminary_int) >= pre_cap:
break break
# Ora refine + verify solo sui candidati pre-NMS # Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap)
kept: list[Match] = [] kept: list[Match] = []
tw, th = self.template_size tw, th = self.template_size
for score, cx_f, cy_f, vi in preliminary: for score, xi, yi, vi in preliminary_int:
if subpixel and vi in score_maps:
cx_f, cy_f = self._subpixel_peak(
score_maps[vi], xi, yi, plateau_radius=plateau_r,
)
else:
cx_f, cy_f = float(xi), float(yi)
var = self.variants[vi] var = self.variants[vi]
ang_f = var.angle_deg ang_f = var.angle_deg
score_f = score score_f = score
@@ -685,6 +817,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,
+103
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,6 +455,19 @@ 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]
tech_for_cache = {
"num_features": p.num_features,
"weak_grad": p.weak_grad, "strong_grad": p.strong_grad,
"angle_min": p.angle_min, "angle_max": p.angle_max,
"angle_step": p.angle_step,
"scale_min": p.scale_min, "scale_max": p.scale_max,
"scale_step": p.scale_step,
"spread_radius": p.spread_radius,
"pyramid_levels": p.pyramid_levels,
}
key = _matcher_cache_key(roi_img, tech_for_cache)
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
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,
@@ -386,6 +479,9 @@ def match(p: MatchParams):
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); 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,6 +525,9 @@ def match_simple(p: SimpleMatchParams):
tech = _simple_to_technical(p, roi_img) tech = _simple_to_technical(p, roi_img)
key = _matcher_cache_key(roi_img, tech)
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
num_features=tech["num_features"], num_features=tech["num_features"],
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
@@ -440,11 +539,15 @@ def match_simple(p: SimpleMatchParams):
pyramid_levels=tech["pyramid_levels"], pyramid_levels=tech["pyramid_levels"],
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 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>