Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e148667ec | |||
| b5bbca0e85 | |||
| ca3882c59c | |||
| 7f6571bdd1 | |||
| 7cb1ae2df7 | |||
| 6ebb08e7a2 | |||
| eba9d478a7 | |||
| 0df0d98aa5 | |||
| b2b959e801 | |||
| b05246b492 | |||
| aeaa7fb5f7 | |||
| f347a10fad | |||
| 0b24be4d94 | |||
| 0296083e3c | |||
| 39208aadab | |||
| 2b7ee6799c | |||
| 5059ce1d89 | |||
| f05dec5183 | |||
| f8f6a15166 | |||
| 5bd8fca248 | |||
| 796ccb8052 | |||
| 0a8a9365bb | |||
| 9ed779637e | |||
| 077d44c3c8 | |||
| e038ee3a1d | |||
| 84b73dc651 | |||
| 8d8a89ac35 |
+89
-4
@@ -328,6 +328,65 @@ if HAS_NUMBA:
|
||||
out[vi] = best if best > 0.0 else 0.0
|
||||
return out
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_score_bitmap_rescored_u16(
|
||||
spread: np.ndarray, # uint16 (H, W) - 16 bit di polarity-aware
|
||||
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: np.uint16,
|
||||
bg: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Versione uint16 di _jit_score_bitmap_rescored per polarity 16-bin.
|
||||
|
||||
Identica logica ma mask = uint16(1) << b dove b in [0..15]
|
||||
(orientamento mod 2π invece di mod π).
|
||||
"""
|
||||
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.uint16(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)
|
||||
def _jit_popcount_density_u16(spread: np.ndarray) -> np.ndarray:
|
||||
"""Popcount per uint16 (16 bin polarity)."""
|
||||
H, W = spread.shape
|
||||
out = np.zeros((H, W), dtype=np.float32)
|
||||
for y in nb.prange(H):
|
||||
for x in range(W):
|
||||
v = spread[y, x]
|
||||
cnt = 0
|
||||
for b in range(16):
|
||||
if v & (np.uint16(1) << b):
|
||||
cnt += 1
|
||||
out[y, x] = float(cnt)
|
||||
return out
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_popcount_density(spread: np.ndarray) -> np.ndarray:
|
||||
"""Conta bit set per pixel: ritorna (H, W) float32 in [0..8]."""
|
||||
@@ -368,6 +427,11 @@ if HAS_NUMBA:
|
||||
spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx,
|
||||
)
|
||||
_jit_popcount_density(spread)
|
||||
spread16 = np.zeros((32, 32), dtype=np.uint16)
|
||||
_jit_score_bitmap_rescored_u16(
|
||||
spread16, dx, dy, b, np.uint16(0xFFFF), bg,
|
||||
)
|
||||
_jit_popcount_density_u16(spread16)
|
||||
|
||||
else: # pragma: no cover
|
||||
|
||||
@@ -392,6 +456,12 @@ else: # pragma: no cover
|
||||
):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_score_bitmap_rescored_u16(spread, dx, dy, bins, bit_active, bg):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_popcount_density_u16(spread):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_popcount_density(spread):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
@@ -426,16 +496,20 @@ def score_bitmap_rescored(
|
||||
) -> 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.
|
||||
Dispatch per dtype: uint16 → kernel polarity 16-bin, uint8 → kernel
|
||||
standard 8-bin (con eventuale stride > 1 per coarse top-level).
|
||||
"""
|
||||
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 spread.dtype == np.uint16:
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
|
||||
return _jit_score_bitmap_rescored_u16(
|
||||
spread_c, dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
|
||||
)
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
|
||||
if stride > 1:
|
||||
return _jit_score_bitmap_rescored_strided(
|
||||
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
|
||||
@@ -528,6 +602,17 @@ def popcount_density(spread: np.ndarray) -> np.ndarray:
|
||||
2) numpy.bitwise_count (NumPy 2.0+, SIMD ma single-thread)
|
||||
3) Fallback numpy bit-shift puro
|
||||
"""
|
||||
if spread.dtype == np.uint16:
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
|
||||
if HAS_NUMBA:
|
||||
return _jit_popcount_density_u16(spread_c)
|
||||
if _HAS_NP_BITCOUNT:
|
||||
return np.bitwise_count(spread_c).astype(np.float32, copy=False)
|
||||
H, W = spread_c.shape
|
||||
out = np.zeros((H, W), dtype=np.float32)
|
||||
for b in range(16):
|
||||
out += ((spread_c >> b) & 1).astype(np.float32)
|
||||
return out
|
||||
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
|
||||
if HAS_NUMBA:
|
||||
return _jit_popcount_density(spread_c)
|
||||
|
||||
@@ -152,11 +152,103 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _self_validate(template_bgr: np.ndarray, params: dict,
|
||||
mask: np.ndarray | None = None) -> dict:
|
||||
"""Halcon-style self-validation: train il matcher coi parametri tentativi
|
||||
e verifica che il template stesso sia trovato con recall ≥ 1.0.
|
||||
|
||||
Se recall < target o score basso, regola i parametri:
|
||||
- alza weak_grad se troppi edge spuri (recall solido ma molti picchi falsi)
|
||||
- abbassa strong_grad se troppe feature scartate (low feature count)
|
||||
- riduce pyramid_levels se variants[0].levels[top] ha <8 feature
|
||||
|
||||
Halcon usa internamente questo loop in inspect_shape_model. Costo: 1
|
||||
train + 1 find sul template (~50ms su template 100x100). Ne vale la
|
||||
pena se evita match-time errors su scene reali.
|
||||
|
||||
Mutates `params` in place e ritorna lo stesso dict per chaining.
|
||||
"""
|
||||
# Import lazy: evita ciclo (line_matcher importa nulla da auto_tune)
|
||||
from pm2d.line_matcher import LineShapeMatcher
|
||||
|
||||
# Caso degenerato: troppe poche feature pre-validation → riduci soglia
|
||||
if params.get("_n_strong_pixels", 0) < 30:
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.6)
|
||||
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.6)
|
||||
|
||||
# Train minimale: 1 sola pose orientazione 0 (range degenerato che
|
||||
# produce comunque 1 variante via fallback in _angle_list).
|
||||
m = LineShapeMatcher(
|
||||
num_features=params["num_features"],
|
||||
weak_grad=params["weak_grad"],
|
||||
strong_grad=params["strong_grad"],
|
||||
angle_range_deg=(0.0, 0.0), # fallback _angle_list = [0.0]
|
||||
angle_step_deg=10.0,
|
||||
scale_range=(1.0, 1.0),
|
||||
spread_radius=params["spread_radius"],
|
||||
pyramid_levels=params["pyramid_levels"],
|
||||
)
|
||||
n_var = m.train(template_bgr, mask=mask)
|
||||
if n_var == 0:
|
||||
# Soglie troppo alte: nessuna variante generata → dimezza
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.5)
|
||||
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.5)
|
||||
params["_validation"] = "fallback: soglie dimezzate (no variants)"
|
||||
return params
|
||||
|
||||
# Verifica densita' feature al top-level (rischio collasso)
|
||||
top_lvl = m.variants[0].levels[-1]
|
||||
if top_lvl.n < 8 and params["pyramid_levels"] > 1:
|
||||
params["pyramid_levels"] = max(1, params["pyramid_levels"] - 1)
|
||||
params["_validation"] = (
|
||||
f"pyramid_levels ridotto a {params['pyramid_levels']} "
|
||||
f"(top aveva {top_lvl.n} feature)"
|
||||
)
|
||||
return params
|
||||
|
||||
# Self-find: cerca il template stesso nella propria immagine
|
||||
h, w = template_bgr.shape[:2]
|
||||
# Embed template in scena leggermente più grande per evitare bordo
|
||||
pad = 20
|
||||
canvas = np.full(
|
||||
(h + 2 * pad, w + 2 * pad, 3 if template_bgr.ndim == 3 else 1),
|
||||
128, dtype=np.uint8,
|
||||
)
|
||||
canvas[pad:pad + h, pad:pad + w] = template_bgr
|
||||
matches = m.find(
|
||||
canvas, min_score=0.3, max_matches=5,
|
||||
verify_ncc=False, # template stesso → NCC = 1 sempre, skip per velocita'
|
||||
refine_angle=False, subpixel=False,
|
||||
nms_iou_threshold=0.3,
|
||||
)
|
||||
if not matches:
|
||||
# Nessun match sul proprio template: parametri troppo restrittivi
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.7)
|
||||
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.7)
|
||||
params["num_features"] = max(48, int(params["num_features"] * 0.8))
|
||||
params["_validation"] = "soglie/feature ridotte (no self-match)"
|
||||
return params
|
||||
|
||||
# Misura score top match
|
||||
top_score = float(matches[0].score)
|
||||
params["_self_score"] = round(top_score, 3)
|
||||
if top_score < 0.7:
|
||||
# Score basso sul template stesso = parametri davvero subottimali
|
||||
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.85)
|
||||
params["_validation"] = (
|
||||
f"weak_grad ridotto (self-score era {top_score:.2f})"
|
||||
)
|
||||
else:
|
||||
params["_validation"] = f"OK (self-score {top_score:.2f})"
|
||||
return params
|
||||
|
||||
|
||||
def auto_tune(
|
||||
template_bgr: np.ndarray,
|
||||
mask: np.ndarray | None = None,
|
||||
angle_tolerance_deg: float | None = None,
|
||||
angle_center_deg: float = 0.0,
|
||||
self_validate: bool = True,
|
||||
) -> dict:
|
||||
"""Analizza template e ritorna dict parametri suggeriti.
|
||||
|
||||
@@ -168,6 +260,11 @@ def auto_tune(
|
||||
meccanico): training molto piu rapido (24x meno varianti per
|
||||
tol=15° vs 360° pieno).
|
||||
|
||||
self_validate: se True (default), dopo la stima dei parametri
|
||||
esegue un dry-run del matching sul template stesso e regola
|
||||
weak_grad/strong_grad/pyramid_levels se i parametri tentativi
|
||||
non garantiscono auto-match (Halcon-style inspect_shape_model).
|
||||
|
||||
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
|
||||
"""
|
||||
ck = _cache_key(template_bgr, mask)
|
||||
@@ -265,7 +362,15 @@ def auto_tune(
|
||||
"_symmetry_order": sym["order"],
|
||||
"_symmetry_conf": round(sym["confidence"], 2),
|
||||
"_orient_entropy": round(stats["orient_entropy"], 2),
|
||||
"_n_strong_pixels": stats["n_strong"],
|
||||
}
|
||||
# Halcon-style self-validation: dry-run training+find sul template per
|
||||
# auto-correggere parametri tentativi che non garantirebbero match.
|
||||
if self_validate:
|
||||
result = _self_validate(template_bgr, result, mask=mask)
|
||||
# Round numerici dopo eventuali aggiustamenti
|
||||
result["weak_grad"] = round(result["weak_grad"], 1)
|
||||
result["strong_grad"] = round(result["strong_grad"], 1)
|
||||
# Store in LRU cache
|
||||
_TUNE_CACHE[ck] = dict(result)
|
||||
_TUNE_CACHE.move_to_end(ck)
|
||||
|
||||
+633
-34
@@ -46,7 +46,54 @@ from pm2d._jit_kernels import (
|
||||
HAS_NUMBA,
|
||||
)
|
||||
|
||||
N_BINS = 8 # orientamenti quantizzati modulo π
|
||||
N_BINS = 8 # default: orientamento mod π (no 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(
|
||||
@@ -103,6 +150,11 @@ class _Variant:
|
||||
kw: int
|
||||
cx_local: float # centro-modello dentro al bbox kernel
|
||||
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:
|
||||
@@ -122,6 +174,8 @@ class LineShapeMatcher:
|
||||
pyramid_levels: int = 2,
|
||||
top_score_factor: float = 0.5,
|
||||
n_threads: int | None = None,
|
||||
use_polarity: bool = False,
|
||||
use_gpu: bool = False,
|
||||
) -> None:
|
||||
self.num_features = num_features
|
||||
self.weak_grad = weak_grad
|
||||
@@ -135,12 +189,28 @@ class LineShapeMatcher:
|
||||
self.pyramid_levels = max(1, pyramid_levels)
|
||||
self.top_score_factor = top_score_factor
|
||||
self.n_threads = n_threads or max(1, (os.cpu_count() or 2) - 1)
|
||||
# Polarity-aware: 16 bin (orientamento mod 2π) usando bitmap uint16.
|
||||
# Distingue edge "chiaro→scuro" da "scuro→chiaro" → 2x selettività.
|
||||
# Usare quando background di scena varia (chiaro/scuro) e orientamento
|
||||
# template e' direzionale.
|
||||
self.use_polarity = use_polarity
|
||||
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.template_size: tuple[int, int] = (0, 0)
|
||||
self.template_gray: np.ndarray | None = None
|
||||
# Maschera usata in training (propagata al refine per coerenza).
|
||||
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 -------------------------------------------------------
|
||||
|
||||
@@ -150,24 +220,70 @@ class LineShapeMatcher:
|
||||
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def _gradient(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)
|
||||
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
|
||||
mag = cv2.magnitude(gx, gy)
|
||||
ang = np.arctan2(gy, gx)
|
||||
# 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) # [-π, π]
|
||||
if self.use_polarity:
|
||||
# Mod 2π: bin 0..15 codifica direzione + polarity edge.
|
||||
ang_full = np.where(ang < 0, ang + 2.0 * np.pi, ang)
|
||||
bins = np.floor(ang_full / (2.0 * np.pi) * N_BINS_POL).astype(np.int16)
|
||||
bins = np.clip(bins, 0, N_BINS_POL - 1)
|
||||
else:
|
||||
ang_mod = np.where(ang < 0, ang + np.pi, ang)
|
||||
bins = np.floor(ang_mod / np.pi * N_BINS).astype(np.int16)
|
||||
bins = np.clip(bins, 0, N_BINS - 1)
|
||||
return mag, bins
|
||||
|
||||
def _hysteresis_mask(self, mag: np.ndarray) -> np.ndarray:
|
||||
"""Edge mask con hysteresis (Halcon Contrast='auto' two-threshold).
|
||||
|
||||
Procedura:
|
||||
1. seed = pixel con mag >= strong_grad (edge nitidi)
|
||||
2. weak = pixel con mag >= weak_grad (edge candidati)
|
||||
3. Espande seed dentro weak via componenti connesse 8-vicini
|
||||
|
||||
Risultato: edge debole connesso a edge forte viene PROMOSSO a
|
||||
feature valida; edge debole isolato (rumore) viene SCARTATO.
|
||||
|
||||
Riduce sia falsi-positivi (rumore puro) sia falsi-negativi
|
||||
(continuita' interrotta su edge sottili a basso contrasto).
|
||||
"""
|
||||
weak = (mag >= self.weak_grad).astype(np.uint8)
|
||||
strong = (mag >= self.strong_grad).astype(np.uint8)
|
||||
# connectedComponentsWithStats su weak: per ogni componente,
|
||||
# se contiene almeno un pixel strong → tutto componente accettato
|
||||
n_lab, labels = cv2.connectedComponents(weak, connectivity=8)
|
||||
if n_lab <= 1:
|
||||
return strong.astype(bool)
|
||||
# Label dei pixel strong: marker per componenti da accettare
|
||||
strong_labels = np.unique(labels[strong > 0])
|
||||
strong_labels = strong_labels[strong_labels > 0] # 0 = bg
|
||||
if len(strong_labels) == 0:
|
||||
return strong.astype(bool)
|
||||
# Mask = appartiene a label di componente "promosso"
|
||||
keep = np.isin(labels, strong_labels)
|
||||
return keep
|
||||
|
||||
def _extract_features(
|
||||
self, mag: np.ndarray, bins: np.ndarray, mask: np.ndarray | None,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
if mask is not None:
|
||||
mag = np.where(mask > 0, mag, 0)
|
||||
strong = mag >= self.strong_grad
|
||||
ys, xs = np.where(strong)
|
||||
# Halcon-style edge selection: hysteresis tra weak_grad e strong_grad.
|
||||
# Edge weak connessi a edge strong sono inclusi (continuita' bordi).
|
||||
# Se weak_grad >= strong_grad → fallback a soglia singola strong.
|
||||
if self.weak_grad < self.strong_grad:
|
||||
edge = self._hysteresis_mask(mag)
|
||||
else:
|
||||
edge = mag >= self.strong_grad
|
||||
ys, xs = np.where(edge)
|
||||
if len(xs) == 0:
|
||||
return (np.zeros(0, np.int32),) * 3
|
||||
vals = mag[ys, xs]
|
||||
@@ -192,6 +308,120 @@ class LineShapeMatcher:
|
||||
np.array(picked_y, np.int32),
|
||||
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:
|
||||
@@ -280,8 +510,60 @@ class LineShapeMatcher:
|
||||
self._train_mask = mask_full.copy()
|
||||
|
||||
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.
|
||||
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():
|
||||
sw = max(16, int(round(w * s)))
|
||||
sh = max(16, int(round(h * s)))
|
||||
@@ -335,9 +617,8 @@ class LineShapeMatcher:
|
||||
levels=levels,
|
||||
kh=kh, kw=kw,
|
||||
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:
|
||||
"""Rimuove varianti con feature-set identico (post-quantizzazione).
|
||||
@@ -389,20 +670,32 @@ class LineShapeMatcher:
|
||||
return raw
|
||||
|
||||
def _spread_bitmap(self, gray: np.ndarray) -> np.ndarray:
|
||||
"""Spread bitmap uint8: bit b acceso dove bin b è presente nel raggio.
|
||||
"""Spread bitmap: bit b acceso dove bin b è presente nel raggio.
|
||||
|
||||
Formato compatto 32× più denso della response map (N_BINS, H, W) float32.
|
||||
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
|
||||
k = 2 * self.spread_radius + 1
|
||||
kernel = np.ones((k, k), dtype=np.uint8)
|
||||
H, W = gray.shape
|
||||
spread = np.zeros((H, W), dtype=np.uint8)
|
||||
for b in range(N_BINS):
|
||||
H, W = (gray.shape if isinstance(gray, np.ndarray)
|
||||
else (gray.get().shape[0], gray.get().shape[1]))
|
||||
nb = self._n_bins
|
||||
dtype = np.uint16 if nb > 8 else np.uint8
|
||||
spread = np.zeros((H, W), dtype=dtype)
|
||||
for b in range(nb):
|
||||
mask_b = ((bins == b) & valid).astype(np.uint8)
|
||||
d = cv2.dilate(mask_b, kernel)
|
||||
spread |= (d << b)
|
||||
if self.use_gpu:
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
@@ -652,9 +945,10 @@ class LineShapeMatcher:
|
||||
x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1
|
||||
sh_w = y_hi - y_lo; sw_w = x_hi - x_lo
|
||||
acc = np.zeros((sh_w, sw_w), dtype=np.float32)
|
||||
spread_dtype = spread0.dtype.type
|
||||
for i in range(len(dx)):
|
||||
ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i])
|
||||
bit = np.uint8(1 << b)
|
||||
bit = spread_dtype(1 << b)
|
||||
sy0 = y_lo + ddy; sy1 = y_hi + ddy
|
||||
sx0 = x_lo + ddx; sx1 = x_hi + ddx
|
||||
a_y0 = max(0, -sy0); a_y1 = sh_w - max(0, sy1 - H)
|
||||
@@ -703,9 +997,230 @@ class LineShapeMatcher:
|
||||
s2, cx2, cy2 = _score_at_angle(x2)
|
||||
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(
|
||||
self, scene_gray: np.ndarray, cx: float, cy: float,
|
||||
angle_deg: float, scale: float,
|
||||
angle_deg: float, scale: float, view_idx: int = 0,
|
||||
) -> float:
|
||||
"""NCC tra template warpato alla pose e scena sottostante.
|
||||
|
||||
@@ -717,9 +1232,9 @@ class LineShapeMatcher:
|
||||
il matcher linemod può dare score alto su texture generiche ma
|
||||
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
|
||||
t = self.template_gray
|
||||
h, w = t.shape
|
||||
cx_t = (w - 1) / 2.0
|
||||
cy_t = (h - 1) / 2.0
|
||||
@@ -744,8 +1259,8 @@ class LineShapeMatcher:
|
||||
t, M, (cw, ch),
|
||||
flags=cv2.INTER_LINEAR, borderValue=0,
|
||||
)
|
||||
if self._train_mask is not None:
|
||||
mask_src = self._train_mask
|
||||
if train_mask is not None:
|
||||
mask_src = train_mask
|
||||
else:
|
||||
mask_src = np.full_like(t, 255)
|
||||
mask_w = cv2.warpAffine(
|
||||
@@ -759,7 +1274,15 @@ class LineShapeMatcher:
|
||||
scn = scn_crop[valid].astype(np.float32)
|
||||
tm = tpl - tpl.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)
|
||||
|
||||
def find(
|
||||
@@ -782,6 +1305,10 @@ class LineShapeMatcher:
|
||||
refine_pose_joint: bool = False,
|
||||
greediness: float = 0.0,
|
||||
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]:
|
||||
"""
|
||||
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
|
||||
@@ -822,12 +1349,25 @@ class LineShapeMatcher:
|
||||
# map float32 → MOLTO più cache-friendly per _score_by_shift.
|
||||
spread_top = self._spread_bitmap(grays[top])
|
||||
bit_active_top = int(
|
||||
sum(1 << b for b in range(N_BINS)
|
||||
if (spread_top & np.uint8(1 << b)).any())
|
||||
sum(1 << b for b in range(self._n_bins)
|
||||
if (spread_top & (spread_top.dtype.type(1) << b)).any())
|
||||
)
|
||||
if nms_radius is None:
|
||||
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
|
||||
density_top = _jit_popcount(spread_top)
|
||||
@@ -859,7 +1399,7 @@ class LineShapeMatcher:
|
||||
|
||||
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)
|
||||
cf = cf_eff
|
||||
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)
|
||||
@@ -980,8 +1520,8 @@ class LineShapeMatcher:
|
||||
# Full-res (parallelizzato) con bitmap
|
||||
spread0 = self._spread_bitmap(gray0)
|
||||
bit_active_full = int(
|
||||
sum(1 << b for b in range(N_BINS)
|
||||
if (spread0 & np.uint8(1 << b)).any())
|
||||
sum(1 << b for b in range(self._n_bins)
|
||||
if (spread0 & (spread0.dtype.type(1) << b)).any())
|
||||
)
|
||||
density_full = _jit_popcount(spread0)
|
||||
for sc in unique_scales:
|
||||
@@ -1118,6 +1658,13 @@ class LineShapeMatcher:
|
||||
search_radius=self._effective_angle_step() / 2.0,
|
||||
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
|
||||
# il calcolo per shape-score gia alti. Default 1.01 = NCC sempre,
|
||||
# piu sicuro contro falsi positivi (lo shape-score satura facile).
|
||||
@@ -1126,10 +1673,37 @@ class LineShapeMatcher:
|
||||
# ranking/visualizzazione (uno score 1.0 vero richiede sia
|
||||
# match shape sia template gray identici).
|
||||
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:
|
||||
continue
|
||||
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.
|
||||
cx_out = cx_f + roi_offset[0]
|
||||
@@ -1137,17 +1711,42 @@ class LineShapeMatcher:
|
||||
poly = _oriented_bbox_polygon(
|
||||
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
|
||||
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)
|
||||
)
|
||||
# NMS post-refine: refine puo spostare il match di nms_radius;
|
||||
# ricontrollo overlap su match gia accettati per evitare
|
||||
# duplicati (stesso oggetto trovato da varianti angolari diverse).
|
||||
# NMS post-refine cross-variant: usa IoU bbox-poligonale invece
|
||||
# di sola distanza centro. Due match orientati diversi ma vicini
|
||||
# (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
|
||||
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
|
||||
break
|
||||
if dup:
|
||||
|
||||
+101
-3
@@ -48,6 +48,10 @@ IMAGES_DIR = Path(_images_dir_raw)
|
||||
if not IMAGES_DIR.is_absolute():
|
||||
IMAGES_DIR = PROJECT_ROOT / IMAGES_DIR
|
||||
|
||||
# Cartella ricette pre-trained (V feature: save/load matcher)
|
||||
RECIPES_DIR = PROJECT_ROOT / "recipes"
|
||||
RECIPES_DIR.mkdir(exist_ok=True)
|
||||
|
||||
from pm2d.line_matcher import LineShapeMatcher, Match
|
||||
from pm2d.auto_tune import auto_tune
|
||||
|
||||
@@ -249,9 +253,9 @@ PRECISION_ANGLE_STEP = {
|
||||
# Un operatore sceglie il livello di rigore, non un numero astratto.
|
||||
FILTRO_FP_MAP = {
|
||||
"off": 0.0, # disabilitato: mantieni tutti i match shape-based
|
||||
"leggero": 0.20, # tollera variazioni intensità/illuminazione forti
|
||||
"medio": 0.35, # default bilanciato (consigliato)
|
||||
"forte": 0.50, # scarta match con intensità molto diversa dal template
|
||||
"leggero": 0.30, # tollera variazioni intensità/illuminazione forti
|
||||
"medio": 0.50, # default bilanciato (consigliato)
|
||||
"forte": 0.70, # scarta match con intensità molto diversa dal template
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +271,20 @@ class SimpleMatchParams(BaseModel):
|
||||
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
|
||||
min_score: float = 0.65
|
||||
max_matches: int = 25
|
||||
# --- Halcon-mode flags (default off = backward compat) ---
|
||||
# Init-time (richiede ri-train se cambiato)
|
||||
use_polarity: bool = False # F: 16 bin orientation mod 2pi
|
||||
use_gpu: bool = False # R: OpenCL UMat (silent fallback)
|
||||
# Find-time (no retrain)
|
||||
min_recall: float = 0.0 # M: filtra match con poche feature combaciate
|
||||
use_soft_score: bool = False # Y: cosine sim continua dei gradients
|
||||
subpixel_lm: bool = False # Z: precisione 0.05 px
|
||||
nms_iou_threshold: float = 0.3 # A: IoU bbox poligonale
|
||||
coarse_stride: int = 1 # sub-sampling top-level (>=1)
|
||||
pyramid_propagate: bool = False # propagazione candidati top->full
|
||||
greediness: float = 0.0 # early-exit kernel (0..1)
|
||||
refine_pose_joint: bool = False # Nelder-Mead 3D (cx, cy, angle)
|
||||
search_roi: list[int] | None = None # [x, y, w, h] limita area
|
||||
|
||||
|
||||
def _simple_to_technical(
|
||||
@@ -526,6 +544,9 @@ def match_simple(p: SimpleMatchParams):
|
||||
tech = _simple_to_technical(p, roi_img)
|
||||
|
||||
key = _matcher_cache_key(roi_img, tech)
|
||||
# Halcon-mode init params: incidono sul training, includere in cache key
|
||||
halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}"
|
||||
key = key + halcon_init_key
|
||||
m = _cache_get_matcher(key)
|
||||
if m is None:
|
||||
m = LineShapeMatcher(
|
||||
@@ -537,17 +558,30 @@ def match_simple(p: SimpleMatchParams):
|
||||
scale_step=tech["scale_step"],
|
||||
spread_radius=tech["spread_radius"],
|
||||
pyramid_levels=tech["pyramid_levels"],
|
||||
use_polarity=p.use_polarity,
|
||||
use_gpu=p.use_gpu,
|
||||
)
|
||||
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
|
||||
search_roi_t = tuple(p.search_roi) if p.search_roi else None
|
||||
t0 = time.time()
|
||||
matches = m.find(
|
||||
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
|
||||
nms_radius=nms, verify_threshold=tech["verify_threshold"],
|
||||
scale_penalty=tech.get("scale_penalty", 0.0),
|
||||
# Halcon-mode flags
|
||||
min_recall=p.min_recall,
|
||||
use_soft_score=p.use_soft_score,
|
||||
subpixel_lm=p.subpixel_lm,
|
||||
nms_iou_threshold=p.nms_iou_threshold,
|
||||
coarse_stride=p.coarse_stride,
|
||||
pyramid_propagate=p.pyramid_propagate,
|
||||
greediness=p.greediness,
|
||||
refine_pose_joint=p.refine_pose_joint,
|
||||
search_roi=search_roi_t,
|
||||
)
|
||||
t_find = time.time() - t0
|
||||
|
||||
@@ -576,6 +610,70 @@ def tune(p: TuneParams):
|
||||
return {k: v for k, v in t.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
# --- V: Save/Load ricette pre-trained ---
|
||||
|
||||
class SaveRecipeParams(BaseModel):
|
||||
model_id: str
|
||||
scene_id: str | None = None
|
||||
roi: list[int]
|
||||
# Riusa stessi param simple per training equivalente
|
||||
tipo: str = "intero"
|
||||
simmetria: str = "nessuna"
|
||||
scala: str = "fissa"
|
||||
precisione: str = "normale"
|
||||
use_polarity: bool = False
|
||||
use_gpu: bool = False
|
||||
name: str # nome file ricetta (no path)
|
||||
|
||||
|
||||
@app.post("/recipes")
|
||||
def save_recipe(p: SaveRecipeParams):
|
||||
"""Allena matcher e salva su disco come ricetta riutilizzabile."""
|
||||
model = _load_image(p.model_id)
|
||||
if model is None:
|
||||
raise HTTPException(404, "Modello non trovato")
|
||||
x, y, w, h = p.roi
|
||||
roi_img = model[y:y + h, x:x + w]
|
||||
sp = SimpleMatchParams(
|
||||
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
|
||||
tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
|
||||
precisione=p.precisione,
|
||||
use_polarity=p.use_polarity, use_gpu=p.use_gpu,
|
||||
)
|
||||
tech = _simple_to_technical(sp, roi_img)
|
||||
m = LineShapeMatcher(
|
||||
num_features=tech["num_features"],
|
||||
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
|
||||
angle_range_deg=(tech["angle_min"], tech["angle_max"]),
|
||||
angle_step_deg=tech["angle_step"],
|
||||
scale_range=(tech["scale_min"], tech["scale_max"]),
|
||||
scale_step=tech["scale_step"],
|
||||
spread_radius=tech["spread_radius"],
|
||||
pyramid_levels=tech["pyramid_levels"],
|
||||
use_polarity=p.use_polarity,
|
||||
use_gpu=p.use_gpu,
|
||||
)
|
||||
m.train(roi_img)
|
||||
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
|
||||
if not safe_name:
|
||||
raise HTTPException(400, "Nome ricetta non valido")
|
||||
if not safe_name.endswith(".npz"):
|
||||
safe_name += ".npz"
|
||||
target = RECIPES_DIR / safe_name
|
||||
m.save_model(str(target))
|
||||
return {"name": safe_name, "size": target.stat().st_size,
|
||||
"n_variants": len(m.variants)}
|
||||
|
||||
|
||||
@app.get("/recipes")
|
||||
def list_recipes():
|
||||
files = []
|
||||
if RECIPES_DIR.is_dir():
|
||||
for f in sorted(RECIPES_DIR.glob("*.npz")):
|
||||
files.append({"name": f.name, "size": f.stat().st_size})
|
||||
return {"files": files, "dir": str(RECIPES_DIR)}
|
||||
|
||||
|
||||
# Mount static
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
+82
-4
@@ -52,6 +52,39 @@ function readUserParams() {
|
||||
document.getElementById("p-penalita-scala").value),
|
||||
min_score: parseFloat(document.getElementById("p-min-score").value),
|
||||
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
|
||||
...readHalconFlags(),
|
||||
};
|
||||
}
|
||||
|
||||
function readHalconFlags() {
|
||||
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
|
||||
const $cb = (id) => document.getElementById(id)?.checked ?? false;
|
||||
const $num = (id, def) => {
|
||||
const v = parseFloat(document.getElementById(id)?.value);
|
||||
return Number.isFinite(v) ? v : def;
|
||||
};
|
||||
const $int = (id, def) => {
|
||||
const v = parseInt(document.getElementById(id)?.value, 10);
|
||||
return Number.isFinite(v) ? v : def;
|
||||
};
|
||||
const roiStr = document.getElementById("hc-search-roi")?.value.trim() ?? "";
|
||||
let search_roi = null;
|
||||
if (roiStr) {
|
||||
const p = roiStr.split(/[ ,;]+/).map((x) => parseInt(x, 10));
|
||||
if (p.length === 4 && p.every((v) => Number.isFinite(v))) search_roi = p;
|
||||
}
|
||||
return {
|
||||
use_polarity: $cb("hc-use-polarity"),
|
||||
use_gpu: $cb("hc-use-gpu"),
|
||||
use_soft_score: $cb("hc-soft-score"),
|
||||
subpixel_lm: $cb("hc-subpixel-lm"),
|
||||
refine_pose_joint: $cb("hc-refine-joint"),
|
||||
pyramid_propagate: $cb("hc-pyr-propagate"),
|
||||
min_recall: $num("hc-min-recall", 0),
|
||||
nms_iou_threshold: $num("hc-nms-iou", 0.3),
|
||||
greediness: $num("hc-greediness", 0),
|
||||
coarse_stride: $int("hc-coarse-stride", 1),
|
||||
search_roi: search_roi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,12 +327,17 @@ async function doMatch() {
|
||||
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]};
|
||||
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];
|
||||
// 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 = {
|
||||
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
||||
angle_min: 0, angle_max: SYM_MAP[user.simmetria] || 360,
|
||||
angle_step: PREC_MAP[user.precisione] || 5,
|
||||
angle_min: 0, angle_max: angMax,
|
||||
angle_step: PREC_MAP[user.precisione] ?? 5,
|
||||
scale_min: smin, scale_max: smax, scale_step: sstep,
|
||||
min_score: user.min_score, max_matches: user.max_matches,
|
||||
num_features: adv.num_features ?? 96,
|
||||
@@ -307,7 +345,7 @@ async function doMatch() {
|
||||
strong_grad: adv.strong_grad ?? 60,
|
||||
spread_radius: adv.spread_radius ?? 5,
|
||||
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,
|
||||
};
|
||||
} else {
|
||||
@@ -362,6 +400,44 @@ function setStatus(s) {
|
||||
}
|
||||
|
||||
// ---------- Init ----------
|
||||
// ---------- V: Save recipe ----------
|
||||
async function saveRecipe() {
|
||||
if (!state.model || !state.roi) {
|
||||
alert("Seleziona modello e disegna ROI prima di salvare la ricetta.");
|
||||
return;
|
||||
}
|
||||
const name = document.getElementById("hc-recipe-name").value.trim();
|
||||
if (!name) {
|
||||
alert("Inserisci un nome per la ricetta.");
|
||||
return;
|
||||
}
|
||||
const user = readUserParams();
|
||||
const body = {
|
||||
model_id: state.model.id,
|
||||
scene_id: state.scene?.id || state.model.id,
|
||||
roi: state.roi,
|
||||
tipo: user.tipo,
|
||||
simmetria: user.simmetria,
|
||||
scala: user.scala,
|
||||
precisione: user.precisione,
|
||||
use_polarity: user.use_polarity,
|
||||
use_gpu: user.use_gpu,
|
||||
name: name,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/recipes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const j = await r.json();
|
||||
alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`);
|
||||
} catch (e) {
|
||||
alert(`Errore salvataggio: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async () => {
|
||||
buildAdvancedForm();
|
||||
setupROI();
|
||||
@@ -389,6 +465,8 @@ window.addEventListener("DOMContentLoaded", async () => {
|
||||
e.target.value = ""; // consente re-upload stesso file
|
||||
});
|
||||
document.getElementById("btn-match").addEventListener("click", doMatch);
|
||||
document.getElementById("btn-save-recipe").addEventListener("click",
|
||||
saveRecipe);
|
||||
const slider = document.getElementById("p-min-score");
|
||||
slider.addEventListener("input", (e) => {
|
||||
document.getElementById("v-score").textContent =
|
||||
|
||||
@@ -129,6 +129,67 @@
|
||||
<input type="number" id="p-max-matches" value="25" min="1" max="200">
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Modalità Halcon</summary>
|
||||
<div class="halcon-grid">
|
||||
<label class="hc-row" title="16-bin orientation polarity-aware (mod 2π)">
|
||||
<input type="checkbox" id="hc-use-polarity">
|
||||
<span>Polarity 16-bin (F)</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Score continuo cos(θ_t-θ_s) invece di bin">
|
||||
<input type="checkbox" id="hc-soft-score">
|
||||
<span>Soft-margin score (Y)</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Sub-pixel refinement gradient field LM">
|
||||
<input type="checkbox" id="hc-subpixel-lm">
|
||||
<span>Sub-pixel LM 0.05 px (Z)</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Refine congiunto Nelder-Mead (cx,cy,θ)">
|
||||
<input type="checkbox" id="hc-refine-joint">
|
||||
<span>Refine pose joint</span>
|
||||
</label>
|
||||
<label class="hc-row" title="Pyramid candidates propagation">
|
||||
<input type="checkbox" id="hc-pyr-propagate">
|
||||
<span>Pyramid propagate</span>
|
||||
</label>
|
||||
<label class="hc-row" title="OpenCL GPU offload (silent fallback CPU)">
|
||||
<input type="checkbox" id="hc-use-gpu">
|
||||
<span>GPU OpenCL (R)</span>
|
||||
</label>
|
||||
|
||||
<div class="hc-row hc-num">
|
||||
<label>Min recall (M)</label>
|
||||
<input type="number" id="hc-min-recall" value="0.0" min="0" max="1" step="0.05">
|
||||
</div>
|
||||
<div class="hc-row hc-num">
|
||||
<label>NMS IoU thr (A)</label>
|
||||
<input type="number" id="hc-nms-iou" value="0.3" min="0" max="1" step="0.05">
|
||||
</div>
|
||||
<div class="hc-row hc-num">
|
||||
<label>Greediness</label>
|
||||
<input type="number" id="hc-greediness" value="0.0" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
<div class="hc-row hc-num">
|
||||
<label>Coarse stride</label>
|
||||
<input type="number" id="hc-coarse-stride" value="1" min="1" max="4" step="1">
|
||||
</div>
|
||||
<div class="hc-row hc-num" style="grid-column:1/-1">
|
||||
<label title="Limita area di ricerca scena: x,y,w,h (vuoto = tutta scena)">
|
||||
Search ROI (x,y,w,h)
|
||||
</label>
|
||||
<input type="text" id="hc-search-roi" placeholder="es. 100,50,800,400">
|
||||
</div>
|
||||
|
||||
<div class="hc-row" style="grid-column:1/-1; border-top:1px solid #444; padding-top:8px">
|
||||
<label>Ricetta pre-trained (V)</label>
|
||||
<div style="display:flex; gap:6px; margin-top:4px">
|
||||
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
|
||||
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Avanzate</summary>
|
||||
<div id="adv-form"></div>
|
||||
|
||||
@@ -156,3 +156,20 @@ footer h2 {
|
||||
}
|
||||
|
||||
#col-model, #col-scene { min-width: 0; }
|
||||
|
||||
/* Halcon-mode panel */
|
||||
.halcon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 12px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.hc-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.hc-row.hc-num {
|
||||
flex-direction: column; align-items: flex-start;
|
||||
}
|
||||
.hc-row.hc-num label { font-size: 11px; color: #aaa; }
|
||||
.hc-row.hc-num input { width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user