Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cc7881a51 | |||
| 9218cb2741 | |||
| 159f9089a5 | |||
| b718e81ccf | |||
| d46197a81a |
@@ -8,3 +8,5 @@ __pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
models/
|
models/
|
||||||
|
# Ricette pre-trained (generate da utente, non versionare)
|
||||||
|
recipes/*.npz
|
||||||
|
|||||||
+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())
|
||||||
@@ -676,6 +676,102 @@ def list_recipes():
|
|||||||
return {"files": files, "dir": str(RECIPES_DIR)}
|
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)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Mount static
|
# Mount static
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="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 ----------
|
||||||
@@ -307,7 +308,42 @@ 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;
|
||||||
|
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; }
|
||||||
@@ -447,6 +483,57 @@ async function doAutoTune() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 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 ----------
|
// ---------- V: Save recipe ----------
|
||||||
async function saveRecipe() {
|
async function saveRecipe() {
|
||||||
if (!state.model || !state.roi) {
|
if (!state.model || !state.roi) {
|
||||||
@@ -480,6 +567,7 @@ async function saveRecipe() {
|
|||||||
if (!r.ok) throw new Error(await r.text());
|
if (!r.ok) throw new Error(await r.text());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`);
|
alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`);
|
||||||
|
refreshRecipeList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(`Errore salvataggio: ${e.message}`);
|
alert(`Errore salvataggio: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -515,6 +603,11 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|||||||
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
|
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
|
||||||
document.getElementById("btn-save-recipe").addEventListener("click",
|
document.getElementById("btn-save-recipe").addEventListener("click",
|
||||||
saveRecipe);
|
saveRecipe);
|
||||||
|
document.getElementById("btn-load-recipe").addEventListener("click",
|
||||||
|
loadRecipe);
|
||||||
|
document.getElementById("btn-unload-recipe").addEventListener("click",
|
||||||
|
unloadRecipe);
|
||||||
|
refreshRecipeList();
|
||||||
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 =
|
||||||
|
|||||||
@@ -190,6 +190,16 @@
|
|||||||
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
|
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
|
||||||
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
|
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -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