Compare commits

...

5 Commits

Author SHA1 Message Date
Adriano 5f0c4542d3 merge: param edge in find+ricetta, match solo UCS 2026-05-05 11:37:00 +02:00
Adriano 29c034fb05 fix: param edge usati anche in find/ricetta + match overlay solo UCS
Due richieste utente:

1. Param di pulizia rumore (weak/strong/num_features/spacing dal pannello
   "Anteprima edge") devono essere usati anche in find e salvati nelle
   ricette. Prima l'utente li regolava ma erano ignorati: il match usava
   sempre i valori auto_tune.

   Fix:
   - SimpleMatchParams.edge_* (4 campi opzionali): None = usa auto_tune,
     valore = override
   - _simple_to_technical applica gli override se presenti, propagati
     a min_feature_spacing nel matcher init
   - Cache key matcher include min_feature_spacing
   - SaveRecipeParams stessi 4 campi: la ricetta salva i param di
     pulizia rumore identici a quelli del preview
   - UI readEdgeOverrides() legge sempre i valori slider ed inietta
     in body sia di /match_simple sia di POST /recipes

2. Match overlay sulla scena: solo UCS (X rosso, Y verde) ruotato
   secondo m.angle_deg, posizionato sul baricentro feature del
   modello (proiettato alla pose). Niente edge filtrati, niente
   cerchietti feature, niente bbox, niente label/score sulla scena
   reale: l'overlay deve essere pulito, gli edge si vedono solo
   nell'anteprima modello.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:37:00 +02:00
Adriano 6fb1efcab8 merge: fix UCS match + feature pre-computate 2026-05-05 11:02:04 +02:00
Adriano 35df4c473c fix: UCS match e numero feature ora coerenti con anteprima modello
Bug visibili da screenshot:
1. UCS match diverso da UCS anteprima modello (centro pose vs baricentro)
2. Numero feature disegnate < di quelle anteprima modello

Cause:
1. Match UCS era posto su (cx, cy) = centro template, mentre l'anteprima
   modello mostra UCS sul baricentro feature (mean fx, fy).
2. _draw_matches estraeva feature dal template warpato → re-quantizza
   gradient su immagine warp+interp, perdendo precisione vs feature
   pre-computate del matcher.

Fix:
- Match.variant_idx: nuovo field con indice variante usata dal find()
- _draw_matches usa lvl0.dx/dy/bin pre-computati invece di re-estrarre:
  * applica delta-rotation (m.angle_deg - var.angle_deg) per refine
    sub-step
  * proietta in scene coords intorno a (m.cx, m.cy)
  * stesso identico set di feature dell'anteprima modello (modulo
    rotazione+traslazione)
- UCS match calcolato sul baricentro delle feature warpate, non su
  (cx, cy) → coerente con UCS anteprima

