Files
appRobotHoming/public/calibration.js
2026-06-10 16:54:36 +02:00

407 lines
15 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.
/* calibration.js Kalibrierungs-Frontend */
// ── Panel-Loading (lazy) ───────────────────────────────────────────────────────
const _panelLoaded = new Set();
async function loadPanel(tab, src) {
if (_panelLoaded.has(tab)) return;
try {
const r = await fetch(src);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
document.getElementById('tab-' + tab).innerHTML = await r.text();
_panelLoaded.add(tab);
// h2-Klick → Section ein-/ausklappen
document.getElementById('tab-' + tab).querySelectorAll('.section h2').forEach(h2 => {
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
});
// Tab-spezifische Initialisierung
if (tab === 'camera-npz') initCameraNpz();
else if (tab === 'board') initBoard();
} catch (err) {
document.getElementById('tab-' + tab).innerHTML =
`<p style="color:#f87171;margin:16px">Panel konnte nicht geladen werden: ${err}</p>`;
}
}
// ── Tab-Switching ─────────────────────────────────────────────────────────────
document.getElementById('tabSidebar').addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
loadPanel(btn.dataset.tab, btn.dataset.src);
});
// Erstes Tab sofort laden
(function () {
const first = document.querySelector('.tab-btn.active');
if (first) loadPanel(first.dataset.tab, first.dataset.src);
})();
// ── Shared: SSE-Stream lesen ──────────────────────────────────────────────────
async function readSseStream(response, logFn, onDone) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop(); // unvollständiges letztes Fragment behalten
for (const part of parts) {
for (const line of part.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const evt = JSON.parse(line.slice(6));
if (evt.type === 'log') { if (evt.text !== '') logFn(evt.text); }
else if (evt.type === 'done') { onDone(evt); }
} catch { /* ungültiges JSON überspringen */ }
}
}
}
}
// ── Camera NPZ ────────────────────────────────────────────────────────────────
function initCameraNpz() {
const logCamera = document.getElementById('log-camera');
function logC(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logCamera.value += `[${ts}] ${msg}\n`;
logCamera.scrollTop = logCamera.scrollHeight;
}
function formatDate(isoString) {
if (!isoString) return '';
return new Date(isoString).toLocaleString('de-CH');
}
function updateCalibInfo(meta) {
const sel = document.getElementById('cam-select-calib');
if (!meta) {
document.getElementById('info-timestamp').textContent = '(keine Session vorhanden)';
document.getElementById('info-created').textContent = '';
document.getElementById('info-images').textContent = '';
sel.innerHTML = '<option value=""> Kamera </option>';
return;
}
document.getElementById('info-timestamp').textContent = meta.timestamp ?? '';
document.getElementById('info-created').textContent = formatDate(meta.createdAt);
const cameras = meta.cameras ?? [];
const imgTxt = meta.imageCount != null
? `${meta.imageCount} Bilder total. ${cameras.length} Kamera(s) verwendet.`
: '';
document.getElementById('info-images').textContent = imgTxt;
const prev = sel.value;
sel.innerHTML = '<option value=""> Kamera </option>';
for (const cam of cameras) {
const opt = document.createElement('option');
opt.value = cam; opt.textContent = cam;
if (cam === prev) opt.selected = true;
sel.appendChild(opt);
}
if (cameras.length === 1) sel.value = cameras[0];
}
async function loadCalibCurrent() {
try {
const r = await fetch('/api/calibration/current');
const d = await r.json();
updateCalibInfo(d.meta);
if (d.session) logC(`Session geladen: ${d.session}`);
else logC('Noch keine Kalibrierungs-Session vorhanden.');
} catch (err) {
logC(`Fehler beim Laden: ${err}`);
}
}
async function safeJson(r) {
const raw = await r.text().catch(() => '');
try { return { ok: r.ok, status: r.status, data: JSON.parse(raw) }; }
catch { return { ok: r.ok, status: r.status, data: null, raw: raw.slice(0, 300) }; }
}
// Aktuelle Session laden
loadCalibCurrent();
// "Neue Kalibrierung anlegen"
document.getElementById('btn-new-calib').addEventListener('click', async () => {
logC('Neue Kalibrierung wird angelegt …');
try {
const r = await fetch('/api/calibration/new', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
if (d.warning) logC(`Warnung: ${d.warning}`);
else logC(`Session angelegt: ${d.session} | Fotos: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
// "Foto aufnehmen"
document.getElementById('btn-foto-calib').addEventListener('click', async () => {
logC('Fotos werden aufgenommen …');
try {
const r = await fetch('/api/calibration/foto', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
logC(`Gespeichert: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
// "Kalibrierung berechnen" SSE
document.getElementById('btn-compute-calib').addEventListener('click', async () => {
const camera = document.getElementById('cam-select-calib').value;
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
logC(`Starte Kalibrierung für ${camera}`);
const btn = document.getElementById('btn-compute-calib');
btn.disabled = true;
try {
const response = await fetch('/api/calibration/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera }),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || response.statusText; }
logC(`❌ HTTP ${response.status}: ${msg || '(kein Fehlertext evtl. Server neu starten?)'}`);
return;
}
await readSseStream(response, logC, (evt) => {
if (evt.exitCode === 0) logC('✅ Kalibrierung abgeschlossen.');
else logC(`❌ Script beendet mit Exit-Code ${evt.exitCode}`);
});
} catch (err) {
logC(`Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
// "NPZ speichern" → an Webcam-Service übertragen
document.getElementById('btn-upload-npz').addEventListener('click', async () => {
const camera = document.getElementById('cam-select-calib').value;
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
logC(`NPZ wird an Webcam-Service übertragen (${camera}) …`);
try {
const { ok, status, data, raw } = await safeJson(await fetch('/api/calibration/upload-npz', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera }),
}));
if (!ok || data?.error) {
logC(`❌ HTTP ${status}: ${data?.error ?? raw ?? '(kein Fehlertext)'}`);
return;
}
logC(`✅ Gespeichert: ${data.webcam?.saved} (${data.size} Bytes)`);
logC(` calibrationUrl: ${data.webcam?.calibrationUrl}`);
} catch (err) {
logC(`❌ Fehler: ${err}`);
}
});
}
// ── Board: Marker-Tabelle ─────────────────────────────────────────────────────
async function loadBoardTable() {
const wrap = document.getElementById('board-marker-table-wrap');
if (!wrap) return;
const th = (a) => `style="text-align:${a};padding:3px 8px;border-bottom:1px solid #2a2d35;white-space:nowrap;background:#1e293b;color:#555b6e;font-weight:normal"`;
const td = (a, x = '') => `style="padding:2px 8px;border-bottom:1px solid #111418;text-align:${a};white-space:nowrap;${x}"`;
try {
const r = await fetch('/api/board/latest');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (!data.runDir) {
wrap.innerHTML = '<p style="font-size:11px;color:#555b6e;padding:4px 0">Noch kein Board-Run vorhanden.</p>';
return;
}
const meas = data.measuredMarkers;
if (!meas?.markers?.length) {
wrap.innerHTML = `<p style="font-size:11px;color:#555b6e;padding:4px 0">Run: ${data.runDir} kein 3b-Output (≥2 Kameras erforderlich).</p>`;
return;
}
// Modell-Info aus robot.json
const boardMarkers = data.robot?.links?.Board?.markers ?? [];
const modelMap = {};
for (const m of boardMarkers) modelMap[m.id] = m;
const f1 = v => (v == null ? '' : Number(v).toFixed(1));
const f2 = v => (v == null ? '' : Number(v).toFixed(2));
const f4 = v => (v == null ? '' : Number(v).toFixed(4));
const markers = [...meas.markers].sort((a, b) => {
if (a.link !== b.link) return a.link === 'Board' ? -1 : 1;
return a.marker_id - b.marker_id;
});
let html = `<p style="font-size:10px;color:#555b6e;margin-bottom:4px">
Run: ${data.runDir} · ${markers.length} Marker trianguliert
</p>
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:100%">
<thead><tr>
<th ${th('left')}>ID</th>
<th ${th('left')}>Set</th>
<th ${th('left')}>Link</th>
<th ${th('right')}>Kam.</th>
<th ${th('right')}>x mm</th>
<th ${th('right')}>y mm</th>
<th ${th('right')}>z mm</th>
<th ${th('right')}>nx</th>
<th ${th('right')}>ny</th>
<th ${th('right')}>nz</th>
<th ${th('right')}>dist mm</th>
<th ${th('right')}>Δz mm</th>
<th ${th('right')}>Kante mm</th>
</tr></thead>
<tbody>`;
for (const m of markers) {
const model = modelMap[m.marker_id];
const set = m.set ?? model?.set ?? '';
const [px, py, pz] = m.position_mm;
const [mnx, mny, mnz] = m.normal;
let dist = '', dz = '';
if (model && m.link === 'Board') {
const [mx, my, mz] = model.position;
const ddx = px - mx, ddy = py - my, ddz = pz - mz;
dist = Math.sqrt(ddx * ddx + ddy * ddy + ddz * ddz).toFixed(2);
dz = ddz.toFixed(2);
}
const col = m.link === 'Board' ? '' : 'color:#3b82f6;';
html += `<tr>
<td ${td('left', col)}>${m.marker_id}</td>
<td ${td('left', col)}>${set}</td>
<td ${td('left', col)}>${m.link}</td>
<td ${td('right', col)}>${m.num_cameras}</td>
<td ${td('right', col)}>${f1(px)}</td>
<td ${td('right', col)}>${f1(py)}</td>
<td ${td('right', col)}>${f1(pz)}</td>
<td ${td('right', col)}>${f4(mnx)}</td>
<td ${td('right', col)}>${f4(mny)}</td>
<td ${td('right', col)}>${f4(mnz)}</td>
<td ${td('right', col)}>${dist}</td>
<td ${td('right', col)}>${dz}</td>
<td ${td('right', col)}>${f1(m.edge_length_mm)}</td>
</tr>`;
}
html += `</tbody></table>`;
// Kamera-Tabelle
const cams = meas.cameras ?? [];
if (cams.length > 0) {
html += `<p style="font-size:10px;color:#555b6e;margin-top:12px;margin-bottom:4px">KAMERAS</p>
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:auto">
<thead><tr>
<th ${th('left')}>ID</th>
<th ${th('right')}>x mm</th>
<th ${th('right')}>y mm</th>
<th ${th('right')}>z mm</th>
<th ${th('right')}>dir_x</th>
<th ${th('right')}>dir_y</th>
<th ${th('right')}>dir_z</th>
</tr></thead>
<tbody>`;
for (const c of cams) {
const [cx, cy, cz] = c.position_mm ?? [null, null, null];
const [dx, dy, dz] = c.direction ?? [null, null, null];
html += `<tr>
<td ${td('left', 'color:#4a9eff;')}>${c.camera_id}</td>
<td ${td('right')}>${f1(cx)}</td>
<td ${td('right')}>${f1(cy)}</td>
<td ${td('right')}>${f1(cz)}</td>
<td ${td('right')}>${f4(dx)}</td>
<td ${td('right')}>${f4(dy)}</td>
<td ${td('right')}>${f4(dz)}</td>
</tr>`;
}
html += `</tbody></table>`;
}
wrap.innerHTML = html;
} catch (err) {
if (wrap) wrap.innerHTML = `<p style="color:#f87171;font-size:11px">Fehler: ${err}</p>`;
}
}
// ── Board ─────────────────────────────────────────────────────────────────────
function initBoard() {
const logBoard = document.getElementById('log-board');
function logB(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logBoard.value += `[${ts}] ${msg}\n`;
logBoard.scrollTop = logBoard.scrollHeight;
}
// Tabelle beim ersten Öffnen des Tabs befüllen
loadBoardTable();
document.getElementById('btn-board-run').addEventListener('click', async () => {
logB('Board-Erkennung wird gestartet …');
const btn = document.getElementById('btn-board-run');
btn.disabled = true;
try {
const response = await fetch('/api/board/run', { method: 'POST' });
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
logB(`❌ HTTP ${response.status}: ${msg}`);
return;
}
await readSseStream(response, logB, (evt) => {
if (evt.exitCode === 0) {
logB('✅ Board-Run abgeschlossen.');
if (evt.runDir) {
document.getElementById('board-last-run').textContent = evt.runDir;
// Board-Viewer im iframe nach dem Run neu laden
const frame = document.getElementById('board-viewer-frame');
if (frame?.contentWindow) {
frame.contentWindow.postMessage({ type: 'reload' }, '*');
}
// Marker-Tabelle aktualisieren
loadBoardTable();
}
} else {
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
logB(`❌ Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
}