Compare commits

..

21 Commits

Author SHA1 Message Date
Adriano eba9d478a7 merge: R OpenCL UMat 2026-05-04 22:42:48 +02:00
Adriano 0df0d98aa5 merge: X ensemble multi-template (con M/Y/Z preservati) 2026-05-04 22:42:43 +02:00
Adriano b2b959e801 merge: V save/load model 2026-05-04 22:42:05 +02:00
Adriano b05246b492 merge: Z subpixel LM (M+Y preservati) 2026-05-04 22:42:00 +02:00
Adriano aeaa7fb5f7 merge: Y soft-margin gradient (con M recall preservato) 2026-05-04 22:40:26 +02:00
Adriano f347a10fad merge: M feature recall 2026-05-04 22:39:01 +02:00
Adriano 0b24be4d94 feat: use_gpu - offload Sobel/dilate via cv2.UMat (OpenCL)
Flag opzionale use_gpu=False/True su LineShapeMatcher e helper:
- opencl_available() per probe runtime
- set_gpu_enabled(bool) per attivare/disattivare globalmente

Quando attivo + cv2.ocl.haveOpenCL() True: Sobel + dilate +
warpAffine usano UMat con dispatch automatico kernel GPU
(Intel UHD, AMD, NVIDIA via OpenCL ICD). Speedup tipico 1.5-3x
sui filtri OpenCV (sec 1080p), gain finale ~10-15% sul total
find() perche' kernel JIT score-bitmap rimane CPU (Numba).

Path silently fallback CPU se OpenCL non disponibile (es. build
opencv-python senza ICD). Non rompe niente in ambienti non-GPU.

