Compare commits

..

11 Commits

Author SHA1 Message Date
Adriano 8c46a6ca9b fix: rimossa traslazione fissa edge overlay match
Causa principale: erode di (2*spread_radius+1) sulla maschera warpata
toglieva troppo bordo. Per spread_radius=8 → kernel 17x17 = -8px da
ogni lato. L'edge map applicata sopra mostrava i bordi spostati di ~8px
verso l'interno del pezzo, creando apparente "traslazione fissa".

Soluzione: erode 3x3 solo per rimuovere ~1px di bordo nero residuo
da warpAffine borderValue=0 (artefatto di padding). Bordi del pezzo
ora visualizzati nelle posizioni corrette.

Bonus fix: cx_t calcolato come w/2 invece di (w-1)/2, coerente con
center=diag/2.0 usato in training (era 0.5px di shift residuo per
template di lato pari).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:45:11 +02:00
Adriano d335f866a3 merge: refine veloce + UCS Y visibile 2026-05-05 12:38:47 +02:00
Adriano 88f80a2cad fix: refine angolo piu' veloce + edge overlay ciano (no clash con asse Y)
Bug visibili dallo screenshot:
1. Rallentamento sostanziale: il fix precedente aggiungeva 16 iter golden
   (era 8) + 3 chiamate parabolic fit = ~19 _score_at_angle vs 11 prima.
2. Asse Y dell'UCS invisibile sul match: edge overlay era verde brillante
   (0,220,0) e si sovrapponeva esattamente al verde dell'asse Y dell'UCS.
3. Angolo non corretto: il parabolic fit finale era instabile su template
   simmetrici (multiple local max ravvicinati lo facevano divergere fuori
   dal vero picco trovato dal golden).

