Files
Presa-Mano/web/app.js
T
Adriano Dal PastroandClaude Opus 4.7 ac573505b7 feat: initial commit — orchestratore presa-mano con AI assistant + dual-robot
Pipeline completa per calibrazione hand-eye e grasp planning su robot
Unitree G1/H1 con camera depth Nuwa-HP60C. Esposta come FastAPI + GUI vanilla
JS, con assistente AI (OpenRouter + tool calling curato) per pilotare il
sistema in linguaggio naturale.

Architettura:
- FastAPI orchestrator (porta da .env, default 8890) che coordina 4
  microservizi upstream: Nuwa-HP60C, marker-finder, YOLO, robot bridge.
- Dual-robot: services.g1 e services.h1 in config.yaml; switchabili a
  runtime via PUT /api/home/robot.
- Calibrazione hand-eye eye-to-hand split per-side (right/left).
- Marker pose: marker-finder riceve gli intrinseci RGB della Nuwa al boot,
  PnP server-side con quei dati. Nessun re-PnP locale richiesto.
- Depth-to-RGB: registrazione iterativa IR↔RGB per back-projection 3D
  corretta (Nuwa /api/measure usa coords IR; il nostro /api/perception/
  measure_rgb prende pixel RGB e ritorna 3D nel frame RGB OpenCV).
- Correzione affine sul depth bias (calibrabile via marker come
  ground-truth) — riduce errore Z da ~6% a ~1.5% nei test.
- AI assistant: 16 tool curati (move_wrist_*, hand_action, get_marker[_3d],
  detect_object, calibration_*, set_active_robot…), system prompt
  dinamico che riflette robot attivo + marker config + addendum
  customizzabile da GUI. Temperature di default 0.1 per evitare invenzioni.

GUI (7 tab):
- Home: stato comunicazione device + selettore robot attivo G1/H1
- Assistente AI: chat con OpenRouter, history persistita lato browser,
  task riutilizzabili (prompt salvati), trace tool calls
- Impostazioni AI: modello da combo curata, temperature, max iterations,
  prompt addendum, anteprima system prompt
- Calibrazione, Auto-Calibrazione, TrovaUtensile, Servo Visuale, Vista 3D,
  Movimento EE — invariate dalle versioni precedenti

Setup:
- Variabili in presa-mano/.env (template in .env.example)
- OPENROUTER_API_KEY richiesta solo per il tab Assistente AI
- PRESA_MANO_PORT configurabile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:28:23 +02:00

