ui: parametri user-friendly (tipo/simmetria/scala/precisione)

Nascosti i parametri tecnici (num_features, weak/strong_grad, spread,
pyramid) incomprensibili per operatori. Sostituiti da scelte semantiche:

- Tipo modello: intero | parziale
- Simmetria: nessuna | bilaterale (180) | rotazionale 3/4/6/8x
- Variazione scala: fissa | 10% | 25% | 50%
- Precisione: veloce 10 | normale 5 | preciso 2
- Score minimo: slider
- Max match: input

Server: nuovo endpoint POST /match_simple. Deriva tecnici via
_simple_to_technical(roi) che analizza la ROI:
- weak/strong_grad da percentili
- num_features da densita edge x tipo
- pyramid_levels da min(h,w) ROI
- spread_radius proporzionale

Frontend: select + slider, sezione Avanzate collassabile per override.

Test rings_and_nuts preset intero/nessuna/medio/normale: 3/3 ruote in 2.14s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 09:56:34 +02:00
parent fd7585acc5
commit d35bb574ef
4 changed files with 322 additions and 112 deletions
+140
View File
@@ -146,6 +146,94 @@ class TuneParams(BaseModel):
roi: list[int] roi: list[int]
# ---------- User-facing (simple) params ----------
SYMMETRY_TO_ANGLE_MAX = {
"nessuna": 360.0,
"bilaterale": 180.0,
"rot_3": 120.0,
"rot_4": 90.0,
"rot_6": 60.0,
"rot_8": 45.0,
}
SCALE_PRESETS = {
"fissa": (1.0, 1.0, 0.1),
"mini": (0.9, 1.1, 0.05), # ±10%
"medio": (0.75, 1.25, 0.05), # ±25%
"max": (0.5, 1.5, 0.05), # ±50%
}
PRECISION_ANGLE_STEP = {
"veloce": 10.0,
"normale": 5.0,
"preciso": 2.0,
}
class SimpleMatchParams(BaseModel):
model_id: str
scene_id: str
roi: list[int]
tipo: str = "intero" # "intero" | "parziale"
simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX
scala: str = "fissa" # chiave SCALE_PRESETS
precisione: str = "normale" # chiave PRECISION_ANGLE_STEP
min_score: float = 0.70
max_matches: int = 25
def _simple_to_technical(
p: SimpleMatchParams, roi_img: np.ndarray,
) -> dict:
"""Converti parametri user-facing → tecnici usando analisi della ROI."""
from pm2d.auto_tune import auto_tune as _auto
tune = _auto(roi_img)
h, w = roi_img.shape[:2]
min_side = min(h, w)
# Feature count: parziale = meno feature (area minore)
nf = tune["num_features"]
if p.tipo == "parziale":
nf = max(32, int(nf * 0.6))
# Piramide derivata da dimensione ROI
if min_side < 60:
pyr = 1
elif min_side < 150:
pyr = 2
elif min_side < 400:
pyr = 3
else:
pyr = 4
# Spread radius ~2-3% del lato minimo
spread = max(3, min(10, int(round(min_side * 0.03))))
angle_max = SYMMETRY_TO_ANGLE_MAX.get(p.simmetria, 360.0)
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0)
return {
"num_features": nf,
"weak_grad": tune["weak_grad"],
"strong_grad": tune["strong_grad"],
"spread_radius": spread,
"pyramid_levels": pyr,
"angle_min": 0.0,
"angle_max": angle_max,
"angle_step": ang_step,
"scale_min": smin,
"scale_max": smax,
"scale_step": sstep,
"min_score": p.min_score,
"max_matches": p.max_matches,
"nms_radius": 0,
"verify_threshold": 0.4,
}
# ---------------- Endpoints ---------------- # ---------------- Endpoints ----------------
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@@ -220,6 +308,58 @@ def match(p: MatchParams):
) )
@app.post("/match_simple", response_model=MatchResp)
def match_simple(p: SimpleMatchParams):
"""Match con parametri user-facing (tipo/simmetria/scala/precisione).
Il server deriva i parametri tecnici (num_features, soglie gradiente,
piramide, ecc.) dall'analisi automatica della ROI.
"""
model = _IMAGES.get(p.model_id)
scene = _IMAGES.get(p.scene_id)
if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi
x = max(0, x); y = max(0, y)
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w]
tech = _simple_to_technical(p, 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"],
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
t0 = time.time()
matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"],
)
t_find = time.time() - t0
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
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=t_train, find_time=t_find,
num_variants=n, annotated_id=ann_id,
)
@app.post("/auto_tune") @app.post("/auto_tune")
def tune(p: TuneParams): def tune(p: TuneParams):
model = _IMAGES.get(p.model_id) model = _IMAGES.get(p.model_id)
+94 -100
View File
@@ -1,21 +1,14 @@
// Pattern Matching 2D - frontend // Pattern Matching 2D - frontend (UI semplificata operator-friendly)
const PARAMS = [ // Parametri avanzati (sezione collassabile)
["angle_min", "Angolo min [°]", "float", 0], const ADV_PARAMS = [
["angle_max", "Angolo max [°]", "float", 360], ["num_features", "Num feature", "int", ""],
["angle_step", "Angolo step [°]", "float", 5], ["weak_grad", "Weak grad", "float", ""],
["scale_min", "Scala min", "float", 1], ["strong_grad", "Strong grad", "float", ""],
["scale_max", "Scala max", "float", 1], ["spread_radius", "Spread radius", "int", ""],
["scale_step", "Scala step", "float", 0.1], ["pyramid_levels", "Pyramid levels", "int", ""],
["min_score", "Score min [0..1]", "float", 0.55],
["max_matches", "Max match", "int", 25],
["nms_radius", "NMS radius (0=auto)", "int", 0],
["num_features", "Num feature", "int", 96],
["weak_grad", "Weak grad", "float", 30],
["strong_grad", "Strong grad", "float", 60],
["spread_radius", "Spread radius", "int", 5],
["pyramid_levels", "Pyramid levels", "int", 3],
["verify_threshold", "Verify NCC thr", "float", 0.4], ["verify_threshold", "Verify NCC thr", "float", 0.4],
["nms_radius", "NMS radius (0=auto)", "int", 0],
]; ];
const PALETTE = [ const PALETTE = [
@@ -24,27 +17,22 @@ const PALETTE = [
]; ];
const state = { const state = {
model: null, // {id, w, h, img:HTMLImageElement, scale, ox, oy} model: null, scene: null, roi: null, drag: null,
scene: null, matches: [], annotatedImg: null,
roi: null, // [x, y, w, h] in originale
drag: null, // {x0,y0,x1,y1} in canvas coords
matches: [],
annotatedImg: null, // HTMLImageElement annotated
}; };
// ---------- Params form ---------- // ---------- Forms ----------
function buildForm() { function buildAdvancedForm() {
const form = document.getElementById("params-form"); const form = document.getElementById("adv-form");
form.innerHTML = ""; form.innerHTML = "";
for (const [key, label, type, def] of PARAMS) { for (const [key, label, , def] of ADV_PARAMS) {
const lbl = document.createElement("label"); const lbl = document.createElement("label");
lbl.textContent = label; lbl.textContent = label;
lbl.htmlFor = `p-${key}`;
const inp = document.createElement("input"); const inp = document.createElement("input");
inp.id = `p-${key}`; inp.id = `adv-${key}`;
inp.name = key;
inp.value = String(def);
inp.type = "text"; inp.type = "text";
inp.placeholder = "auto";
inp.value = def === "" ? "" : String(def);
inp.addEventListener("keydown", (e) => { inp.addEventListener("keydown", (e) => {
if (e.key === "Enter") doMatch(); if (e.key === "Enter") doMatch();
}); });
@@ -53,23 +41,27 @@ function buildForm() {
} }
} }
function readParams() { function readUserParams() {
return {
tipo: document.getElementById("p-tipo").value,
simmetria: document.getElementById("p-simmetria").value,
scala: document.getElementById("p-scala").value,
precisione: document.getElementById("p-precisione").value,
min_score: parseFloat(document.getElementById("p-min-score").value),
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
};
}
function readAdvancedOverrides() {
const out = {}; const out = {};
for (const [key, , type] of PARAMS) { for (const [key, , type] of ADV_PARAMS) {
const v = document.getElementById(`p-${key}`).value.trim(); const v = document.getElementById(`adv-${key}`).value.trim();
if (v === "") continue;
out[key] = type === "int" ? parseInt(v, 10) : parseFloat(v); out[key] = type === "int" ? parseInt(v, 10) : parseFloat(v);
} }
return out; return out;
} }
function setParams(values) {
for (const [key] of PARAMS) {
if (values[key] !== undefined) {
document.getElementById(`p-${key}`).value = String(values[key]);
}
}
}
// ---------- Upload ---------- // ---------- Upload ----------
async function uploadFile(file) { async function uploadFile(file) {
const fd = new FormData(); const fd = new FormData();
@@ -103,8 +95,7 @@ async function onLoadScene(file) {
const meta = await uploadFile(file); const meta = await uploadFile(file);
const img = await loadImage(`/image/${meta.id}/raw`); const img = await loadImage(`/image/${meta.id}/raw`);
state.scene = { id: meta.id, w: meta.width, h: meta.height, img }; state.scene = { id: meta.id, w: meta.width, h: meta.height, img };
state.matches = []; state.matches = []; state.annotatedImg = null;
state.annotatedImg = null;
setStatus(`Scena: ${file.name} ${meta.width}x${meta.height}`); setStatus(`Scena: ${file.name} ${meta.width}x${meta.height}`);
renderScene(); renderScene();
} }
@@ -112,11 +103,8 @@ async function onLoadScene(file) {
// ---------- Rendering ---------- // ---------- Rendering ----------
function fitToCanvas(img, cw, ch) { function fitToCanvas(img, cw, ch) {
const sc = Math.min(cw / img.width, ch / img.height); const sc = Math.min(cw / img.width, ch / img.height);
const dw = img.width * sc; const dw = img.width * sc, dh = img.height * sc;
const dh = img.height * sc; return { sc, ox: (cw - dw) / 2, oy: (ch - dh) / 2, dw, dh };
const ox = (cw - dw) / 2;
const oy = (ch - dh) / 2;
return { sc, ox, oy, dw, dh };
} }
function renderModel() { function renderModel() {
@@ -127,26 +115,22 @@ function renderModel() {
if (!state.model) return; if (!state.model) return;
const fit = fitToCanvas(state.model.img, cnv.width, cnv.height); const fit = fitToCanvas(state.model.img, cnv.width, cnv.height);
state.model.scale = fit.sc; state.model.scale = fit.sc;
state.model.ox = fit.ox; state.model.ox = fit.ox; state.model.oy = fit.oy;
state.model.oy = fit.oy;
ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh); ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh);
if (state.roi) { if (state.roi) {
const [x, y, w, h] = state.roi; const [x, y, w, h] = state.roi;
ctx.strokeStyle = "#00ff80"; ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2;
ctx.lineWidth = 2;
ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc, ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc,
w * fit.sc, h * fit.sc); w * fit.sc, h * fit.sc);
} }
if (state.drag) { if (state.drag) {
ctx.strokeStyle = "#ffff00"; ctx.strokeStyle = "#ffff00";
ctx.setLineDash([4, 2]); ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
ctx.lineWidth = 2;
ctx.strokeRect( ctx.strokeRect(
Math.min(state.drag.x0, state.drag.x1), Math.min(state.drag.x0, state.drag.x1),
Math.min(state.drag.y0, state.drag.y1), Math.min(state.drag.y0, state.drag.y1),
Math.abs(state.drag.x1 - state.drag.x0), Math.abs(state.drag.x1 - state.drag.x0),
Math.abs(state.drag.y1 - state.drag.y0), Math.abs(state.drag.y1 - state.drag.y0));
);
ctx.setLineDash([]); ctx.setLineDash([]);
} }
} }
@@ -156,13 +140,10 @@ function renderScene() {
const ctx = cnv.getContext("2d"); const ctx = cnv.getContext("2d");
ctx.fillStyle = "#141414"; ctx.fillStyle = "#141414";
ctx.fillRect(0, 0, cnv.width, cnv.height); ctx.fillRect(0, 0, cnv.width, cnv.height);
if (state.annotatedImg) { const img = state.annotatedImg || (state.scene && state.scene.img);
const fit = fitToCanvas(state.annotatedImg, cnv.width, cnv.height); if (!img) return;
ctx.drawImage(state.annotatedImg, fit.ox, fit.oy, fit.dw, fit.dh); const fit = fitToCanvas(img, cnv.width, cnv.height);
} else if (state.scene) { ctx.drawImage(img, fit.ox, fit.oy, fit.dw, fit.dh);
const fit = fitToCanvas(state.scene.img, cnv.width, cnv.height);
ctx.drawImage(state.scene.img, fit.ox, fit.oy, fit.dw, fit.dh);
}
} }
// ---------- ROI drag ---------- // ---------- ROI drag ----------
@@ -185,12 +166,10 @@ function setupROI() {
state.drag.x1 = p.x; state.drag.y1 = p.y; state.drag.x1 = p.x; state.drag.y1 = p.y;
renderModel(); renderModel();
}); });
cnv.addEventListener("mouseup", (e) => { cnv.addEventListener("mouseup", () => {
if (!state.drag || !state.model) return; if (!state.drag || !state.model) return;
const d = state.drag; const d = state.drag; state.drag = null;
state.drag = null;
if (Math.abs(d.x1 - d.x0) < 4 || Math.abs(d.y1 - d.y0) < 4) return; if (Math.abs(d.x1 - d.x0) < 4 || Math.abs(d.y1 - d.y0) < 4) return;
// Canvas → originale
const m = state.model; const m = state.model;
const ix0 = Math.round((Math.min(d.x0, d.x1) - m.ox) / m.scale); const ix0 = Math.round((Math.min(d.x0, d.x1) - m.ox) / m.scale);
const iy0 = Math.round((Math.min(d.y0, d.y1) - m.oy) / m.scale); const iy0 = Math.round((Math.min(d.y0, d.y1) - m.oy) / m.scale);
@@ -207,57 +186,68 @@ function setupROI() {
}); });
} }
// ---------- Actions ---------- // ---------- Match action ----------
async function doMatch() { async function doMatch() {
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; }
const params = readParams(); const user = readUserParams();
const adv = readAdvancedOverrides();
setStatus("Match in corso..."); setStatus("Match in corso...");
const body = {
model_id: state.model.id, // Se utente ha fornito override avanzati → usa /match (tecnico)
scene_id: state.scene.id, // altrimenti /match_simple (operator mode)
roi: state.roi, const hasAdv = Object.keys(adv).length > 0;
...params, const url = hasAdv ? "/match" : "/match_simple";
}; let body;
const r = await fetch("/match", { if (hasAdv) {
// Merge simple → tecnici base, poi overrides
const SYM_MAP = {nessuna:360, bilaterale:180, rot_3:120,
rot_4:90, rot_6:60, rot_8:45};
const SCALE_MAP = {fissa:[1,1,0.1], mini:[0.9,1.1,0.05],
medio:[0.75,1.25,0.05], max:[0.5,1.5,0.05]};
const PREC_MAP = {veloce:10, normale:5, preciso:2};
const [smin, smax, sstep] = SCALE_MAP[user.scala];
body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
angle_min: 0, angle_max: SYM_MAP[user.simmetria] || 360,
angle_step: PREC_MAP[user.precisione] || 5,
scale_min: smin, scale_max: smax, scale_step: sstep,
min_score: user.min_score, max_matches: user.max_matches,
num_features: adv.num_features ?? 96,
weak_grad: adv.weak_grad ?? 30,
strong_grad: adv.strong_grad ?? 60,
spread_radius: adv.spread_radius ?? 5,
pyramid_levels: adv.pyramid_levels ?? 3,
verify_threshold: adv.verify_threshold ?? 0.4,
nms_radius: adv.nms_radius ?? 0,
};
} else {
body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
...user,
};
}
const r = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!r.ok) { if (!r.ok) {
const t = await r.text(); setStatus(`Errore: ${await r.text()}`); return;
setStatus(`Errore: ${t}`);
return;
} }
const data = await r.json(); const data = await r.json();
state.matches = data.matches; state.matches = data.matches;
state.annotatedImg = await loadImage( state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`, `/image/${data.annotated_id}/raw?t=${Date.now()}`);
);
renderScene(); renderScene();
renderLegend(); renderLegend();
document.getElementById("t-train").textContent = `${data.train_time.toFixed(2)}s`; document.getElementById("t-train").textContent = `${data.train_time.toFixed(2)}s`;
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;
setStatus(`${data.matches.length} match trovati`); setStatus(`${data.matches.length} match trovati${hasAdv ? " (avanzato)" : ""}`);
}
async function doAutoTune() {
if (!state.model || !state.roi) {
setStatus("Carica modello + seleziona ROI");
return;
}
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) { setStatus("Errore auto-tune"); return; }
const tune = await r.json();
setParams(tune);
setStatus("Auto-tune applicato — premi MATCH");
} }
function renderLegend() { function renderLegend() {
@@ -285,7 +275,7 @@ function setStatus(s) {
// ---------- Init ---------- // ---------- Init ----------
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
buildForm(); buildAdvancedForm();
setupROI(); setupROI();
document.getElementById("file-model").addEventListener("change", (e) => { document.getElementById("file-model").addEventListener("change", (e) => {
if (e.target.files[0]) onLoadModel(e.target.files[0]); if (e.target.files[0]) onLoadModel(e.target.files[0]);
@@ -294,7 +284,11 @@ window.addEventListener("DOMContentLoaded", () => {
if (e.target.files[0]) onLoadScene(e.target.files[0]); if (e.target.files[0]) onLoadScene(e.target.files[0]);
}); });
document.getElementById("btn-match").addEventListener("click", doMatch); document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-tune").addEventListener("click", doAutoTune); const slider = document.getElementById("p-min-score");
slider.addEventListener("input", (e) => {
document.getElementById("v-score").textContent =
parseFloat(e.target.value).toFixed(2);
});
renderModel(); renderModel();
renderScene(); renderScene();
}); });
+58 -5
View File
@@ -15,9 +15,8 @@
<label class="btn">📂 Scena <label class="btn">📂 Scena
<input type="file" id="file-scene" accept="image/*" hidden> <input type="file" id="file-scene" accept="image/*" hidden>
</label> </label>
<button class="btn" id="btn-tune">Auto-tune</button>
<button class="btn btn-go" id="btn-match">▶ MATCH</button> <button class="btn btn-go" id="btn-match">▶ MATCH</button>
<span id="status">Carica modello + disegna ROI + carica scena</span> <span id="status">Carica modello, disegna ROI, carica scena</span>
</div> </div>
</header> </header>
@@ -38,9 +37,63 @@
</section> </section>
<section class="col" id="col-params"> <section class="col" id="col-params">
<h2>PARAMETRI</h2> <h2>IMPOSTAZIONI</h2>
<div id="params-form"></div>
<h2 style="margin-top:16px">TEMPI</h2> <div class="field">
<label>Tipo modello</label>
<select id="p-tipo">
<option value="intero">Oggetto intero</option>
<option value="parziale">Parte di oggetto</option>
</select>
</div>
<div class="field">
<label>Simmetria</label>
<select id="p-simmetria">
<option value="nessuna">Nessuna (0..360°)</option>
<option value="bilaterale">Bilaterale (speculare 180°)</option>
<option value="rot_3">Rotazionale 3× (120°)</option>
<option value="rot_4">Rotazionale 4× (90°)</option>
<option value="rot_6">Rotazionale 6× (60°)</option>
<option value="rot_8">Rotazionale 8× (45°)</option>
</select>
</div>
<div class="field">
<label>Variazione scala</label>
<select id="p-scala">
<option value="fissa">Fissa (setup calibrato)</option>
<option value="mini">±10%</option>
<option value="medio">±25%</option>
<option value="max">±50%</option>
</select>
</div>
<div class="field">
<label>Precisione angolare</label>
<select id="p-precisione">
<option value="veloce">Veloce (10°)</option>
<option value="normale" selected>Normale (5°)</option>
<option value="preciso">Preciso (2°)</option>
</select>
</div>
<div class="field">
<label>Score minimo <span id="v-score">0.70</span></label>
<input type="range" id="p-min-score" min="0.30" max="0.95" step="0.05" value="0.70">
</div>
<div class="field">
<label>Max match</label>
<input type="number" id="p-max-matches" value="25" min="1" max="200">
</div>
<details>
<summary>Avanzate</summary>
<div id="adv-form"></div>
</details>
<h2 style="margin-top:14px">TEMPI</h2>
<div class="kv"><span>train:</span><span id="t-train">-</span></div> <div class="kv"><span>train:</span><span id="t-train">-</span></div>
<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>
+30 -7
View File
@@ -56,18 +56,41 @@ canvas {
margin-top: 6px; font-size: 12px; color: #aaa; margin-top: 6px; font-size: 12px; color: #aaa;
} }
#params-form { .field {
display: grid; grid-template-columns: 1fr 100px; gap: 4px 8px; margin-bottom: 10px;
} }
#params-form label { .field label {
font-size: 12px; display: flex; align-items: center; display: block; font-size: 12px; color: #b0b0b0;
margin-bottom: 3px;
} }
#params-form input { .field select, .field input {
width: 100%; background: #2a2a2a; color: #dcdcdc;
border: 1px solid #444; padding: 5px 6px; border-radius: 3px;
font-size: 12px;
}
.field input[type="range"] {
padding: 0; height: 26px;
}
.field select:focus, .field input:focus {
outline: 1px solid #00c8ff;
}
#v-score { color: #00c8ff; font-weight: bold; }
details { margin-top: 10px; font-size: 12px; }
details summary {
cursor: pointer; padding: 4px 0; color: #00c8ff;
}
#adv-form {
display: grid; grid-template-columns: 1fr 80px; gap: 3px 6px;
margin-top: 4px;
}
#adv-form label { font-size: 11px; color: #999; }
#adv-form input {
background: #2a2a2a; color: #dcdcdc; border: 1px solid #444; background: #2a2a2a; color: #dcdcdc; border: 1px solid #444;
padding: 4px 6px; border-radius: 3px; font-size: 12px; padding: 3px 5px; border-radius: 3px; font-size: 11px;
width: 100%; width: 100%;
} }
#params-form input:focus { outline: 1px solid #00c8ff; }
.kv { .kv {
display: flex; justify-content: space-between; display: flex; justify-content: space-between;