Fix:
- _refine_angle: 10 iter golden con tol 0.05 (compromesso tra precisione
  e velocita'). Rimosso parabolic fit finale instabile. search_radius
  resta a step pieno (utile per recuperare estremi del bin).
- Edge overlay color: ciano (BGR 255,200,0) invece di verde brillante.
  L'asse Y verde dell'UCS ora ben visibile sopra l'overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:38:47 +02:00
Adriano d52d0d0489 merge: precisione rotazione + default Nessuna 2026-05-05 12:32:17 +02:00
Adriano 9451a418a6 fix: precisione rotazione +UI simmetria default Nessuna
Precisione rotazione:
- _refine_angle: tol 0.1 -> 0.02 deg, 8 -> 16 iter golden-section
- search_radius default = step pieno (era step/2): copre il caso peggiore
  in cui il picco vero e' all'estremo del bin angolare grezzo
- Aggiunto parabolic fit finale sui 3 punti vicini al best (precisione
  <0.01 deg quando lo score map e' smooth attorno al picco)

Default UI:
- Simmetria "Nessuna" come default (era "Invariante" che limitava
  matching a una singola pose - confondente per l'operatore tipico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:32:17 +02:00
Adriano 2c9160e4be merge: perf profile/bench/prune 2026-05-05 12:25:15 +02:00
Adriano 6d6dcc3b7a feat: profile mode + bench suite + skip-bin-vuoti + variant pruning histogram
4 ottimizzazioni performance + visibilita':

GGG. find(profile=True) → timing per fase
- _checkpoint() registra ms tra: to_gray, spread_top, top_pruning,
  full_kernel, refine_verify_nms
- get_last_profile() ritorna dict ms per identificare bottleneck
- Costo runtime trascurabile (~5 us per call)

HHH. pm2d.bench - benchmark suite eseguibile
- 3 scenarios (rect/L/circle x scene clean/cluttered)
- 5 configs (baseline, polarity, propagate, greedy, stride)
- Auto-aggiunge gpu_umat se opencl_available()
- Tabella ms/find + profile per ogni combo
- Entry-point pm2d-bench (--quick per smoke test 2 iter)

XX. Skip dilate per bin vuoti in _spread_bitmap
- Pre-calcolo bin presenti via np.unique sui pixel valid
- Su scene a bassa varianza orientation skip 50-70% delle dilate
- Misurato benchmark: spread_top da ~0.3ms a ~0.1ms in molti casi

VV. Variant pruning preliminare via histogramma orientation
- Per ogni variante calcolo overlap (feature bins ∩ scene bins) /
  total feature bins
- Se overlap < 0.5 * min_score → skip variante (no kernel call)
- Counter n_variants_pruned_histogram nel diag
- Vantaggio: scene focalizzate (poche direzioni dominanti) skippano
  varianti template con bin assenti dalla scena

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:25:15 +02:00
Adriano ee1c4a8f92 merge: fix edge bordi spuri overlay match 2026-05-05 12:13:07 +02:00
Adriano 5002515b41 fix: rimuove edge spuri sui bordi template warpato (apparivano come ROI)
Bug: per ogni match l'overlay edge del modello includeva anche il
PERIMETRO del template warpato (transizione bordo nero borderValue=0
→ scena = forte gradient artefatto). Con N match si vedevano N
rettangoli verdi attorno ai pezzi, simili a "ROI ripetute".

Fix:
- Warpa anche _train_mask alla pose
- Erode di (2*spread_radius+1) per scartare la fascia di transizione
  bordo che produce gradient spurio
- Maschera edge_mask con warped_mask: solo edge interni al pezzo
  vengono visualizzati

Risultato: overlay edge pulito che mostra solo i veri edge del
modello allineati al pezzo trovato, niente cornici fasulle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:13:07 +02:00
Adriano 8029a1e12b merge: UCS coerente centro pose 2026-05-05 12:04:24 +02:00
Adriano d37833076e fix: UCS coerente sul centro pose, no traslazione fissata sbagliata
L'UCS del match precedentemente proiettava il baricentro feature
template alla pose, ma:
- Il baricentro veniva calcolato da una variante a 0° (v0) i cui dx/dy
  sono offsets relativi al centro PADDED (non al centro template puro)
- _extract_features dipende dai parametri matcher che possono differire
  da quelli del preview se la ricetta e' caricata
- Risultato: UCS appariva con offset costante errato rispetto al centro
  visibile del pezzo

Fix: UCS sul centro POSE del match (m.cx, m.cy) = posizione del centro
template originale nella scena (questo e' esattamente cio' che
_subpixel_peak ritorna). Coerente, prevedibile, "fissato" sul centro
del pezzo.

Per coerenza visiva, anche preview_edges sposta UCS dal baricentro al
CENTRO ROI (rh/2, rw/2). Cosi' il modello mostra UCS nello stesso
identico punto relativo dove apparira' nel match dopo
traslazione+rotazione della pose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:04:24 +02:00
5 changed files with 322 additions and 48 deletions
+179
View File
@@ -0,0 +1,179 @@
"""Benchmark suite per LineShapeMatcher.
Usage:
python -m pm2d.bench [--quick]
Misura tempi find() su 3 template-tipo × 3 scene-tipo × N config:
- Template: rettangolo 80×80, L-shape 120×120, cerchio 150×150
- Scene: pulita 800×600, cluttered 1080×1920, multi-pezzo 1080×1920
- Config: baseline, polarity, gpu, pyramid_propagate, greediness=0.7
Per ogni config stampa: ms/find, ms per fase (profile), n. match.
Output tabellare per detectare regressioni in CI.
"""
from __future__ import annotations
import argparse
import time
import cv2
import numpy as np
from pm2d.line_matcher import LineShapeMatcher, opencl_available
# ---------- Sintetizzatori template/scena ----------
def _tpl_rect() -> np.ndarray:
t = np.zeros((80, 80, 3), np.uint8)
cv2.rectangle(t, (15, 15), (65, 65), (255, 255, 255), 3)
return t
def _tpl_lshape() -> np.ndarray:
t = np.zeros((120, 120, 3), np.uint8)
cv2.rectangle(t, (20, 20), (50, 100), (255, 255, 255), -1)
cv2.rectangle(t, (20, 70), (100, 100), (255, 255, 255), -1)
return t
def _tpl_circle() -> np.ndarray:
t = np.zeros((150, 150, 3), np.uint8)
cv2.circle(t, (75, 75), 60, (255, 255, 255), 4)
return t
def _scene_clean(W: int, H: int, n_pieces: int = 1) -> np.ndarray:
np.random.seed(0)
s = np.zeros((H, W, 3), np.uint8)
for _ in range(n_pieces):
cx = np.random.randint(80, W - 80)
cy = np.random.randint(80, H - 80)
cv2.rectangle(s, (cx - 25, cy - 25), (cx + 25, cy + 25), (255, 255, 255), 3)
return s
def _scene_cluttered(W: int, H: int) -> np.ndarray:
np.random.seed(0)
s = np.random.randint(50, 200, (H, W, 3), np.uint8)
cv2.rectangle(s, (300, 200), (350, 250), (255, 255, 255), 3)
cv2.rectangle(s, (1500, 800), (1550, 850), (255, 255, 255), 3)
return s
# ---------- Single benchmark ----------
def _bench_config(template, scene, config_name: str,
init_kw: dict, find_kw: dict,
n_iter: int = 5) -> dict:
m = LineShapeMatcher(**init_kw)
t0 = time.perf_counter()
n_var = m.train(template)
t_train = time.perf_counter() - t0
# Warmup (Numba JIT)
m.find(scene, **find_kw)
m.find(scene, **find_kw)
# Run
times_ms = []
for _ in range(n_iter):
t0 = time.perf_counter()
matches = m.find(scene, **find_kw)
times_ms.append((time.perf_counter() - t0) * 1000.0)
# Profile (1 iter)
m.find(scene, profile=True, **find_kw)
prof = m.get_last_profile() or {}
return {
"config": config_name,
"n_variants": n_var,
"t_train_s": round(t_train, 3),
"ms_avg": round(float(np.mean(times_ms)), 1),
"ms_min": round(float(np.min(times_ms)), 1),
"ms_max": round(float(np.max(times_ms)), 1),
"n_matches": len(matches),
"profile_ms": {k: round(v, 1) for k, v in prof.items()},
}
# ---------- Suite ----------
CONFIGS = [
("baseline",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2}),
("polarity",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_polarity": True},
{"min_score": 0.4, "verify_threshold": 0.2}),
("propagate",
{"angle_step_deg": 10, "pyramid_levels": 3},
{"min_score": 0.4, "verify_threshold": 0.2,
"pyramid_propagate": True, "propagate_topk": 4}),
("greedy_07",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "greediness": 0.7}),
("stride2",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "coarse_stride": 2}),
]
if opencl_available():
CONFIGS.append(
("gpu_umat",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_gpu": True},
{"min_score": 0.4, "verify_threshold": 0.2})
)
SCENARIOS = [
("rect_80 vs scene_800x600", _tpl_rect, lambda: _scene_clean(800, 600, 1)),
("lshape_120 vs scene_1080x1920_clutter",
_tpl_lshape, lambda: _scene_cluttered(1920, 1080)),
("circle_150 vs scene_clean_3pieces",
_tpl_circle, lambda: _scene_clean(1920, 1080, 3)),
]
def run(quick: bool = False) -> int:
n_iter = 2 if quick else 5
print(f"=== PM2D Benchmark Suite ({len(SCENARIOS)} scenarios x "
f"{len(CONFIGS)} configs, n_iter={n_iter}) ===\n")
rows = []
for sc_name, tpl_fn, scn_fn in SCENARIOS:
template = tpl_fn()
scene = scn_fn()
print(f"--- Scenario: {sc_name} (tpl={template.shape}, "
f"scn={scene.shape}) ---")
for cfg_name, init_kw, find_kw in CONFIGS:
r = _bench_config(template, scene, cfg_name, init_kw, find_kw,
n_iter=n_iter)
r["scenario"] = sc_name
rows.append(r)
prof_str = " ".join(
f"{k}={v:.1f}" for k, v in r["profile_ms"].items()
)
print(f" {cfg_name:14s} {r['ms_avg']:6.1f}ms "
f"(min {r['ms_min']:.1f} max {r['ms_max']:.1f}) "
f"vars={r['n_variants']:3d} "
f"matches={r['n_matches']:2d}")
if prof_str:
print(f" profile: {prof_str}")
print()
print("=== Done ===")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="PM2D benchmark suite")
p.add_argument("--quick", action="store_true",
help="2 iterazioni per config invece di 5 (smoke test)")
args = p.parse_args(argv)
return run(quick=args.quick)
if __name__ == "__main__":
import sys
sys.exit(main())
+94 -6
View File
@@ -736,7 +736,24 @@ class LineShapeMatcher:
nb = self._n_bins
dtype = np.uint16 if nb > 8 else np.uint8
spread = np.zeros((H, W), dtype=dtype)
# XX optimization: skip dilate per bin senza pixel attivi.
# Su scene a bassa varianza orientation (es. pezzi industriali con
# poche direzioni dominanti) tipicamente 50-70% dei bin sono vuoti.
# Pre-calcolo bin presenti via mask globale; per bin assenti niente
# dilate (resta zero nel bitmap).
if isinstance(bins, np.ndarray):
valid_bins = bins[valid] if isinstance(valid, np.ndarray) else None
if valid_bins is not None and valid_bins.size > 0:
bin_present = np.zeros(nb, dtype=bool)
unique_bins = np.unique(valid_bins)
bin_present[unique_bins[unique_bins < nb]] = True
else:
bin_present = np.zeros(nb, dtype=bool)
else:
bin_present = np.ones(nb, dtype=bool)
for b in range(nb):
if not bin_present[b]:
continue # XX: nessun pixel di questo bin sopra weak_grad
mask_b = ((bins == b) & valid).astype(np.uint8)
if self.use_gpu:
d = cv2.dilate(cv2.UMat(mask_b), kernel)
@@ -937,7 +954,7 @@ class LineShapeMatcher:
# variante e' quantizzato a multipli di angle_step (5 deg default).
# Refine angolare e' essenziale per orientamento sub-step.
if search_radius is None:
search_radius = self._effective_angle_step() / 2.0
search_radius = self._effective_angle_step()
h, w = template_gray.shape
sw = max(16, int(round(w * scale)))
@@ -1025,8 +1042,12 @@ class LineShapeMatcher:
# Score all'origine come riferimento (ang offset 0)
s0, cx0_s, cy0_s = _score_at_angle(0.0)
best = (angle_deg, s0, cx0_s, cy0_s)
tol = 0.1 # gradi
for _ in range(8):
# Precisione angolare: 10 iter golden con tol 0.05 deg.
# Compromesso speed/accuracy: il parabolic fit aggiuntivo era
# instabile su score map non-smooth (template simmetrici producono
# multipli local max ravvicinati che lo facevano divergere).
tol = 0.05
for _ in range(10):
if s1 > best[1]:
best = (angle_deg + x1, s1, cx1, cy1)
if s2 > best[1]:
@@ -1284,8 +1305,9 @@ class LineShapeMatcher:
if t is None:
return 1.0
h, w = t.shape
cx_t = (w - 1) / 2.0
cy_t = (h - 1) / 2.0
# Coerente con training (center = diag / 2.0, no -1)
cx_t = w / 2.0
cy_t = h / 2.0
# Bounding box del template ruotato/scalato attorno a (cx, cy)
diag = int(np.ceil(np.hypot(w, h) * scale)) + 8
@@ -1358,6 +1380,7 @@ class LineShapeMatcher:
use_soft_score: bool = False,
subpixel_lm: bool = False,
debug: bool = False,
profile: bool = False,
) -> list[Match]:
"""
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
@@ -1390,6 +1413,7 @@ class LineShapeMatcher:
"drop_recall_low": 0,
"drop_bbox_out_of_scene": 0,
"drop_nms_iou": 0,
"n_variants_pruned_histogram": 0,
"n_final": 0,
"top_thresh_used": 0.0,
"verify_threshold_used": float(verify_threshold),
@@ -1401,7 +1425,21 @@ class LineShapeMatcher:
}
self._last_diag = diag
# GGG: profile mode → timing per fase, esposto via get_last_profile()
import time as _time
prof = {} if profile else None
_t_prev = _time.perf_counter() if profile else 0.0
def _checkpoint(name: str):
nonlocal _t_prev
if prof is None:
return
now = _time.perf_counter()
prof[name] = (now - _t_prev) * 1000.0 # ms
_t_prev = now
self._last_profile = prof
gray_full = self._to_gray(scene_bgr)
_checkpoint("to_gray")
# Applica ROI di ricerca: restringe scena a crop, ricorda offset per
# ri-traslare le coordinate dei match a fine pipeline.
if search_roi is not None:
@@ -1440,6 +1478,7 @@ class LineShapeMatcher:
spread0 = None
bit_active_full = None
density_full = None
_checkpoint("spread_top")
if nms_radius is None:
nms_radius = max(8, min(self.template_size) // 2)
# Pruning adattivo allo step angolare: con step piccolo (<= 3 deg)
@@ -1501,6 +1540,38 @@ class LineShapeMatcher:
end = min(n, i + half + 1)
neighbor_map[vi_c] = vi_sorted[start:end]
# VV: pruning preliminare via overlap istogramma orientation.
# Scene-bins-attivi vs variant-feature-bins. Se la variante ha bin
# dominanti che la scena non possiede → score impossibile, skip
# senza chiamare il kernel. Costo: O(n_variants * 8 ops).
scene_bins = np.array(
[bool((bit_active_top >> b) & 1) for b in range(self._n_bins)],
dtype=bool,
)
if scene_bins.any():
n_scene_active = int(scene_bins.sum())
# Soglia: variante deve avere >= 50% delle sue feature in bin
# presenti nella scena. Sotto = score certamente < 0.5.
pruned_idx_list = []
n_pruned = 0
for vi in coarse_idx_list:
lvl = self.variants[vi].levels[
min(top, len(self.variants[vi].levels) - 1)
]
if len(lvl.bin) == 0:
continue
feat_in_scene = int(np.isin(lvl.bin, np.where(scene_bins)[0]).sum())
ratio = feat_in_scene / len(lvl.bin)
if ratio < 0.5 * min_score:
n_pruned += 1
continue
pruned_idx_list.append(vi)
if n_pruned > 0 and pruned_idx_list:
coarse_idx_list = pruned_idx_list
diag["n_variants_pruned_histogram"] = n_pruned
else:
diag["n_variants_pruned_histogram"] = 0
# Pruning varianti via top-level (parallelizzato).
# coarse_stride > 1: 1 pixel ogni stride (~stride^2 speed-up).
# pyramid_propagate=True: top-K picchi per restringere full-res.
@@ -1596,6 +1667,7 @@ class LineShapeMatcher:
kept_variants: list[tuple[int, float]] = [
(vi, score_by_vi[vi]) for vi in expanded
]
_checkpoint("top_pruning")
if not kept_variants:
return []
@@ -1702,6 +1774,7 @@ class LineShapeMatcher:
raw.sort(key=lambda c: -c[0])
diag["n_raw_candidates"] = len(raw)
_checkpoint("full_kernel")
# Mappa vi → score_map per subpixel/refinement
score_maps = dict(candidates_per_var)
@@ -1757,7 +1830,10 @@ class LineShapeMatcher:
ang_f, score_f, cx_f, cy_f = self._refine_angle(
spread0, bit_active_full, self.template_gray, cx_f, cy_f,
var.angle_deg, var.scale, mask_full,
search_radius=self._effective_angle_step() / 2.0,
# Search radius esteso allo step pieno (era step/2):
# copre il caso peggiore in cui il picco vero e' all'estremo
# del bin angolare della variante grezza.
search_radius=self._effective_angle_step(),
original_score=score,
)
# Halcon SubPixel='least_squares_high': refinement iterativo
@@ -1869,6 +1945,9 @@ class LineShapeMatcher:
if len(kept) >= max_matches:
break
diag["n_final"] = len(kept)
_checkpoint("refine_verify_nms")
if profile:
self._last_profile = prof
if debug:
# Debug mode: stampa diagnostica su stderr per visibilita' immediata.
import sys as _sys
@@ -1892,6 +1971,15 @@ class LineShapeMatcher:
f"final={diag['n_final']} (top_thresh={diag['top_thresh_used']:.2f})"
)
def get_last_profile(self) -> dict | None:
"""Ritorna timing per fase dell'ultimo find(profile=True).
Chiavi: to_gray, spread_top, top_pruning, full_kernel,
refine_verify_nms (millisecondi). Util per identificare bottleneck
dove ottimizzare.
"""
return getattr(self, "_last_profile", None)
def get_last_diag(self) -> dict | None:
"""Ritorna dict diagnostica dell'ultima chiamata find().
+37 -31
View File
@@ -143,17 +143,6 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
nell'anteprima modello.
"""
out = scene.copy()
# Baricentro UCS in coord template (calcolato una volta dal matcher
# se disponibile): mean delle feature di una variante a 0°. Questo e'
# lo stesso baricentro mostrato nell'anteprima modello.
bary_dx = bary_dy = 0.0
if matcher is not None and matcher.variants:
# Trova variante con angle_deg piu vicino a 0
v0 = min(matcher.variants, key=lambda v: abs(v.angle_deg))
if len(v0.levels[0].dx) > 0:
bary_dx = float(np.mean(v0.levels[0].dx))
bary_dy = float(np.mean(v0.levels[0].dy))
# Lunghezza assi UCS: stessa formula dell'anteprima modello
# (0.15 * max lato template) scalata per m.scale → coerenza dimensionale.
if matcher is not None and matcher.template_size != (0, 0):
@@ -161,37 +150,57 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
else:
L_base = 30
H_scene, W_scene = scene.shape[:2]
for i, m in enumerate(matches):
# Proietta baricentro template alla pose del match.
# cv2.getRotationMatrix2D con angle positivo applica:
# new_x = cos*x + sin*y new_y = -sin*x + cos*y
# Visivamente in image y-down e' rotazione anti-clockwise.
# UCS posizionato esattamente sul CENTRO POSE del match (m.cx, m.cy):
# equivale al centro template traslato alla scena, ruotato con
# m.angle_deg. Coerente con UCS dell'anteprima modello che ora
# e' anche sul centro ROI (vedi preview_edges).
ax = np.deg2rad(m.angle_deg)
ca, sa = np.cos(ax), np.sin(ax)
bx_scene = m.cx + (bary_dx * ca + bary_dy * sa) * m.scale
by_scene = m.cy + (-bary_dx * sa + bary_dy * ca) * m.scale
cx, cy = int(round(bx_scene)), int(round(by_scene))
cx, cy = int(round(m.cx)), int(round(m.cy))
# Overlay edge del modello orientato (richiesta utente):
# warpa template alla pose, applica hysteresis identica al matcher,
# disegna pixel edge come overlay verde tenue.
# disegna pixel edge come overlay verde tenue. Maschera col
# _train_mask warpato + erode per rimuovere edge sui BORDI del
# rettangolo template (transizione bordo nero → scena = falso edge
# che appariva come "ROI" attorno a ogni match).
if template_gray is not None and matcher is not None:
t = template_gray
th, tw = t.shape
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
# Centro template coerente col training: in train si usa
# `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
# introduceva uno shift di 0.5px per template di lato pari.
cx_t = tw / 2.0; cy_t = th / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
M[0, 2] += m.cx - cx_t
M[1, 2] += m.cy - cy_t
warped_gray = cv2.warpAffine(
t, M, (W_scene, H_scene),
flags=cv2.INTER_LINEAR, borderValue=0)
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno
mask_src = (matcher._train_mask if matcher._train_mask is not None
else np.full((th, tw), 255, dtype=np.uint8))
warped_mask = cv2.warpAffine(
mask_src, M, (W_scene, H_scene),
flags=cv2.INTER_NEAREST, borderValue=0)
# Erode minimo (3x3) per togliere SOLO artefatti border-padding
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
# grande spostava visualmente l'edge verso l'interno e creava
# apparente "traslazione fissa" rispetto al bordo del pezzo.
kernel_er = np.ones((3, 3), np.uint8)
warped_mask = cv2.erode(warped_mask, kernel_er)
mag, _ = matcher._gradient(warped_gray)
if matcher.weak_grad < matcher.strong_grad:
edge_mask = matcher._hysteresis_mask(mag)
else:
edge_mask = mag >= matcher.strong_grad
edge_mask = edge_mask & (warped_mask > 0)
if edge_mask.any():
edge_overlay = np.zeros_like(out)
edge_overlay[edge_mask] = (0, 220, 0) # verde brillante
# Ciano (cambiato da verde): non collide col verde dell'asse
# Y dell'UCS che altrimenti scompariva nell'overlay edge.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR)
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0)
L = max(20, int(L_base * m.scale))
# X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin)
@@ -763,26 +772,23 @@ def preview_edges(p: EdgePreviewParams):
b = int(fb[i])
col = bin_colors[b % len(bin_colors)]
cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA)
# UCS sul baricentro feature (richiesta utente): assi X rosso, Y verde
bary_cx = bary_cy = None
if len(fx) > 0:
bary_cx = float(np.mean(fx))
bary_cy = float(np.mean(fy))
bx, by = int(round(bary_cx)), int(round(bary_cy))
axis_len = max(20, int(0.15 * max(out.shape[:2])))
# X axis (rosso, verso destra)
# UCS sul CENTRO ROI (coerente con _draw_matches che usa centro pose).
# In questo modo l'UCS visualizzato nel modello = UCS del match (modulo
# rotazione/traslazione data dalla pose del pezzo trovato).
rh, rw = roi_img.shape[:2]
bx, by = (rw - 1) // 2, (rh - 1) // 2
axis_len = max(20, int(0.15 * max(rw, rh)))
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
# Y axis (verde, verso il basso = convenzione image y-down)
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# Origine: cerchio bianco con bordo nero
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA)
bary_cx, bary_cy = float(bx), float(by)
img_id = _store_image(out)
n_edge_strong = int((mag >= m.strong_grad).sum())
n_edge_total = int(edge_mask.sum() / 255)
+1 -1
View File
@@ -102,8 +102,8 @@
<div class="field">
<label>Simmetria</label>
<select id="p-simmetria">
<option value="nessuna" selected>Nessuna (0..360°)</option>
<option value="invariante">Invariante (cerchi — no rotazione)</option>
<option value="nessuna">Nessuna (0..360°)</option>
<option value="bilaterale">Bilaterale (speculare 180°)</option>
<option value="rot_3">Rotazionale 3× (120°)</option>
<option value="rot_4">Rotazionale 4× (90°)</option>
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
[project.scripts]
pm2d-eval = "pm2d.eval:main"
pm2d-bench = "pm2d.bench:main"
[dependency-groups]
dev = [