Viewer
This commit is contained in:
344
public/boardViewer.html
Normal file
344
public/boardViewer.html
Normal file
@@ -0,0 +1,344 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Board Viewer</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0d0f13;
|
||||
--panel: #161920;
|
||||
--border: #2a2d35;
|
||||
--text: #c8cdd8;
|
||||
--muted: #555b6e;
|
||||
--accent: #4a9eff;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 11px/1.5 'IBM Plex Mono', 'Cascadia Code', 'Courier New', monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 5px 12px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.legend { display: flex; gap: 12px; align-items: center; }
|
||||
.dot {
|
||||
display: inline-block; width: 10px; height: 10px;
|
||||
border-radius: 2px; margin-right: 3px; vertical-align: middle;
|
||||
}
|
||||
#status { color: var(--muted); flex: 1; min-width: 120px; }
|
||||
#stats { color: var(--accent); }
|
||||
.btn {
|
||||
background: #1e293b;
|
||||
color: var(--text);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 3px;
|
||||
padding: 3px 9px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ── Canvas ── */
|
||||
#canvas-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* ── Hint overlay ── */
|
||||
#hint {
|
||||
position: absolute;
|
||||
bottom: 6px; right: 10px;
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.162.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.162.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="topbar">
|
||||
<div class="legend">
|
||||
<span><span class="dot" style="background:#22c55e"></span>Erkannt</span>
|
||||
<span><span class="dot" style="background:#ef4444"></span>Nicht erkannt</span>
|
||||
<span><span class="dot" style="background:#9b7bff"></span>Kamera</span>
|
||||
</div>
|
||||
<span id="stats"></span>
|
||||
<span id="status">Laden …</span>
|
||||
<button class="btn" id="btnReload">↺</button>
|
||||
</div>
|
||||
|
||||
<div id="canvas-wrap">
|
||||
<canvas id="cv"></canvas>
|
||||
<div id="hint">Orbit · Scroll · Rechte Taste = Pan</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
const S = 1 / 1000; // mm → m
|
||||
|
||||
// robot (x=right, y=backward, z=up) → Three.js (x=right, y=up, z=toward viewer)
|
||||
function r2v(rx, ry, rz) { return new THREE.Vector3(rx * S, rz * S, -ry * S); }
|
||||
function r2vArr([rx, ry, rz]) { return r2v(rx, ry, rz); }
|
||||
// direction vector (no scale)
|
||||
function r2dir([dx, dy, dz]) { return new THREE.Vector3(dx, dz, -dy).normalize(); }
|
||||
|
||||
// ── Renderer ──────────────────────────────────────────────────────────────────
|
||||
const canvas = document.getElementById('cv');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(devicePixelRatio);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0d0f13);
|
||||
|
||||
// Initial camera: top-angled view, board center ≈ (0.49, -0.03, 0.015)
|
||||
const CAM_TARGET = new THREE.Vector3(0.49, -0.03, 0.015);
|
||||
const cam = new THREE.PerspectiveCamera(45, 1, 0.001, 20);
|
||||
cam.position.set(0.49, 1.5, 0.85);
|
||||
cam.lookAt(CAM_TARGET);
|
||||
|
||||
const controls = new OrbitControls(cam, canvas);
|
||||
controls.target.copy(CAM_TARGET);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
|
||||
// Lighting
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.75));
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 0.6);
|
||||
sun.position.set(-0.5, 1.2, 0.5);
|
||||
scene.add(sun);
|
||||
|
||||
// World-origin axes
|
||||
scene.add(new THREE.AxesHelper(0.08));
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||
const gPaper = new THREE.Group(); // white A0 plane
|
||||
const gMarkers = new THREE.Group(); // ArUco squares
|
||||
const gCameras = new THREE.Group(); // camera frustums
|
||||
scene.add(gPaper, gMarkers, gCameras);
|
||||
|
||||
function clearGroup(g) {
|
||||
while (g.children.length) {
|
||||
const c = g.children[0];
|
||||
c.geometry?.dispose?.();
|
||||
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
|
||||
else c.material?.dispose?.();
|
||||
g.remove(c);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
||||
function makePlane(w, d, color, opacity = 1) {
|
||||
const geo = new THREE.PlaneGeometry(w, d);
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
color, side: THREE.DoubleSide,
|
||||
transparent: opacity < 1, opacity,
|
||||
});
|
||||
return new THREE.Mesh(geo, mat);
|
||||
}
|
||||
|
||||
function makeSquareMarker(pos, size, color) {
|
||||
const geo = new THREE.PlaneGeometry(size, size);
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
const mat = new THREE.MeshPhongMaterial({ color, side: THREE.DoubleSide });
|
||||
const m = new THREE.Mesh(geo, mat);
|
||||
m.position.copy(pos);
|
||||
return m;
|
||||
}
|
||||
|
||||
function makeCameraFrustum(posThree, dirThree, size) {
|
||||
const geo = new THREE.ConeGeometry(size * 0.55, size, 4);
|
||||
geo.translate(0, -size / 2, 0); // apex → local origin
|
||||
geo.rotateY(Math.PI / 4);
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
color: 0x9b7bff, transparent: true, opacity: 0.55, side: THREE.DoubleSide,
|
||||
});
|
||||
const m = new THREE.Mesh(geo, mat);
|
||||
m.position.copy(posThree);
|
||||
const d = dirThree.clone().normalize();
|
||||
if (d.lengthSq() > 1e-9) {
|
||||
m.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), d);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function makeLine(p1, p2, color, opacity = 1) {
|
||||
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||
const mat = new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity });
|
||||
return new THREE.Line(geo, mat);
|
||||
}
|
||||
|
||||
// ── Build scene from API data ─────────────────────────────────────────────────
|
||||
function buildScene(data) {
|
||||
clearGroup(gPaper);
|
||||
clearGroup(gMarkers);
|
||||
clearGroup(gCameras);
|
||||
|
||||
const { robot, detections, cameraPoses } = data;
|
||||
|
||||
// Alle erkannten Marker-IDs über alle Kameras sammeln
|
||||
const detectedIds = new Set();
|
||||
for (const det of (detections ?? [])) {
|
||||
for (const id of (det.detectedMarkerIds ?? [])) detectedIds.add(id);
|
||||
}
|
||||
|
||||
const boardMarkers = robot?.links?.Board?.markers ?? [];
|
||||
const markerSize = (robot?.vision_config?.MarkerSize ?? 0.025); // m
|
||||
|
||||
// ── A0-Papier (weißes Rechteck unter den Markern) ──
|
||||
if (boardMarkers.length > 0) {
|
||||
let minRx = Infinity, maxRx = -Infinity;
|
||||
let minRy = Infinity, maxRy = -Infinity;
|
||||
let markerRz = -27.3;
|
||||
for (const m of boardMarkers) {
|
||||
const [rx, ry, rz] = m.position;
|
||||
minRx = Math.min(minRx, rx); maxRx = Math.max(maxRx, rx);
|
||||
minRy = Math.min(minRy, ry); maxRy = Math.max(maxRy, ry);
|
||||
markerRz = rz;
|
||||
}
|
||||
const pad = 40; // mm Rand
|
||||
const planeW = (maxRx - minRx + 2 * pad) * S;
|
||||
const planeH = (maxRy - minRy + 2 * pad) * S;
|
||||
const planeCx = ((minRx + maxRx) / 2) * S;
|
||||
const planeCz = -((minRy + maxRy) / 2) * S; // robot y → Three.js -z
|
||||
const planeCy = markerRz * S - 0.001; // leicht unter den Markern
|
||||
|
||||
const paper = makePlane(planeW, planeH, 0xf0ebe0); // off-white
|
||||
paper.position.set(planeCx, planeCy, planeCz);
|
||||
gPaper.add(paper);
|
||||
}
|
||||
|
||||
// ── ArUco-Marker ──
|
||||
let nDetected = 0;
|
||||
const visSize = markerSize * 0.9;
|
||||
|
||||
for (const m of boardMarkers) {
|
||||
const id = m.id;
|
||||
const pos = r2vArr(m.position);
|
||||
const detected = detectedIds.has(id);
|
||||
if (detected) nDetected++;
|
||||
|
||||
const color = detected ? 0x22c55e : 0xef4444;
|
||||
const sq = makeSquareMarker(pos, visSize, color);
|
||||
sq.position.y += 0.0005; // ganz knapp über der Papier-Ebene
|
||||
gMarkers.add(sq);
|
||||
|
||||
// dünner Rahmen
|
||||
const borderGeo = new THREE.EdgesGeometry(new THREE.PlaneGeometry(visSize, visSize));
|
||||
const borderMat = new THREE.LineBasicMaterial({
|
||||
color: detected ? 0x4ade80 : 0xfca5a5,
|
||||
transparent: true, opacity: 0.8,
|
||||
});
|
||||
const border = new THREE.LineSegments(borderGeo, borderMat);
|
||||
border.rotation.x = -Math.PI / 2;
|
||||
border.position.copy(pos);
|
||||
border.position.y += 0.001;
|
||||
gMarkers.add(border);
|
||||
}
|
||||
|
||||
// ── Kamera-Frusta ──
|
||||
const boardCenter = r2v(490, 0, -27.3);
|
||||
for (const cp of (cameraPoses ?? [])) {
|
||||
if (!cp.position_mm) continue;
|
||||
const camPos = r2vArr(cp.position_mm);
|
||||
|
||||
// Kamera-Blickrichtung: R_wc^T · [0,0,1] = dritte Zeile von R_wc
|
||||
let dirWorld = [0, 0, 1];
|
||||
if (cp.rotation_matrix?.length >= 3) dirWorld = cp.rotation_matrix[2];
|
||||
const dirThree = r2dir(dirWorld);
|
||||
|
||||
gCameras.add(makeCameraFrustum(camPos, dirThree, 0.07));
|
||||
gCameras.add(makeLine(camPos, boardCenter, 0x9b7bff, 0.35));
|
||||
|
||||
// Kleiner Sphere an Kamera-Position
|
||||
const sg = new THREE.SphereGeometry(0.012, 10, 8);
|
||||
const sm = new THREE.MeshPhongMaterial({ color: 0x9b7bff });
|
||||
const sp = new THREE.Mesh(sg, sm);
|
||||
sp.position.copy(camPos);
|
||||
gCameras.add(sp);
|
||||
}
|
||||
|
||||
// ── Stats-Zeile ──
|
||||
const camInfo = (cameraPoses ?? []).map(cp => {
|
||||
const rms = cp.rms_px != null ? `${cp.rms_px.toFixed(1)} px` : '?';
|
||||
return `${cp.cameraId}: ${cp.usedMarkerIds?.length ?? 0} Marker, ${rms} RMS`;
|
||||
}).join(' │ ');
|
||||
|
||||
document.getElementById('stats').textContent =
|
||||
`Erkannt ${nDetected}/${boardMarkers.length}` +
|
||||
(camInfo ? ` │ ${camInfo}` : '');
|
||||
}
|
||||
|
||||
// ── Daten laden ───────────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
const statusEl = document.getElementById('status');
|
||||
statusEl.textContent = 'Laden …';
|
||||
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) {
|
||||
statusEl.textContent = 'Kein Board-Run vorhanden.';
|
||||
document.getElementById('stats').textContent = '';
|
||||
clearGroup(gPaper); clearGroup(gMarkers); clearGroup(gCameras);
|
||||
return;
|
||||
}
|
||||
buildScene(data);
|
||||
statusEl.textContent = `Run: ${data.runDir} • ${new Date().toLocaleTimeString('de-CH')}`;
|
||||
} catch (err) {
|
||||
statusEl.textContent = `Fehler: ${err.message ?? err}`;
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
document.getElementById('btnReload').addEventListener('click', loadData);
|
||||
|
||||
// Reload-Trigger vom Parent-Frame (nach Board-Run)
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data?.type === 'reload') loadData();
|
||||
});
|
||||
|
||||
// ── Resize & Render-Loop ──────────────────────────────────────────────────────
|
||||
function onResize() {
|
||||
const wrap = document.getElementById('canvas-wrap');
|
||||
const w = wrap.clientWidth, h = wrap.clientHeight;
|
||||
renderer.setSize(w, h);
|
||||
cam.aspect = w / h;
|
||||
cam.updateProjectionMatrix();
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
onResize();
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, cam);
|
||||
}
|
||||
animate();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -250,7 +250,14 @@ function initBoard() {
|
||||
await readSseStream(response, logB, (evt) => {
|
||||
if (evt.exitCode === 0) {
|
||||
logB('✅ Board-Run abgeschlossen.');
|
||||
if (evt.runDir) document.getElementById('board-last-run').textContent = evt.runDir;
|
||||
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' }, '*');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
|
||||
}
|
||||
|
||||
@@ -25,4 +25,17 @@
|
||||
<textarea id="log-board" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="section full">
|
||||
<h2>Board-Viewer</h2>
|
||||
<p style="font-size: 12px; color: var(--muted); margin-bottom: 10px;">
|
||||
Wird nach jedem Board-Run automatisch aktualisiert.
|
||||
</p>
|
||||
<iframe
|
||||
id="board-viewer-frame"
|
||||
src="/boardViewer.html"
|
||||
style="width: 100%; height: 520px; border: 1px solid #334155; border-radius: 6px; background: #0d0f13; display: block;"
|
||||
title="Board-Viewer"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -572,6 +572,73 @@ app.post('/api/board/run', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Neuestes Board-Run-Verzeichnis (Timestamp-Name) oder null */
|
||||
async function findLatestBoardRun() {
|
||||
try {
|
||||
await fsPromises.access(boardDataDir);
|
||||
const entries = await fsPromises.readdir(boardDataDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort().reverse();
|
||||
return dirs[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/board/latest
|
||||
* Gibt Daten des letzten Board-Runs zurück: robot.json + Detection-Ergebnisse + Kamera-Posen.
|
||||
* Wird vom Board-Viewer (boardViewer.html) abgefragt.
|
||||
*/
|
||||
app.get('/api/board/latest', async (req, res) => {
|
||||
try {
|
||||
const runName = await findLatestBoardRun();
|
||||
if (!runName) return res.json({ runDir: null, robot: null, detections: [], cameraPoses: [] });
|
||||
|
||||
const runDir = path.join(boardDataDir, runName);
|
||||
|
||||
let robot = null;
|
||||
try { robot = JSON.parse(await fsPromises.readFile(ROBOT_JSON, 'utf8')); } catch {}
|
||||
|
||||
let files = [];
|
||||
try { files = await fsPromises.readdir(runDir); } catch {}
|
||||
|
||||
const detections = [];
|
||||
const cameraPoses = [];
|
||||
|
||||
for (const f of files.sort()) {
|
||||
if (f.endsWith('_aruco_detection.json')) {
|
||||
try {
|
||||
const data = JSON.parse(await fsPromises.readFile(path.join(runDir, f), 'utf8'));
|
||||
detections.push({
|
||||
file: f,
|
||||
cameraId: data.camera?.camera_id ?? f.replace('_aruco_detection.json', ''),
|
||||
detectedMarkerIds: (data.detections ?? []).map(d => d.marker_id),
|
||||
numDetected: data.aruco?.num_detected_markers ?? 0,
|
||||
numRejected: data.aruco?.num_rejected_candidates ?? 0,
|
||||
});
|
||||
} catch {}
|
||||
} else if (f.endsWith('_camera_pose.json')) {
|
||||
try {
|
||||
const data = JSON.parse(await fsPromises.readFile(path.join(runDir, f), 'utf8'));
|
||||
const cp = data.camera_pose;
|
||||
cameraPoses.push({
|
||||
file: f,
|
||||
cameraId: data.camera?.camera_id ?? f.replace('_camera_pose.json', ''),
|
||||
position_mm: cp?.camera_in_world?.position_mm ?? null,
|
||||
rotation_matrix: cp?.world_to_camera?.rotation_matrix ?? null,
|
||||
usedMarkerIds: data.estimation?.used_marker_ids ?? [],
|
||||
rms_px: data.estimation?.residual_rms_px ?? null,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ runDir: runName, robot, detections, cameraPoses });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/calibration/upload-npz
|
||||
* Liest {camera}_calibration.npz aus der aktuellen Session und
|
||||
|
||||
Reference in New Issue
Block a user