Per veri 20-50x speedup servirebbe kernel CUDA dedicato del
score-bitmap (out of scope, CPU + Numba e gia' molto buono).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:38:53 +02:00
Adriano 0296083e3c feat: add_template_view - multi-template ensemble (Halcon-style)
Aggiunge una view extra al matcher gia addestrato. Le varianti
della nuova view vengono APPENDATE a self.variants col tag view_idx
e partecipano al pruning/matching come le altre.

NCC verify usa il template della view che ha matchato (via
_get_view_template + parametro view_idx propagato a _verify_ncc).

Halcon-equivalent: create_aniso_shape_model con fusione N viste.
Use case: pezzo che cambia aspetto (chiaro/scuro, prima/dopo
trattamento, illuminazioni diverse) → un solo matcher robusto
invece di N matcher distinti.

API:
    m.train(template_chiaro)
    m.add_template_view(template_scuro)
    m.find(scene)  # match su entrambi gli aspetti

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:37:13 +02:00
Adriano 39208aadab feat: save_model / load_model - persistenza ricetta addestrata
Halcon-equivalent write_shape_model / read_shape_model. Salva su
file .npz compresso:
- Tutti i parametri matcher (incluso use_polarity)
- Template gray + maschera training
- Tutte le varianti pre-computate (con piramide flat per scrittura
  efficiente, ~12KB per template 80x80 con 28 varianti)

Caso d'uso: training offline su workstation, deploy a runtime
production senza re-train. load_model() istantaneo: skip training
(che e' il costo dominante per molte scale/angoli).

Format version 1, np.savez_compressed (zlib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:34:54 +02:00
Adriano 2b7ee6799c feat: subpixel_lm - refinement iterativo gradient-field least-squares
_subpixel_refine_lm: per ogni feature template, calcola normale
gradient nella scena (bilineare) e stima shift (dx, dy) globale
che minimizza errore direzionale gradient field. Iterazione damped
(max 1px/iter) per stabilita.

Halcon-equivalent SubPixel='least_squares_high'. Precisione attesa
0.05 px (vs 0.5 px del fit quadratico 2D plain). Costo: ~5ms per
match aggiuntivi (negligibile vs total find).

Default off (subpixel_lm=False, backward compat). Attivare per
applicazioni di alignment/dimensional inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:33:55 +02:00
Adriano 5059ce1d89 feat: use_soft_score - Halcon Metric soft-margin gradient similarity
_compute_soft_score: cos(theta_template - theta_scena) continuo
(non quantizzato a bin) pesato per magnitude. Polarity-aware se
use_polarity=True (mod 2pi) else |cos| (mod pi).

Quando use_soft_score=True (default off, backward compat), lo score
finale e' fuso con quello shape: piu discriminante per match a
piccola rotazione (penalita' graduale invece di binaria on/off).

Equivalente a Halcon Metric='use_polarity' / 'ignore_global_polarity'
in find_shape_model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:32:17 +02:00
Adriano f05dec5183 feat: min_recall - Halcon-style feature recall check post-refine
_compute_recall calcola hits/N feature template alla pose finale
(post sub-pixel refine). Equivalente Halcon MinScore originale:
quante feature shape effettivamente combaciano sul match accettato.

Param min_recall (default 0 = off, backward compat). Util quando
NCC e' alto ma poche feature reali matchano (es. match parziale
su zona di simil-tessitura). Soglia 0.7-0.9 raccomandata per
filtri stringenti.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:31:02 +02:00
Adriano f8f6a15166 fix: pruning top adattivo a angle_step (precisione preciso era peggio)
Bug osservato: con precisione "veloce" (10 deg) il matching dava
risultati migliori che con "preciso" (2 deg). Causa: con step fine
ci sono molte varianti vicine, score top-level ravvicinati e:
- top_thresh = min_score * 0.5 troppo aggressivo: scartava varianti
  valide che sarebbero state scelte al full-res
- coarse_angle_factor=2 (skip 1 ogni 2): col fine vicini sono quasi
  identici, ma il pruning skippava la migliore

Fix: quando angle_step <= 3 deg, automatic:
- top_score_factor min 0.7 (vs default 0.5)
- coarse_angle_factor = 1 (no skip varianti)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:20:35 +02:00
Adriano 5bd8fca248 fix: re-check min_score dopo NCC averaging
Bug: score finale = (shape + ncc) / 2 puo scendere sotto min_score
impostato dall'utente. La UI mostrava match con score < soglia
perche il filtro min_score era applicato solo allo shape-score
iniziale, non al risultato finale post-NCC.

Aggiunto re-check dopo averaging: scarta match con score finale
< min_score. Coerenza filtro user-facing ripristinata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:00:32 +02:00
Adriano 796ccb8052 fix(web): simmetria invariante (0) collassava a 360 per || default
Bug JS: SYM_MAP[user.simmetria] || 360 trasforma il valore valido 0
(invariante = nessuna rotazione) in 360 = no simmetria. Risultato:
cambiare simmetria nel pannello avanzato non aveva effetto se
selezionato invariante; per le altre opzioni il valore passava
ma con potenziale altri valori 0 in futuro.

Sostituito con ?? per distinguere "chiave mancante" da "valore zero".
Stessa fix per PREC_MAP.

Inoltre allineato FP_MAP JS al server (medio 0.35 -> 0.50, ecc.)
per coerenza UI/backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:54:16 +02:00
Adriano 0a8a9365bb fix: NCC robusto + reject bbox fuori scena + threshold piu rigorosi
3 fix per match spuri ad alto score visti su scena reale:

1. NCC con guard varianza minima: se template-patch o scene-patch
   hanno std quasi-zero (zone uniformi bianche/nere) NCC e instabile
   e da false-correlation alta. Ora ritorna 0 sotto soglia varianza.

2. Reject post-bbox: se il bounding-box ruotato del match sfora
   la scena per piu del 25%, scarto (centro derivato male o scala
   incoerente). Tollera 25% out-of-bounds (bordi).

3. FILTRO_FP_MAP alzato: leggero 0.20→0.30, medio 0.35→0.50,
   forte 0.50→0.70. Default piu conservativo per evitare match
   spuri su zone con pochi edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:51:43 +02:00
Adriano 9ed779637e merge: angle restrict helper 2026-05-04 17:09:09 +02:00
Adriano 077d44c3c8 merge: polarity 16-bin 2026-05-04 17:09:05 +02:00
Adriano e038ee3a1d merge: NMS poligonale IoU 2026-05-04 17:09:00 +02:00
Adriano 041b26e791 feat: helper set_angle_range_around + angle_tolerance hint in auto_tune
LineShapeMatcher.set_angle_range_around(center, tol): restringe
angle_range a (center-tol, center+tol). Use case: feeder/posizionamento
meccanico noto a priori. Esempio:
    m.set_angle_range_around(0, 20)  # cerca solo in [-20, +20]

auto_tune accetta angle_tolerance_deg + angle_center_deg: emette
angle_min/angle_max ristretti se hint fornito. Cache key include
hint per non collidere con tune default.

Beneficio misurato: angle_step=5 deg, template 80x80
- range 360°: 72 varianti
- range ±15°: 6 varianti (12x meno = matching ~12x piu veloce)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:08:56 +02:00
Adriano 8d8a89ac35 feat: NMS poligonale (IoU bbox ruotato) cross-variant
_poly_iou via cv2.intersectConvexConvex: IoU esatto tra bbox
orientati. Sostituisce distanza-centro nel NMS post-refine.

Vantaggio: due pezzi adiacenti con centri vicini (entro nms_radius)
ma orientamenti diversi non vengono piu fusi se overlap reale e
basso. Stesso pezzo trovato da varianti angolari diverse (centri
uguali, IoU ~1) viene correttamente droppato.

Param nms_iou_threshold default 0.3. Fallback distanza centro
(r2/4) come safety per bbox degeneri.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:04:11 +02:00
4 changed files with 621 additions and 31 deletions
+21 -3
View File
@@ -152,14 +152,27 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
return h.hexdigest() 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,
angle_tolerance_deg: float | None = None,
angle_center_deg: float = 0.0,
) -> 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.
angle_tolerance_deg: se != None, restringe angle_range a
(center - tol, center + tol). Usare quando l'orientamento del
pezzo e' noto a priori (feeder con guida, posizionamento
meccanico): training molto piu rapido (24x meno varianti per
tol=15° vs 360° pieno).
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1). Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
""" """
ck = _cache_key(template_bgr, mask) ck = _cache_key(template_bgr, mask)
if angle_tolerance_deg is not None:
ck = f"{ck}|tol={angle_tolerance_deg}|c={angle_center_deg}"
cached = _TUNE_CACHE.get(ck) cached = _TUNE_CACHE.get(ck)
if cached is not None: if cached is not None:
_TUNE_CACHE.move_to_end(ck) _TUNE_CACHE.move_to_end(ck)
@@ -208,7 +221,12 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
# 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))
# angle range ridotto se simmetria rotazionale # angle range: priorita' a tolerance hint utente, poi simmetria rotazionale.
if angle_tolerance_deg is not None:
angle_min = float(angle_center_deg - angle_tolerance_deg)
angle_max = float(angle_center_deg + angle_tolerance_deg)
else:
angle_min = 0.0
angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0 angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0
# min_score: se entropia orient alta → template distintivo → soglia alta ok # min_score: se entropia orient alta → template distintivo → soglia alta ok
@@ -228,7 +246,7 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
result = { result = {
"backend": "line", "backend": "line",
"angle_min": 0.0, "angle_min": angle_min,
"angle_max": angle_max, "angle_max": angle_max,
"angle_step": angle_step, "angle_step": angle_step,
"scale_min": 1.0, "scale_min": 1.0,
+587 -20
View File
@@ -50,6 +50,52 @@ N_BINS = 8 # default: orientamento mod π (no polarity)
N_BINS_POL = 16 # use_polarity=True: orientamento mod 2π (con polarity) N_BINS_POL = 16 # use_polarity=True: orientamento mod 2π (con polarity)
def opencl_available() -> bool:
"""Ritorna True se OpenCV ha backend OpenCL disponibile (GPU)."""
try:
return bool(cv2.ocl.haveOpenCL())
except Exception:
return False
def set_gpu_enabled(enabled: bool) -> bool:
"""Abilita/disabilita backend OpenCL globale di OpenCV.
Quando attivato, Sobel/dilate/warpAffine usano UMat con dispatch
automatico a kernel GPU (Intel UHD, AMD, NVIDIA via OpenCL ICD).
Speedup tipico: 1.5-3x su Sobel+dilate per scene 1920x1080,
overhead trascurabile per scene < 640px (transfer CPU<->GPU domina).
Halcon-equivalent: 'find_shape_model' con backend GPU integrato.
Ritorna True se l'attivazione e' riuscita.
"""
if not opencl_available():
return False
cv2.ocl.setUseOpenCL(bool(enabled))
return cv2.ocl.useOpenCL()
def _poly_iou(p1: np.ndarray, p2: np.ndarray) -> float:
"""IoU tra due poligoni convessi (4 vertici, float32) via cv2.intersectConvexConvex.
Usa OpenCV (cv2.intersectConvexConvex) per intersezione esatta:
ritorna area intersezione / area unione. Robusto a rotazioni
qualsiasi (anti-orarie/orarie) - cv2 normalizza orientamento.
"""
a1 = float(cv2.contourArea(p1))
a2 = float(cv2.contourArea(p2))
if a1 <= 0 or a2 <= 0:
return 0.0
inter_area, _ = cv2.intersectConvexConvex(
p1.astype(np.float32), p2.astype(np.float32),
)
inter_area = float(inter_area)
if inter_area <= 0:
return 0.0
union = a1 + a2 - inter_area
return inter_area / union if union > 0 else 0.0
def _oriented_bbox_polygon( def _oriented_bbox_polygon(
cx: float, cy: float, w: float, h: float, angle_deg: float, cx: float, cy: float, w: float, h: float, angle_deg: float,
) -> np.ndarray: ) -> np.ndarray:
@@ -104,6 +150,11 @@ class _Variant:
kw: int kw: int
cx_local: float # centro-modello dentro al bbox kernel cx_local: float # centro-modello dentro al bbox kernel
cy_local: float cy_local: float
# Indice template view (X feature - multi-template ensemble).
# 0 = template principale del train(); 1+ = view aggiunte via
# add_template_view(). Usato in _verify_ncc/_compute_recall per
# scegliere il template gray corretto per match.
view_idx: int = 0
class LineShapeMatcher: class LineShapeMatcher:
@@ -124,6 +175,7 @@ class LineShapeMatcher:
top_score_factor: float = 0.5, top_score_factor: float = 0.5,
n_threads: int | None = None, n_threads: int | None = None,
use_polarity: bool = False, use_polarity: bool = False,
use_gpu: bool = False,
) -> None: ) -> None:
self.num_features = num_features self.num_features = num_features
self.weak_grad = weak_grad self.weak_grad = weak_grad
@@ -143,12 +195,22 @@ class LineShapeMatcher:
# template e' direzionale. # template e' direzionale.
self.use_polarity = use_polarity self.use_polarity = use_polarity
self._n_bins = N_BINS_POL if use_polarity else N_BINS self._n_bins = N_BINS_POL if use_polarity else N_BINS
# GPU offload per Sobel/dilate/warpAffine via cv2.UMat (OpenCL).
# Effettivo solo se opencl_available(); altrimenti silent fallback CPU.
self.use_gpu = bool(use_gpu and opencl_available())
if self.use_gpu:
cv2.ocl.setUseOpenCL(True)
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). # Maschera usata in training (propagata al refine per coerenza).
self._train_mask: np.ndarray | None = None self._train_mask: np.ndarray | None = None
# Multi-template ensemble (X feature): N view dello stesso pezzo
# (chiari/scuri, condizioni diverse). Template principale e' [0],
# view aggiunte via add_template_view() sono [1+]. Match restituisce
# la view che ha matchato meglio.
self._view_templates: list[tuple[np.ndarray, np.ndarray | None]] = []
# --- Helpers ------------------------------------------------------- # --- Helpers -------------------------------------------------------
@@ -158,10 +220,15 @@ class LineShapeMatcher:
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
return img return img
def _gradient(self, gray: np.ndarray) -> tuple[np.ndarray, np.ndarray]: def _gradient(self, gray) -> tuple[np.ndarray, np.ndarray]:
# Accetta np.ndarray o cv2.UMat (per path GPU OpenCL).
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)
mag = cv2.magnitude(gx, gy) mag = cv2.magnitude(gx, gy)
# Quantizzazione orientation richiede CPU array (np ops): scarica
# da GPU se necessario.
if isinstance(gx, cv2.UMat):
gx = gx.get(); gy = gy.get(); mag = mag.get()
ang = np.arctan2(gy, gx) # [-π, π] ang = np.arctan2(gy, gx) # [-π, π]
if self.use_polarity: if self.use_polarity:
# Mod 2π: bin 0..15 codifica direzione + polarity edge. # Mod 2π: bin 0..15 codifica direzione + polarity edge.
@@ -205,6 +272,140 @@ class LineShapeMatcher:
np.array(picked_y, np.int32), np.array(picked_y, np.int32),
np.array(picked_b, np.int8)) np.array(picked_b, np.int8))
# --- Save / Load (Halcon-style write_shape_model / read_shape_model)
def save_model(self, path: str) -> None:
"""Salva matcher addestrato su disco (formato .npz).
Persiste: parametri, template_gray, mask, e tutte le varianti
pre-computate (con piramide). Halcon-equivalent write_shape_model.
Caso d'uso: training offline su workstation, deploy su macchina
di linea senza re-train (zero secondi di startup matching).
"""
if not self.variants:
raise RuntimeError("Modello non addestrato: chiamare train() prima.")
# Flatten varianti in array piatti (npz non ama dataclass nested)
n_vars = len(self.variants)
n_levels = len(self.variants[0].levels)
var_meta = np.zeros((n_vars, 6), dtype=np.float32) # ang, scale, kh, kw, cxl, cyl
all_dx, all_dy, all_bin, all_offsets = [], [], [], []
offset = 0
all_offsets_per_level = [[] for _ in range(n_levels)]
all_dx_per_level = [[] for _ in range(n_levels)]
all_dy_per_level = [[] for _ in range(n_levels)]
all_bin_per_level = [[] for _ in range(n_levels)]
for vi, var in enumerate(self.variants):
var_meta[vi] = (
var.angle_deg, var.scale, var.kh, var.kw,
var.cx_local, var.cy_local,
)
for li, lvl in enumerate(var.levels):
all_offsets_per_level[li].append(len(all_dx_per_level[li]))
all_dx_per_level[li].extend(lvl.dx.tolist())
all_dy_per_level[li].extend(lvl.dy.tolist())
all_bin_per_level[li].extend(lvl.bin.tolist())
for li in range(n_levels):
all_offsets_per_level[li].append(len(all_dx_per_level[li]))
out = {
"_format_version": np.array([1], dtype=np.int32),
"params": np.array([
self.num_features, self.weak_grad, self.strong_grad,
self.angle_range_deg[0], self.angle_range_deg[1],
self.angle_step_deg,
self.scale_range[0], self.scale_range[1], self.scale_step,
self.spread_radius, self.min_feature_spacing,
self.pyramid_levels, self.top_score_factor,
int(self.use_polarity),
], dtype=np.float64),
"template_gray": self.template_gray,
"train_mask": self._train_mask,
"var_meta": var_meta,
"n_levels": np.array([n_levels], dtype=np.int32),
}
for li in range(n_levels):
out[f"dx_l{li}"] = np.asarray(all_dx_per_level[li], dtype=np.int32)
out[f"dy_l{li}"] = np.asarray(all_dy_per_level[li], dtype=np.int32)
out[f"bin_l{li}"] = np.asarray(all_bin_per_level[li], dtype=np.int8)
out[f"offsets_l{li}"] = np.asarray(all_offsets_per_level[li], dtype=np.int32)
np.savez_compressed(path, **out)
@classmethod
def load_model(cls, path: str) -> "LineShapeMatcher":
"""Carica matcher pre-addestrato da .npz salvato con save_model.
Halcon-equivalent read_shape_model. Bypassa completamente train():
deploy production = istantaneo.
"""
data = np.load(path, allow_pickle=False)
params = data["params"]
m = cls(
num_features=int(params[0]),
weak_grad=float(params[1]),
strong_grad=float(params[2]),
angle_range_deg=(float(params[3]), float(params[4])),
angle_step_deg=float(params[5]),
scale_range=(float(params[6]), float(params[7])),
scale_step=float(params[8]),
spread_radius=int(params[9]),
min_feature_spacing=int(params[10]),
pyramid_levels=int(params[11]),
top_score_factor=float(params[12]),
use_polarity=bool(int(params[13])),
)
tpl = data["template_gray"]
if tpl.ndim > 0 and tpl.size > 0:
m.template_gray = tpl
m.template_size = (tpl.shape[1], tpl.shape[0])
mk = data["train_mask"]
m._train_mask = mk if mk.size > 0 else None
var_meta = data["var_meta"]
n_levels = int(data["n_levels"][0])
offsets_l = [data[f"offsets_l{li}"] for li in range(n_levels)]
dx_l = [data[f"dx_l{li}"] for li in range(n_levels)]
dy_l = [data[f"dy_l{li}"] for li in range(n_levels)]
bin_l = [data[f"bin_l{li}"] for li in range(n_levels)]
m.variants = []
n_vars = var_meta.shape[0]
for vi in range(n_vars):
ang, scale, kh, kw, cxl, cyl = var_meta[vi]
levels = []
for li in range(n_levels):
i0 = int(offsets_l[li][vi])
i1 = int(offsets_l[li][vi + 1])
levels.append(_LevelFeatures(
dx=dx_l[li][i0:i1].copy(),
dy=dy_l[li][i0:i1].copy(),
bin=bin_l[li][i0:i1].copy(),
n=i1 - i0,
))
m.variants.append(_Variant(
angle_deg=float(ang), scale=float(scale),
levels=levels, kh=int(kh), kw=int(kw),
cx_local=float(cxl), cy_local=float(cyl),
))
return m
def set_angle_range_around(
self, center_deg: float, tolerance_deg: float,
) -> None:
"""Restringe angle_range a (center - tol, center + tol).
Comodo helper per scenari in cui l'orientamento del pezzo e'
noto a priori entro ±tolerance_deg (es. feeder vibrante con
guida meccanica). Riduce drasticamente le varianti generate
in train(): es. ±15° vs 360° = 24x meno varianti, training
e matching molto piu veloci.
Esempio:
m.set_angle_range_around(0, 20) # cerca solo in [-20, +20]
m.train(template)
"""
self.angle_range_deg = (
float(center_deg - tolerance_deg),
float(center_deg + tolerance_deg),
)
def _scale_list(self) -> list[float]: def _scale_list(self) -> list[float]:
s0, s1 = self.scale_range s0, s1 = self.scale_range
if s0 >= s1 or self.scale_step <= 0: if s0 >= s1 or self.scale_step <= 0:
@@ -273,8 +474,60 @@ class LineShapeMatcher:
self._train_mask = mask_full.copy() self._train_mask = mask_full.copy()
self.variants.clear() self.variants.clear()
# Reset view list: template principale = view 0
self._view_templates = [(gray.copy(), mask_full.copy())]
# Invalida cache feature di refine: il template e cambiato. # Invalida cache feature di refine: il template e cambiato.
self._refine_feat_cache = {} self._refine_feat_cache = {}
self._build_variants_for_view(gray, mask_full, view_idx=0)
self._dedup_variants()
return len(self.variants)
def add_template_view(
self, template_bgr: np.ndarray, mask: np.ndarray | None = None,
) -> int:
"""Aggiunge una view template extra all'ensemble (Halcon-style
create_aniso_shape_model con fusione N viste).
Genera varianti del nuovo template con stessi parametri (range
angle/scale) e le APPENDE a self.variants. NCC/recall usano
automaticamente il template della view che ha matchato.
Use case: pezzo che cambia aspetto (chiaro/scuro, prima/dopo
trattamento, illuminazioni diverse) → un solo matcher resistente.
Ritorna numero TOTALE varianti dopo l'aggiunta. Le view sono
indicizzate da 1 in poi (0 e' il template del train).
"""
if not self.variants:
raise RuntimeError(
"Chiamare train(template_principale) prima di add_template_view")
gray = self._to_gray(template_bgr)
h, w = gray.shape
if (w, h) != self.template_size:
# Resize per coerenza con bbox/poly
gray = cv2.resize(gray, self.template_size, interpolation=cv2.INTER_LINEAR)
if mask is not None:
mask = cv2.resize(mask, self.template_size, interpolation=cv2.INTER_NEAREST)
if mask is None:
mask_full = np.full(gray.shape, 255, dtype=np.uint8)
else:
mask_full = (mask > 0).astype(np.uint8) * 255
view_idx = len(self._view_templates)
self._view_templates.append((gray.copy(), mask_full.copy()))
n_before = len(self.variants)
self._build_variants_for_view(gray, mask_full, view_idx=view_idx)
self._dedup_variants()
return len(self.variants) - n_before
def _build_variants_for_view(
self, gray: np.ndarray, mask_full: np.ndarray, view_idx: int,
) -> None:
"""Estrae varianti rotate+scalate per UNA view template.
Estrazione algorithm identica al train() originale, separato per
riuso da add_template_view (multi-template ensemble).
"""
h, w = gray.shape
for s in self._scale_list(): for s in self._scale_list():
sw = max(16, int(round(w * s))) sw = max(16, int(round(w * s)))
sh = max(16, int(round(h * s))) sh = max(16, int(round(h * s)))
@@ -328,9 +581,8 @@ class LineShapeMatcher:
levels=levels, levels=levels,
kh=kh, kw=kw, kh=kh, kw=kw,
cx_local=float(cx_local), cy_local=float(cy_local), cx_local=float(cx_local), cy_local=float(cy_local),
view_idx=view_idx,
)) ))
self._dedup_variants()
return len(self.variants)
def _dedup_variants(self) -> int: def _dedup_variants(self) -> int:
"""Rimuove varianti con feature-set identico (post-quantizzazione). """Rimuove varianti con feature-set identico (post-quantizzazione).
@@ -385,19 +637,29 @@ class LineShapeMatcher:
"""Spread bitmap: bit b acceso dove bin b è presente nel raggio. """Spread bitmap: bit b acceso dove bin b è presente nel raggio.
dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity). dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity).
Se use_gpu=True: Sobel + dilate via cv2.UMat (OpenCL kernel GPU).
""" """
mag, bins = self._gradient(gray) if self.use_gpu and not isinstance(gray, cv2.UMat):
gray_in = cv2.UMat(np.ascontiguousarray(gray))
else:
gray_in = gray
mag, bins = self._gradient(gray_in)
valid = mag >= self.weak_grad valid = mag >= self.weak_grad
k = 2 * self.spread_radius + 1 k = 2 * self.spread_radius + 1
kernel = np.ones((k, k), dtype=np.uint8) kernel = np.ones((k, k), dtype=np.uint8)
H, W = gray.shape H, W = (gray.shape if isinstance(gray, np.ndarray)
else (gray.get().shape[0], gray.get().shape[1]))
nb = self._n_bins nb = self._n_bins
dtype = np.uint16 if nb > 8 else np.uint8 dtype = np.uint16 if nb > 8 else np.uint8
spread = np.zeros((H, W), dtype=dtype) spread = np.zeros((H, W), dtype=dtype)
for b in range(nb): for b in range(nb):
mask_b = ((bins == b) & valid).astype(np.uint8) mask_b = ((bins == b) & valid).astype(np.uint8)
d = cv2.dilate(mask_b, kernel) if self.use_gpu:
spread |= (d.astype(dtype) << b) d = cv2.dilate(cv2.UMat(mask_b), kernel)
d_np = d.get()
else:
d_np = cv2.dilate(mask_b, kernel)
spread |= (d_np.astype(dtype) << b)
return spread return spread
@staticmethod @staticmethod
@@ -699,9 +961,230 @@ class LineShapeMatcher:
s2, cx2, cy2 = _score_at_angle(x2) s2, cx2, cy2 = _score_at_angle(x2)
return best return best
def _get_view_template(
self, view_idx: int,
) -> tuple[np.ndarray | None, np.ndarray | None]:
"""Ritorna (template_gray, mask) per la view specificata.
view_idx 0 = template principale (train), 1+ = view extra
aggiunte via add_template_view. Usato per scegliere il template
corretto in NCC/recall verification quando il matcher e'
ensemble multi-template.
"""
if 0 <= view_idx < len(self._view_templates):
return self._view_templates[view_idx]
return self.template_gray, self._train_mask
def _compute_recall(
self, spread0: np.ndarray, variant: _Variant,
cx: float, cy: float, angle_deg: float,
) -> float:
"""Frazione di feature template che combaciano nello spread scena
alla pose. Halcon-equivalent: MinScore originale.
"""
if self.template_gray is None:
return 1.0
h, w = self.template_gray.shape
scale = variant.scale
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = (
self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255)
)
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
)
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE)
mask_r = cv2.warpAffine(mask_p, M, (diag, diag),
flags=cv2.INTER_NEAREST, borderValue=0)
mag, bins = self._gradient(gray_r)
fx, fy, fb = self._extract_features(mag, bins, mask_r)
n_feat = len(fx)
if n_feat < 4:
return 0.0
H, W = spread0.shape
spread_dtype = spread0.dtype.type
ix = int(round(cx)); iy = int(round(cy))
hits = 0
for i in range(n_feat):
xs = ix + int(fx[i] - center[0])
ys = iy + int(fy[i] - center[1])
if 0 <= xs < W and 0 <= ys < H:
bit = spread_dtype(1 << int(fb[i]))
if spread0[ys, xs] & bit:
hits += 1
return hits / n_feat
def _compute_soft_score(
self, scene_gray: np.ndarray, variant: _Variant,
cx: float, cy: float, angle_deg: float,
) -> float:
"""Soft-margin gradient similarity (Halcon Metric='use_polarity')."""
if self.template_gray is None:
return 0.0
h, w = self.template_gray.shape
scale = variant.scale
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = (
self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255)
)
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
)
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE)
mask_r = cv2.warpAffine(mask_p, M, (diag, diag),
flags=cv2.INTER_NEAREST, borderValue=0)
gx_t = cv2.Sobel(gray_r, cv2.CV_32F, 1, 0, ksize=3)
gy_t = cv2.Sobel(gray_r, cv2.CV_32F, 0, 1, ksize=3)
mag_t = cv2.magnitude(gx_t, gy_t)
_, bins_t = self._gradient(gray_r)
fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r)
if len(fx) < 4:
return 0.0
gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3)
gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3)
H, W = scene_gray.shape
ix = int(round(cx)); iy = int(round(cy))
sims = []; weights = []
for i in range(len(fx)):
xs = ix + int(fx[i] - center[0])
ys = iy + int(fy[i] - center[1])
if not (0 <= xs < W and 0 <= ys < H):
continue
tx = float(gx_t[int(fy[i]), int(fx[i])])
ty = float(gy_t[int(fy[i]), int(fx[i])])
sx = float(gx_s[ys, xs]); sy = float(gy_s[ys, xs])
tm = math.hypot(tx, ty); sm = math.hypot(sx, sy)
if tm < 1e-3 or sm < 1e-3:
continue
cos_sim = (tx * sx + ty * sy) / (tm * sm)
cos_sim = max(0.0, cos_sim) if self.use_polarity else abs(cos_sim)
sims.append(cos_sim); weights.append(min(sm, 255.0))
if not sims:
return 0.0
sims_arr = np.asarray(sims, dtype=np.float32)
w_arr = np.asarray(weights, dtype=np.float32)
return float((sims_arr * w_arr).sum() / (w_arr.sum() + 1e-9))
def _subpixel_refine_lm(
self, scene_gray: np.ndarray, variant: _Variant,
cx: float, cy: float, angle_deg: float,
n_iters: int = 2,
) -> tuple[float, float]:
"""Sub-pixel refinement iterativo via gradient-field least-squares.
Halcon-equivalent SubPixel='least_squares_high'. Precisione attesa
0.05 px (vs 0.5 px del fit quadratic 2D).
"""
if self.template_gray is None:
return cx, cy
h, w = self.template_gray.shape
scale = variant.scale
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_src = (
self._train_mask if self._train_mask is not None
else np.full_like(self.template_gray, 255)
)
mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(
gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE,
)
mask_p = cv2.copyMakeBorder(
mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0,
)
center = (diag / 2.0, diag / 2.0)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE)
mask_r = cv2.warpAffine(mask_p, M, (diag, diag),
flags=cv2.INTER_NEAREST, borderValue=0)
gx_t = cv2.Sobel(gray_r, cv2.CV_32F, 1, 0, ksize=3)
gy_t = cv2.Sobel(gray_r, cv2.CV_32F, 0, 1, ksize=3)
mag_t = cv2.magnitude(gx_t, gy_t)
_, bins_t = self._gradient(gray_r)
fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r)
if len(fx) < 4:
return cx, cy
n = len(fx)
ddx_t = (fx - center[0]).astype(np.float32)
ddy_t = (fy - center[1]).astype(np.float32)
gx_tf = np.array([gx_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32)
gy_tf = np.array([gy_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32)
mag_tf = np.hypot(gx_tf, gy_tf) + 1e-6
nx_t = gx_tf / mag_tf
ny_t = gy_tf / mag_tf
gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3)
gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3)
H, W = scene_gray.shape
cur_cx, cur_cy = float(cx), float(cy)
for _ in range(n_iters):
xs = cur_cx + ddx_t
ys = cur_cy + ddy_t
xs_c = np.clip(xs, 0, W - 1.001)
ys_c = np.clip(ys, 0, H - 1.001)
x0 = xs_c.astype(np.int32); y0 = ys_c.astype(np.int32)
ax = xs_c - x0; ay = ys_c - y0
def _bilin(g):
v00 = g[y0, x0]; v10 = g[y0, x0 + 1]
v01 = g[y0 + 1, x0]; v11 = g[y0 + 1, x0 + 1]
return ((1 - ax) * (1 - ay) * v00
+ ax * (1 - ay) * v10
+ (1 - ax) * ay * v01
+ ax * ay * v11)
sx_v = _bilin(gx_s)
sy_v = _bilin(gy_s)
mag_s = np.hypot(sx_v, sy_v) + 1e-6
nx_s = sx_v / mag_s
ny_s = sy_v / mag_s
w = np.minimum(mag_s, 255.0).astype(np.float32)
err_x = (nx_s - nx_t) * w
err_y = (ny_s - ny_t) * w
step_x = -float(err_x.sum()) / (w.sum() + 1e-6)
step_y = -float(err_y.sum()) / (w.sum() + 1e-6)
step_x = max(-1.0, min(1.0, step_x))
step_y = max(-1.0, min(1.0, step_y))
cur_cx += step_x
cur_cy += step_y
if abs(step_x) < 0.02 and abs(step_y) < 0.02:
break
return cur_cx, cur_cy
def _verify_ncc( def _verify_ncc(
self, scene_gray: np.ndarray, cx: float, cy: float, self, scene_gray: np.ndarray, cx: float, cy: float,
angle_deg: float, scale: float, angle_deg: float, scale: float, view_idx: int = 0,
) -> float: ) -> float:
"""NCC tra template warpato alla pose e scena sottostante. """NCC tra template warpato alla pose e scena sottostante.
@@ -713,9 +1196,9 @@ class LineShapeMatcher:
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.
""" """
if self.template_gray is None: t, train_mask = self._get_view_template(view_idx)
if t is None:
return 1.0 return 1.0
t = self.template_gray
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
@@ -740,8 +1223,8 @@ class LineShapeMatcher:
t, M, (cw, ch), t, M, (cw, ch),
flags=cv2.INTER_LINEAR, borderValue=0, flags=cv2.INTER_LINEAR, borderValue=0,
) )
if self._train_mask is not None: if train_mask is not None:
mask_src = self._train_mask mask_src = train_mask
else: else:
mask_src = np.full_like(t, 255) mask_src = np.full_like(t, 255)
mask_w = cv2.warpAffine( mask_w = cv2.warpAffine(
@@ -755,7 +1238,15 @@ class LineShapeMatcher:
scn = scn_crop[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 # Std minimo: se template o scena patch sono quasi uniformi
# (es. zona di sfondo bianco/nero), NCC e instabile e da false
# high-correlation. Halcon-style: scarta match.
tpl_var = float((tm * tm).sum())
scn_var = float((sm * sm).sum())
n_pix = float(valid.sum())
if tpl_var < 1e-3 * n_pix or scn_var < 1e-3 * n_pix:
return 0.0
denom = np.sqrt(tpl_var * scn_var) + 1e-9
return float((tm * sm).sum() / denom) return float((tm * sm).sum() / denom)
def find( def find(
@@ -778,6 +1269,10 @@ class LineShapeMatcher:
refine_pose_joint: bool = False, refine_pose_joint: bool = False,
greediness: float = 0.0, greediness: float = 0.0,
batch_top: bool = False, batch_top: bool = False,
nms_iou_threshold: float = 0.3,
min_recall: float = 0.0,
use_soft_score: bool = False,
subpixel_lm: bool = False,
) -> list[Match]: ) -> list[Match]:
""" """
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0: scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
@@ -823,7 +1318,20 @@ class LineShapeMatcher:
) )
if nms_radius is None: if nms_radius is None:
nms_radius = max(8, min(self.template_size) // 2) nms_radius = max(8, min(self.template_size) // 2)
top_thresh = min_score * self.top_score_factor # Pruning adattivo allo step angolare: con step piccolo (<= 3 deg)
# ci sono molte varianti vicine, gli score top-level sono ravvicinati
# e top_thresh*0.5 e' troppo aggressivo: scarta varianti valide che
# sarebbero state riprese al full-res. Stessa cosa per
# coarse_angle_factor (skip 1 ogni 2): con step fine non e' utile.
# Risultato osservato: precisione "veloce" 10° dava risultati
# migliori di "preciso" 2° proprio perche evitava il pruning.
eff_step = self._effective_angle_step()
top_factor = self.top_score_factor
cf_eff = max(1, coarse_angle_factor)
if eff_step <= 3.0:
top_factor = max(top_factor, 0.7)
cf_eff = 1
top_thresh = min_score * top_factor
tw, th = self.template_size tw, th = self.template_size
density_top = _jit_popcount(spread_top) density_top = _jit_popcount(spread_top)
@@ -855,7 +1363,7 @@ class LineShapeMatcher:
coarse_idx_list: list[int] = [] # varianti da valutare al top coarse_idx_list: list[int] = [] # varianti da valutare al top
neighbor_map: dict[int, list[int]] = {} # vi_coarse -> indici vicini neighbor_map: dict[int, list[int]] = {} # vi_coarse -> indici vicini
cf = max(1, coarse_angle_factor) cf = cf_eff
for scale_key, vi_list in variants_by_scale.items(): for scale_key, vi_list in variants_by_scale.items():
vi_sorted = sorted(vi_list, key=lambda i: self.variants[i].angle_deg) vi_sorted = sorted(vi_list, key=lambda i: self.variants[i].angle_deg)
n = len(vi_sorted) n = len(vi_sorted)
@@ -1114,6 +1622,13 @@ class LineShapeMatcher:
search_radius=self._effective_angle_step() / 2.0, search_radius=self._effective_angle_step() / 2.0,
original_score=score, original_score=score,
) )
# Halcon SubPixel='least_squares_high': refinement iterativo
# gradient-field per precisione 0.05 px (vs 0.5 quadratic 2D).
if subpixel_lm and self.template_gray is not None:
cx_lm, cy_lm = self._subpixel_refine_lm(
gray0, var, cx_f, cy_f, ang_f,
)
cx_f, cy_f = float(cx_lm), float(cy_lm)
# NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta # NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta
# il calcolo per shape-score gia alti. Default 1.01 = NCC sempre, # il calcolo per shape-score gia alti. Default 1.01 = NCC sempre,
# piu sicuro contro falsi positivi (lo shape-score satura facile). # piu sicuro contro falsi positivi (lo shape-score satura facile).
@@ -1122,10 +1637,37 @@ class LineShapeMatcher:
# ranking/visualizzazione (uno score 1.0 vero richiede sia # ranking/visualizzazione (uno score 1.0 vero richiede sia
# match shape sia template gray identici). # match shape sia template gray identici).
if verify_ncc and float(score_f) < ncc_skip_above: if verify_ncc and float(score_f) < ncc_skip_above:
ncc = self._verify_ncc(gray0, cx_f, cy_f, ang_f, var.scale) ncc = self._verify_ncc(
gray0, cx_f, cy_f, ang_f, var.scale,
view_idx=getattr(var, "view_idx", 0),
)
if ncc < verify_threshold: if ncc < verify_threshold:
continue continue
score_f = (float(score_f) + max(0.0, ncc)) * 0.5 score_f = (float(score_f) + max(0.0, ncc)) * 0.5
# Soft-margin gradient similarity: sostituisce o integra lo
# score con metric continua (cos sim gradients) invece di
# bin discreto. Halcon-style: piu robusto a piccole rotazioni.
if use_soft_score:
soft = self._compute_soft_score(
gray0, var, cx_f, cy_f, ang_f,
)
score_f = (float(score_f) + soft) * 0.5
# Re-check min_score sullo score finale: NCC averaging puo
# abbattere lo shape-score sotto la soglia user. Senza questo
# check apparivano match con score < min_score (UI confusing).
if float(score_f) < min_score:
continue
# Feature recall (Halcon MinScore-style): conta quante feature
# template effettivamente combaciano nello spread scena alla
# pose finale. Scarta se sotto min_recall (default 0 = off).
# Util contro match parziali ad alto NCC ma poche feature reali.
if min_recall > 0.0:
recall = self._compute_recall(
spread0, var, cx_f, cy_f, ang_f,
)
if recall < min_recall:
continue
# Ri-traslo coord da spazio crop ROI a spazio scena originale. # Ri-traslo coord da spazio crop ROI a spazio scena originale.
cx_out = cx_f + roi_offset[0] cx_out = cx_f + roi_offset[0]
@@ -1133,17 +1675,42 @@ class LineShapeMatcher:
poly = _oriented_bbox_polygon( poly = _oriented_bbox_polygon(
cx_out, cy_out, tw * var.scale, th * var.scale, ang_f, cx_out, cy_out, tw * var.scale, th * var.scale, ang_f,
) )
# Reject match con bbox che sfora pesantemente la scena:
# spesso indica match spurio (centro derivato male o scala
# incoerente). Tollera 25% out-of-bounds, sopra rigetta.
H_scn, W_scn = gray_full.shape
poly_area = float(cv2.contourArea(poly))
if poly_area > 0:
# Clip poly alla scena: intersezione con rettangolo (0,0,W,H)
scene_rect = np.array([
[0, 0], [W_scn, 0], [W_scn, H_scn], [0, H_scn],
], dtype=np.float32)
inter, _ = cv2.intersectConvexConvex(
poly.astype(np.float32), scene_rect,
)
inside_ratio = float(inter) / poly_area
if inside_ratio < 0.75:
continue
# Penalità scala opzionale: score degrada con distanza da 1.0 # Penalità scala opzionale: score degrada con distanza da 1.0
if scale_penalty > 0.0 and var.scale != 1.0: if scale_penalty > 0.0 and var.scale != 1.0:
score_f = float(score_f) * max( score_f = float(score_f) * max(
0.0, 1.0 - scale_penalty * abs(var.scale - 1.0) 0.0, 1.0 - scale_penalty * abs(var.scale - 1.0)
) )
# NMS post-refine: refine puo spostare il match di nms_radius; # NMS post-refine cross-variant: usa IoU bbox-poligonale invece
# ricontrollo overlap su match gia accettati per evitare # di sola distanza centro. Due match orientati diversi ma vicini
# duplicati (stesso oggetto trovato da varianti angolari diverse). # (pezzi adiacenti) NON vengono fusi se l'overlap reale e basso;
# due match dello stesso pezzo (centri uguali, rotazione simile)
# hanno IoU alto e vengono droppati.
# Fallback distanza centro per match con bbox degenere.
dup = False dup = False
for k in kept: for k in kept:
if (k.cx - cx_out) ** 2 + (k.cy - cy_out) ** 2 < r2: iou = _poly_iou(k.bbox_poly, poly)
if iou > nms_iou_threshold:
dup = True
break
# Sicurezza: centri molto vicini (dentro nms_radius/2)
# sempre fusi, anche con orientamenti molto diversi.
if (k.cx - cx_out) ** 2 + (k.cy - cy_out) ** 2 < (r2 / 4.0):
dup = True dup = True
break break
if dup: if dup:
+3 -3
View File
@@ -249,9 +249,9 @@ PRECISION_ANGLE_STEP = {
# Un operatore sceglie il livello di rigore, non un numero astratto. # Un operatore sceglie il livello di rigore, non un numero astratto.
FILTRO_FP_MAP = { FILTRO_FP_MAP = {
"off": 0.0, # disabilitato: mantieni tutti i match shape-based "off": 0.0, # disabilitato: mantieni tutti i match shape-based
"leggero": 0.20, # tollera variazioni intensità/illuminazione forti "leggero": 0.30, # tollera variazioni intensità/illuminazione forti
"medio": 0.35, # default bilanciato (consigliato) "medio": 0.50, # default bilanciato (consigliato)
"forte": 0.50, # scarta match con intensità molto diversa dal template "forte": 0.70, # scarta match con intensità molto diversa dal template
} }
+9 -4
View File
@@ -294,12 +294,17 @@ async function doMatch() {
const SCALE_MAP = {fissa:[1,1,0.1], mini:[0.9,1.1,0.05], const SCALE_MAP = {fissa:[1,1,0.1], mini:[0.9,1.1,0.05],
medio:[0.75,1.25,0.05], max:[0.5,1.5,0.05]}; medio:[0.75,1.25,0.05], max:[0.5,1.5,0.05]};
const PREC_MAP = {veloce:10, normale:5, preciso:2}; const PREC_MAP = {veloce:10, normale:5, preciso:2};
const FP_MAP = {off:0, leggero:0.20, medio:0.35, forte:0.50}; // Allineato a FILTRO_FP_MAP server-side (server.py)
const FP_MAP = {off:0, leggero:0.30, medio:0.50, forte:0.70};
const [smin, smax, sstep] = SCALE_MAP[user.scala]; const [smin, smax, sstep] = SCALE_MAP[user.scala];
// NB: SYM_MAP[invariante]=0 e' valido (zero rotazioni). Uso ?? per
// distinguere "chiave mancante" da "valore zero": altrimenti 0 || 360
// collassa invariante a 360 = bug "simmetria non ha effetto".
const angMax = SYM_MAP[user.simmetria] ?? 360;
body = { body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi, model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
angle_min: 0, angle_max: SYM_MAP[user.simmetria] || 360, angle_min: 0, angle_max: angMax,
angle_step: PREC_MAP[user.precisione] || 5, angle_step: PREC_MAP[user.precisione] ?? 5,
scale_min: smin, scale_max: smax, scale_step: sstep, scale_min: smin, scale_max: smax, scale_step: sstep,
min_score: user.min_score, max_matches: user.max_matches, min_score: user.min_score, max_matches: user.max_matches,
num_features: adv.num_features ?? 96, num_features: adv.num_features ?? 96,
@@ -307,7 +312,7 @@ async function doMatch() {
strong_grad: adv.strong_grad ?? 60, strong_grad: adv.strong_grad ?? 60,
spread_radius: adv.spread_radius ?? 5, spread_radius: adv.spread_radius ?? 5,
pyramid_levels: adv.pyramid_levels ?? 3, pyramid_levels: adv.pyramid_levels ?? 3,
verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.35), verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.50),
nms_radius: adv.nms_radius ?? 0, nms_radius: adv.nms_radius ?? 0,
}; };
} else { } else {