1733 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* presa-mano GUI */
const $ = (id) => document.getElementById(id);
// ---------- tab switching ----------
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
$('tab-' + btn.dataset.tab).classList.add('active');
});
});
function setStatus(msg, cls = '') {
const strip = $('statusStrip');
strip.textContent = msg || '';
strip.style.color = cls === 'err' ? 'var(--danger)' : cls === 'ok' ? 'var(--ok)' : 'var(--dim)';
}
// ---------- button flash + toast feedback ----------
// Used by every command-sending handler so the user sees an immediate,
// unambiguous success/failure signal (beyond the log panels that require
// scrolling). Call flashBtn(btn, ok) to flash the button, and toast(title,
// body, ok) to show a corner notification.
function flashBtn(btn, ok) {
if (!btn || !btn.classList) return;
btn.classList.remove('flash-ok', 'flash-err');
// restart animation
void btn.offsetWidth;
btn.classList.add(ok ? 'flash-ok' : 'flash-err');
setTimeout(() => btn.classList.remove(ok ? 'flash-ok' : 'flash-err'),
ok ? 900 : 1300);
}
function toast(title, body, ok = true, ttlMs = 3500) {
const host = $('toastHost');
if (!host) return;
const el = document.createElement('div');
el.className = 'toast ' + (ok ? 'ok' : 'err');
const t = document.createElement('div');
t.className = 't-title';
t.textContent = (ok ? '✓ ' : '✗ ') + title;
el.appendChild(t);
if (body) {
const b = document.createElement('div');
b.className = 't-body';
b.textContent = typeof body === 'string' ? body : JSON.stringify(body);
el.appendChild(b);
}
host.appendChild(el);
setTimeout(() => { el.classList.add('fade'); }, ttlMs - 400);
setTimeout(() => { el.remove(); }, ttlMs);
}
// Short synthetic beep via Web Audio — no external asset. Used to audibly flag
// new anomalies that flip the system from OK -> WARN/ERR. Gated by the
// `diagBeep` checkbox so users can silence it.
function beep(freq = 880, ms = 140, gain = 0.18) {
if (!window.AudioContext && !window.webkitAudioContext) return;
try {
const ctx = beep._ctx || (beep._ctx = new (window.AudioContext || window.webkitAudioContext)());
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
g.gain.value = gain;
osc.connect(g); g.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + ms / 1000);
} catch {}
}
function beepWarn() { beep(880, 130); }
function beepErr() {
beep(440, 150);
setTimeout(() => beep(330, 180), 170);
}
// Summarise a successful response in one short line — prefer "dry-run" hint
// or response 'ok', else truncated JSON.
function summarizeOk(resp) {
if (!resp || typeof resp !== 'object') return '';
if (resp.dry_run) return 'dry-run (nessun comando reale inviato)';
if (resp.ok === true && Object.keys(resp).length <= 3) return 'ok';
const short = JSON.stringify(resp);
return short.length > 140 ? short.slice(0, 140) + '…' : short;
}
function fmtTimings(t) {
if (!t || typeof t !== 'object') return '';
// Put 'total' last; others in insertion order.
const keys = Object.keys(t).filter(k => k !== 'total');
const parts = keys.map(k => `${k}=${Number(t[k]).toFixed(1)}ms`);
if (t.total != null) parts.push(`TOTAL=${Number(t.total).toFixed(1)}ms`);
return parts.join(' ');
}
async function api(method, path, body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
const text = await r.text();
let data; try { data = JSON.parse(text); } catch { data = text; }
if (!r.ok) {
const detail = (data && data.detail) || text || r.statusText;
throw new Error(`${r.status} ${detail}`);
}
return data;
}
// ==================== CALIBRAZIONE ====================
const calibCanvas = $('calibCanvas');
const calibCtx = calibCanvas.getContext('2d');
let displayedRgb = null; // last successfully loaded Image (stays on screen)
let rgbInFlight = false;
let previewInFlight = false;
let lastMarker = null;
let lastMarkerTs = 0;
const MARKER_STALE_MS = 1500; // keep overlay this long across hiccups
function drawCalibFrame() {
if (displayedRgb) {
if (calibCanvas.width !== displayedRgb.naturalWidth) {
calibCanvas.width = displayedRgb.naturalWidth;
calibCanvas.height = displayedRgb.naturalHeight;
}
calibCtx.drawImage(displayedRgb, 0, 0);
} else {
calibCtx.fillStyle = '#000';
calibCtx.fillRect(0, 0, calibCanvas.width, calibCanvas.height);
calibCtx.fillStyle = '#666';
calibCtx.font = '14px sans-serif';
calibCtx.fillText('no RGB', 20, 30);
}
const fresh = lastMarker && lastMarker.found && (performance.now() - lastMarkerTs < MARKER_STALE_MS);
if (fresh) {
const c = lastMarker.corners;
calibCtx.strokeStyle = '#3ecf8e';
calibCtx.lineWidth = 3;
calibCtx.beginPath();
calibCtx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i++) calibCtx.lineTo(c[i][0], c[i][1]);
calibCtx.closePath();
calibCtx.stroke();
calibCtx.fillStyle = '#3ecf8e';
c.forEach(p => { calibCtx.beginPath(); calibCtx.arc(p[0], p[1], 4, 0, 2 * Math.PI); calibCtx.fill(); });
const cx = lastMarker.center_px[0], cy = lastMarker.center_px[1];
calibCtx.font = 'bold 14px sans-serif';
const d = lastMarker.pose_camera && lastMarker.pose_camera.distance_m;
calibCtx.fillText(`ID ${lastMarker.marker_id}` + (d ? ` · ${(d*100).toFixed(1)} cm` : ''), cx + 10, cy - 10);
}
}
function refreshRgb() {
if (rgbInFlight) return;
rgbInFlight = true;
const img = new Image();
img.onload = () => {
displayedRgb = img;
rgbInFlight = false;
drawCalibFrame();
};
img.onerror = () => { rgbInFlight = false; /* keep previous frame */ };
img.src = `/api/calibration/rgb.jpg?t=${Date.now()}`;
}
async function previewMarker() {
if (previewInFlight) return;
if (!$('livePreview').checked) {
if (lastMarker) { lastMarker = null; drawCalibFrame(); }
return;
}
previewInFlight = true;
try {
const body = { marker_id: +$('arucoId').value, size_mm: +$('arucoSize').value };
const data = await api('POST', '/api/calibration/preview', body);
lastMarker = data;
lastMarkerTs = performance.now();
const info = $('markerInfo');
const t = fmtTimings(data.timings_ms);
if (data.found) {
const d = data.pose_camera && data.pose_camera.distance_m;
info.textContent = `marker ${data.marker_id} @ ${(d*100).toFixed(1)} cm · ${t}`;
info.style.color = 'var(--ok)';
} else {
info.textContent = `marker ${data.marker_id} non trovato · ${t}`;
info.style.color = 'var(--warn)';
}
} catch (e) {
// transient failure — don't wipe the overlay
$('markerInfo').textContent = `preview: ${e.message}`;
$('markerInfo').style.color = 'var(--danger)';
} finally {
previewInFlight = false;
drawCalibFrame();
}
}
// Two independent loops so a slow preview doesn't starve the RGB redraw,
// and a slow RGB doesn't block the marker polling.
setInterval(() => {
if ($('tab-calibration').classList.contains('active')) refreshRgb();
}, 150);
setInterval(() => {
if ($('tab-calibration').classList.contains('active')) previewMarker();
}, 500);
$('sampleBtn').addEventListener('click', async () => {
try {
setStatus('Acquisisco sample…');
const body = {
marker_id: +$('arucoId').value,
size_mm: +$('arucoSize').value,
side: $('side').value,
};
const r = await api('POST', '/api/calibration/sample', body);
setStatus(`Sample #${r.total} acquisito · ${fmtTimings(r.timings_ms)}`, 'ok');
await loadSamples();
} catch (e) { setStatus(e.message, 'err'); }
});
$('solveBtn').addEventListener('click', async () => {
try {
setStatus('Calcolo matrice…');
const side = $('side').value;
const r = await api('POST', '/api/calibration/solve', { method: $('method').value, side });
renderCalibResult(r);
setStatus(`Matrice calcolata (${side}) · ${fmtTimings(r.timings_ms)}`, 'ok');
} catch (e) { setStatus(e.message, 'err'); }
});
$('clearSamplesBtn').addEventListener('click', async () => {
const side = $('side').value;
if (!confirm(`Rimuovere tutti i sample del braccio ${side}?`)) return;
await api('DELETE', `/api/calibration/samples?side=${encodeURIComponent(side)}`);
await loadSamples();
});
$('saveConfigBtn').addEventListener('click', async () => {
const patch = {
aruco: { wrist_marker: { id: +$('arucoId').value, size_mm: +$('arucoSize').value } },
robot: { wrist_side: $('side').value },
calibration: { method: $('method').value },
};
try {
await api('PUT', '/api/config', patch);
setStatus('Configurazione salvata', 'ok');
} catch (e) { setStatus(e.message, 'err'); }
});
// When the user changes the side dropdown, reload per-side samples/result.
$('side').addEventListener('change', async () => {
await loadSamples().catch(() => {});
await loadCalibResult().catch(() => {});
});
// index -> { reason, detail } for samples the last solve dropped
const lastDrops = new Map();
async function loadSamples() {
const side = $('side') ? $('side').value : 'right';
const r = await api('GET', `/api/calibration/samples?side=${encodeURIComponent(side)}`);
$('sampleCount').textContent = r.total;
const ul = $('sampleList');
ul.innerHTML = '';
// Display newest first while keeping the server-side index (used in drop reports).
for (let i = r.samples.length - 1; i >= 0; i--) {
const s = r.samples[i];
const t = s.T_base_wrist;
const p = [t[0][3], t[1][3], t[2][3]].map(v => v.toFixed(3)).join(', ');
const li = document.createElement('li');
const drop = lastDrops.get(i);
let line = `#${i+1} side=${s.side} pos=[${p}] ${s.note || ''}`;
if (drop) {
line += ` · scartato (${drop.reason}: ${drop.detail})`;
li.classList.add('sample-dropped');
}
li.textContent = line;
ul.appendChild(li);
}
}
function renderCalibResult(r) {
const m = r.T_base_camera;
const fmt = (v) => (v >= 0 ? ' ' : '') + v.toFixed(4);
const rows = m.map(row => row.map(fmt).join(' '));
$('calibMatrix').textContent = rows.join('\n');
const meta = [
`method=${r.method}`,
`n=${r.n_samples}`,
`residual: ${r.residual_translation_mm.toFixed(2)} mm / ${r.residual_rotation_deg.toFixed(2)}°`,
];
const sc = r.sanity_check;
lastDrops.clear();
if (sc && sc.applied) {
const drops = [];
const pa = sc.dropped_by_pair_angle || [];
const wm = sc.dropped_by_wm_cluster || [];
const pq = sc.dropped_by_pose_quality || [];
for (const d of pq) {
const key = d.reprojection_px != null ? 'reproj' : 'ambiguity';
const val = d.reprojection_px != null ? `${d.reprojection_px.toFixed(2)} px` : `amb ${(d.ambiguity_ratio*100).toFixed(0)}%`;
lastDrops.set(d.sample_index, { reason: key, detail: val });
}
for (const d of pa) {
lastDrops.set(d.sample_index,
{ reason: 'pair-angle', detail: `${d.pair_angle_err_deg.toFixed(1)}°` });
}
for (const d of wm) {
// A sample can fail both checks; prefer the wm message (happens after solve).
lastDrops.set(d.sample_index,
{ reason: 'T_wrist_marker', detail: `${d.wm_translation_dev_mm.toFixed(1)} mm` });
}
if (pq.length) drops.push(`pose-quality: ${pq.map(d => `#${d.sample_index+1}(${d.reprojection_px != null ? d.reprojection_px.toFixed(2)+'px' : (d.ambiguity_ratio*100).toFixed(0)+'%'})`).join(', ')}`);
if (pa.length) drops.push(`pair-angle: ${pa.map(d => `#${d.sample_index+1}(${d.pair_angle_err_deg.toFixed(1)}°)`).join(', ')}`);
if (wm.length) drops.push(`T_wrist_marker: ${wm.map(d => `#${d.sample_index+1}(${d.wm_translation_dev_mm.toFixed(1)}mm)`).join(', ')}`);
const totalDropped = (sc.dropped_by_pose_quality || []).length + pa.length + wm.length;
if (drops.length) {
meta.push(`\n⚠ scartati ${totalDropped}/${sc.n_input}: ${drops.join(' · ')}`);
} else {
meta.push(`\n✓ nessun outlier (${sc.n_input} sample usati)`);
}
}
$('calibMeta').textContent = meta.join(' ');
$('calibMeta').style.whiteSpace = 'pre-wrap';
// Refresh the list so dropped rows turn red (if already visible).
loadSamples().catch(() => {});
}
async function loadCalibResult() {
const side = $('side') ? $('side').value : 'right';
try { renderCalibResult(await api('GET', `/api/calibration/result?side=${encodeURIComponent(side)}`)); } catch {}
}
// auto-grid
$('runGridBtn').addEventListener('click', async () => {
const quat = $('gridQuat').value.split(',').map(Number);
if (quat.length !== 4) { setStatus('quat deve avere 4 valori', 'err'); return; }
const body = {
start_position: [+$('gridX0').value, +$('gridY0').value, +$('gridZ0').value],
orientation_quaternion_xyzw: quat,
step_x_m: +$('gridDX').value,
step_y_m: +$('gridDY').value,
step_z_m: +$('gridDZ').value,
n_x: 3, n_y: 3, n_z: 3,
min_z_m: +$('gridMinZ').value,
settle_s: +$('gridSettle').value,
dry_run: $('gridDryRun').checked,
auto_solve: true,
};
$('gridLog').textContent = 'run in progress…';
try {
const r = await api('POST', '/api/calibration/auto_grid', body);
$('gridLog').textContent = JSON.stringify(r, null, 2);
if (r.calibration) renderCalibResult(r.calibration);
await loadSamples();
setStatus(body.dry_run ? 'auto-grid dry-run completato' : 'auto-grid completato', 'ok');
} catch (e) {
$('gridLog').textContent = e.message;
setStatus(e.message, 'err');
}
});
// ==================== TROVA UTENSILE ====================
const graspCanvas = $('graspCanvas');
const graspCtx = graspCanvas.getContext('2d');
let displayedGrasp = null;
let graspRgbInFlight = false;
const cropCanvas = $('cropCanvas');
const cropCtx = cropCanvas.getContext('2d');
const scatterXY = $('scatterXY');
const scatterXZ = $('scatterXZ');
let lastDetections = []; // all detections, sorted by confidence desc
let lastSelectedClass = null; // class name whose point cloud is displayed
let lastCropBbox = null;
let lastPoints = null; // array of [x,y,z]
let knownClasses = []; // names discovered from /api/perception/classes
// Stable per-class color palette (Okabe-Ito, colour-blind safe).
const CLASS_PALETTE = ['#4da3ff', '#f39c12', '#2ecc71', '#e74c3c', '#9b59b6', '#1abc9c', '#e67e22', '#fd79a8'];
function colorForClass(name) {
if (!name) return '#888';
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
return CLASS_PALETTE[Math.abs(h) % CLASS_PALETTE.length];
}
function drawGraspFrame() {
if (displayedGrasp) {
if (graspCanvas.width !== displayedGrasp.naturalWidth) {
graspCanvas.width = displayedGrasp.naturalWidth;
graspCanvas.height = displayedGrasp.naturalHeight;
}
graspCtx.drawImage(displayedGrasp, 0, 0);
}
lastDetections.forEach(d => {
const b = d.bbox;
const color = colorForClass(d.class);
const isSel = lastSelectedClass && d.class === lastSelectedClass;
graspCtx.strokeStyle = color;
graspCtx.lineWidth = isSel ? 4 : 2;
graspCtx.setLineDash(isSel ? [] : [6, 4]);
graspCtx.strokeRect(b.x1, b.y1, b.x2 - b.x1, b.y2 - b.y1);
graspCtx.setLineDash([]);
const label = `${d.class || '?'} ${(d.confidence*100).toFixed(0)}%${isSel ? ' ★' : ''}`;
graspCtx.font = 'bold 13px sans-serif';
const tw = graspCtx.measureText(label).width + 8;
const ty = b.y1 > 18 ? b.y1 - 4 : b.y1 + 16;
graspCtx.fillStyle = color;
graspCtx.fillRect(b.x1, ty - 14, tw, 18);
graspCtx.fillStyle = '#0b0f14';
graspCtx.fillText(label, b.x1 + 4, ty);
});
}
function drawCrop() {
cropCtx.fillStyle = '#000';
cropCtx.fillRect(0, 0, cropCanvas.width, cropCanvas.height);
if (!lastCropBbox || !displayedGrasp) return;
const { x1, y1, x2, y2 } = lastCropBbox;
const sw = Math.max(1, x2 - x1), sh = Math.max(1, y2 - y1);
const scale = Math.min(cropCanvas.width / sw, cropCanvas.height / sh);
const dw = sw * scale, dh = sh * scale;
const dx = (cropCanvas.width - dw) / 2, dy = (cropCanvas.height - dh) / 2;
cropCtx.drawImage(displayedGrasp, x1, y1, sw, sh, dx, dy, dw, dh);
cropCtx.strokeStyle = '#4da3ff';
cropCtx.lineWidth = 2;
cropCtx.strokeRect(dx, dy, dw, dh);
}
function drawScatter(canvas, pts, axU, axV, label) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0d1014';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (!pts || pts.length === 0) {
ctx.fillStyle = '#666';
ctx.font = '12px sans-serif';
ctx.fillText('(no points)', 10, 20);
return;
}
let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
for (const p of pts) {
if (p[axU] < uMin) uMin = p[axU]; if (p[axU] > uMax) uMax = p[axU];
if (p[axV] < vMin) vMin = p[axV]; if (p[axV] > vMax) vMax = p[axV];
}
const pad = 20;
const du = Math.max(1e-6, uMax - uMin), dv = Math.max(1e-6, vMax - vMin);
const s = Math.min((canvas.width - 2*pad) / du, (canvas.height - 2*pad) / dv);
// axes
ctx.strokeStyle = '#2a2f37';
ctx.strokeRect(pad, pad, canvas.width - 2*pad, canvas.height - 2*pad);
// points
ctx.fillStyle = '#3ecf8e';
for (const p of pts) {
const x = pad + (p[axU] - uMin) * s;
const y = canvas.height - pad - (p[axV] - vMin) * s;
ctx.fillRect(x - 1, y - 1, 2, 2);
}
ctx.fillStyle = '#9aa0a6';
ctx.font = '11px ui-monospace, monospace';
ctx.fillText(label, pad + 2, 14);
ctx.fillText(`u:[${uMin.toFixed(2)},${uMax.toFixed(2)}] v:[${vMin.toFixed(2)},${vMax.toFixed(2)}]`, pad, canvas.height - 4);
}
function drawScatters() {
drawScatter(scatterXY, lastPoints, 0, 1, 'Top-down (X→, Y↑)');
drawScatter(scatterXZ, lastPoints, 0, 2, 'Laterale (X→, Z↑)');
}
function refreshGraspRgb() {
if (graspRgbInFlight) return;
graspRgbInFlight = true;
const img = new Image();
img.onload = () => {
displayedGrasp = img;
graspRgbInFlight = false;
drawGraspFrame();
drawCrop();
};
img.onerror = () => { graspRgbInFlight = false; };
img.src = `/api/calibration/rgb.jpg?t=${Date.now()}`;
}
setInterval(() => {
if ($('tab-grasp').classList.contains('active')) refreshGraspRgb();
}, 150);
// click-to-measure on RGB
graspCanvas.addEventListener('click', async (ev) => {
const rect = graspCanvas.getBoundingClientRect();
const u = Math.round((ev.clientX - rect.left) * (graspCanvas.width / rect.width));
const v = Math.round((ev.clientY - rect.top) * (graspCanvas.height / rect.height));
$('measureInfo').textContent = `measuring (u=${u}, v=${v})…`;
try {
const r = await api('GET', `/api/perception/measure?u=${u}&v=${v}&radius=6`);
const cam = r.camera || {};
let line = `(u=${u}, v=${v}) cam: `;
const xyz = (o) => o && (o.x !== undefined ? `x=${(+o.x).toFixed(3)} y=${(+o.y).toFixed(3)} z=${(+o.z).toFixed(3)}` : JSON.stringify(o));
line += xyz(cam.point || cam.xyz || cam);
const d = cam.distance_m ?? cam.distance ?? null;
if (d != null) line += ` d=${(+d*100).toFixed(1)} cm`;
if (r.base) line += ` base: ${xyz(r.base)}`;
const t = fmtTimings(r.timings_ms);
if (t) line += ` [${t}]`;
$('measureInfo').textContent = line;
// marker
graspCtx.fillStyle = '#e74c3c';
graspCtx.beginPath(); graspCtx.arc(u, v, 5, 0, 2*Math.PI); graspCtx.fill();
} catch (e) { $('measureInfo').textContent = `measure error: ${e.message}`; }
});
function pickedClass() {
const v = $('graspObjectPick').value;
return v ? v : null;
}
// Apply a /scan response to local state + canvases.
function applyScanResponse(r) {
lastDetections = Array.isArray(r.detections) ? r.detections : [];
const sel = r.selected || null;
lastSelectedClass = sel ? sel.class : null;
lastCropBbox = sel ? sel.bbox : null;
lastPoints = r.points_base || r.points_camera || null;
refreshObjectPickOptions(lastDetections);
renderGraspLegend(lastDetections);
drawGraspFrame(); drawCrop(); drawScatters();
}
$('detectBtn').addEventListener('click', async () => {
try {
setStatus('YOLO + pointcloud…');
const body = {
conf: +$('graspConf').value || null,
class_name: pickedClass(),
include_points: true,
};
const r = await api('POST', '/api/perception/scan', body);
applyScanResponse(r);
$('graspOut').textContent = JSON.stringify({
detections: r.detections,
selected: r.selected,
n_points: r.n_points,
frame: r.points_base ? 'base' : 'camera',
timings_ms: r.timings_ms,
}, null, 2);
const nDet = r.detections ? r.detections.length : 0;
setStatus(`${nDet} detection · ${r.n_points || 0} punti · ${fmtTimings(r.timings_ms)}`, 'ok');
} catch (e) { setStatus(e.message, 'err'); }
});
$('planBtn').addEventListener('click', async () => {
try {
setStatus('Pianifico…');
const body = { conf: +$('graspConf').value || null, class_name: pickedClass() };
const r = await api('POST', '/api/grasp/plan', body);
if (r.detection) {
// keep the multi-class view, but highlight the planned class
lastSelectedClass = r.detection.class || lastSelectedClass;
lastCropBbox = r.detection.bbox;
if (!lastDetections.some(d => d.class === r.detection.class)) {
lastDetections = [r.detection, ...lastDetections];
}
}
$('graspOut').textContent = JSON.stringify(r, null, 2);
renderGraspLegend(lastDetections);
drawGraspFrame(); drawCrop();
setStatus(`Plan ok · ${fmtTimings(r.timings_ms)}`, 'ok');
} catch (e) { setStatus(e.message, 'err'); }
});
// --- continuous detection loop ---
const STATS_WINDOW = 5;
const MISS_LIMIT = 5; // consecutive frames without detection before clearing the view
let loopTimer = null;
let loopInFlight = false;
let recentCentroids = []; // [[x,y,z], ...] in base frame if calibrated, else camera
let missStreak = 0;
function clearDetectionState(reason) {
lastDetections = [];
lastSelectedClass = null;
lastCropBbox = null;
lastPoints = null;
recentCentroids = [];
drawGraspFrame(); // redraws RGB without bbox overlay
drawCrop(); // wipes crop thumbnail
drawScatters(); // wipes scatter plots
updateStats(); // resets table to dashes
refreshObjectPickOptions([]);
renderGraspLegend([]);
$('graspOut').textContent = reason || '(nessuna detection)';
}
function centroidOf(pts) {
if (!pts || !pts.length) return null;
let sx = 0, sy = 0, sz = 0;
for (const p of pts) { sx += p[0]; sy += p[1]; sz += p[2]; }
return [sx / pts.length, sy / pts.length, sz / pts.length];
}
function updateStats() {
const rows = $('statsTable').querySelectorAll('tbody tr');
const empty = ['—','—','—','—','—'];
$('statsWindow').textContent = STATS_WINDOW;
if (recentCentroids.length === 0) {
rows.forEach(r => { r.children[1].textContent='—'; r.children[2].textContent='—'; r.children[3].textContent='—'; r.children[4].textContent='—'; r.children[5].textContent='—'; });
return;
}
const n = recentCentroids.length;
for (let axis = 0; axis < 3; axis++) {
const vals = recentCentroids.map(c => c[axis]);
const mean = vals.reduce((a,b) => a+b, 0) / n;
const vmin = Math.min(...vals), vmax = Math.max(...vals);
const variance = n > 1 ? vals.reduce((s,v) => s + (v-mean)*(v-mean), 0) / (n - 1) : 0;
const std = Math.sqrt(variance);
const r = rows[axis];
r.children[1].textContent = mean.toFixed(4);
r.children[2].textContent = (std * 1000).toFixed(2);
r.children[3].textContent = ((vmax - vmin) * 1000).toFixed(2);
r.children[4].textContent = vmin.toFixed(4);
r.children[5].textContent = vmax.toFixed(4);
}
}
async function loopTick() {
if (loopInFlight) return;
loopInFlight = true;
const t0 = performance.now();
try {
const body = {
conf: +$('graspConf').value || null,
class_name: pickedClass(),
include_points: true,
};
const r = await api('POST', '/api/perception/scan', body);
const pts = r.points_base || r.points_camera || [];
const frame = r.points_base ? 'base' : 'camera';
lastDetections = Array.isArray(r.detections) ? r.detections : [];
lastSelectedClass = r.selected ? r.selected.class : null;
lastCropBbox = r.selected ? r.selected.bbox : null;
lastPoints = pts;
refreshObjectPickOptions(lastDetections);
renderGraspLegend(lastDetections);
drawGraspFrame(); drawCrop(); drawScatters();
const c = centroidOf(pts);
if (c) {
recentCentroids.push(c);
if (recentCentroids.length > STATS_WINDOW) recentCentroids.shift();
updateStats();
}
const nDet = lastDetections.length;
if (nDet === 0) throw new Error('404 no object detected');
missStreak = 0;
const dt = performance.now() - t0;
const selMsg = r.selected
? `sel=${lastSelectedClass}`
: (pickedClass() ? `sel=${pickedClass()} NON rilevata` : 'sel=—');
$('statsMeta').textContent =
`loop ON · ${nDet} detection · ${selMsg} · frame=${frame} · n=${r.n_points} · iter ${dt.toFixed(0)}ms · ${fmtTimings(r.timings_ms)}`;
$('statsMeta').style.color = r.selected ? 'var(--ok)' : 'var(--warn)';
} catch (e) {
const msg = e.message || '';
// Classify:
// - true miss (404/422): object not detected → increments miss streak
// - transient upstream (500/502/503 from YOLO/Nuwa): skip silently, no streak bump
// - anything else: surface as real error
const isMiss = /^404\b|no object|^422\b|too few/i.test(msg);
const isTransient = /^(500|502|503)\b|bad gateway|service unavailable|temporarily/i.test(msg);
if (isMiss) {
missStreak++;
if (missStreak >= MISS_LIMIT) {
clearDetectionState(`nessun oggetto per ${MISS_LIMIT} frame consecutivi`);
$('statsMeta').textContent = `loop ON · nessun oggetto per ${MISS_LIMIT} frame → dati azzerati`;
$('statsMeta').style.color = 'var(--warn)';
} else {
$('statsMeta').textContent = `loop ON · miss ${missStreak}/${MISS_LIMIT}`;
$('statsMeta').style.color = 'var(--warn)';
}
} else if (isTransient) {
$('statsMeta').textContent = `loop ON · upstream busy (skip): ${msg}`;
$('statsMeta').style.color = 'var(--warn)';
} else {
$('statsMeta').textContent = `loop error: ${msg}`;
$('statsMeta').style.color = 'var(--danger)';
}
} finally {
loopInFlight = false;
}
}
function startLoop() {
if (loopTimer) return;
const rate = Math.max(100, +$('loopRateMs').value || 500);
missStreak = 0;
recentCentroids = [];
updateStats();
loopTick();
loopTimer = setInterval(loopTick, rate);
const b = $('loopBtn');
b.textContent = '■ Stop loop';
b.classList.add('running');
$('statsMeta').textContent = 'loop ON';
$('statsMeta').style.color = 'var(--ok)';
}
function stopLoop() {
if (loopTimer) { clearInterval(loopTimer); loopTimer = null; }
const b = $('loopBtn');
b.textContent = '▶ Loop riconoscimento';
b.classList.remove('running');
$('statsMeta').textContent = 'loop inattivo';
$('statsMeta').style.color = 'var(--dim)';
}
$('loopBtn').addEventListener('click', () => (loopTimer ? stopLoop() : startLoop()));
// If user leaves the tab, pause to avoid background load
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.tab !== 'grasp' && loopTimer) stopLoop();
}));
$('executeBtn').addEventListener('click', async () => {
if (!$('graspDryRun').checked && !confirm('Eseguire realmente la presa sul robot?')) return;
try {
setStatus('Esecuzione…');
const body = {
conf: +$('graspConf').value || null,
class_name: pickedClass(),
dry_run: $('graspDryRun').checked,
};
const r = await api('POST', '/api/grasp/execute', body);
$('graspOut').textContent = JSON.stringify(r, null, 2);
setStatus(`Grasp eseguito · ${fmtTimings(r.timings_ms)}`, 'ok');
} catch (e) { setStatus(e.message, 'err'); }
});
// --- object pick select + legend + class discovery ---
// Build per-class summary: best (highest-confidence) detection and instance count.
function bestPerClass(dets) {
const byClass = new Map();
const counts = new Map();
for (const d of dets) {
if (!d.class) continue;
counts.set(d.class, (counts.get(d.class) || 0) + 1);
const cur = byClass.get(d.class);
if (!cur || d.confidence > cur.confidence) byClass.set(d.class, d);
}
return { byClass, counts };
}
function refreshObjectPickOptions(dets) {
const sel = $('graspObjectPick');
const prev = sel.value;
// known classes (from /classes, if any) keep a stable entry order;
// detected-only classes are added after.
const known = new Set(knownClasses);
const ordered = [];
knownClasses.forEach(n => ordered.push(n));
dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } });
const { byClass, counts } = bestPerClass(dets);
sel.innerHTML = '';
const opt0 = document.createElement('option');
opt0.value = ''; opt0.textContent = '(top overall)';
sel.appendChild(opt0);
for (const name of ordered) {
const d = byClass.get(name);
const n = counts.get(name) || 0;
const o = document.createElement('option');
o.value = name;
const suffix = n > 1 ? ` ×${n}` : '';
o.textContent = d
? `${name} · ${(d.confidence*100).toFixed(0)}%${suffix}`
: `${name} · —`;
if (!d) o.style.color = '#888';
sel.appendChild(o);
}
// Preserve the previous selection if still valid, else fall back to '(top)'.
const stillAvailable = prev === '' || ordered.includes(prev);
sel.value = stillAvailable ? prev : '';
}
function renderGraspLegend(dets) {
const host = $('graspLegend');
if (!host) return;
const known = new Set(knownClasses);
const ordered = [];
knownClasses.forEach(n => ordered.push(n));
dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } });
const { byClass, counts } = bestPerClass(dets);
host.innerHTML = '';
if (ordered.length === 0) { host.textContent = '(nessuna classe)'; return; }
for (const name of ordered) {
const d = byClass.get(name);
const n = counts.get(name) || 0;
const item = document.createElement('span');
item.className = 'item' + (name === lastSelectedClass ? ' selected' : '') + (!d ? ' missing' : '');
const sw = document.createElement('span');
sw.className = 'sw';
sw.style.background = colorForClass(name);
item.appendChild(sw);
const suffix = n > 1 ? ` ×${n}` : '';
item.appendChild(document.createTextNode(
d ? `${name} ${(d.confidence*100).toFixed(0)}%${suffix}` : `${name} (non rilevata)`
));
host.appendChild(item);
}
}
async function loadKnownClasses() {
try {
const r = await api('GET', '/api/perception/classes');
knownClasses = (r.classes || []).map(c => c.name).filter(Boolean);
} catch (_e) {
knownClasses = [];
}
refreshObjectPickOptions(lastDetections);
renderGraspLegend(lastDetections);
}
loadKnownClasses();
// Changing the pick while the loop is idle → fire a one-off scan so the cloud
// updates immediately. While looping, the next tick picks it up automatically.
$('graspObjectPick').addEventListener('change', () => {
renderGraspLegend(lastDetections);
drawGraspFrame();
if (!loopTimer) $('detectBtn').click();
});
// ==================== EE ====================
async function refreshWrist() {
try {
const r = await api('GET', '/api/wrist');
$('wristCurrent').textContent = JSON.stringify(r, null, 2);
} catch (e) { $('wristCurrent').textContent = e.message; }
}
$('refreshWristBtn').addEventListener('click', refreshWrist);
$('absSendBtn').addEventListener('click', async () => {
const body = {
side: $('eeSide').value,
position: [+$('absPx').value, +$('absPy').value, +$('absPz').value],
quaternion: [+$('absQx').value, +$('absQy').value, +$('absQz').value, +$('absQw').value],
wait: true, timeout_s: 5.0, precise: false,
dry_run: $('eeDryRun').checked,
};
try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/absolute', body), null, 2); }
catch (e) { $('eeLog').textContent = e.message; }
});
$('relSendBtn').addEventListener('click', async () => {
const body = {
side: $('eeSide').value,
delta_position: [+$('relDx').value, +$('relDy').value, +$('relDz').value],
wait: true, timeout_s: 5.0,
dry_run: $('eeDryRun').checked,
};
try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/relative', body), null, 2); }
catch (e) { $('eeLog').textContent = e.message; }
});
const handBtn = (id, action) => $(id).addEventListener('click', async () => {
try {
const r = await api('POST', '/api/wrist/hand', { action, dry_run: $('eeDryRun').checked });
$('eeLog').textContent = JSON.stringify(r, null, 2);
} catch (e) { $('eeLog').textContent = e.message; }
});
handBtn('handOpenBtn', 'RIGHT_OPEN');
handBtn('handCloseBtn', 'RIGHT_CLOSE');
handBtn('handOpenLBtn', 'LEFT_OPEN');
handBtn('handCloseLBtn', 'LEFT_CLOSE');
$('estopBtn').addEventListener('click', async () => {
if (!confirm('EMERGENCY STOP: confermi?')) return;
try {
const r = await api('POST', `/api/wrist/emergency_stop?dry_run=${$('eeDryRun').checked}`);
$('eeLog').textContent = JSON.stringify(r, null, 2);
} catch (e) { $('eeLog').textContent = e.message; }
});
$('unlockBtn').addEventListener('click', async () => {
try {
const r = await api('POST', `/api/wrist/unlock?dry_run=${$('eeDryRun').checked}`);
$('eeLog').textContent = JSON.stringify(r, null, 2);
} catch (e) { $('eeLog').textContent = e.message; }
});
// ==================== VISTA 3D ====================
// Minimal canvas-2D 3D renderer: orthographic camera with yaw/pitch around a
// target point, painter's-algorithm depth sorting, mouse drag/zoom/pan.
const view3dCanvas = $('view3dCanvas');
const view3dCtx = view3dCanvas.getContext('2d');
const view3d = {
// camera
yaw: -0.6, // around base Z
pitch: 0.5, // 0 = horizontal, π/2 = top-down
scale: 350, // pixels per meter
target: [0.30, 0.0, 0.20], // world point at canvas center
// scene data
wrist: null, // { left:{position,quaternion}, right:{...} }
object: null, // { points:[[x,y,z],...], centroid:[x,y,z], n_points }
plan: null, // { grasp:{position,quaternion_xyzw}, pre_grasp:{position} }
calibrated: true, // false → object data unavailable
lastErr: null,
};
// ---- math helpers ----
function quatToR(q) {
const x=q[0], y=q[1], z=q[2], w=q[3];
const n = x*x + y*y + z*z + w*w;
if (n === 0) return [[1,0,0],[0,1,0],[0,0,1]];
const s = 2/n;
return [
[1 - s*(y*y + z*z), s*(x*y - z*w), s*(x*z + y*w)],
[s*(x*y + z*w), 1 - s*(x*x + z*z), s*(y*z - x*w)],
[s*(x*z - y*w), s*(y*z + x*w), 1 - s*(x*x + y*y)],
];
}
// Project world point → {sx, sy, depth}. depth = camera-axis distance for sorting.
function project(p) {
const dx = p[0] - view3d.target[0];
const dy = p[1] - view3d.target[1];
const dz = p[2] - view3d.target[2];
const cy = Math.cos(view3d.yaw), sy = Math.sin(view3d.yaw);
// Yaw around Z
const x1 = cy*dx + sy*dy;
const y1 = -sy*dx + cy*dy;
const z1 = dz;
// Pitch around X (camera looks roughly toward +Y after yaw, tilting down by pitch)
const cp = Math.cos(view3d.pitch), sp = Math.sin(view3d.pitch);
const y2 = cp*y1 + sp*z1;
const z2 = -sp*y1 + cp*z1;
// Orthographic: screen X = +x1; screen Y = -z2 (so +Z up on screen)
const sx = view3dCanvas.width / 2 + view3d.scale * x1;
const sxy = view3dCanvas.height / 2 - view3d.scale * z2;
// depth: y2 increases away from viewer → larger y2 = farther
return { sx, sy: sxy, depth: y2 };
}
function drawSegment(items, p1, p2, color, width = 2) {
const a = project(p1), b = project(p2);
items.push({ depth: (a.depth + b.depth) / 2, draw: () => {
view3dCtx.strokeStyle = color;
view3dCtx.lineWidth = width;
view3dCtx.beginPath();
view3dCtx.moveTo(a.sx, a.sy);
view3dCtx.lineTo(b.sx, b.sy);
view3dCtx.stroke();
}});
}
function drawDot(items, p, color, radius = 3) {
const a = project(p);
items.push({ depth: a.depth, draw: () => {
view3dCtx.fillStyle = color;
view3dCtx.beginPath();
view3dCtx.arc(a.sx, a.sy, radius, 0, 2 * Math.PI);
view3dCtx.fill();
}});
}
function drawLabel(items, p, text, color, dx = 8, dy = -8) {
const a = project(p);
items.push({ depth: a.depth - 1e-6, draw: () => {
view3dCtx.fillStyle = color;
view3dCtx.font = 'bold 11px ui-monospace, monospace';
view3dCtx.fillText(text, a.sx + dx, a.sy + dy);
}});
}
function drawTriad(items, position, R, len = 0.06) {
// Columns of R = axis directions in base frame.
const axes = [
{ col: 0, color: '#e74c3c' }, // X
{ col: 1, color: '#3ecf8e' }, // Y
{ col: 2, color: '#4da3ff' }, // Z
];
for (const a of axes) {
const tip = [
position[0] + R[0][a.col] * len,
position[1] + R[1][a.col] * len,
position[2] + R[2][a.col] * len,
];
drawSegment(items, position, tip, a.color, 2);
}
}
function drawGroundGrid(items) {
if (!$('view3dShowGrid').checked) return;
const half = 0.6, step = 0.05;
const minor = '#1a1f27', major = '#2a2f37';
for (let x = -half; x <= half + 1e-6; x += step) {
const isMajor = Math.abs(x % 0.10) < 1e-6;
drawSegment(items, [x, -half, 0], [x, half, 0], isMajor ? major : minor, 1);
}
for (let y = -half; y <= half + 1e-6; y += step) {
const isMajor = Math.abs(y % 0.10) < 1e-6;
drawSegment(items, [-half, y, 0], [half, y, 0], isMajor ? major : minor, 1);
}
}
function drawBaseAxes(items) {
drawTriad(items, [0,0,0], [[1,0,0],[0,1,0],[0,0,1]], 0.12);
drawLabel(items, [0.12, 0, 0], 'X', '#e74c3c');
drawLabel(items, [0, 0.12, 0], 'Y', '#3ecf8e');
drawLabel(items, [0, 0, 0.12], 'Z', '#4da3ff');
drawLabel(items, [0,0,0], 'BASE', '#9aa0a6', 8, 14);
}
function drawWrist(items, side, pose, color) {
if (!pose || !pose.position || !pose.quaternion) return;
const p = pose.position;
const R = quatToR(pose.quaternion);
drawTriad(items, p, R, 0.08);
drawDot(items, p, color, 5);
drawLabel(items, p, `polso ${side}`, color, 10, -6);
}
function drawObject(items) {
if (!view3d.object) return;
if ($('view3dShowCloud').checked && view3d.object.points) {
// Cap render to avoid huge per-frame work.
const max = 1500;
const pts = view3d.object.points;
const stride = Math.max(1, Math.ceil(pts.length / max));
for (let i = 0; i < pts.length; i += stride) {
drawDot(items, pts[i], '#aaaaaa', 1.4);
}
}
if (view3d.object.centroid) {
drawDot(items, view3d.object.centroid, '#ffd54f', 6);
drawLabel(items, view3d.object.centroid, 'oggetto', '#ffd54f', 10, -8);
}
}
function drawPlan(items) {
if (!view3d.plan) return;
const g = view3d.plan.grasp, pg = view3d.plan.pre_grasp;
if (!g || !g.position) return;
const R = quatToR(g.quaternion_xyzw);
// grasp pose triad + marker
drawTriad(items, g.position, R, 0.07);
drawDot(items, g.position, '#ff5e7e', 6);
drawLabel(items, g.position, 'grasp', '#ff5e7e', 10, -8);
// pre-grasp marker + approach line
if (pg && pg.position) {
drawDot(items, pg.position, '#ffffff', 4);
drawLabel(items, pg.position, 'pre-grasp', '#ffffff', 10, -8);
drawSegment(items, pg.position, g.position, '#ffffff', 1.5);
}
}
function renderView3d() {
const ctx = view3dCtx;
ctx.fillStyle = '#0d1014';
ctx.fillRect(0, 0, view3dCanvas.width, view3dCanvas.height);
const items = [];
drawGroundGrid(items);
drawBaseAxes(items);
if (view3d.wrist) {
drawWrist(items, 'right', view3d.wrist.right, '#f0ad4e');
drawWrist(items, 'left', view3d.wrist.left, '#9b59b6');
}
drawObject(items);
drawPlan(items);
// Painter's algorithm: far first.
items.sort((a, b) => b.depth - a.depth);
items.forEach(it => it.draw());
// HUD
ctx.fillStyle = '#9aa0a6';
ctx.font = '11px ui-monospace, monospace';
ctx.fillText(`yaw=${(view3d.yaw*180/Math.PI).toFixed(0)}° pitch=${(view3d.pitch*180/Math.PI).toFixed(0)}° scale=${view3d.scale.toFixed(0)} px/m`, 8, 14);
ctx.fillText(`target=[${view3d.target.map(v => v.toFixed(2)).join(', ')}]`, 8, 28);
if (!view3d.calibrated) {
ctx.fillStyle = '#f0ad4e';
ctx.fillText('⚠ calibrazione mancante: punti oggetto non disponibili in frame base', 8, view3dCanvas.height - 8);
}
if (view3d.lastErr) {
ctx.fillStyle = '#e74c3c';
ctx.fillText(view3d.lastErr, 8, view3dCanvas.height - 22);
}
}
// ---- mouse interaction ----
let drag3d = null;
view3dCanvas.addEventListener('mousedown', (ev) => {
drag3d = { x: ev.clientX, y: ev.clientY, pan: ev.shiftKey };
});
window.addEventListener('mouseup', () => { drag3d = null; });
window.addEventListener('mousemove', (ev) => {
if (!drag3d) return;
const dx = ev.clientX - drag3d.x;
const dy = ev.clientY - drag3d.y;
drag3d.x = ev.clientX; drag3d.y = ev.clientY;
if (drag3d.pan) {
// Pan in screen plane → world XY (approx, ignoring pitch foreshortening on Z)
const cy = Math.cos(view3d.yaw), sy = Math.sin(view3d.yaw);
const wx = cy*dx - sy*(-dy) * Math.cos(view3d.pitch);
const wy = sy*dx + cy*(-dy) * Math.cos(view3d.pitch);
view3d.target[0] -= wx / view3d.scale;
view3d.target[1] -= wy / view3d.scale;
// also let vertical drag move target Z when looking near horizontal
view3d.target[2] += (-dy) * Math.sin(view3d.pitch) / view3d.scale;
} else {
view3d.yaw += dx * 0.01;
view3d.pitch += dy * 0.01;
view3d.pitch = Math.max(-Math.PI/2 + 0.05, Math.min(Math.PI/2 - 0.05, view3d.pitch));
}
renderView3d();
});
view3dCanvas.addEventListener('wheel', (ev) => {
ev.preventDefault();
const f = Math.exp(-ev.deltaY * 0.001);
view3d.scale = Math.max(50, Math.min(3000, view3d.scale * f));
renderView3d();
}, { passive: false });
view3dCanvas.addEventListener('dblclick', () => resetView3d());
function resetView3d() {
view3d.yaw = -0.6;
view3d.pitch = 0.5;
view3d.scale = 350;
view3d.target = [0.30, 0.0, 0.20];
renderView3d();
}
$('view3dResetBtn').addEventListener('click', resetView3d);
function autoFitView3d() {
if (!$('view3dAutoFit').checked) return;
const pts = [];
if (view3d.wrist) {
if (view3d.wrist.right && view3d.wrist.right.position) pts.push(view3d.wrist.right.position);
if (view3d.wrist.left && view3d.wrist.left.position) pts.push(view3d.wrist.left.position);
}
if (view3d.object && view3d.object.centroid) pts.push(view3d.object.centroid);
if (view3d.plan && view3d.plan.grasp && view3d.plan.grasp.position) pts.push(view3d.plan.grasp.position);
if (pts.length === 0) return;
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (const p of pts) for (let i = 0; i < 3; i++) {
if (p[i] < min[i]) min[i] = p[i];
if (p[i] > max[i]) max[i] = p[i];
}
view3d.target = [(min[0]+max[0])/2, (min[1]+max[1])/2, (min[2]+max[2])/2];
const span = Math.max(0.15, max[0]-min[0], max[1]-min[1], max[2]-min[2]);
// fit ~70% of canvas width
view3d.scale = Math.min(2000, (view3dCanvas.width * 0.7) / (span * 2.5));
}
// ---- data fetching ----
async function refreshView3d() {
view3d.lastErr = null;
const out = { wrist: null, object: null, plan: null, calibrated: true };
const summary = [];
// Wrist (always; doesn't need calibration — already in base frame).
try {
const w = await api('GET', '/api/wrist');
// Server returns {left_wrist, right_wrist}; tolerate {left,right} too.
const data = (w && w.data) || w || {};
out.wrist = {
left: data.left_wrist || data.left || null,
right: data.right_wrist || data.right || null,
};
summary.push(`wrist: ${out.wrist.right ? 'R✓' : 'R—'} ${out.wrist.left ? 'L✓' : 'L—'}`);
} catch (e) {
summary.push(`wrist: err (${e.message})`);
}
// Object points + centroid in base frame.
try {
const body = {
conf: +$('view3dConf').value || null,
class_name: $('view3dClass').value || null,
max_points: 3000,
};
const r = await api('POST', '/api/perception/object_points', body);
const pts = r.points_base || null;
if (!pts) {
out.calibrated = false;
summary.push('oggetto: serve calibrazione');
} else {
let cx=0, cy=0, cz=0;
for (const p of pts) { cx+=p[0]; cy+=p[1]; cz+=p[2]; }
const n = Math.max(1, pts.length);
out.object = {
points: pts,
centroid: [cx/n, cy/n, cz/n],
n_points: pts.length,
detection: r.detection,
};
summary.push(`oggetto: ${pts.length} pt`);
}
} catch (e) {
if (/^412\b/.test(e.message)) out.calibrated = false;
summary.push(`oggetto: ${e.message}`);
}
// Grasp plan (optional).
if ($('view3dShowPlan').checked && out.calibrated && out.object) {
try {
const body = {
conf: +$('view3dConf').value || null,
class_name: $('view3dClass').value || null,
};
const r = await api('POST', '/api/grasp/plan', body);
out.plan = r.plan || null;
if (out.plan && out.plan.grasp) {
const p = out.plan.grasp.position;
summary.push(`grasp: [${p.map(v => v.toFixed(2)).join(', ')}]`);
}
} catch (e) {
summary.push(`plan: ${e.message}`);
}
}
Object.assign(view3d, out);
autoFitView3d();
renderView3d();
$('view3dStatusLine').textContent = summary.join(' · ');
$('view3dStatusLine').style.color = view3d.lastErr ? 'var(--danger)' : 'var(--dim)';
$('view3dOut').textContent = JSON.stringify({
wrist: out.wrist,
object: out.object && {
n_points: out.object.n_points,
centroid: out.object.centroid,
detection: out.object.detection,
},
plan: out.plan,
}, null, 2);
}
$('view3dRefreshBtn').addEventListener('click', async () => {
setStatus('Aggiorno scena 3D…');
try { await refreshView3d(); setStatus('Scena 3D aggiornata', 'ok'); }
catch (e) { setStatus(e.message, 'err'); }
});
// loop
let view3dTimer = null;
let view3dInFlight = false;
async function view3dTick() {
if (view3dInFlight) return;
view3dInFlight = true;
try { await refreshView3d(); } catch {} finally { view3dInFlight = false; }
}
function startView3dLoop() {
if (view3dTimer) return;
const rate = Math.max(200, +$('view3dRateMs').value || 800);
view3dTick();
view3dTimer = setInterval(view3dTick, rate);
const b = $('view3dLoopBtn');
b.textContent = '■ Stop loop';
b.classList.add('running');
}
function stopView3dLoop() {
if (view3dTimer) { clearInterval(view3dTimer); view3dTimer = null; }
const b = $('view3dLoopBtn');
b.textContent = '▶ Loop';
b.classList.remove('running');
}
$('view3dLoopBtn').addEventListener('click', () => view3dTimer ? stopView3dLoop() : startView3dLoop());
// Pause loop when leaving the tab (mirrors the grasp-tab behavior).
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.tab !== 'view3d' && view3dTimer) stopView3dLoop();
if (btn.dataset.tab === 'view3d') renderView3d();
}));
// Initial empty render so the canvas isn't blank when first shown.
renderView3d();
// ==================== HOME ====================
// Shows reachability + latency for every upstream microservice and lets the
// user switch the active robot (G1 ↔ H1). The orchestrator runs the actual
// switch via PUT /api/home/robot, which deep-merges into config.yaml.
let homePollTimer = null;
let homePollInFlight = false;
function fmtLatency(ms) {
if (ms == null) return '—';
if (ms < 1) return '<1 ms';
return `${Math.round(ms)} ms`;
}
function renderHomeStatus(data) {
const tbody = document.querySelector('#homeDeviceTable tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!data || !Array.isArray(data.devices) || data.devices.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="dim">nessun servizio configurato</td></tr>';
return;
}
for (const d of data.devices) {
const tr = document.createElement('tr');
const status = d.ok ? 'ok' : 'err';
const icon = d.ok ? '✓' : '✗';
const detail = d.ok
? (d.status_code != null ? `HTTP ${d.status_code}` : 'reachable')
: (d.error || (d.status_code != null ? `HTTP ${d.status_code}` : 'unreachable'));
tr.innerHTML = `
<td>${d.label}${d.active ? ' <span class="badge ok">attivo</span>' : ''}</td>
<td><code>${d.url}</code></td>
<td><span class="device-pill ${status}">${icon} ${d.ok ? 'online' : 'offline'}</span></td>
<td>${fmtLatency(d.latency_ms)}</td>
<td>${detail}</td>
`;
tbody.appendChild(tr);
}
const pill = $('activeRobotPill');
if (pill) pill.textContent = `robot: ${(data.active_robot || '—').toUpperCase()}`;
}
async function homePollOnce() {
if (homePollInFlight) return;
homePollInFlight = true;
try {
const r = await api('GET', '/api/home/status');
renderHomeStatus(r);
} catch (e) {
const tbody = document.querySelector('#homeDeviceTable tbody');
if (tbody) tbody.innerHTML = `<tr><td colspan="5" class="err">errore: ${e.message}</td></tr>`;
} finally {
homePollInFlight = false;
}
}
function startHomePoll() {
stopHomePoll();
if (!$('homeAutoPoll').checked) return;
const rate = Math.max(500, +$('homePollMs').value || 2000);
homePollOnce();
homePollTimer = setInterval(homePollOnce, rate);
}
function stopHomePoll() {
if (homePollTimer) { clearInterval(homePollTimer); homePollTimer = null; }
}
$('homeAutoPoll').addEventListener('change', startHomePoll);
$('homePollMs').addEventListener('change', startHomePoll);
$('homeRefreshBtn').addEventListener('click', homePollOnce);
$('homeApplyRobotBtn').addEventListener('click', async (ev) => {
const btn = ev.currentTarget;
const target = $('homeRobotSelect').value;
try {
const r = await api('PUT', '/api/home/robot', { active: target });
$('homeLog').textContent = `robot attivo: ${r.active}`;
$('homeLog').classList.remove('err');
flashBtn(btn, true);
toast('Robot attivo', `${r.active.toUpperCase()}`, true);
homePollOnce();
} catch (e) {
$('homeLog').textContent = e.message;
$('homeLog').classList.add('err');
flashBtn(btn, false);
toast('Errore cambio robot', e.message, false);
}
});
// Sync the dropdown with whatever the backend currently reports as active.
async function refreshHomeRobotSelector() {
try {
const r = await api('GET', '/api/home/robot');
if (r && r.active) $('homeRobotSelect').value = r.active;
const pill = $('activeRobotPill');
if (pill) pill.textContent = `robot: ${(r.active || '—').toUpperCase()}`;
} catch {}
}
refreshHomeRobotSelector();
startHomePoll();
// ---------- init ----------
(async () => {
try {
const cfg = await api('GET', '/api/config');
$('arucoId').value = cfg.aruco.wrist_marker.id;
$('arucoSize').value = cfg.aruco.wrist_marker.size_mm;
$('side').value = cfg.robot.wrist_side;
$('eeSide').value = cfg.robot.wrist_side;
} catch {}
await loadSamples();
await loadCalibResult();
refreshWrist();
refreshRgb();
})();
// ==================== ASSISTENTE AI ====================
// Curated tool-use chat: l'utente parla in linguaggio naturale, il backend
// passa la richiesta a OpenRouter con i tool definiti in agent/tools.py.
// La cronologia è tenuta lato browser e ribaltata indietro a ogni POST.
const agentState = {
history: [], // OpenAI-format messages (escluso system, ricostruito server-side)
visualMessages: [], // {role, content, traceIdx?} per il render
busy: false,
health: null,
};
function agentEscape(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderAgentChat() {
const host = $('agentChat');
host.innerHTML = '';
if (agentState.visualMessages.length === 0) {
const sys = document.createElement('div');
sys.className = 'agent-msg system';
sys.textContent = 'Conversazione vuota. Scrivi una richiesta sotto.';
host.appendChild(sys);
return;
}
for (const m of agentState.visualMessages) {
const div = document.createElement('div');
div.className = `agent-msg ${m.role}`;
div.textContent = m.content || '';
if (m.tools && m.tools.length) {
const meta = document.createElement('div');
meta.className = 'meta';
for (const t of m.tools) {
const pill = document.createElement('span');
pill.className = 'agent-tool-pill' + (t.ok ? '' : ' err');
pill.textContent = `${t.name} · ${t.latency_ms}ms`;
pill.title = JSON.stringify(t.args || {}, null, 2);
meta.appendChild(pill);
}
div.appendChild(meta);
}
host.appendChild(div);
}
host.scrollTop = host.scrollHeight;
}
async function refreshAgentHealth() {
try {
const h = await api('GET', '/api/agent/health');
agentState.health = h;
const el = $('agentHealth');
if (h.configured) {
el.textContent = `pronto · modello ${h.model}`;
el.style.color = 'var(--ok)';
$('agentSendBtn').disabled = false;
} else {
el.textContent = `API key mancante (env ${h.api_key_env}): chat disabilitata`;
el.style.color = 'var(--danger)';
$('agentSendBtn').disabled = true;
}
} catch (e) {
$('agentHealth').textContent = `errore: ${e.message}`;
$('agentHealth').style.color = 'var(--danger)';
}
// mirror del robot attivo nella pill della tab
try {
const r = await api('GET', '/api/home/robot');
const pill = $('agentActiveRobotPill');
if (pill) pill.textContent = `robot: ${(r.active || '—').toUpperCase()}`;
} catch {}
}
async function sendAgentMessage() {
if (agentState.busy) return;
const input = $('agentInput');
const message = input.value.trim();
if (!message) return;
agentState.busy = true;
$('agentSendBtn').disabled = true;
$('agentStatus').textContent = 'invio…';
$('agentStatus').style.color = 'var(--dim)';
agentState.visualMessages.push({ role: 'user', content: message });
renderAgentChat();
input.value = '';
try {
const r = await api('POST', '/api/agent/chat', {
message,
history: agentState.history,
});
agentState.history = r.history || [];
const tools = (r.tool_calls || []).map(tc => ({
name: tc.name, ok: tc.result && tc.result.ok !== false,
latency_ms: tc.latency_ms, args: tc.arguments,
}));
agentState.visualMessages.push({
role: 'assistant', content: r.reply || '(nessuna risposta)',
tools,
});
renderAgentChat();
// Tool trace dettagliata nel pannello di destra.
if (r.tool_calls && r.tool_calls.length) {
$('agentTrace').textContent = JSON.stringify(r.tool_calls, null, 2);
} else {
$('agentTrace').textContent = '(nessuna chiamata)';
}
const usage = r.usage || {};
const u = usage.total_tokens != null ? `· ${usage.total_tokens} tokens` : '';
$('agentStatus').textContent = `ok · ${r.iterations} iterazioni · ${tools.length} tool ${u}`;
$('agentStatus').style.color = 'var(--ok)';
// se la risposta ha cambiato robot attivo, aggiorna pill
refreshAgentHealth().catch(() => {});
} catch (e) {
agentState.visualMessages.push({
role: 'error', content: e.message,
});
renderAgentChat();
$('agentStatus').textContent = `errore: ${e.message}`;
$('agentStatus').style.color = 'var(--danger)';
} finally {
agentState.busy = false;
$('agentSendBtn').disabled = !(agentState.health && agentState.health.configured);
}
}
$('agentSendBtn').addEventListener('click', sendAgentMessage);
$('agentInput').addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
sendAgentMessage();
}
});
$('agentClearBtn').addEventListener('click', () => {
if (agentState.visualMessages.length === 0 && agentState.history.length === 0) return;
if (!confirm('Resetta la conversazione corrente?')) return;
agentState.history = [];
agentState.visualMessages = [];
renderAgentChat();
$('agentTrace').textContent = '(nessuna chiamata)';
$('agentStatus').textContent = 'conversazione svuotata';
});
// ----- Task salvati -----
async function loadAgentTasks() {
const ul = $('agentTaskList');
try {
const r = await api('GET', '/api/agent/tasks');
const items = r.tasks || [];
ul.innerHTML = '';
if (items.length === 0) {
ul.innerHTML = '<li class="dim">(nessun task salvato)</li>';
return;
}
for (const it of items) {
const li = document.createElement('li');
const name = document.createElement('span');
name.className = 'task-name';
name.textContent = it.name;
const desc = document.createElement('span');
desc.className = 'task-desc';
desc.textContent = it.description || it.prompt;
desc.title = it.prompt;
const useBtn = document.createElement('button');
useBtn.className = 'link-btn';
useBtn.textContent = '↥ usa';
useBtn.title = 'Carica questo prompt nella casella di input';
useBtn.addEventListener('click', () => {
$('agentInput').value = it.prompt;
$('agentInput').focus();
});
const runBtn = document.createElement('button');
runBtn.className = 'link-btn';
runBtn.textContent = '▶ esegui';
runBtn.addEventListener('click', () => {
$('agentInput').value = it.prompt;
sendAgentMessage();
});
const delBtn = document.createElement('button');
delBtn.className = 'link-btn';
delBtn.textContent = '✕';
delBtn.title = 'Elimina';
delBtn.addEventListener('click', async () => {
if (!confirm(`Eliminare task "${it.name}"?`)) return;
try {
await api('DELETE', `/api/agent/tasks/${encodeURIComponent(it.name)}`);
loadAgentTasks();
} catch (e) { toast('Eliminazione fallita', e.message, false); }
});
li.appendChild(name);
li.appendChild(desc);
li.appendChild(useBtn);
li.appendChild(runBtn);
li.appendChild(delBtn);
ul.appendChild(li);
}
} catch (e) {
ul.innerHTML = `<li class="err">errore: ${agentEscape(e.message)}</li>`;
}
}
$('agentTaskSaveBtn').addEventListener('click', async (ev) => {
const name = $('agentTaskName').value.trim();
const prompt = $('agentInput').value.trim();
const description = $('agentTaskDesc').value.trim() || null;
if (!name) { toast('Manca il nome', 'Indica un nome per il task', false); return; }
if (!prompt) { toast('Prompt vuoto', 'Scrivi prima il prompt nella casella', false); return; }
try {
await api('POST', '/api/agent/tasks', { name, prompt, description });
flashBtn(ev.currentTarget, true);
toast('Task salvato', name, true);
$('agentTaskName').value = '';
$('agentTaskDesc').value = '';
loadAgentTasks();
} catch (e) {
flashBtn(ev.currentTarget, false);
toast('Salvataggio fallito', e.message, false);
}
});
$('agentTaskReloadBtn').addEventListener('click', loadAgentTasks);
// Carica health + task quando si apre la tab Assistente AI.
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.tab === 'agent') {
refreshAgentHealth();
loadAgentTasks();
}
}));
// Bootstrap iniziale (anche se la tab non è attiva, così la pill si popola).
refreshAgentHealth();
loadAgentTasks();
// ==================== IMPOSTAZIONI AI ====================
async function loadAiSettings() {
const select = $('aiModelSelect');
// Lista modelli (statica con override custom): si carica una sola volta.
if (select && select.options.length <= 1) {
try {
const r = await api('GET', '/api/agent/known_models');
select.innerHTML = '';
for (const m of r.models) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = `${m.id} — ${m.label}`;
select.appendChild(opt);
}
} catch (e) {
select.innerHTML = `<option value="">errore: ${e.message}</option>`;
}
}
// Carica config corrente
try {
const cfg = await api('GET', '/api/config');
const o = (cfg.chat && cfg.chat.openrouter) || {};
const model = o.model || 'anthropic/claude-sonnet-4.5';
if ([...$('aiModelSelect').options].some(opt => opt.value === model)) {
$('aiModelSelect').value = model;
$('aiModelCustom').value = '';
} else {
$('aiModelCustom').value = model;
}
$('aiTemperature').value = (o.temperature != null) ? o.temperature : 0.1;
$('aiMaxIterations').value = o.max_tool_iterations || 8;
$('aiTimeout').value = o.timeout_s || 60;
$('aiPromptAddendum').value = o.user_prompt_addendum || '';
$('aiSettingsStatus').textContent = 'caricato da config';
$('aiSettingsStatus').style.color = 'var(--dim)';
} catch (e) {
$('aiSettingsStatus').textContent = `errore: ${e.message}`;
$('aiSettingsStatus').style.color = 'var(--danger)';
}
}
async function saveAiSettings(btn) {
const customModel = $('aiModelCustom').value.trim();
const model = customModel || $('aiModelSelect').value;
if (!model) { toast('Modello mancante', 'Seleziona o scrivi un modello', false); return; }
const temperature = parseFloat($('aiTemperature').value);
const max_tool_iterations = parseInt($('aiMaxIterations').value, 10);
const timeout_s = parseFloat($('aiTimeout').value);
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
toast('Temperature non valido', '0..2', false); return;
}
if (Number.isNaN(max_tool_iterations) || max_tool_iterations < 1) {
toast('Max iterations non valido', '>=1', false); return;
}
const patch = {
chat: { openrouter: {
model,
temperature,
max_tool_iterations,
timeout_s,
user_prompt_addendum: $('aiPromptAddendum').value,
}},
};
try {
await api('PUT', '/api/config', patch);
flashBtn(btn, true);
toast('Salvato', `model=${model}, T=${temperature}`, true);
$('aiSettingsStatus').textContent = `salvato · model=${model} · T=${temperature}`;
$('aiSettingsStatus').style.color = 'var(--ok)';
refreshAgentHealth().catch(() => {});
} catch (e) {
flashBtn(btn, false);
toast('Errore salvataggio', e.message, false);
$('aiSettingsStatus').textContent = e.message;
$('aiSettingsStatus').style.color = 'var(--danger)';
}
}
async function previewSystemPrompt() {
const pre = $('aiPromptPreview');
pre.textContent = 'caricamento…';
try {
const r = await api('GET', '/api/agent/system_prompt_preview');
pre.textContent = r.system_prompt || '(vuoto)';
} catch (e) {
pre.textContent = `errore: ${e.message}`;
}
}
$('aiSaveBtn').addEventListener('click', (ev) => saveAiSettings(ev.currentTarget));
$('aiReloadBtn').addEventListener('click', loadAiSettings);
$('aiResetTempBtn').addEventListener('click', () => { $('aiTemperature').value = 0.1; });
$('aiPromptPreviewBtn').addEventListener('click', previewSystemPrompt);
$('aiPromptCopyBtn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText($('aiPromptPreview').textContent);
toast('Copiato', 'Prompt nel clipboard', true);
} catch (e) { toast('Copia fallita', e.message, false); }
});
// Quando l'utente seleziona un modello dalla combo, svuota il custom (priorità chiara).
$('aiModelSelect').addEventListener('change', () => { $('aiModelCustom').value = ''; });
// Carica i valori quando si apre la tab.
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.tab === 'ai-settings') {
loadAiSettings();
previewSystemPrompt();
}
}));