Files
Adriano Dal PastroandClaude Opus 4.7 57954b5c8d feat(right_arm): perm assi cam->polso, precise=true default, verify_tag UI
Lavoro accumulato nella sessione 2026-05-21 prima del brainstorming
della tab Pose 3D:

- compute_delta_wrist generalizzato con cam_to_wrist_perm: la mappa
  cam->polso non e' solo sign-flip ma una permutazione (default
  identita' per backward compat). DeltaOversize ora espone X/Y/Z
  nella stringa d'errore. Test aggiornati.

- G1Client.wrist_absolute / wrist_relative: precise=True di default
  (era False). Stessa cosa per wrist_routes.AbsoluteReq Pydantic
  e per agent.tools move_wrist_*. Movimenti reali ora chiedono
  controllo accurato salvo override esplicito.

- Tab Braccio DX:
  - /verify_tag endpoint: snapshot wrist FK + tag id=1 dalla front
    camera. Per verifica relativa moveto vs marker (no calibrazione).
  - GUI: pannello "Verifica tag id=1" + campi camera_offset_wrist_mm.
  - markUserTouchedOnInput esteso a raOff/raSgn/raCamOff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:44:48 +02:00

433 lines
15 KiB
JavaScript

// Tab "Braccio DX" — auto-refresh immagini, YOLO on-demand, move-toward,
// posa Home/Custom. Vanilla JS, niente dipendenze.
const $ = (id) => document.getElementById(id);
const FRONT_REFRESH_MS = 1000;
const WRIST_REFRESH_MS = 1000;
const POSE_REFRESH_MS = 2000;
let frontTimer = null;
let wristTimer = null;
let poseTimer = null;
// ---------------- Helpers ----------------
async function jsonFetch(url, opts) {
const r = await fetch(url, opts);
const body = await r.json().catch(() => ({}));
if (!r.ok) {
const err = new Error(body.detail || `HTTP ${r.status}`);
err.status = r.status;
throw err;
}
return body;
}
function setText(id, v) { const el = $(id); if (el) el.textContent = v; }
function drawBbox(canvas, img, bbox, label) {
if (!canvas || !img || !bbox) return;
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const x = bbox.x1, y = bbox.y1, w = bbox.x2 - bbox.x1, h = bbox.y2 - bbox.y1;
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 3;
ctx.strokeRect(x, y, w, h);
if (label) {
ctx.fillStyle = '#00ff00';
ctx.font = '14px monospace';
ctx.fillText(label, x, y - 4);
}
}
function clearCanvas(canvas) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
// ---------------- Immagini ----------------
function urlWithBust(path) { return `${path}?t=${Date.now()}`; }
function startFrontLoop() {
if (frontTimer) clearInterval(frontTimer);
const tick = () => { $('raFrontImg').src = urlWithBust('/api/right_arm/snapshot/front'); };
tick();
if ($('raFrontAutoRefresh').checked) {
frontTimer = setInterval(tick, FRONT_REFRESH_MS);
}
}
function startWristLoop() {
if (wristTimer) clearInterval(wristTimer);
const tick = () => { $('raWristImg').src = urlWithBust('/api/right_arm/snapshot/wrist'); };
tick();
if ($('raWristAutoRefresh').checked) {
wristTimer = setInterval(tick, WRIST_REFRESH_MS);
}
}
// ---------------- YOLO classes ----------------
async function populateClassDropdowns() {
let classes = [];
try {
classes = await jsonFetch('/api/perception/yolo/classes');
if (Array.isArray(classes.classes)) classes = classes.classes;
} catch (_) { classes = []; }
const cfg = await jsonFetch('/api/config').catch(() => ({}));
const dflt = (cfg.tab_right_arm && cfg.tab_right_arm.yolo_class) || 'pestello';
for (const id of ['raFrontClass', 'raWristClass']) {
const sel = $(id);
sel.innerHTML = '';
const names = classes.length ? classes.map(c => c.name || c) : [dflt];
for (const n of names) {
const opt = document.createElement('option');
opt.value = n; opt.textContent = n;
if (n === dflt) opt.selected = true;
sel.appendChild(opt);
}
}
if (cfg.tab_right_arm) {
const cf = cfg.tab_right_arm.yolo_conf_front;
const cw = cfg.tab_right_arm.yolo_conf_wrist;
$('raFrontConf').value = (cf === undefined || cf === null) ? 0.4 : cf;
$('raWristConf').value = (cw === undefined || cw === null) ? 0.4 : cw;
}
}
// ---------------- Detect on-demand ----------------
async function onDetectFront() {
const className = $('raFrontClass').value;
const conf = parseFloat($('raFrontConf').value) || null;
const proxy = window.location.origin;
try {
const res = await jsonFetch('/api/right_arm/detect/front', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ class_name: className, conf, proxy_base_url: proxy }),
});
const top = res.detections[0];
drawBbox($('raFrontCanvas'), $('raFrontImg'), top.bbox,
`${top.class} ${(top.confidence * 100).toFixed(0)}%`);
$('raFrontResult').classList.remove('dim');
$('raFrontResult').textContent =
`classe: ${top.class} conf: ${top.confidence.toFixed(2)} ` +
`bbox: [${Math.round(top.bbox.x1)},${Math.round(top.bbox.y1)},` +
`${Math.round(top.bbox.x2)},${Math.round(top.bbox.y2)}]`;
} catch (e) {
clearCanvas($('raFrontCanvas'));
$('raFrontResult').textContent = `Errore: ${e.message}`;
}
}
async function onDetectWrist() {
const className = $('raWristClass').value;
const conf = parseFloat($('raWristConf').value) || null;
try {
const res = await jsonFetch('/api/right_arm/detect/wrist', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ class_name: className, conf }),
});
drawBbox($('raWristCanvas'), $('raWristImg'), res.detection.bbox,
`${res.detection.class} ${(res.detection.confidence * 100).toFixed(0)}%`);
const c = res.centroid_camera_m;
$('raWristResult').classList.remove('dim');
$('raWristResult').textContent =
`classe: ${res.detection.class} conf: ${res.detection.confidence.toFixed(2)}\n` +
`centroide cam (m): x=${c[0].toFixed(3)} y=${c[1].toFixed(3)} z=${c[2].toFixed(3)}\n` +
`distanza: ${(res.distance_m * 1000).toFixed(0)} mm ` +
`punti SAM21: ${res.n_sam21_points}`;
} catch (e) {
clearCanvas($('raWristCanvas'));
$('raWristResult').textContent = `Errore: ${e.message}`;
}
}
// ---------------- Posa polso ----------------
async function refreshPose() {
try {
const s = await jsonFetch('/api/right_arm/state');
const p = s.wrist.position;
const r = s.wrist.rpy_deg;
setText('raPoseX', p[0].toFixed(3));
setText('raPoseY', p[1].toFixed(3));
setText('raPoseZ', p[2].toFixed(3));
setText('raPoseR', r[0].toFixed(1));
setText('raPoseP', r[1].toFixed(1));
setText('raPoseW', r[2].toFixed(1));
// Aggiorna anche i campi delle pose (solo se non sono stati toccati)
if (s.home_pose) fillPoseFields('raHome', s.home_pose);
if (s.custom_pose) fillPoseFields('raCust', s.custom_pose);
if (s.offset_camera_mm) fillIfUntouched('raOff', s.offset_camera_mm);
if (s.axis_signs) fillIfUntouched('raSgn', s.axis_signs);
if (s.camera_offset_wrist_mm) fillIfUntouched('raCamOff', s.camera_offset_wrist_mm);
} catch (e) {
// silenzioso: il polso può non essere disponibile
}
}
function fillPoseFields(prefix, pose) {
const xId = prefix + 'X';
if ($(xId).dataset.userTouched) return;
$(xId).value = pose.position[0];
$(prefix + 'Y').value = pose.position[1];
$(prefix + 'Z').value = pose.position[2];
$(prefix + 'R').value = pose.rpy_deg[0];
$(prefix + 'P').value = pose.rpy_deg[1];
$(prefix + 'W').value = pose.rpy_deg[2];
}
function fillIfUntouched(prefix, xyz) {
for (const k of ['X','Y','Z']) {
const el = $(prefix + k);
if (!el || el.dataset.userTouched) continue;
el.value = xyz[k.toLowerCase()];
}
}
function clearUserTouched(prefix) {
for (const k of ['X','Y','Z','R','P','W']) {
const el = $(prefix + k);
if (el) delete el.dataset.userTouched;
}
}
function readPoseFields(prefix) {
return {
position: [
parseFloat($(prefix + 'X').value),
parseFloat($(prefix + 'Y').value),
parseFloat($(prefix + 'Z').value),
],
rpy_deg: [
parseFloat($(prefix + 'R').value),
parseFloat($(prefix + 'P').value),
parseFloat($(prefix + 'W').value),
],
};
}
// ---------------- Verifica tag (relativa) ----------------
let verifySnapshots = [];
function norm3(a) { return Math.sqrt(a[0]*a[0] + a[1]*a[1] + a[2]*a[2]); }
function sub3(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function renderVerifyTable() {
const n = verifySnapshots.length;
$('raVerifyCount').textContent = `${n} snapshot`;
if (n === 0) {
$('raVerifyResult').textContent = '— nessuno snapshot';
$('raVerifyResult').classList.add('dim');
return;
}
$('raVerifyResult').classList.remove('dim');
let out = '';
for (let i = 0; i < n; i++) {
const s = verifySnapshots[i];
out += `#${i+1} wrist=(${s.wrist_position_m.map(v=>(v*1000).toFixed(0)).join(',')})mm ` +
`tag=(${s.tag_position_m_in_front_cam.map(v=>(v*1000).toFixed(0)).join(',')})mm\n`;
}
if (n >= 2) {
out += '\nConfronti consecutivi (Δ in mm):\n';
for (let i = 1; i < n; i++) {
const dw = sub3(verifySnapshots[i].wrist_position_m,
verifySnapshots[i-1].wrist_position_m);
const dt = sub3(verifySnapshots[i].tag_position_m_in_front_cam,
verifySnapshots[i-1].tag_position_m_in_front_cam);
const nw = norm3(dw) * 1000, nt = norm3(dt) * 1000;
const err = Math.abs(nw - nt);
const pct = nw > 0 ? (err / nw * 100) : 0;
out += ` #${i}→#${i+1} ‖Δpolso‖=${nw.toFixed(1)} ‖Δtag‖=${nt.toFixed(1)} ` +
`err=${err.toFixed(1)} (${pct.toFixed(1)}%)\n`;
}
}
$('raVerifyResult').textContent = out;
}
async function onVerifySnap() {
try {
const res = await jsonFetch('/api/right_arm/verify_tag', { method: 'POST' });
verifySnapshots.push(res);
renderVerifyTable();
} catch (e) {
alert(`Verifica fallita: ${e.message}`);
}
}
function onVerifyReset() {
verifySnapshots = [];
renderVerifyTable();
}
// ---------------- Offset save ----------------
async function onSaveOffset() {
try {
await jsonFetch('/api/right_arm/offset', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
move_offset_camera_mm: {
x: parseFloat($('raOffX').value),
y: parseFloat($('raOffY').value),
z: parseFloat($('raOffZ').value),
},
axis_sign_cam_to_wrist: {
x: parseInt($('raSgnX').value, 10),
y: parseInt($('raSgnY').value, 10),
z: parseInt($('raSgnZ').value, 10),
},
camera_offset_wrist_mm: {
x: parseFloat($('raCamOffX').value),
y: parseFloat($('raCamOffY').value),
z: parseFloat($('raCamOffZ').value),
},
}),
});
clearUserTouched('raOff');
clearUserTouched('raSgn');
clearUserTouched('raCamOff');
alert('Offset salvato.');
} catch (e) {
alert(`Errore salvataggio: ${e.message}`);
}
}
// ---------------- Move toward ----------------
async function onMove() {
const className = $('raWristClass').value;
const conf = parseFloat($('raWristConf').value) || null;
const dryRun = $('raMoveDryRun').checked;
const force = $('raMoveForce').checked;
if (!dryRun) {
const msg = force
? 'Dry-run OFF + force ON (max_step bypassato): eseguire move REALE verso il pezzo?'
: 'Dry-run OFF: eseguire move REALE verso il pezzo?';
if (!confirm(msg)) return;
}
try {
const res = await jsonFetch('/api/right_arm/move/toward', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ class_name: className, conf, dry_run: dryRun, force }),
});
const d = res.delta_wrist_mm;
$('raMoveResult').classList.remove('dim');
$('raMoveResult').textContent =
`delta polso (mm): X=${d[0].toFixed(1)} Y=${d[1].toFixed(1)} Z=${d[2].toFixed(1)} ` +
`distanza: ${(res.distance_m * 1000).toFixed(0)} mm ` +
`applicato: ${res.applied} z_clipped: ${res.z_clipped}`;
if (!dryRun) await refreshPose();
} catch (e) {
$('raMoveResult').textContent = `Errore: ${e.message}`;
}
}
// ---------------- Home / Custom ----------------
async function onHome() {
const dryRun = false; // home è "safe": default false
if (!confirm('Eseguire move verso posa Home? (no dry-run)')) return;
try {
const res = await jsonFetch('/api/right_arm/home', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dry_run: dryRun }),
});
await refreshPose();
alert(`Home eseguita: applied=${res.applied}`);
} catch (e) {
alert(`Errore Home: ${e.message}`);
}
}
async function onCustomGo() {
if (!confirm('Eseguire move verso posa Custom?')) return;
try {
await jsonFetch('/api/right_arm/custom_pose', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dry_run: false }),
});
await refreshPose();
} catch (e) {
alert(`Errore Custom: ${e.message}`);
}
}
async function onCustomSave() {
const pose = readPoseFields('raCust');
try {
await jsonFetch('/api/right_arm/custom_pose', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pose),
});
alert('Posa custom salvata in config.');
} catch (e) {
alert(`Errore salvataggio: ${e.message}`);
}
}
async function onCustomUseCurrent() {
try {
const s = await jsonFetch('/api/right_arm/state');
$('raCustX').value = s.wrist.position[0].toFixed(3);
$('raCustY').value = s.wrist.position[1].toFixed(3);
$('raCustZ').value = s.wrist.position[2].toFixed(3);
$('raCustR').value = s.wrist.rpy_deg[0].toFixed(1);
$('raCustP').value = s.wrist.rpy_deg[1].toFixed(1);
$('raCustW').value = s.wrist.rpy_deg[2].toFixed(1);
// Marca i campi come "user touched" così refreshPose non li sovrascrive
for (const k of ['X','Y','Z','R','P','W']) $('raCust' + k).dataset.userTouched = '1';
} catch (e) {
alert(`Errore lettura posa: ${e.message}`);
}
}
// ---------------- Init ----------------
function markUserTouchedOnInput(prefix) {
for (const k of ['X','Y','Z','R','P','W']) {
const el = $(prefix + k);
if (el) el.addEventListener('input', () => { el.dataset.userTouched = '1'; });
}
}
function init() {
if (!$('tab-right-arm')) return;
$('raFrontDetectBtn').addEventListener('click', onDetectFront);
$('raWristDetectBtn').addEventListener('click', onDetectWrist);
$('raMoveBtn').addEventListener('click', onMove);
$('raSaveOffsetBtn').addEventListener('click', onSaveOffset);
$('raVerifySnapBtn').addEventListener('click', onVerifySnap);
$('raVerifyResetBtn').addEventListener('click', onVerifyReset);
$('raHomeBtn').addEventListener('click', onHome);
$('raCustomGoBtn').addEventListener('click', onCustomGo);
$('raCustomSaveBtn').addEventListener('click', onCustomSave);
$('raCustomUseCurrentBtn').addEventListener('click', onCustomUseCurrent);
$('raPoseRefreshBtn').addEventListener('click', refreshPose);
$('raFrontAutoRefresh').addEventListener('change', startFrontLoop);
$('raWristAutoRefresh').addEventListener('change', startWristLoop);
markUserTouchedOnInput('raHome');
markUserTouchedOnInput('raCust');
markUserTouchedOnInput('raOff');
markUserTouchedOnInput('raSgn');
markUserTouchedOnInput('raCamOff');
populateClassDropdowns();
startFrontLoop();
startWristLoop();
refreshPose();
if (poseTimer) clearInterval(poseTimer);
poseTimer = setInterval(refreshPose, POSE_REFRESH_MS);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}