faebccb69e
Problema: matcher linemod con solo orientamento gradient può dare score alto
su texture dense/rumore che per caso accumulano orientamenti compatibili.
Esempio: template ruota dentata su scena clip → match a score 0.9 (errati).
Fix in 2 livelli:
1. Background score LOCALE nel find()
- _bg_map(resp, box_size) = densità media bin attivi in bbox template
- Rinormalizza score: s' = max(0, (s - bg) / (1 - bg))
- Annulla contributo di zone sature ma preserva pattern puliti
2. Verify NCC post-hoc
- _verify_ncc(): warpa template alla pose (cx, cy, angle, scale) e
calcola NCC classico su intensità con la scena sottostante
- Threshold di default 0.4 elimina FP con edge orientati casualmente
- Parametro esposto in GUI (verify_threshold)
Rimossa penalty di saturazione nel response_map (ridondante).
Test regression (ruote dentate vs clip, clip vs ruote dentate):
no verify: 12+ falsi positivi con score ~0.7
verify 0.4: 1-2 falsi positivi rimanenti, true positive invariati
verify 0.5: 0 falsi positivi, 1 TP scale piccola perso
Benchmark clip→clip (13 istanze):
full pipeline (Numba + threads + refine + subpix + verify): 1.12s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
7.1 KiB
Python
213 lines
7.1 KiB
Python
"""Auto-tune parametri PM2D da analisi del template.
|
|
|
|
Analizza la ROI del modello e suggerisce valori ragionevoli per i principali
|
|
parametri del `LineShapeMatcher`, tenendo conto di:
|
|
|
|
- **distribuzione magnitude del gradiente** → soglie `weak_grad` / `strong_grad`
|
|
- **numero di edge utili** → `num_features`
|
|
- **dimensione template** → `pyramid_levels`, `spread_radius`
|
|
- **simmetria rotazionale** (autocorrelazione su rotazione) → `angle_range_deg`
|
|
- **entropia orientamenti** → suggerimento `min_score`
|
|
|
|
Ritorna dict con i key esatti del form `edit_params`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
|
|
def _to_gray(img: np.ndarray) -> np.ndarray:
|
|
if img.ndim == 3:
|
|
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
return img
|
|
|
|
|
|
def detect_rotational_symmetry(
|
|
gray: np.ndarray, step_deg: float = 5.0, corr_thresh: float = 0.75,
|
|
) -> dict:
|
|
"""Rileva simmetria rotazionale su edge map (più robusto a sfondo uniforme).
|
|
|
|
Ritorna dict con:
|
|
- order: int, 1=nessuna, 2=180°, 3=120°, 4=90°, 6=60°, 8=45°
|
|
- period_deg: float, periodo minimo di simmetria (360/order)
|
|
- confidence: float [0..1], correlazione minima tra rotazioni equivalenti
|
|
"""
|
|
h, w = gray.shape
|
|
# Usa magnitude gradiente (rotation-invariant rispetto a bg uniforme)
|
|
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).astype(np.float32)
|
|
|
|
center = (w / 2.0, h / 2.0)
|
|
ref = mag
|
|
|
|
correlations: list[tuple[float, float]] = []
|
|
for ang in np.arange(step_deg, 360.0, step_deg):
|
|
M = cv2.getRotationMatrix2D(center, float(ang), 1.0)
|
|
rot = cv2.warpAffine(
|
|
mag, M, (w, h), borderValue=0.0,
|
|
)
|
|
rm = ref - ref.mean()
|
|
rs = rot - rot.mean()
|
|
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
|
|
c = float((rm * rs).sum() / denom)
|
|
correlations.append((float(ang), c))
|
|
|
|
# Candidati simmetria: 2,3,4,6,8 (90/45)
|
|
candidates = [2, 3, 4, 6, 8]
|
|
best_order = 1
|
|
best_conf = 0.0
|
|
for order in candidates:
|
|
period = 360.0 / order
|
|
# Verifica che ALLE rotazioni n*period (n=1..order-1) ci sia alta corr
|
|
corrs = []
|
|
for n in range(1, order):
|
|
target = period * n
|
|
# trova angolo più vicino in correlations
|
|
closest = min(correlations, key=lambda p: abs(p[0] - target))
|
|
if abs(closest[0] - target) > step_deg * 1.5:
|
|
corrs.append(0.0)
|
|
else:
|
|
corrs.append(closest[1])
|
|
conf = min(corrs) if corrs else 0.0
|
|
if conf >= corr_thresh and conf > best_conf:
|
|
best_order = order
|
|
best_conf = conf
|
|
return {
|
|
"order": best_order,
|
|
"period_deg": 360.0 / best_order,
|
|
"confidence": best_conf,
|
|
}
|
|
|
|
|
|
def analyze_gradients(gray: np.ndarray) -> dict:
|
|
"""Statistiche magnitude / orientation gradiente."""
|
|
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)
|
|
|
|
# Percentili magnitude
|
|
p50 = float(np.percentile(mag, 50))
|
|
p80 = float(np.percentile(mag, 80))
|
|
p95 = float(np.percentile(mag, 95))
|
|
mag_max = float(mag.max())
|
|
|
|
# Numero pixel "forti"
|
|
strong_pct = float((mag > p95).sum()) / mag.size
|
|
weak_pct = float((mag > p50).sum()) / mag.size
|
|
|
|
# Entropia orientamenti (solo pixel forti)
|
|
ang = np.arctan2(gy, gx)
|
|
ang_mod = np.where(ang < 0, ang + np.pi, ang)
|
|
mask = mag > p80
|
|
if mask.sum() > 10:
|
|
bins_count, _ = np.histogram(
|
|
ang_mod[mask], bins=16, range=(0, np.pi),
|
|
)
|
|
p = bins_count / (bins_count.sum() + 1e-9)
|
|
ent = float(-np.sum(p * np.log(p + 1e-9)) / np.log(16))
|
|
else:
|
|
ent = 0.0
|
|
|
|
return {
|
|
"p50": p50, "p80": p80, "p95": p95, "mag_max": mag_max,
|
|
"strong_pct": strong_pct, "weak_pct": weak_pct,
|
|
"orient_entropy": ent,
|
|
"n_pixels": mag.size,
|
|
"n_strong": int((mag > p95).sum()),
|
|
}
|
|
|
|
|
|
def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
|
|
"""Analizza template e ritorna dict parametri suggeriti.
|
|
|
|
Chiavi compatibili con edit_params PARAM_SCHEMA.
|
|
"""
|
|
gray = _to_gray(template_bgr)
|
|
h, w = gray.shape
|
|
if mask is not None:
|
|
# Zero fuori maschera per statistiche
|
|
gray_for_stats = np.where(mask > 0, gray, int(np.median(gray))).astype(np.uint8)
|
|
else:
|
|
gray_for_stats = gray
|
|
|
|
stats = analyze_gradients(gray_for_stats)
|
|
sym = detect_rotational_symmetry(gray_for_stats)
|
|
|
|
# Soglie magnitude: usa percentili per robustezza illuminazione.
|
|
# Target: strong_grad ~= valore a percentile 80-90 in assoluto, ma
|
|
# clamp per compatibilità uint8 (Sobel può sforare).
|
|
strong_grad = float(np.clip(stats["p80"], 20.0, 100.0))
|
|
weak_grad = float(np.clip(strong_grad * 0.5, 10.0, 60.0))
|
|
|
|
# num_features: 1 feature ogni ~25 px forti, clamp 48..192
|
|
target_feat = int(np.clip(stats["n_strong"] / 25, 48, 192))
|
|
|
|
# pyramid_levels in base alla dimensione minima
|
|
min_side = min(h, w)
|
|
if min_side < 60:
|
|
pyr = 1
|
|
elif min_side < 120:
|
|
pyr = 2
|
|
elif min_side < 320:
|
|
pyr = 3
|
|
else:
|
|
pyr = 4
|
|
|
|
# spread_radius proporzionale a risoluzione + pyramid (tolleranza ~1% dim)
|
|
spread_radius = int(np.clip(max(3, min_side * 0.02), 3, 8))
|
|
|
|
# angle range ridotto se simmetria rotazionale
|
|
angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0
|
|
|
|
# min_score: se entropia orient alta → template distintivo → soglia alta ok
|
|
# se entropia bassa → template ambiguo → soglia più permissiva
|
|
if stats["orient_entropy"] > 0.75:
|
|
min_score = 0.65
|
|
elif stats["orient_entropy"] > 0.55:
|
|
min_score = 0.55
|
|
else:
|
|
min_score = 0.45
|
|
|
|
# angle step: 5° default; se simmetria, mantengo step ma range ridotto
|
|
angle_step = 5.0
|
|
|
|
return {
|
|
"backend": "line",
|
|
"angle_min": 0.0,
|
|
"angle_max": angle_max,
|
|
"angle_step": angle_step,
|
|
"scale_min": 1.0,
|
|
"scale_max": 1.0,
|
|
"scale_step": 0.1,
|
|
"min_score": round(min_score, 2),
|
|
"max_matches": 25,
|
|
"nms_radius": 0,
|
|
"num_features": target_feat,
|
|
"weak_grad": round(weak_grad, 1),
|
|
"strong_grad": round(strong_grad, 1),
|
|
"spread_radius": spread_radius,
|
|
"pyramid_levels": pyr,
|
|
"verify_threshold": 0.4,
|
|
# meta (non in PARAM_SCHEMA, usato per log)
|
|
"_symmetry_order": sym["order"],
|
|
"_symmetry_conf": round(sym["confidence"], 2),
|
|
"_orient_entropy": round(stats["orient_entropy"], 2),
|
|
}
|
|
|
|
|
|
def summarize(tune: dict) -> str:
|
|
"""Stringa one-line delle scelte principali."""
|
|
so = tune.get("_symmetry_order", 1)
|
|
sc = tune.get("_symmetry_conf", 0)
|
|
ent = tune.get("_orient_entropy", 0)
|
|
return (
|
|
f"sym={so}x (conf={sc:.2f}) entropia={ent:.2f} "
|
|
f"feat={tune['num_features']} pyr={tune['pyramid_levels']} "
|
|
f"grad={tune['weak_grad']:.0f}/{tune['strong_grad']:.0f} "
|
|
f"ang=[0..{tune['angle_max']:.0f}]@{tune['angle_step']:.0f}d "
|
|
f"min_score={tune['min_score']}"
|
|
)
|