Fallback (variant_idx == -1, es. ricetta caricata da save_model
prima di questo commit): usa estrazione warpata legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:02:04 +02:00
Adriano 64f2c8b5dc merge: match overlay edges+UCS, no ROI 2026-05-05 10:55:54 +02:00
3 changed files with 98 additions and 77 deletions
+2
View File
@@ -127,6 +127,7 @@ class Match:
scale: float scale: float
score: float score: float
bbox_poly: np.ndarray # (4, 2) float32 - 4 vertici ordinati (ruotato) bbox_poly: np.ndarray # (4, 2) float32 - 4 vertici ordinati (ruotato)
variant_idx: int = -1 # indice variante usata (per overlay coerente)
@dataclass @dataclass
@@ -1863,6 +1864,7 @@ class LineShapeMatcher:
scale=var.scale, scale=var.scale,
score=score_f, score=score_f,
bbox_poly=poly, bbox_poly=poly,
variant_idx=int(vi),
)) ))
if len(kept) >= max_matches: if len(kept) >= max_matches:
break break
+68 -77
View File
@@ -78,6 +78,7 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h.update(roi.tobytes()) h.update(roi.tobytes())
# Solo parametri che influenzano il training # Solo parametri che influenzano il training
relevant = ("num_features", "weak_grad", "strong_grad", relevant = ("num_features", "weak_grad", "strong_grad",
"min_feature_spacing",
"angle_min", "angle_max", "angle_step", "angle_min", "angle_max", "angle_step",
"scale_min", "scale_max", "scale_step", "scale_min", "scale_max", "scale_step",
"spread_radius", "pyramid_levels") "spread_radius", "pyramid_levels")
@@ -133,87 +134,47 @@ def _encode_png(img: np.ndarray) -> bytes:
def _draw_matches(scene: np.ndarray, matches: list[Match], def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None, template_gray: np.ndarray | None,
matcher: "LineShapeMatcher | None" = None) -> np.ndarray: matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
"""Disegna match annotati sulla scena. """Disegna SOLO UCS (richiesta utente) per ogni match trovato.
Se matcher e' passato, usa la stessa pipeline di edge filtering UCS = sistema di coordinate (X rosso, Y verde) posizionato sul
(hysteresis weak/strong_grad) e selezione feature usata in training, baricentro feature del modello, ruotato secondo l'angolo del match.
cosi' l'overlay nel match riflette ESATTAMENTE quello che l'utente Niente edge, niente cerchietti feature, niente bbox: i match sulla
ha visto nel preview "Anteprima edge". Inoltre disegna UCS scena reale devono essere puliti, gli edge filtrati si vedono solo
(asse X rosso, Y verde) sul centro pose del match. nell'anteprima modello.
Senza matcher: fallback Canny (legacy).
""" """
out = scene.copy() out = scene.copy()
H, W = scene.shape[:2] # Baricentro UCS in coord template (calcolato una volta dal matcher
palette = [ # se disponibile): mean delle feature di una variante a 0°. Questo e'
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0), # lo stesso baricentro mostrato nell'anteprima modello.
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255), bary_dx = bary_dy = 0.0
] if matcher is not None and matcher.variants:
bin_colors = [ # Trova variante con angle_deg piu vicino a 0
(255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0), v0 = min(matcher.variants, key=lambda v: abs(v.angle_deg))
(0, 255, 255), (0, 128, 255), (0, 0, 255), (255, 0, 255), if len(v0.levels[0].dx) > 0:
(255, 100, 100), (255, 180, 100), (255, 230, 100), (180, 255, 100), bary_dx = float(np.mean(v0.levels[0].dx))
(100, 255, 200), (100, 180, 255), (180, 100, 255), (255, 100, 200), bary_dy = float(np.mean(v0.levels[0].dy))
]
for i, m in enumerate(matches): for i, m in enumerate(matches):
color = palette[i % len(palette)] # Proietta baricentro template alla pose del match
if template_gray is not None: # (delta-rotation rispetto alla variante a 0)
t = template_gray
th, tw = t.shape
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 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
if matcher is not None:
# Edge filtrati con stessi param matcher (hysteresis)
warped_gray = cv2.warpAffine(
t, M, (W, H), flags=cv2.INTER_LINEAR, borderValue=0)
mag, bins = matcher._gradient(warped_gray)
if matcher.weak_grad < matcher.strong_grad:
edge_mask = matcher._hysteresis_mask(mag)
else:
edge_mask = mag >= matcher.strong_grad
# Background edge filtrati: tinta scura colore match
if edge_mask.any():
bg_overlay = np.zeros_like(out)
dark = tuple(int(c * 0.35) for c in color)
bg_overlay[edge_mask] = dark
out = cv2.addWeighted(out, 1.0, bg_overlay, 0.7, 0)
# Feature scelte: estrazione alla pose, dot colorati per bin
fx, fy, fb = matcher._extract_features(mag, bins, None)
for k in range(len(fx)):
px, py = int(fx[k]), int(fy[k])
if 0 <= px < W and 0 <= py < H:
bcol = bin_colors[int(fb[k]) % len(bin_colors)]
cv2.circle(out, (px, py), 2, bcol, -1, cv2.LINE_AA)
else:
# Legacy Canny
edge = cv2.Canny(t, 50, 150)
warped = cv2.warpAffine(edge, M, (W, H),
flags=cv2.INTER_NEAREST, borderValue=0)
mask = warped > 0
if mask.any():
overlay = np.zeros_like(out)
overlay[mask] = color
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8)
# bbox poly e linea-marker rimossi (richiesta utente "togli la ROI"):
# UCS + edge filtrati gia' identificano pose e orientamento,
# il rettangolo aggiunto era ridondante e copriva il pezzo.
cx, cy = int(round(m.cx)), int(round(m.cy))
# UCS sul centro pose match (richiesta utente: come nell'anteprima
# modello). Asse X rosso destra, Y verde basso (image y-down).
# Lunghezza derivata dalla diagonale bbox per scala-invariante.
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
if L < 10:
L = 30 # fallback se bbox degenere
ax = np.deg2rad(m.angle_deg) ax = np.deg2rad(m.angle_deg)
# X axis ruotato (rosso) 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))
# Lunghezza assi: 30% del lato bbox per essere visibile e scalato
if m.bbox_poly is not None and len(m.bbox_poly) >= 2:
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0]) * 0.4)
else:
L = 40
L = max(20, L)
# X axis (rosso) ruotato secondo angle del match
x_end = (int(cx + L * np.cos(ax)), int(cy - L * np.sin(ax))) x_end = (int(cx + L * np.cos(ax)), int(cy - L * np.sin(ax)))
cv2.arrowedLine(out, (cx, cy), x_end, cv2.arrowedLine(out, (cx, cy), x_end,
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2) (0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (x_end[0] + 4, x_end[1] + 5), cv2.putText(out, "X", (x_end[0] + 4, x_end[1] + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA) cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
# Y axis perpendicolare (verde, +90° in image coords = giu' visivo) # Y axis (verde) perpendicolare; +90° in image coords = giu' visivo
y_end = (int(cx + L * np.cos(ax + np.pi / 2)), y_end = (int(cx + L * np.cos(ax + np.pi / 2)),
int(cy - L * np.sin(ax + np.pi / 2))) int(cy - L * np.sin(ax + np.pi / 2)))
cv2.arrowedLine(out, (cx, cy), y_end, cv2.arrowedLine(out, (cx, cy), y_end,
@@ -223,9 +184,6 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
# Origine UCS: cerchio bianco con bordo nero # Origine UCS: cerchio bianco con bordo nero
cv2.circle(out, (cx, cy), 4, (0, 0, 0), -1, cv2.LINE_AA) cv2.circle(out, (cx, cy), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (cx, cy), 3, (255, 255, 255), -1, cv2.LINE_AA) cv2.circle(out, (cx, cy), 3, (255, 255, 255), -1, cv2.LINE_AA)
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
cv2.putText(out, label, (cx + 12, cy - 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
return out return out
@@ -328,6 +286,15 @@ class SimpleMatchParams(BaseModel):
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1 penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
min_score: float = 0.65 min_score: float = 0.65
max_matches: int = 25 max_matches: int = 25
# --- Override edge da pannello "Anteprima edge" (None = auto_tune) ---
# Quando settati, sovrascrivono i valori derivati da auto_tune e
# vengono usati identici sia nel training del matcher sia nel find.
# Salvati nella ricetta cosi' la stessa pulizia rumore e' replicata
# quando la ricetta viene caricata.
edge_weak_grad: float | None = None
edge_strong_grad: float | None = None
edge_num_features: int | None = None
edge_min_feature_spacing: int | None = None
# --- Halcon-mode flags (default off = backward compat) --- # --- Halcon-mode flags (default off = backward compat) ---
# Init-time (richiede ri-train se cambiato) # Init-time (richiede ri-train se cambiato)
use_polarity: bool = False # F: 16 bin orientation mod 2pi use_polarity: bool = False # F: 16 bin orientation mod 2pi
@@ -376,10 +343,24 @@ def _simple_to_technical(
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1)) smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0) ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0)
# Override edge dal pannello "Anteprima edge" se utente li ha settati.
# Questi sostituiscono i valori auto_tune nel training del matcher,
# garantendo che la selezione edge identica a quella del preview
# venga usata sia in training sia in find.
weak_g = (p.edge_weak_grad if p.edge_weak_grad is not None
else tune["weak_grad"])
strong_g = (p.edge_strong_grad if p.edge_strong_grad is not None
else tune["strong_grad"])
n_feat = (p.edge_num_features if p.edge_num_features is not None
else nf)
min_sp = (p.edge_min_feature_spacing if p.edge_min_feature_spacing is not None
else 3)
return { return {
"num_features": nf, "num_features": n_feat,
"weak_grad": tune["weak_grad"], "weak_grad": weak_g,
"strong_grad": tune["strong_grad"], "strong_grad": strong_g,
"min_feature_spacing": min_sp,
"spread_radius": spread, "spread_radius": spread,
"pyramid_levels": pyr, "pyramid_levels": pyr,
"angle_min": 0.0, "angle_min": 0.0,
@@ -615,6 +596,7 @@ def match_simple(p: SimpleMatchParams):
scale_range=(tech["scale_min"], tech["scale_max"]), scale_range=(tech["scale_min"], tech["scale_max"]),
scale_step=tech["scale_step"], scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"], spread_radius=tech["spread_radius"],
min_feature_spacing=tech.get("min_feature_spacing", 3),
pyramid_levels=tech["pyramid_levels"], pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity, use_polarity=p.use_polarity,
use_gpu=p.use_gpu, use_gpu=p.use_gpu,
@@ -684,6 +666,11 @@ class SaveRecipeParams(BaseModel):
precisione: str = "normale" precisione: str = "normale"
use_polarity: bool = False use_polarity: bool = False
use_gpu: bool = False use_gpu: bool = False
# Override edge dal pannello "Anteprima edge" (None = auto_tune)
edge_weak_grad: float | None = None
edge_strong_grad: float | None = None
edge_num_features: int | None = None
edge_min_feature_spacing: int | None = None
name: str # nome file ricetta (no path) name: str # nome file ricetta (no path)
@@ -801,6 +788,10 @@ def save_recipe(p: SaveRecipeParams):
tipo=p.tipo, simmetria=p.simmetria, scala=p.scala, tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
precisione=p.precisione, precisione=p.precisione,
use_polarity=p.use_polarity, use_gpu=p.use_gpu, use_polarity=p.use_polarity, use_gpu=p.use_gpu,
edge_weak_grad=p.edge_weak_grad,
edge_strong_grad=p.edge_strong_grad,
edge_num_features=p.edge_num_features,
edge_min_feature_spacing=p.edge_min_feature_spacing,
) )
tech = _simple_to_technical(sp, roi_img) tech = _simple_to_technical(sp, roi_img)
m = LineShapeMatcher( m = LineShapeMatcher(
+28
View File
@@ -53,10 +53,34 @@ function readUserParams() {
document.getElementById("p-penalita-scala").value), document.getElementById("p-penalita-scala").value),
min_score: parseFloat(document.getElementById("p-min-score").value), min_score: parseFloat(document.getElementById("p-min-score").value),
max_matches: parseInt(document.getElementById("p-max-matches").value, 10), max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
...readEdgeOverrides(),
...readHalconFlags(), ...readHalconFlags(),
}; };
} }
function readEdgeOverrides() {
// Override edge dal pannello "Anteprima edge". Settati = utente li ha
// toccati (anche se uguali al default attuale). Vengono propagati a
// _simple_to_technical e usati identici sia in training sia in find.
// Inoltre salvati nella ricetta cosi' si replicano al load.
const _v = (id, parser) => {
const el = document.getElementById(id);
if (!el) return null;
const v = parser(el.value);
return Number.isFinite(v) ? v : null;
};
// Sempre passa i valori correnti degli slider: e' la richiesta utente
// che i param di pulizia rumore vengano usati anche nel find/ricetta.
const polCb = document.getElementById("hc-use-polarity");
return {
edge_weak_grad: _v("ep-weak", parseFloat),
edge_strong_grad: _v("ep-strong", parseFloat),
edge_num_features: _v("ep-nf", parseInt),
edge_min_feature_spacing: _v("ep-sp", parseInt),
use_polarity: polCb?.checked || document.getElementById("ep-pol")?.checked,
};
}
function readHalconFlags() { function readHalconFlags() {
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon" // Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
const $cb = (id) => document.getElementById(id)?.checked ?? false; const $cb = (id) => document.getElementById(id)?.checked ?? false;
@@ -716,6 +740,10 @@ async function saveRecipe() {
precisione: user.precisione, precisione: user.precisione,
use_polarity: user.use_polarity, use_polarity: user.use_polarity,
use_gpu: user.use_gpu, use_gpu: user.use_gpu,
edge_weak_grad: user.edge_weak_grad,
edge_strong_grad: user.edge_strong_grad,
edge_num_features: user.edge_num_features,
edge_min_feature_spacing: user.edge_min_feature_spacing,
name: name, name: name,
}; };
try { try {