Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5059ce1d89 |
+97
-114
@@ -226,120 +226,6 @@ 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:
|
||||
@@ -854,6 +740,94 @@ class LineShapeMatcher:
|
||||
s2, cx2, cy2 = _score_at_angle(x2)
|
||||
return best
|
||||
|
||||
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').
|
||||
|
||||
Score = mean(max(0, cos(theta_template - theta_scene))) sulle
|
||||
feature template alla pose, pesato per magnitude scena. Continuo
|
||||
in [0, 1], piu discriminante della metric a bin (Y di "Halcon
|
||||
improvements"): match a leggera rotazione = penalita' graduale
|
||||
invece di on/off bin.
|
||||
|
||||
Polarity:
|
||||
- use_polarity=True: cos(theta_t - theta_s) considera direzione
|
||||
completa (mod 2pi)
|
||||
- use_polarity=False: |cos(theta_t - theta_s)| considera solo
|
||||
orientazione (mod pi)
|
||||
"""
|
||||
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)
|
||||
# Gradient template (continuo, non quantizzato)
|
||||
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)
|
||||
# Estrai posizioni feature alla pose
|
||||
_, bins_t = self._gradient(gray_r)
|
||||
fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r)
|
||||
if len(fx) < 4:
|
||||
return 0.0
|
||||
# Gradient scena (continuo)
|
||||
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(theta_t - theta_s) = (tx*sx + ty*sy) / (tm*sm)
|
||||
cos_sim = (tx * sx + ty * sy) / (tm * sm)
|
||||
if not self.use_polarity:
|
||||
# Mod pi: |cos| considera solo orientazione (no polarity)
|
||||
cos_sim = abs(cos_sim)
|
||||
else:
|
||||
cos_sim = max(0.0, 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 _verify_ncc(
|
||||
self, scene_gray: np.ndarray, cx: float, cy: float,
|
||||
angle_deg: float, scale: float,
|
||||
@@ -942,6 +916,7 @@ class LineShapeMatcher:
|
||||
greediness: float = 0.0,
|
||||
batch_top: bool = False,
|
||||
nms_iou_threshold: float = 0.3,
|
||||
use_soft_score: bool = False,
|
||||
) -> list[Match]:
|
||||
"""
|
||||
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
|
||||
@@ -1303,6 +1278,14 @@ class LineShapeMatcher:
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user