Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d37833076e | |||
| e1ed9206a3 | |||
| e84ae199ac | |||
| 5f0c4542d3 | |||
| 29c034fb05 | |||
| 6fb1efcab8 | |||
| 35df4c473c | |||
| 64f2c8b5dc | |||
| 7e076deb80 | |||
| 852597ed51 | |||
| a78884f950 | |||
| 543ae0f643 | |||
| a12574f3c5 | |||
| 110dc87b08 | |||
| 2bb2cf63cc | |||
| ea6a9163ad | |||
| 1cc7881a51 | |||
| 74a332a2dd | |||
| dae49eb4a3 | |||
| 9218cb2741 | |||
| 159f9089a5 | |||
| b718e81ccf | |||
| d46197a81a | |||
| 37c645984f | |||
| 0e148667ec | |||
| b5bbca0e85 | |||
| ca3882c59c | |||
| 7f6571bdd1 | |||
| 7cb1ae2df7 | |||
| 6ebb08e7a2 | |||
| eba9d478a7 | |||
| 0df0d98aa5 | |||
| b2b959e801 | |||
| b05246b492 | |||
| aeaa7fb5f7 | |||
| f347a10fad | |||
| 0b24be4d94 | |||
| 0296083e3c | |||
| 39208aadab | |||
| 2b7ee6799c | |||
| 5059ce1d89 | |||
| f05dec5183 |
@@ -8,3 +8,5 @@ __pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
models/
|
models/
|
||||||
|
# Ricette pre-trained (generate da utente, non versionare)
|
||||||
|
recipes/*.npz
|
||||||
|
|||||||
@@ -152,11 +152,103 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
|
|||||||
return h.hexdigest()
|
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(
|
def auto_tune(
|
||||||
template_bgr: np.ndarray,
|
template_bgr: np.ndarray,
|
||||||
mask: np.ndarray | None = None,
|
mask: np.ndarray | None = None,
|
||||||
angle_tolerance_deg: float | None = None,
|
angle_tolerance_deg: float | None = None,
|
||||||
angle_center_deg: float = 0.0,
|
angle_center_deg: float = 0.0,
|
||||||
|
self_validate: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Analizza template e ritorna dict parametri suggeriti.
|
"""Analizza template e ritorna dict parametri suggeriti.
|
||||||
|
|
||||||
@@ -168,6 +260,11 @@ def auto_tune(
|
|||||||
meccanico): training molto piu rapido (24x meno varianti per
|
meccanico): training molto piu rapido (24x meno varianti per
|
||||||
tol=15° vs 360° pieno).
|
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).
|
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
|
||||||
"""
|
"""
|
||||||
ck = _cache_key(template_bgr, mask)
|
ck = _cache_key(template_bgr, mask)
|
||||||
@@ -265,7 +362,15 @@ def auto_tune(
|
|||||||
"_symmetry_order": sym["order"],
|
"_symmetry_order": sym["order"],
|
||||||
"_symmetry_conf": round(sym["confidence"], 2),
|
"_symmetry_conf": round(sym["confidence"], 2),
|
||||||
"_orient_entropy": round(stats["orient_entropy"], 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
|
# Store in LRU cache
|
||||||
_TUNE_CACHE[ck] = dict(result)
|
_TUNE_CACHE[ck] = dict(result)
|
||||||
_TUNE_CACHE.move_to_end(ck)
|
_TUNE_CACHE.move_to_end(ck)
|
||||||
|
|||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
"""CLI validation harness per LineShapeMatcher.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m pm2d.eval dataset.json [opzioni]
|
||||||
|
|
||||||
|
Formato dataset (JSON):
|
||||||
|
{
|
||||||
|
"template": "path/to/template.png",
|
||||||
|
"mask": "path/to/mask.png", # opzionale
|
||||||
|
"params": { # opzionali, override su matcher init
|
||||||
|
"use_polarity": true,
|
||||||
|
"angle_step_deg": 5,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"find_params": { # opzionali, passati a find()
|
||||||
|
"min_score": 0.6,
|
||||||
|
"use_soft_score": true,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"scenes": [
|
||||||
|
{
|
||||||
|
"image": "path/to/scene1.png",
|
||||||
|
"ground_truth": [
|
||||||
|
{"cx": 320.0, "cy": 240.0, "angle_deg": 12.0,
|
||||||
|
"scale": 1.0, "tolerance_px": 5.0,
|
||||||
|
"tolerance_deg": 3.0}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Output: report precision/recall/IoU/timing per ogni scena + aggregati.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from pm2d.line_matcher import LineShapeMatcher, _poly_iou, _oriented_bbox_polygon
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image(path: str | Path) -> np.ndarray:
|
||||||
|
img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
||||||
|
if img is None:
|
||||||
|
raise FileNotFoundError(f"Immagine non trovata: {path}")
|
||||||
|
if img.ndim == 2:
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _gt_to_poly(gt: dict, tw: int, th: int) -> np.ndarray:
|
||||||
|
"""Costruisce bbox poligonale per un ground truth."""
|
||||||
|
s = float(gt.get("scale", 1.0))
|
||||||
|
return _oriented_bbox_polygon(
|
||||||
|
float(gt["cx"]), float(gt["cy"]),
|
||||||
|
tw * s, th * s, float(gt["angle_deg"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_to_gt(match, gt: dict, tw: int, th: int,
|
||||||
|
iou_thr: float = 0.3) -> bool:
|
||||||
|
"""True se il match corrisponde al ground truth.
|
||||||
|
|
||||||
|
Criterio: distanza centro <= tolerance_px AND |angle_deg - gt| <= tolerance_deg
|
||||||
|
OR IoU bbox >= iou_thr (fallback per pose con tolerance ampie).
|
||||||
|
"""
|
||||||
|
tol_px = float(gt.get("tolerance_px", 5.0))
|
||||||
|
tol_deg = float(gt.get("tolerance_deg", 3.0))
|
||||||
|
dx = match.cx - float(gt["cx"])
|
||||||
|
dy = match.cy - float(gt["cy"])
|
||||||
|
dist = math.hypot(dx, dy)
|
||||||
|
da = abs((match.angle_deg - float(gt["angle_deg"]) + 180) % 360 - 180)
|
||||||
|
if dist <= tol_px and da <= tol_deg:
|
||||||
|
return True
|
||||||
|
# Fallback IoU
|
||||||
|
poly_gt = _gt_to_poly(gt, tw, th)
|
||||||
|
poly_m = match.bbox_poly
|
||||||
|
if _poly_iou(poly_m, poly_gt) >= iou_thr:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_scene(matcher: LineShapeMatcher, scene_bgr: np.ndarray,
|
||||||
|
gt_list: list[dict], find_params: dict,
|
||||||
|
tw: int, th: int) -> dict:
|
||||||
|
"""Esegue match e calcola TP/FP/FN per una scena."""
|
||||||
|
t0 = time.time()
|
||||||
|
matches = matcher.find(scene_bgr, **find_params)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
gt_matched = [False] * len(gt_list)
|
||||||
|
match_is_tp = [False] * len(matches)
|
||||||
|
iou_per_match = [0.0] * len(matches)
|
||||||
|
for i, m in enumerate(matches):
|
||||||
|
for j, gt in enumerate(gt_list):
|
||||||
|
if gt_matched[j]:
|
||||||
|
continue
|
||||||
|
if _match_to_gt(m, gt, tw, th):
|
||||||
|
gt_matched[j] = True
|
||||||
|
match_is_tp[i] = True
|
||||||
|
# Calcolo IoU per metrica
|
||||||
|
poly_gt = _gt_to_poly(gt, tw, th)
|
||||||
|
iou_per_match[i] = _poly_iou(m.bbox_poly, poly_gt)
|
||||||
|
break
|
||||||
|
tp = sum(match_is_tp)
|
||||||
|
fp = len(matches) - tp
|
||||||
|
fn = len(gt_list) - sum(gt_matched)
|
||||||
|
return {
|
||||||
|
"n_matches": len(matches),
|
||||||
|
"n_gt": len(gt_list),
|
||||||
|
"tp": tp, "fp": fp, "fn": fn,
|
||||||
|
"find_time_s": elapsed,
|
||||||
|
"iou_mean": float(np.mean([i for i, t in zip(iou_per_match, match_is_tp) if t])
|
||||||
|
if tp > 0 else 0.0),
|
||||||
|
"diag": (matcher.get_last_diag()
|
||||||
|
if hasattr(matcher, "get_last_diag") else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run(dataset_path: str, scene_filter: str | None = None,
|
||||||
|
verbose: bool = False) -> dict:
|
||||||
|
"""Esegue eval su dataset, ritorna report aggregato."""
|
||||||
|
dataset_path = Path(dataset_path)
|
||||||
|
base = dataset_path.parent
|
||||||
|
with open(dataset_path) as f:
|
||||||
|
ds = json.load(f)
|
||||||
|
|
||||||
|
template = _load_image(base / ds["template"])
|
||||||
|
mask = None
|
||||||
|
if ds.get("mask"):
|
||||||
|
mask_img = cv2.imread(str(base / ds["mask"]), cv2.IMREAD_GRAYSCALE)
|
||||||
|
if mask_img is not None:
|
||||||
|
mask = (mask_img > 128).astype(np.uint8) * 255
|
||||||
|
init_params = ds.get("params", {})
|
||||||
|
find_params = ds.get("find_params", {})
|
||||||
|
|
||||||
|
matcher = LineShapeMatcher(**init_params)
|
||||||
|
n_var = matcher.train(template, mask=mask)
|
||||||
|
tw, th = matcher.template_size
|
||||||
|
print(f"Template: {ds['template']} ({tw}x{th}), {n_var} varianti")
|
||||||
|
print(f"Param matcher: {init_params}")
|
||||||
|
print(f"Param find: {find_params}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
scenes = ds["scenes"]
|
||||||
|
if scene_filter:
|
||||||
|
scenes = [s for s in scenes if scene_filter in s["image"]]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
tot_tp = tot_fp = tot_fn = 0
|
||||||
|
tot_time = 0.0
|
||||||
|
for sc in scenes:
|
||||||
|
scene = _load_image(base / sc["image"])
|
||||||
|
gt = sc.get("ground_truth", [])
|
||||||
|
result = evaluate_scene(matcher, scene, gt, find_params, tw, th)
|
||||||
|
rows.append({"scene": sc["image"], **result})
|
||||||
|
tot_tp += result["tp"]; tot_fp += result["fp"]; tot_fn += result["fn"]
|
||||||
|
tot_time += result["find_time_s"]
|
||||||
|
prec = result["tp"] / max(1, result["tp"] + result["fp"])
|
||||||
|
rec = result["tp"] / max(1, result["tp"] + result["fn"])
|
||||||
|
line = (f" {sc['image']:30s} "
|
||||||
|
f"TP={result['tp']} FP={result['fp']} FN={result['fn']} "
|
||||||
|
f"P={prec:.2f} R={rec:.2f} "
|
||||||
|
f"IoU={result['iou_mean']:.2f} "
|
||||||
|
f"t={result['find_time_s']*1000:.0f}ms")
|
||||||
|
print(line)
|
||||||
|
if verbose and result["diag"] and hasattr(matcher, "_format_diag"):
|
||||||
|
print(f" diag: {matcher._format_diag(result['diag'])}")
|
||||||
|
|
||||||
|
# Aggregati
|
||||||
|
precision = tot_tp / max(1, tot_tp + tot_fp)
|
||||||
|
recall = tot_tp / max(1, tot_tp + tot_fn)
|
||||||
|
f1 = 2 * precision * recall / max(1e-9, precision + recall)
|
||||||
|
print()
|
||||||
|
print(f"AGGREGATO: precision={precision:.3f} recall={recall:.3f} "
|
||||||
|
f"F1={f1:.3f} TP={tot_tp} FP={tot_fp} FN={tot_fn}")
|
||||||
|
print(f"TIME: total={tot_time:.2f}s avg={tot_time / max(1, len(scenes)) * 1000:.0f}ms/scene")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"precision": precision, "recall": recall, "f1": f1,
|
||||||
|
"tp": tot_tp, "fp": tot_fp, "fn": tot_fn,
|
||||||
|
"total_time_s": tot_time, "n_scenes": len(scenes),
|
||||||
|
"per_scene": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="pm2d-eval: validation harness per LineShapeMatcher"
|
||||||
|
)
|
||||||
|
p.add_argument("dataset", help="JSON dataset (template + scenes + GT)")
|
||||||
|
p.add_argument("--scene-filter", default=None,
|
||||||
|
help="Filtro substring sui nomi scena (debug)")
|
||||||
|
p.add_argument("--verbose", "-v", action="store_true",
|
||||||
|
help="Stampa diag dict per ogni scena")
|
||||||
|
p.add_argument("--out", default=None,
|
||||||
|
help="Salva report JSON su file")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
report = run(args.dataset, scene_filter=args.scene_filter,
|
||||||
|
verbose=args.verbose)
|
||||||
|
if args.out:
|
||||||
|
with open(args.out, "w") as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
print(f"Report salvato: {args.out}")
|
||||||
|
return 0 if report["f1"] > 0.5 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+670
-21
@@ -50,6 +50,31 @@ N_BINS = 8 # default: orientamento mod π (no polarity)
|
|||||||
N_BINS_POL = 16 # use_polarity=True: orientamento mod 2π (con 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:
|
def _poly_iou(p1: np.ndarray, p2: np.ndarray) -> float:
|
||||||
"""IoU tra due poligoni convessi (4 vertici, float32) via cv2.intersectConvexConvex.
|
"""IoU tra due poligoni convessi (4 vertici, float32) via cv2.intersectConvexConvex.
|
||||||
|
|
||||||
@@ -102,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
|
||||||
@@ -125,6 +151,11 @@ class _Variant:
|
|||||||
kw: int
|
kw: int
|
||||||
cx_local: float # centro-modello dentro al bbox kernel
|
cx_local: float # centro-modello dentro al bbox kernel
|
||||||
cy_local: float
|
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:
|
class LineShapeMatcher:
|
||||||
@@ -145,6 +176,7 @@ class LineShapeMatcher:
|
|||||||
top_score_factor: float = 0.5,
|
top_score_factor: float = 0.5,
|
||||||
n_threads: int | None = None,
|
n_threads: int | None = None,
|
||||||
use_polarity: bool = False,
|
use_polarity: bool = False,
|
||||||
|
use_gpu: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.num_features = num_features
|
self.num_features = num_features
|
||||||
self.weak_grad = weak_grad
|
self.weak_grad = weak_grad
|
||||||
@@ -164,12 +196,22 @@ class LineShapeMatcher:
|
|||||||
# template e' direzionale.
|
# template e' direzionale.
|
||||||
self.use_polarity = use_polarity
|
self.use_polarity = use_polarity
|
||||||
self._n_bins = N_BINS_POL if use_polarity else N_BINS
|
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.variants: list[_Variant] = []
|
||||||
self.template_size: tuple[int, int] = (0, 0)
|
self.template_size: tuple[int, int] = (0, 0)
|
||||||
self.template_gray: np.ndarray | None = None
|
self.template_gray: np.ndarray | None = None
|
||||||
# Maschera usata in training (propagata al refine per coerenza).
|
# Maschera usata in training (propagata al refine per coerenza).
|
||||||
self._train_mask: np.ndarray | None = None
|
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 -------------------------------------------------------
|
# --- Helpers -------------------------------------------------------
|
||||||
|
|
||||||
@@ -179,10 +221,15 @@ class LineShapeMatcher:
|
|||||||
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
def _gradient(self, 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)
|
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
|
||||||
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
|
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
|
||||||
mag = cv2.magnitude(gx, gy)
|
mag = cv2.magnitude(gx, gy)
|
||||||
|
# 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) # [-π, π]
|
ang = np.arctan2(gy, gx) # [-π, π]
|
||||||
if self.use_polarity:
|
if self.use_polarity:
|
||||||
# Mod 2π: bin 0..15 codifica direzione + polarity edge.
|
# Mod 2π: bin 0..15 codifica direzione + polarity edge.
|
||||||
@@ -195,13 +242,49 @@ class LineShapeMatcher:
|
|||||||
bins = np.clip(bins, 0, N_BINS - 1)
|
bins = np.clip(bins, 0, N_BINS - 1)
|
||||||
return mag, bins
|
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(
|
def _extract_features(
|
||||||
self, mag: np.ndarray, bins: np.ndarray, mask: np.ndarray | None,
|
self, mag: np.ndarray, bins: np.ndarray, mask: np.ndarray | None,
|
||||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
if mask is not None:
|
if mask is not None:
|
||||||
mag = np.where(mask > 0, mag, 0)
|
mag = np.where(mask > 0, mag, 0)
|
||||||
strong = mag >= self.strong_grad
|
# Halcon-style edge selection: hysteresis tra weak_grad e strong_grad.
|
||||||
ys, xs = np.where(strong)
|
# 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:
|
if len(xs) == 0:
|
||||||
return (np.zeros(0, np.int32),) * 3
|
return (np.zeros(0, np.int32),) * 3
|
||||||
vals = mag[ys, xs]
|
vals = mag[ys, xs]
|
||||||
@@ -226,6 +309,120 @@ class LineShapeMatcher:
|
|||||||
np.array(picked_y, np.int32),
|
np.array(picked_y, np.int32),
|
||||||
np.array(picked_b, np.int8))
|
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(
|
def set_angle_range_around(
|
||||||
self, center_deg: float, tolerance_deg: float,
|
self, center_deg: float, tolerance_deg: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -314,8 +511,62 @@ class LineShapeMatcher:
|
|||||||
self._train_mask = mask_full.copy()
|
self._train_mask = mask_full.copy()
|
||||||
|
|
||||||
self.variants.clear()
|
self.variants.clear()
|
||||||
# Invalida cache feature di refine: il template e cambiato.
|
# Reset view list: template principale = view 0
|
||||||
|
self._view_templates = [(gray.copy(), mask_full.copy())]
|
||||||
|
# Invalida cache: template/param cambiati → spread/feature obsoleti.
|
||||||
self._refine_feat_cache = {}
|
self._refine_feat_cache = {}
|
||||||
|
if hasattr(self, "_scene_cache"):
|
||||||
|
self._scene_cache.clear()
|
||||||
|
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():
|
for s in self._scale_list():
|
||||||
sw = max(16, int(round(w * s)))
|
sw = max(16, int(round(w * s)))
|
||||||
sh = max(16, int(round(h * s)))
|
sh = max(16, int(round(h * s)))
|
||||||
@@ -369,9 +620,8 @@ class LineShapeMatcher:
|
|||||||
levels=levels,
|
levels=levels,
|
||||||
kh=kh, kw=kw,
|
kh=kh, kw=kw,
|
||||||
cx_local=float(cx_local), cy_local=float(cy_local),
|
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:
|
def _dedup_variants(self) -> int:
|
||||||
"""Rimuove varianti con feature-set identico (post-quantizzazione).
|
"""Rimuove varianti con feature-set identico (post-quantizzazione).
|
||||||
@@ -422,23 +672,78 @@ class LineShapeMatcher:
|
|||||||
raw[b] = d.astype(np.float32)
|
raw[b] = d.astype(np.float32)
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
# --- Scene precompute cache (II Halcon-style) -----------------------
|
||||||
|
_SCENE_CACHE_SIZE = 4
|
||||||
|
|
||||||
|
def _scene_cache_key(self, gray: np.ndarray) -> str | None:
|
||||||
|
"""Hash compatto della scena + param che influenzano spread/density.
|
||||||
|
|
||||||
|
Hash su prime 64KB della scena (sufficiente discriminante per
|
||||||
|
scene fotografiche) + parametri matcher rilevanti. None se cache
|
||||||
|
disabilitata (es. scene troppo piccole).
|
||||||
|
"""
|
||||||
|
if gray.size < 100:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import hashlib
|
||||||
|
h = hashlib.md5()
|
||||||
|
sample = gray.tobytes()[:65536]
|
||||||
|
h.update(sample)
|
||||||
|
h.update(f"|{gray.shape}|{gray.dtype}".encode())
|
||||||
|
h.update(
|
||||||
|
f"|{self.weak_grad}|{self.strong_grad}"
|
||||||
|
f"|{self.spread_radius}|{self._n_bins}"
|
||||||
|
f"|{self.pyramid_levels}".encode()
|
||||||
|
)
|
||||||
|
return h.hexdigest()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _scene_cache_get(self, key: str) -> tuple | None:
|
||||||
|
cache = getattr(self, "_scene_cache", None)
|
||||||
|
if cache is None:
|
||||||
|
return None
|
||||||
|
v = cache.get(key)
|
||||||
|
if v is not None:
|
||||||
|
cache.move_to_end(key)
|
||||||
|
return v
|
||||||
|
|
||||||
|
def _scene_cache_put(self, key: str, value: tuple) -> None:
|
||||||
|
from collections import OrderedDict
|
||||||
|
if not hasattr(self, "_scene_cache"):
|
||||||
|
self._scene_cache = OrderedDict()
|
||||||
|
self._scene_cache[key] = value
|
||||||
|
self._scene_cache.move_to_end(key)
|
||||||
|
while len(self._scene_cache) > self._SCENE_CACHE_SIZE:
|
||||||
|
self._scene_cache.popitem(last=False)
|
||||||
|
|
||||||
def _spread_bitmap(self, gray: np.ndarray) -> np.ndarray:
|
def _spread_bitmap(self, gray: np.ndarray) -> np.ndarray:
|
||||||
"""Spread bitmap: bit b acceso dove bin b è presente nel raggio.
|
"""Spread bitmap: bit b acceso dove bin b è presente nel raggio.
|
||||||
|
|
||||||
dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity).
|
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
|
valid = mag >= self.weak_grad
|
||||||
k = 2 * self.spread_radius + 1
|
k = 2 * self.spread_radius + 1
|
||||||
kernel = np.ones((k, k), dtype=np.uint8)
|
kernel = np.ones((k, k), dtype=np.uint8)
|
||||||
H, W = gray.shape
|
H, W = (gray.shape if isinstance(gray, np.ndarray)
|
||||||
|
else (gray.get().shape[0], gray.get().shape[1]))
|
||||||
nb = self._n_bins
|
nb = self._n_bins
|
||||||
dtype = np.uint16 if nb > 8 else np.uint8
|
dtype = np.uint16 if nb > 8 else np.uint8
|
||||||
spread = np.zeros((H, W), dtype=dtype)
|
spread = np.zeros((H, W), dtype=dtype)
|
||||||
for b in range(nb):
|
for b in range(nb):
|
||||||
mask_b = ((bins == b) & valid).astype(np.uint8)
|
mask_b = ((bins == b) & valid).astype(np.uint8)
|
||||||
d = cv2.dilate(mask_b, kernel)
|
if self.use_gpu:
|
||||||
spread |= (d.astype(dtype) << b)
|
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
|
return spread
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -740,9 +1045,230 @@ class LineShapeMatcher:
|
|||||||
s2, cx2, cy2 = _score_at_angle(x2)
|
s2, cx2, cy2 = _score_at_angle(x2)
|
||||||
return best
|
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(
|
def _verify_ncc(
|
||||||
self, scene_gray: np.ndarray, cx: float, cy: float,
|
self, scene_gray: np.ndarray, cx: float, cy: float,
|
||||||
angle_deg: float, scale: float,
|
angle_deg: float, scale: float, view_idx: int = 0,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""NCC tra template warpato alla pose e scena sottostante.
|
"""NCC tra template warpato alla pose e scena sottostante.
|
||||||
|
|
||||||
@@ -754,9 +1280,9 @@ class LineShapeMatcher:
|
|||||||
il matcher linemod può dare score alto su texture generiche ma
|
il matcher linemod può dare score alto su texture generiche ma
|
||||||
sovrapponendo il template gray i pixel non corrispondono.
|
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
|
return 1.0
|
||||||
t = self.template_gray
|
|
||||||
h, w = t.shape
|
h, w = t.shape
|
||||||
cx_t = (w - 1) / 2.0
|
cx_t = (w - 1) / 2.0
|
||||||
cy_t = (h - 1) / 2.0
|
cy_t = (h - 1) / 2.0
|
||||||
@@ -781,8 +1307,8 @@ class LineShapeMatcher:
|
|||||||
t, M, (cw, ch),
|
t, M, (cw, ch),
|
||||||
flags=cv2.INTER_LINEAR, borderValue=0,
|
flags=cv2.INTER_LINEAR, borderValue=0,
|
||||||
)
|
)
|
||||||
if self._train_mask is not None:
|
if train_mask is not None:
|
||||||
mask_src = self._train_mask
|
mask_src = train_mask
|
||||||
else:
|
else:
|
||||||
mask_src = np.full_like(t, 255)
|
mask_src = np.full_like(t, 255)
|
||||||
mask_w = cv2.warpAffine(
|
mask_w = cv2.warpAffine(
|
||||||
@@ -828,6 +1354,10 @@ class LineShapeMatcher:
|
|||||||
greediness: float = 0.0,
|
greediness: float = 0.0,
|
||||||
batch_top: bool = False,
|
batch_top: bool = False,
|
||||||
nms_iou_threshold: float = 0.3,
|
nms_iou_threshold: float = 0.3,
|
||||||
|
min_recall: float = 0.0,
|
||||||
|
use_soft_score: bool = False,
|
||||||
|
subpixel_lm: bool = False,
|
||||||
|
debug: bool = False,
|
||||||
) -> list[Match]:
|
) -> list[Match]:
|
||||||
"""
|
"""
|
||||||
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
|
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
|
||||||
@@ -845,6 +1375,32 @@ class LineShapeMatcher:
|
|||||||
if not self.variants:
|
if not self.variants:
|
||||||
raise RuntimeError("Matcher non addestrato: chiamare train() prima.")
|
raise RuntimeError("Matcher non addestrato: chiamare train() prima.")
|
||||||
|
|
||||||
|
# Diagnostic counter: traccia perche' candidati sono droppati lungo
|
||||||
|
# la pipeline. Esposto via get_last_diag() o ritornato implicitamente
|
||||||
|
# se debug=True (vedi sotto).
|
||||||
|
diag = {
|
||||||
|
"n_variants_total": len(self.variants),
|
||||||
|
"n_variants_top_evaluated": 0,
|
||||||
|
"n_variants_top_passed": 0,
|
||||||
|
"n_variants_full_evaluated": 0,
|
||||||
|
"n_raw_candidates": 0,
|
||||||
|
"n_after_pre_nms": 0,
|
||||||
|
"drop_ncc_low": 0,
|
||||||
|
"drop_min_score_post_avg": 0,
|
||||||
|
"drop_recall_low": 0,
|
||||||
|
"drop_bbox_out_of_scene": 0,
|
||||||
|
"drop_nms_iou": 0,
|
||||||
|
"n_final": 0,
|
||||||
|
"top_thresh_used": 0.0,
|
||||||
|
"verify_threshold_used": float(verify_threshold),
|
||||||
|
"min_score_used": float(min_score),
|
||||||
|
"min_recall_used": float(min_recall),
|
||||||
|
"use_polarity": bool(self.use_polarity),
|
||||||
|
"use_soft_score": bool(use_soft_score),
|
||||||
|
"subpixel_lm": bool(subpixel_lm),
|
||||||
|
}
|
||||||
|
self._last_diag = diag
|
||||||
|
|
||||||
gray_full = self._to_gray(scene_bgr)
|
gray_full = self._to_gray(scene_bgr)
|
||||||
# Applica ROI di ricerca: restringe scena a crop, ricorda offset per
|
# Applica ROI di ricerca: restringe scena a crop, ricorda offset per
|
||||||
# ri-traslare le coordinate dei match a fine pipeline.
|
# ri-traslare le coordinate dei match a fine pipeline.
|
||||||
@@ -859,18 +1415,31 @@ class LineShapeMatcher:
|
|||||||
else:
|
else:
|
||||||
gray0 = gray_full
|
gray0 = gray_full
|
||||||
roi_offset = (0, 0)
|
roi_offset = (0, 0)
|
||||||
|
|
||||||
|
# Cache pre-compute scena (II Halcon-style): hash bytes scene + param
|
||||||
|
# gradient/spread → riusa spread piramide + density tra find()
|
||||||
|
# consecutive con stessa scena (typical UI tuning: slider produce
|
||||||
|
# 10+ find() su scena identica). Risparmia ~80% del costo non-kernel.
|
||||||
|
cache_key = self._scene_cache_key(gray0)
|
||||||
|
cached = self._scene_cache_get(cache_key) if cache_key else None
|
||||||
|
if cached is not None:
|
||||||
|
grays, spread_top, bit_active_top, density_top, spread0, \
|
||||||
|
bit_active_full, density_full, top = cached
|
||||||
|
else:
|
||||||
grays = [gray0]
|
grays = [gray0]
|
||||||
for _ in range(self.pyramid_levels - 1):
|
for _ in range(self.pyramid_levels - 1):
|
||||||
grays.append(cv2.pyrDown(grays[-1]))
|
grays.append(cv2.pyrDown(grays[-1]))
|
||||||
top = len(grays) - 1
|
top = len(grays) - 1
|
||||||
|
|
||||||
# Spread bitmap (uint8) al top level: 32× meno memoria della response
|
|
||||||
# map float32 → MOLTO più cache-friendly per _score_by_shift.
|
|
||||||
spread_top = self._spread_bitmap(grays[top])
|
spread_top = self._spread_bitmap(grays[top])
|
||||||
bit_active_top = int(
|
bit_active_top = int(
|
||||||
sum(1 << b for b in range(self._n_bins)
|
sum(1 << b for b in range(self._n_bins)
|
||||||
if (spread_top & (spread_top.dtype.type(1) << b)).any())
|
if (spread_top & (spread_top.dtype.type(1) << b)).any())
|
||||||
)
|
)
|
||||||
|
density_top = _jit_popcount(spread_top)
|
||||||
|
# spread0 + density_full computati piu sotto, quindi salvo dopo.
|
||||||
|
spread0 = None
|
||||||
|
bit_active_full = None
|
||||||
|
density_full = None
|
||||||
if nms_radius is None:
|
if nms_radius is None:
|
||||||
nms_radius = max(8, min(self.template_size) // 2)
|
nms_radius = max(8, min(self.template_size) // 2)
|
||||||
# Pruning adattivo allo step angolare: con step piccolo (<= 3 deg)
|
# Pruning adattivo allo step angolare: con step piccolo (<= 3 deg)
|
||||||
@@ -887,9 +1456,10 @@ class LineShapeMatcher:
|
|||||||
top_factor = max(top_factor, 0.7)
|
top_factor = max(top_factor, 0.7)
|
||||||
cf_eff = 1
|
cf_eff = 1
|
||||||
top_thresh = min_score * top_factor
|
top_thresh = min_score * top_factor
|
||||||
|
diag["top_thresh_used"] = float(top_thresh)
|
||||||
|
|
||||||
tw, th = self.template_size
|
tw, th = self.template_size
|
||||||
density_top = _jit_popcount(spread_top)
|
# density_top gia' computato sopra (cache o miss)
|
||||||
sf_top = 2 ** top
|
sf_top = 2 ** top
|
||||||
bg_cache_top: dict[float, np.ndarray] = {}
|
bg_cache_top: dict[float, np.ndarray] = {}
|
||||||
bg_cache_full: dict[float, np.ndarray] = {}
|
bg_cache_full: dict[float, np.ndarray] = {}
|
||||||
@@ -972,6 +1542,7 @@ class LineShapeMatcher:
|
|||||||
|
|
||||||
kept_coarse: list[tuple[int, float]] = []
|
kept_coarse: list[tuple[int, float]] = []
|
||||||
all_top_scores: list[tuple[int, float]] = []
|
all_top_scores: list[tuple[int, float]] = []
|
||||||
|
diag["n_variants_top_evaluated"] = len(coarse_idx_list)
|
||||||
# batch_top: usa kernel batch single-call con prange-esterno su
|
# batch_top: usa kernel batch single-call con prange-esterno su
|
||||||
# varianti. Vince su threadpool quando n_vars >> n_threads e quando
|
# varianti. Vince su threadpool quando n_vars >> n_threads e quando
|
||||||
# H*W top e' piccolo (overhead chiamate JIT > costo kernel).
|
# H*W top e' piccolo (overhead chiamate JIT > costo kernel).
|
||||||
@@ -1035,14 +1606,24 @@ class LineShapeMatcher:
|
|||||||
kept_variants.sort(key=lambda t: -t[1])
|
kept_variants.sort(key=lambda t: -t[1])
|
||||||
max_vars_full = max(max_matches * 8, len(self.variants) // 2)
|
max_vars_full = max(max_matches * 8, len(self.variants) // 2)
|
||||||
kept_variants = kept_variants[:max_vars_full]
|
kept_variants = kept_variants[:max_vars_full]
|
||||||
|
diag["n_variants_top_passed"] = len(kept_coarse)
|
||||||
|
diag["n_variants_full_evaluated"] = len(kept_variants)
|
||||||
|
|
||||||
# Full-res (parallelizzato) con bitmap
|
# Full-res (parallelizzato) con bitmap.
|
||||||
|
# Riusa cache se disponibile, altrimenti computa e salva.
|
||||||
|
if spread0 is None:
|
||||||
spread0 = self._spread_bitmap(gray0)
|
spread0 = self._spread_bitmap(gray0)
|
||||||
bit_active_full = int(
|
bit_active_full = int(
|
||||||
sum(1 << b for b in range(self._n_bins)
|
sum(1 << b for b in range(self._n_bins)
|
||||||
if (spread0 & (spread0.dtype.type(1) << b)).any())
|
if (spread0 & (spread0.dtype.type(1) << b)).any())
|
||||||
)
|
)
|
||||||
density_full = _jit_popcount(spread0)
|
density_full = _jit_popcount(spread0)
|
||||||
|
# Salva cache scena complete
|
||||||
|
if cache_key is not None:
|
||||||
|
self._scene_cache_put(cache_key, (
|
||||||
|
grays, spread_top, bit_active_top, density_top,
|
||||||
|
spread0, bit_active_full, density_full, top,
|
||||||
|
))
|
||||||
for sc in unique_scales:
|
for sc in unique_scales:
|
||||||
bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1)
|
bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1)
|
||||||
|
|
||||||
@@ -1120,6 +1701,7 @@ class LineShapeMatcher:
|
|||||||
raw.append((float(vals[i]), int(xs[i]), int(ys[i]), vi))
|
raw.append((float(vals[i]), int(xs[i]), int(ys[i]), vi))
|
||||||
|
|
||||||
raw.sort(key=lambda c: -c[0])
|
raw.sort(key=lambda c: -c[0])
|
||||||
|
diag["n_raw_candidates"] = len(raw)
|
||||||
|
|
||||||
# Mappa vi → score_map per subpixel/refinement
|
# Mappa vi → score_map per subpixel/refinement
|
||||||
score_maps = dict(candidates_per_var)
|
score_maps = dict(candidates_per_var)
|
||||||
@@ -1151,6 +1733,7 @@ class LineShapeMatcher:
|
|||||||
preliminary_int.append((score, xi, yi, vi))
|
preliminary_int.append((score, xi, yi, vi))
|
||||||
if len(preliminary_int) >= pre_cap:
|
if len(preliminary_int) >= pre_cap:
|
||||||
break
|
break
|
||||||
|
diag["n_after_pre_nms"] = len(preliminary_int)
|
||||||
|
|
||||||
# Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap)
|
# Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap)
|
||||||
kept: list[Match] = []
|
kept: list[Match] = []
|
||||||
@@ -1177,6 +1760,13 @@ class LineShapeMatcher:
|
|||||||
search_radius=self._effective_angle_step() / 2.0,
|
search_radius=self._effective_angle_step() / 2.0,
|
||||||
original_score=score,
|
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
|
# NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta
|
||||||
# il calcolo per shape-score gia alti. Default 1.01 = NCC sempre,
|
# il calcolo per shape-score gia alti. Default 1.01 = NCC sempre,
|
||||||
# piu sicuro contro falsi positivi (lo shape-score satura facile).
|
# piu sicuro contro falsi positivi (lo shape-score satura facile).
|
||||||
@@ -1185,14 +1775,39 @@ class LineShapeMatcher:
|
|||||||
# ranking/visualizzazione (uno score 1.0 vero richiede sia
|
# ranking/visualizzazione (uno score 1.0 vero richiede sia
|
||||||
# match shape sia template gray identici).
|
# match shape sia template gray identici).
|
||||||
if verify_ncc and float(score_f) < ncc_skip_above:
|
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:
|
if ncc < verify_threshold:
|
||||||
|
diag["drop_ncc_low"] += 1
|
||||||
continue
|
continue
|
||||||
score_f = (float(score_f) + max(0.0, ncc)) * 0.5
|
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
|
# Re-check min_score sullo score finale: NCC averaging puo
|
||||||
# abbattere lo shape-score sotto la soglia user. Senza questo
|
# abbattere lo shape-score sotto la soglia user. Senza questo
|
||||||
# check apparivano match con score < min_score (UI confusing).
|
# check apparivano match con score < min_score (UI confusing).
|
||||||
if float(score_f) < min_score:
|
if float(score_f) < min_score:
|
||||||
|
diag["drop_min_score_post_avg"] += 1
|
||||||
|
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:
|
||||||
|
diag["drop_recall_low"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Ri-traslo coord da spazio crop ROI a spazio scena originale.
|
# Ri-traslo coord da spazio crop ROI a spazio scena originale.
|
||||||
@@ -1216,6 +1831,7 @@ class LineShapeMatcher:
|
|||||||
)
|
)
|
||||||
inside_ratio = float(inter) / poly_area
|
inside_ratio = float(inter) / poly_area
|
||||||
if inside_ratio < 0.75:
|
if inside_ratio < 0.75:
|
||||||
|
diag["drop_bbox_out_of_scene"] += 1
|
||||||
continue
|
continue
|
||||||
# Penalità scala opzionale: score degrada con distanza da 1.0
|
# Penalità scala opzionale: score degrada con distanza da 1.0
|
||||||
if scale_penalty > 0.0 and var.scale != 1.0:
|
if scale_penalty > 0.0 and var.scale != 1.0:
|
||||||
@@ -1240,6 +1856,7 @@ class LineShapeMatcher:
|
|||||||
dup = True
|
dup = True
|
||||||
break
|
break
|
||||||
if dup:
|
if dup:
|
||||||
|
diag["drop_nms_iou"] += 1
|
||||||
continue
|
continue
|
||||||
kept.append(Match(
|
kept.append(Match(
|
||||||
cx=cx_out, cy=cy_out,
|
cx=cx_out, cy=cy_out,
|
||||||
@@ -1247,7 +1864,39 @@ 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
|
||||||
|
diag["n_final"] = len(kept)
|
||||||
|
if debug:
|
||||||
|
# Debug mode: stampa diagnostica su stderr per visibilita' immediata.
|
||||||
|
import sys as _sys
|
||||||
|
_sys.stderr.write(f"[pm2d.find debug] {self._format_diag(diag)}\n")
|
||||||
return kept
|
return kept
|
||||||
|
|
||||||
|
def _format_diag(self, diag: dict) -> str:
|
||||||
|
"""Formatta dict diagnostica in una linea leggibile."""
|
||||||
|
return (
|
||||||
|
f"vars: {diag['n_variants_total']} -> "
|
||||||
|
f"top_eval={diag['n_variants_top_evaluated']} "
|
||||||
|
f"top_pass={diag['n_variants_top_passed']} "
|
||||||
|
f"full_eval={diag['n_variants_full_evaluated']} | "
|
||||||
|
f"raw={diag['n_raw_candidates']} "
|
||||||
|
f"pre_nms={diag['n_after_pre_nms']} -> "
|
||||||
|
f"drop[ncc={diag['drop_ncc_low']}, "
|
||||||
|
f"score={diag['drop_min_score_post_avg']}, "
|
||||||
|
f"recall={diag['drop_recall_low']}, "
|
||||||
|
f"bbox={diag['drop_bbox_out_of_scene']}, "
|
||||||
|
f"nms={diag['drop_nms_iou']}] = "
|
||||||
|
f"final={diag['n_final']} (top_thresh={diag['top_thresh_used']:.2f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_last_diag(self) -> dict | None:
|
||||||
|
"""Ritorna dict diagnostica dell'ultima chiamata find().
|
||||||
|
|
||||||
|
Halcon-equivalent: oggi inspect_shape_model espone parziali contatori.
|
||||||
|
Util per debug 'perche' 0 match', tuning interattivo, validation.
|
||||||
|
Vedi diag keys per significato (n_variants_top_evaluated, drop_*, ...).
|
||||||
|
"""
|
||||||
|
return getattr(self, "_last_diag", None)
|
||||||
|
|||||||
+396
-37
@@ -48,6 +48,10 @@ IMAGES_DIR = Path(_images_dir_raw)
|
|||||||
if not IMAGES_DIR.is_absolute():
|
if not IMAGES_DIR.is_absolute():
|
||||||
IMAGES_DIR = PROJECT_ROOT / IMAGES_DIR
|
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.line_matcher import LineShapeMatcher, Match
|
||||||
from pm2d.auto_tune import auto_tune
|
from pm2d.auto_tune import auto_tune
|
||||||
|
|
||||||
@@ -74,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")
|
||||||
@@ -127,45 +132,72 @@ 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) -> np.ndarray:
|
template_gray: np.ndarray | None,
|
||||||
|
matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
|
||||||
|
"""Disegna SOLO UCS (richiesta utente) per ogni match trovato.
|
||||||
|
|
||||||
|
UCS = sistema di coordinate (X rosso, Y verde) posizionato sul
|
||||||
|
baricentro feature del modello, ruotato secondo l'angolo del match.
|
||||||
|
Niente edge, niente cerchietti feature, niente bbox: i match sulla
|
||||||
|
scena reale devono essere puliti, gli edge filtrati si vedono solo
|
||||||
|
nell'anteprima modello.
|
||||||
|
"""
|
||||||
out = scene.copy()
|
out = scene.copy()
|
||||||
H, W = scene.shape[:2]
|
# Lunghezza assi UCS: stessa formula dell'anteprima modello
|
||||||
palette = [
|
# (0.15 * max lato template) scalata per m.scale → coerenza dimensionale.
|
||||||
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0),
|
if matcher is not None and matcher.template_size != (0, 0):
|
||||||
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255),
|
L_base = int(0.15 * max(matcher.template_size))
|
||||||
]
|
else:
|
||||||
|
L_base = 30
|
||||||
|
H_scene, W_scene = scene.shape[:2]
|
||||||
|
|
||||||
for i, m in enumerate(matches):
|
for i, m in enumerate(matches):
|
||||||
color = palette[i % len(palette)]
|
# UCS posizionato esattamente sul CENTRO POSE del match (m.cx, m.cy):
|
||||||
if template_gray is not None:
|
# equivale al centro template traslato alla scena, ruotato con
|
||||||
|
# m.angle_deg. Coerente con UCS dell'anteprima modello che ora
|
||||||
|
# e' anche sul centro ROI (vedi preview_edges).
|
||||||
|
ax = np.deg2rad(m.angle_deg)
|
||||||
|
ca, sa = np.cos(ax), np.sin(ax)
|
||||||
|
cx, cy = int(round(m.cx)), int(round(m.cy))
|
||||||
|
# Overlay edge del modello orientato (richiesta utente):
|
||||||
|
# warpa template alla pose, applica hysteresis identica al matcher,
|
||||||
|
# disegna pixel edge come overlay verde tenue.
|
||||||
|
if template_gray is not None and matcher is not None:
|
||||||
t = template_gray
|
t = template_gray
|
||||||
th, tw = t.shape
|
th, tw = t.shape
|
||||||
edge = cv2.Canny(t, 50, 150)
|
|
||||||
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
|
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 = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
|
||||||
M[0, 2] += m.cx - cx_t
|
M[0, 2] += m.cx - cx_t
|
||||||
M[1, 2] += m.cy - cy_t
|
M[1, 2] += m.cy - cy_t
|
||||||
warped = cv2.warpAffine(edge, M, (W, H),
|
warped_gray = cv2.warpAffine(
|
||||||
flags=cv2.INTER_NEAREST, borderValue=0)
|
t, M, (W_scene, H_scene),
|
||||||
mask = warped > 0
|
flags=cv2.INTER_LINEAR, borderValue=0)
|
||||||
if mask.any():
|
mag, _ = matcher._gradient(warped_gray)
|
||||||
overlay = np.zeros_like(out)
|
if matcher.weak_grad < matcher.strong_grad:
|
||||||
overlay[mask] = color
|
edge_mask = matcher._hysteresis_mask(mag)
|
||||||
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8)
|
else:
|
||||||
poly = m.bbox_poly.astype(np.int32).reshape(-1, 1, 2)
|
edge_mask = mag >= matcher.strong_grad
|
||||||
cv2.polylines(out, [poly], True, color, 2, cv2.LINE_AA)
|
if edge_mask.any():
|
||||||
p0 = tuple(m.bbox_poly[0].astype(int))
|
edge_overlay = np.zeros_like(out)
|
||||||
p1 = tuple(m.bbox_poly[1].astype(int))
|
edge_overlay[edge_mask] = (0, 220, 0) # verde brillante
|
||||||
cv2.line(out, p0, p1, color, 4, cv2.LINE_AA)
|
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0)
|
||||||
cx, cy = int(round(m.cx)), int(round(m.cy))
|
L = max(20, int(L_base * m.scale))
|
||||||
cv2.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA)
|
# X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin)
|
||||||
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
|
x_end = (int(cx + L * ca), int(cy - L * sa))
|
||||||
a = np.deg2rad(m.angle_deg)
|
# Y axis = rotazione di (0, 1) con cv2 matrix → (sin, cos)
|
||||||
cv2.arrowedLine(out, (cx, cy),
|
# A m.angle_deg=0 deve puntare GIU' (image y-down convenzione modello)
|
||||||
(int(cx + L * np.cos(a)), int(cy - L * np.sin(a))),
|
y_end = (int(cx + L * sa), int(cy + L * ca))
|
||||||
color, 2, cv2.LINE_AA, tipLength=0.2)
|
cv2.arrowedLine(out, (cx, cy), x_end,
|
||||||
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
|
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
cv2.putText(out, label, (cx + 8, cy - 8),
|
cv2.putText(out, "X", (x_end[0] + 4, x_end[1] + 5),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
cv2.arrowedLine(out, (cx, cy), y_end,
|
||||||
|
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
|
cv2.putText(out, "Y", (y_end[0] + 4, y_end[1] + 12),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
# 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), 3, (255, 255, 255), -1, cv2.LINE_AA)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -213,6 +245,7 @@ class MatchResp(BaseModel):
|
|||||||
find_time: float
|
find_time: float
|
||||||
num_variants: int
|
num_variants: int
|
||||||
annotated_id: str
|
annotated_id: str
|
||||||
|
diag: dict | None = None # CC: diagnostica pipeline (drop reasons)
|
||||||
|
|
||||||
|
|
||||||
class TuneParams(BaseModel):
|
class TuneParams(BaseModel):
|
||||||
@@ -267,6 +300,29 @@ 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) ---
|
||||||
|
# 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(
|
def _simple_to_technical(
|
||||||
@@ -301,10 +357,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,
|
||||||
@@ -492,7 +562,7 @@ def match(p: MatchParams):
|
|||||||
|
|
||||||
# Render annotated image
|
# Render annotated image
|
||||||
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
||||||
annotated = _draw_matches(scene, matches, tg)
|
annotated = _draw_matches(scene, matches, tg, matcher=m)
|
||||||
ann_id = _store_image(annotated)
|
ann_id = _store_image(annotated)
|
||||||
|
|
||||||
return MatchResp(
|
return MatchResp(
|
||||||
@@ -503,6 +573,7 @@ def match(p: MatchParams):
|
|||||||
) for m_ in matches],
|
) for m_ in matches],
|
||||||
train_time=t_train, find_time=t_find,
|
train_time=t_train, find_time=t_find,
|
||||||
num_variants=n, annotated_id=ann_id,
|
num_variants=n, annotated_id=ann_id,
|
||||||
|
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -526,6 +597,9 @@ def match_simple(p: SimpleMatchParams):
|
|||||||
tech = _simple_to_technical(p, roi_img)
|
tech = _simple_to_technical(p, roi_img)
|
||||||
|
|
||||||
key = _matcher_cache_key(roi_img, tech)
|
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)
|
m = _cache_get_matcher(key)
|
||||||
if m is None:
|
if m is None:
|
||||||
m = LineShapeMatcher(
|
m = LineShapeMatcher(
|
||||||
@@ -536,23 +610,37 @@ 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_gpu=p.use_gpu,
|
||||||
)
|
)
|
||||||
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
|
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
|
||||||
_cache_put_matcher(key, m)
|
_cache_put_matcher(key, m)
|
||||||
else:
|
else:
|
||||||
n = len(m.variants); t_train = 0.0
|
n = len(m.variants); t_train = 0.0
|
||||||
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
|
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()
|
t0 = time.time()
|
||||||
matches = m.find(
|
matches = m.find(
|
||||||
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
|
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
|
||||||
nms_radius=nms, verify_threshold=tech["verify_threshold"],
|
nms_radius=nms, verify_threshold=tech["verify_threshold"],
|
||||||
scale_penalty=tech.get("scale_penalty", 0.0),
|
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
|
t_find = time.time() - t0
|
||||||
|
|
||||||
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
|
||||||
annotated = _draw_matches(scene, matches, tg)
|
annotated = _draw_matches(scene, matches, tg, matcher=m)
|
||||||
ann_id = _store_image(annotated)
|
ann_id = _store_image(annotated)
|
||||||
|
|
||||||
return MatchResp(
|
return MatchResp(
|
||||||
@@ -562,6 +650,7 @@ def match_simple(p: SimpleMatchParams):
|
|||||||
) for mt in matches],
|
) for mt in matches],
|
||||||
train_time=t_train, find_time=t_find,
|
train_time=t_train, find_time=t_find,
|
||||||
num_variants=n, annotated_id=ann_id,
|
num_variants=n, annotated_id=ann_id,
|
||||||
|
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -573,7 +662,277 @@ def tune(p: TuneParams):
|
|||||||
x, y, w, h = p.roi
|
x, y, w, h = p.roi
|
||||||
roi_img = model[y:y + h, x:x + w]
|
roi_img = model[y:y + h, x:x + w]
|
||||||
t = auto_tune(roi_img)
|
t = auto_tune(roi_img)
|
||||||
return {k: v for k, v in t.items() if not k.startswith("_")}
|
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
|
||||||
|
# _symmetry_order, _orient_entropy) per feedback UI.
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
class EdgePreviewParams(BaseModel):
|
||||||
|
model_id: str
|
||||||
|
roi: list[int]
|
||||||
|
weak_grad: float = 30.0
|
||||||
|
strong_grad: float = 60.0
|
||||||
|
num_features: int = 96
|
||||||
|
min_feature_spacing: int = 3
|
||||||
|
use_polarity: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/preview_edges")
|
||||||
|
def preview_edges(p: EdgePreviewParams):
|
||||||
|
"""Estrae edge feature dalla ROI con i parametri dati e ritorna
|
||||||
|
immagine annotata con i pixel selezionati come overlay.
|
||||||
|
|
||||||
|
Permette tuning interattivo delle soglie weak/strong_grad e
|
||||||
|
num_features per "togliere le sporcizie" (rumore di sfondo,
|
||||||
|
edge spuri) prima di trainare il matcher vero.
|
||||||
|
"""
|
||||||
|
model = _load_image(p.model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(404, "Modello non trovato")
|
||||||
|
x, y, w, h = p.roi
|
||||||
|
H_m, W_m = model.shape[:2]
|
||||||
|
x = max(0, min(int(x), W_m - 1)); y = max(0, min(int(y), H_m - 1))
|
||||||
|
w = max(1, min(int(w), W_m - x)); h = max(1, min(int(h), H_m - y))
|
||||||
|
roi_img = model[y:y + h, x:x + w]
|
||||||
|
# Matcher temporaneo solo per estrazione feature (no train completo)
|
||||||
|
m = LineShapeMatcher(
|
||||||
|
weak_grad=p.weak_grad,
|
||||||
|
strong_grad=p.strong_grad,
|
||||||
|
num_features=p.num_features,
|
||||||
|
min_feature_spacing=p.min_feature_spacing,
|
||||||
|
use_polarity=p.use_polarity,
|
||||||
|
)
|
||||||
|
gray = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) if roi_img.ndim == 3 else roi_img
|
||||||
|
mag, bins = m._gradient(gray)
|
||||||
|
fx, fy, fb = m._extract_features(mag, bins, None)
|
||||||
|
# Mostra anche i pixel "weak/strong" come heatmap di sfondo
|
||||||
|
out = roi_img.copy() if roi_img.ndim == 3 else cv2.cvtColor(roi_img, cv2.COLOR_GRAY2BGR)
|
||||||
|
# Overlay magnitude leggera
|
||||||
|
mag_norm = np.clip(mag / max(1.0, mag.max()) * 255, 0, 255).astype(np.uint8)
|
||||||
|
mag_color = cv2.applyColorMap(mag_norm, cv2.COLORMAP_BONE)
|
||||||
|
out = cv2.addWeighted(out, 0.6, mag_color, 0.4, 0)
|
||||||
|
# Pixel "strong" con hysteresis: contorno verde scuro tenue
|
||||||
|
if m.weak_grad < m.strong_grad:
|
||||||
|
edge_mask = m._hysteresis_mask(mag).astype(np.uint8) * 255
|
||||||
|
else:
|
||||||
|
edge_mask = (mag >= m.strong_grad).astype(np.uint8) * 255
|
||||||
|
edge_overlay = np.zeros_like(out)
|
||||||
|
edge_overlay[edge_mask > 0] = (0, 80, 0) # verde scuro
|
||||||
|
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.5, 0)
|
||||||
|
# Feature scelte: cerchietti colorati per bin
|
||||||
|
bin_colors = [
|
||||||
|
(255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0),
|
||||||
|
(0, 255, 255), (0, 128, 255), (0, 0, 255), (255, 0, 255),
|
||||||
|
(255, 100, 100), (255, 180, 100), (255, 230, 100), (180, 255, 100),
|
||||||
|
(100, 255, 200), (100, 180, 255), (180, 100, 255), (255, 100, 200),
|
||||||
|
]
|
||||||
|
for i in range(len(fx)):
|
||||||
|
b = int(fb[i])
|
||||||
|
col = bin_colors[b % len(bin_colors)]
|
||||||
|
cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA)
|
||||||
|
# UCS sul CENTRO ROI (coerente con _draw_matches che usa centro pose).
|
||||||
|
# In questo modo l'UCS visualizzato nel modello = UCS del match (modulo
|
||||||
|
# rotazione/traslazione data dalla pose del pezzo trovato).
|
||||||
|
rh, rw = roi_img.shape[:2]
|
||||||
|
bx, by = (rw - 1) // 2, (rh - 1) // 2
|
||||||
|
axis_len = max(20, int(0.15 * max(rw, rh)))
|
||||||
|
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
|
||||||
|
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
|
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
|
||||||
|
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
|
||||||
|
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
|
||||||
|
cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA)
|
||||||
|
bary_cx, bary_cy = float(bx), float(by)
|
||||||
|
img_id = _store_image(out)
|
||||||
|
n_edge_strong = int((mag >= m.strong_grad).sum())
|
||||||
|
n_edge_total = int(edge_mask.sum() / 255)
|
||||||
|
return {
|
||||||
|
"preview_id": img_id,
|
||||||
|
"n_features": len(fx),
|
||||||
|
"n_edge_strong": n_edge_strong,
|
||||||
|
"n_edge_after_hysteresis": n_edge_total,
|
||||||
|
"mag_max": float(mag.max()),
|
||||||
|
"mag_p50": float(np.percentile(mag, 50)),
|
||||||
|
"mag_p85": float(np.percentile(mag, 85)),
|
||||||
|
"ucs_baricentro": (
|
||||||
|
{"cx": round(bary_cx, 2), "cy": round(bary_cy, 2)}
|
||||||
|
if bary_cx is not None else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
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)
|
||||||
|
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)}
|
||||||
|
|
||||||
|
|
||||||
|
# Cache di matcher caricati da .npz (V feature). Key: nome ricetta.
|
||||||
|
_RECIPE_MATCHERS: OrderedDict = OrderedDict()
|
||||||
|
_RECIPE_MATCHERS_SIZE = 4
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/recipes/{name}/load")
|
||||||
|
def load_recipe(name: str):
|
||||||
|
"""Carica ricetta .npz e popola cache matcher in memoria.
|
||||||
|
|
||||||
|
Una volta caricata, /match_recipe la usa direttamente senza
|
||||||
|
re-train. Halcon-equivalent read_shape_model + handle.
|
||||||
|
"""
|
||||||
|
safe_name = "".join(c for c in name if c.isalnum() or c in "._-")
|
||||||
|
if not safe_name.endswith(".npz"):
|
||||||
|
safe_name += ".npz"
|
||||||
|
path = RECIPES_DIR / safe_name
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
|
||||||
|
m = LineShapeMatcher.load_model(str(path))
|
||||||
|
_RECIPE_MATCHERS[safe_name] = m
|
||||||
|
_RECIPE_MATCHERS.move_to_end(safe_name)
|
||||||
|
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
|
||||||
|
_RECIPE_MATCHERS.popitem(last=False)
|
||||||
|
return {
|
||||||
|
"name": safe_name,
|
||||||
|
"n_variants": len(m.variants),
|
||||||
|
"template_size": list(m.template_size),
|
||||||
|
"use_polarity": m.use_polarity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeMatchParams(BaseModel):
|
||||||
|
recipe: str
|
||||||
|
scene_id: str
|
||||||
|
# Solo find-time params (training gia' fatto offline)
|
||||||
|
min_score: float = 0.65
|
||||||
|
max_matches: int = 25
|
||||||
|
min_recall: float = 0.0
|
||||||
|
use_soft_score: bool = False
|
||||||
|
subpixel_lm: bool = False
|
||||||
|
nms_iou_threshold: float = 0.3
|
||||||
|
coarse_stride: int = 1
|
||||||
|
pyramid_propagate: bool = False
|
||||||
|
greediness: float = 0.0
|
||||||
|
refine_pose_joint: bool = False
|
||||||
|
search_roi: list[int] | None = None
|
||||||
|
verify_threshold: float = 0.5
|
||||||
|
scale_penalty: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/match_recipe", response_model=MatchResp)
|
||||||
|
def match_recipe(p: RecipeMatchParams):
|
||||||
|
"""Match con ricetta pre-trained: zero training, solo find."""
|
||||||
|
safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz"
|
||||||
|
m = _RECIPE_MATCHERS.get(safe_name)
|
||||||
|
if m is None:
|
||||||
|
# Auto-load on demand
|
||||||
|
path = RECIPES_DIR / safe_name
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
|
||||||
|
m = LineShapeMatcher.load_model(str(path))
|
||||||
|
_RECIPE_MATCHERS[safe_name] = m
|
||||||
|
scene = _load_image(p.scene_id)
|
||||||
|
if scene is None:
|
||||||
|
raise HTTPException(404, "Scena non trovata")
|
||||||
|
search_roi_t = tuple(p.search_roi) if p.search_roi else None
|
||||||
|
t0 = time.time()
|
||||||
|
matches = m.find(
|
||||||
|
scene,
|
||||||
|
min_score=p.min_score, max_matches=p.max_matches,
|
||||||
|
verify_threshold=p.verify_threshold,
|
||||||
|
scale_penalty=p.scale_penalty,
|
||||||
|
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
|
||||||
|
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
|
||||||
|
annotated = _draw_matches(scene, matches, tg, matcher=m)
|
||||||
|
ann_id = _store_image(annotated)
|
||||||
|
return MatchResp(
|
||||||
|
matches=[MatchResult(
|
||||||
|
cx=mt.cx, cy=mt.cy, angle_deg=mt.angle_deg, scale=mt.scale,
|
||||||
|
score=mt.score, bbox_poly=mt.bbox_poly.tolist(),
|
||||||
|
) for mt in matches],
|
||||||
|
train_time=0.0, find_time=t_find,
|
||||||
|
num_variants=len(m.variants), annotated_id=ann_id,
|
||||||
|
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Mount static
|
# Mount static
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const PALETTE = [
|
|||||||
const state = {
|
const state = {
|
||||||
model: null, scene: null, roi: null, drag: null,
|
model: null, scene: null, roi: null, drag: null,
|
||||||
matches: [], annotatedImg: null,
|
matches: [], annotatedImg: null,
|
||||||
|
active_recipe: null, // V: ricetta caricata (string nome) o null
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Forms ----------
|
// ---------- Forms ----------
|
||||||
@@ -52,6 +53,63 @@ 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
// 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +332,43 @@ function setupROI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Match action ----------
|
// ---------- Match action ----------
|
||||||
|
async function doMatchRecipe() {
|
||||||
|
if (!state.scene) { setStatus("Carica scena"); return; }
|
||||||
|
setStatus(`Match ricetta ${state.active_recipe}...`);
|
||||||
|
const hc = readHalconFlags();
|
||||||
|
const body = {
|
||||||
|
recipe: state.active_recipe,
|
||||||
|
scene_id: state.scene.id,
|
||||||
|
min_score: parseFloat(document.getElementById("p-min-score").value),
|
||||||
|
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
|
||||||
|
verify_threshold: 0.50,
|
||||||
|
...hc,
|
||||||
|
};
|
||||||
|
const r = await fetch("/match_recipe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
|
||||||
|
const data = await r.json();
|
||||||
|
state.matches = data.matches;
|
||||||
|
state.annotatedImg = await loadImage(
|
||||||
|
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
||||||
|
renderScene();
|
||||||
|
renderLegend();
|
||||||
|
document.getElementById("t-train").textContent = "—";
|
||||||
|
document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`;
|
||||||
|
document.getElementById("t-var").textContent = data.num_variants;
|
||||||
|
document.getElementById("t-match").textContent = data.matches.length;
|
||||||
|
renderDiag(data.diag, data.matches.length);
|
||||||
|
setStatus(`${data.matches.length} match trovati (ricetta ${state.active_recipe})`);
|
||||||
|
}
|
||||||
|
|
||||||
async function doMatch() {
|
async function doMatch() {
|
||||||
|
// Path V: ricetta caricata → bypass training, solo find su scena
|
||||||
|
if (state.active_recipe) {
|
||||||
|
return doMatchRecipe();
|
||||||
|
}
|
||||||
if (!state.model) { setStatus("Carica modello"); return; }
|
if (!state.model) { setStatus("Carica modello"); return; }
|
||||||
if (!state.scene) { setStatus("Carica scena"); return; }
|
if (!state.scene) { setStatus("Carica scena"); return; }
|
||||||
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
|
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
|
||||||
@@ -340,6 +434,7 @@ async function doMatch() {
|
|||||||
document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`;
|
document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`;
|
||||||
document.getElementById("t-var").textContent = data.num_variants;
|
document.getElementById("t-var").textContent = data.num_variants;
|
||||||
document.getElementById("t-match").textContent = data.matches.length;
|
document.getElementById("t-match").textContent = data.matches.length;
|
||||||
|
renderDiag(data.diag, data.matches.length);
|
||||||
setStatus(`${data.matches.length} match trovati${hasAdv ? " (avanzato)" : ""}`);
|
setStatus(`${data.matches.length} match trovati${hasAdv ? " (avanzato)" : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +462,305 @@ function setStatus(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Init ----------
|
// ---------- Init ----------
|
||||||
|
// ---------- Edge preview (clean rumore) ----------
|
||||||
|
let _epDebounce = null;
|
||||||
|
let _epLastImg = null;
|
||||||
|
|
||||||
|
async function fetchEdgePreview() {
|
||||||
|
if (!state.model || !state.roi) {
|
||||||
|
document.getElementById("edge-preview-info").textContent =
|
||||||
|
"Disegna prima la ROI sul modello";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
model_id: state.model.id,
|
||||||
|
roi: state.roi,
|
||||||
|
weak_grad: parseFloat(document.getElementById("ep-weak").value),
|
||||||
|
strong_grad: parseFloat(document.getElementById("ep-strong").value),
|
||||||
|
num_features: parseInt(document.getElementById("ep-nf").value, 10),
|
||||||
|
min_feature_spacing: parseInt(document.getElementById("ep-sp").value, 10),
|
||||||
|
use_polarity: document.getElementById("ep-pol").checked,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await fetch("/preview_edges", {
|
||||||
|
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();
|
||||||
|
_epLastImg = await loadImage(`/image/${j.preview_id}/raw?t=${Date.now()}`);
|
||||||
|
drawEdgePreview();
|
||||||
|
const ucs = j.ucs_baricentro
|
||||||
|
? ` | UCS=(${j.ucs_baricentro.cx},${j.ucs_baricentro.cy})`
|
||||||
|
: "";
|
||||||
|
document.getElementById("edge-preview-info").innerHTML =
|
||||||
|
`<b>${j.n_features}</b> feature scelte (di ${j.n_edge_after_hysteresis} edge totali)<br>` +
|
||||||
|
`mag: max=${j.mag_max.toFixed(0)} p50=${j.mag_p50.toFixed(0)} ` +
|
||||||
|
`p85=${j.mag_p85.toFixed(0)}${ucs}`;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("edge-preview-info").textContent =
|
||||||
|
`Errore preview: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEdgePreview() {
|
||||||
|
const cnv = document.getElementById("c-edge-preview");
|
||||||
|
if (!_epLastImg) return;
|
||||||
|
const ctx = cnv.getContext("2d");
|
||||||
|
// Fit-contain
|
||||||
|
const r = Math.min(cnv.width / _epLastImg.width,
|
||||||
|
cnv.height / _epLastImg.height);
|
||||||
|
const w = _epLastImg.width * r;
|
||||||
|
const h = _epLastImg.height * r;
|
||||||
|
const ox = (cnv.width - w) / 2;
|
||||||
|
const oy = (cnv.height - h) / 2;
|
||||||
|
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cnv.width, cnv.height);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.drawImage(_epLastImg, ox, oy, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleEdgePreview() {
|
||||||
|
if (_epDebounce) clearTimeout(_epDebounce);
|
||||||
|
_epDebounce = setTimeout(fetchEdgePreview, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEdgePreviewControls() {
|
||||||
|
const slid = (id, valEl) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const v = document.getElementById(valEl);
|
||||||
|
el.addEventListener("input", () => {
|
||||||
|
v.textContent = el.value;
|
||||||
|
scheduleEdgePreview();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
slid("ep-weak", "ep-weak-v");
|
||||||
|
slid("ep-strong", "ep-strong-v");
|
||||||
|
slid("ep-nf", "ep-nf-v");
|
||||||
|
slid("ep-sp", "ep-sp-v");
|
||||||
|
document.getElementById("ep-pol").addEventListener("change",
|
||||||
|
scheduleEdgePreview);
|
||||||
|
// Auto-refresh quando il pannello viene aperto
|
||||||
|
document.getElementById("edge-preview-panel").addEventListener("toggle",
|
||||||
|
(e) => { if (e.target.open) fetchEdgePreview(); });
|
||||||
|
document.getElementById("btn-edge-apply").addEventListener("click", () => {
|
||||||
|
// Copia i valori correnti nei campi avanzati
|
||||||
|
const map = {
|
||||||
|
"ep-weak": "adv-weak_grad",
|
||||||
|
"ep-strong": "adv-strong_grad",
|
||||||
|
"ep-nf": "adv-num_features",
|
||||||
|
"ep-sp": "adv-min_feature_spacing",
|
||||||
|
};
|
||||||
|
for (const [src, dst] of Object.entries(map)) {
|
||||||
|
const dstEl = document.getElementById(dst);
|
||||||
|
if (dstEl) dstEl.value = document.getElementById(src).value;
|
||||||
|
}
|
||||||
|
// use_polarity: alla checkbox della modalita Halcon
|
||||||
|
const polCb = document.getElementById("hc-use-polarity");
|
||||||
|
if (polCb) polCb.checked = document.getElementById("ep-pol").checked;
|
||||||
|
// Apri pannello Avanzate per feedback
|
||||||
|
const advDetails = document.querySelectorAll("#col-params details");
|
||||||
|
advDetails.forEach((d) => { d.open = true; });
|
||||||
|
alert("Parametri edge applicati. Esegui MATCH per usare i valori scelti.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CC: Diagnostica match ----------
|
||||||
|
function renderDiag(diag, n_matches) {
|
||||||
|
const el = document.getElementById("diag-content");
|
||||||
|
if (!diag) {
|
||||||
|
el.innerHTML = '<em style="color:#888">Diagnostica non disponibile</em>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dropTotal = (diag.drop_ncc_low || 0) + (diag.drop_min_score_post_avg || 0)
|
||||||
|
+ (diag.drop_recall_low || 0) + (diag.drop_bbox_out_of_scene || 0)
|
||||||
|
+ (diag.drop_nms_iou || 0);
|
||||||
|
// Hint contestuali se 0 match
|
||||||
|
let hint = "";
|
||||||
|
if (n_matches === 0) {
|
||||||
|
if (diag.n_after_pre_nms === 0) {
|
||||||
|
hint = `<div style="color:#f88; margin-top:6px">⚠ Nessun candidato sopra soglia.
|
||||||
|
Prova: ↓ <b>min_score</b> o ↓ <b>top_thresh</b> (currently ${diag.top_thresh_used.toFixed(2)})</div>`;
|
||||||
|
} else if (diag.drop_ncc_low > 0 && dropTotal === diag.drop_ncc_low) {
|
||||||
|
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_ncc_low} candidati droppati da NCC.
|
||||||
|
Prova: ↓ <b>verify_threshold</b> (filtro_fp più leggero)</div>`;
|
||||||
|
} else if (diag.drop_min_score_post_avg > 0) {
|
||||||
|
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_min_score_post_avg} match sotto min_score post-NCC.
|
||||||
|
Prova: ↓ <b>min_score</b></div>`;
|
||||||
|
} else if (diag.drop_recall_low > 0) {
|
||||||
|
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_recall_low} match con recall < ${diag.min_recall_used}.
|
||||||
|
Prova: ↓ <b>min_recall</b></div>`;
|
||||||
|
} else if (diag.drop_bbox_out_of_scene > 0) {
|
||||||
|
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_bbox_out_of_scene} match con bbox fuori scena.
|
||||||
|
Centro derivato male: aumenta <b>min_score</b> o restringi <b>search_roi</b></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const flags = [];
|
||||||
|
if (diag.use_polarity) flags.push("polarity");
|
||||||
|
if (diag.use_soft_score) flags.push("soft");
|
||||||
|
if (diag.subpixel_lm) flags.push("subpix-LM");
|
||||||
|
el.innerHTML = `
|
||||||
|
<div><b>Pipeline pruning:</b></div>
|
||||||
|
<div>varianti: ${diag.n_variants_total} → top_eval=${diag.n_variants_top_evaluated}
|
||||||
|
→ top_pass=${diag.n_variants_top_passed} → full_eval=${diag.n_variants_full_evaluated}</div>
|
||||||
|
<div><b>Candidati:</b> raw=${diag.n_raw_candidates}
|
||||||
|
→ pre_nms=${diag.n_after_pre_nms} → final=${diag.n_final}</div>
|
||||||
|
<div><b>Drop reasons:</b> NCC=${diag.drop_ncc_low}, score=${diag.drop_min_score_post_avg},
|
||||||
|
recall=${diag.drop_recall_low}, bbox=${diag.drop_bbox_out_of_scene}, NMS=${diag.drop_nms_iou}</div>
|
||||||
|
<div><b>Soglie:</b> top=${diag.top_thresh_used.toFixed(2)},
|
||||||
|
min_score=${diag.min_score_used.toFixed(2)},
|
||||||
|
NCC=${diag.verify_threshold_used.toFixed(2)},
|
||||||
|
recall=${diag.min_recall_used.toFixed(2)}</div>
|
||||||
|
${flags.length ? `<div><b>Flag attivi:</b> ${flags.join(", ")}</div>` : ""}
|
||||||
|
${hint}
|
||||||
|
`;
|
||||||
|
// Auto-apri pannello se 0 match (segnala problema)
|
||||||
|
if (n_matches === 0) {
|
||||||
|
document.getElementById("diag-panel").open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Auto-tune (Halcon-style) ----------
|
||||||
|
async function doAutoTune() {
|
||||||
|
if (!state.model || !state.roi) {
|
||||||
|
alert("Seleziona modello e disegna ROI prima di Auto-tune.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
status.textContent = "Analisi ROI in corso...";
|
||||||
|
try {
|
||||||
|
const r = await fetch("/auto_tune", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_id: state.model.id,
|
||||||
|
roi: state.roi,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
const t = await r.json();
|
||||||
|
// Applica ai campi avanzati (override automatico)
|
||||||
|
for (const [key] of ADV_PARAMS) {
|
||||||
|
const el = document.getElementById(`adv-${key}`);
|
||||||
|
if (el && t[key] !== undefined) el.value = String(t[key]);
|
||||||
|
}
|
||||||
|
// Espandi la sezione Avanzate per mostrare i valori applicati
|
||||||
|
const advDetails = document.querySelector("#col-params details:last-of-type");
|
||||||
|
if (advDetails) advDetails.open = true;
|
||||||
|
// Feedback diagnostico
|
||||||
|
const lines = [
|
||||||
|
`weak/strong: ${t.weak_grad} / ${t.strong_grad}`,
|
||||||
|
`feature: ${t.num_features}, piramide: ${t.pyramid_levels}`,
|
||||||
|
`angle: [${t.angle_min}..${t.angle_max}]@${t.angle_step}°`,
|
||||||
|
];
|
||||||
|
if (t._symmetry_order > 1) {
|
||||||
|
lines.push(`simmetria rotaz. ${t._symmetry_order}x (conf ${t._symmetry_conf})`);
|
||||||
|
}
|
||||||
|
if (t._self_score !== undefined) {
|
||||||
|
lines.push(`self-validation: ${t._validation}`);
|
||||||
|
}
|
||||||
|
status.textContent = `Auto-tune OK — ${lines[0]}`;
|
||||||
|
alert("Auto-tune completato:\n\n" + lines.join("\n"));
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = `Auto-tune errore: ${e.message}`;
|
||||||
|
alert(`Errore auto-tune: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- V: Recipe load/list/unload ----------
|
||||||
|
async function refreshRecipeList() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/recipes");
|
||||||
|
if (!r.ok) return;
|
||||||
|
const j = await r.json();
|
||||||
|
const sel = document.getElementById("hc-recipe-list");
|
||||||
|
const cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">— ricette disponibili —</option>';
|
||||||
|
for (const f of j.files) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = f.name;
|
||||||
|
o.textContent = `${f.name} (${(f.size / 1024).toFixed(1)} KB)`;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
if (cur) sel.value = cur;
|
||||||
|
} catch (e) { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipe() {
|
||||||
|
const sel = document.getElementById("hc-recipe-list");
|
||||||
|
const name = sel.value;
|
||||||
|
if (!name) {
|
||||||
|
alert("Seleziona una ricetta dalla lista.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/recipes/${encodeURIComponent(name)}/load`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
const j = await r.json();
|
||||||
|
state.active_recipe = j.name;
|
||||||
|
document.getElementById("recipe-status").textContent =
|
||||||
|
`Caricata: ${j.name} — ${j.n_variants} varianti, ` +
|
||||||
|
`${j.template_size[0]}x${j.template_size[1]} px` +
|
||||||
|
(j.use_polarity ? " (polarity)" : "");
|
||||||
|
document.getElementById("recipe-status").style.color = "#0c0";
|
||||||
|
document.getElementById("btn-unload-recipe").disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Errore caricamento: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadRecipe() {
|
||||||
|
state.active_recipe = null;
|
||||||
|
document.getElementById("recipe-status").textContent = "Nessuna ricetta caricata";
|
||||||
|
document.getElementById("recipe-status").style.color = "#888";
|
||||||
|
document.getElementById("btn-unload-recipe").disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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`);
|
||||||
|
refreshRecipeList();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Errore salvataggio: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", async () => {
|
window.addEventListener("DOMContentLoaded", async () => {
|
||||||
buildAdvancedForm();
|
buildAdvancedForm();
|
||||||
setupROI();
|
setupROI();
|
||||||
@@ -394,6 +788,15 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|||||||
e.target.value = ""; // consente re-upload stesso file
|
e.target.value = ""; // consente re-upload stesso file
|
||||||
});
|
});
|
||||||
document.getElementById("btn-match").addEventListener("click", doMatch);
|
document.getElementById("btn-match").addEventListener("click", doMatch);
|
||||||
|
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
|
||||||
|
document.getElementById("btn-save-recipe").addEventListener("click",
|
||||||
|
saveRecipe);
|
||||||
|
document.getElementById("btn-load-recipe").addEventListener("click",
|
||||||
|
loadRecipe);
|
||||||
|
document.getElementById("btn-unload-recipe").addEventListener("click",
|
||||||
|
unloadRecipe);
|
||||||
|
refreshRecipeList();
|
||||||
|
bindEdgePreviewControls();
|
||||||
const slider = document.getElementById("p-min-score");
|
const slider = document.getElementById("p-min-score");
|
||||||
slider.addEventListener("input", (e) => {
|
slider.addEventListener("input", (e) => {
|
||||||
document.getElementById("v-score").textContent =
|
document.getElementById("v-score").textContent =
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
<div class="picker-list"></div>
|
<div class="picker-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
|
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
|
||||||
|
<button class="btn" id="btn-autotune"
|
||||||
|
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
|
||||||
|
⚙ Auto-tune
|
||||||
|
</button>
|
||||||
<label class="btn" title="Carica nuovo file nella cartella immagini">
|
<label class="btn" title="Carica nuovo file nella cartella immagini">
|
||||||
⬆ Carica file
|
⬆ Carica file
|
||||||
<input type="file" id="file-upload" accept="image/*" hidden>
|
<input type="file" id="file-upload" accept="image/*" hidden>
|
||||||
@@ -41,6 +45,40 @@
|
|||||||
<canvas id="c-model" width="380" height="420"></canvas>
|
<canvas id="c-model" width="380" height="420"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div id="roi-info">ROI: (nessuna)</div>
|
<div id="roi-info">ROI: (nessuna)</div>
|
||||||
|
<details id="edge-preview-panel" style="margin-top:10px">
|
||||||
|
<summary>🔬 Anteprima edge / pulizia rumore</summary>
|
||||||
|
<div style="font-size:11px; color:#aaa; margin:4px 0">
|
||||||
|
Regola le soglie per togliere edge spuri (sporcizie). UCS rosso/verde
|
||||||
|
sul baricentro feature.
|
||||||
|
</div>
|
||||||
|
<div class="ep-grid">
|
||||||
|
<label class="ep-row">weak_grad <span id="ep-weak-v">30</span>
|
||||||
|
<input type="range" id="ep-weak" min="5" max="200" value="30" step="1">
|
||||||
|
</label>
|
||||||
|
<label class="ep-row">strong_grad <span id="ep-strong-v">60</span>
|
||||||
|
<input type="range" id="ep-strong" min="10" max="400" value="60" step="1">
|
||||||
|
</label>
|
||||||
|
<label class="ep-row">num_features <span id="ep-nf-v">96</span>
|
||||||
|
<input type="range" id="ep-nf" min="16" max="300" value="96" step="1">
|
||||||
|
</label>
|
||||||
|
<label class="ep-row">spacing <span id="ep-sp-v">3</span>
|
||||||
|
<input type="range" id="ep-sp" min="1" max="15" value="3" step="1">
|
||||||
|
</label>
|
||||||
|
<label class="ep-row" style="flex-direction:row; gap:6px">
|
||||||
|
<input type="checkbox" id="ep-pol"> polarity
|
||||||
|
</label>
|
||||||
|
<button class="btn" id="btn-edge-apply" type="button"
|
||||||
|
style="grid-column:1/-1">
|
||||||
|
✓ Applica ai parametri Avanzate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-wrap" style="margin-top:6px">
|
||||||
|
<canvas id="c-edge-preview" width="380" height="380"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="edge-preview-info" style="font-size:11px; color:#888; margin-top:4px">
|
||||||
|
Disegna ROI e apri questo pannello per generare anteprima
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="col" id="col-scene">
|
<section class="col" id="col-scene">
|
||||||
@@ -129,6 +167,77 @@
|
|||||||
<input type="number" id="p-max-matches" value="25" min="1" max="200">
|
<input type="number" id="p-max-matches" value="25" min="1" max="200">
|
||||||
</div>
|
</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 style="display:flex; gap:6px; margin-top:6px; align-items:center">
|
||||||
|
<select id="hc-recipe-list" style="flex:1">
|
||||||
|
<option value="">— ricette disponibili —</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" id="btn-load-recipe" type="button">📂 Carica</button>
|
||||||
|
<button class="btn" id="btn-unload-recipe" type="button" disabled>✖ Stacca</button>
|
||||||
|
</div>
|
||||||
|
<div id="recipe-status" style="margin-top:4px; font-size:11px; color:#888">
|
||||||
|
Nessuna ricetta caricata
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Avanzate</summary>
|
<summary>Avanzate</summary>
|
||||||
<div id="adv-form"></div>
|
<div id="adv-form"></div>
|
||||||
@@ -139,6 +248,16 @@
|
|||||||
<div class="kv"><span>find:</span><span id="t-find">-</span></div>
|
<div class="kv"><span>find:</span><span id="t-find">-</span></div>
|
||||||
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
|
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
|
||||||
<div class="kv"><span>match:</span><span id="t-match">-</span></div>
|
<div class="kv"><span>match:</span><span id="t-match">-</span></div>
|
||||||
|
|
||||||
|
<details id="diag-panel" style="margin-top:10px">
|
||||||
|
<summary>🔍 Diagnostica (CC)</summary>
|
||||||
|
<div id="diag-content" style="font-family:monospace; font-size:11px;
|
||||||
|
background:#1a1a1a; padding:8px;
|
||||||
|
border-radius:3px; margin-top:6px;
|
||||||
|
line-height:1.5">
|
||||||
|
<em style="color:#888">Esegui un MATCH per vedere la diagnostica</em>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -156,3 +156,35 @@ footer h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#col-model, #col-scene { min-width: 0; }
|
#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%; }
|
||||||
|
|
||||||
|
/* Edge preview panel */
|
||||||
|
.ep-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ep-row {
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
font-size: 11px; color: #aaa;
|
||||||
|
}
|
||||||
|
.ep-row input[type="range"] { width: 100%; }
|
||||||
|
.ep-row span { color: #fff; font-weight: bold; font-family: monospace; }
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ dependencies = [
|
|||||||
"uvicorn[standard]>=0.34",
|
"uvicorn[standard]>=0.34",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pm2d-eval = "pm2d.eval:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user