vision/main/www/index.html

469 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>linefollower-eye</title>
<style>
:root {
--bg: #12141a;
--panel: #1c1f28;
--text: #e8eaed;
--muted: #9aa0a6;
--accent: #3ddc97;
--border: #2a2f3a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.4 system-ui, sans-serif;
}
.wrap {
display: grid;
grid-template-columns: 1fr 300px;
gap: 12px;
padding: 12px;
max-width: 1100px;
margin: 0 auto;
min-height: 100vh;
}
@media (max-width: 800px) {
.wrap { grid-template-columns: 1fr; }
}
.stage {
position: relative;
background: #000;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
align-self: start;
}
.stage img, .stage canvas {
display: block;
width: 100%;
height: auto;
vertical-align: top;
}
.stage canvas {
position: absolute;
left: 0; top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.stage.pick { cursor: crosshair; }
.side {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
h1 { font-size: 16px; margin: 0 0 4px; font-weight: 600; }
h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 8px 0 4px; }
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.swatch {
width: 36px; height: 24px;
border: 1px solid var(--border);
border-radius: 3px;
background: #888;
}
label.chk { display: flex; align-items: center; gap: 6px; cursor: pointer; }
input[type=range] { flex: 1; }
select, button, input[type=color] {
background: #2a2f3a;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 8px;
}
button { cursor: pointer; }
button:hover { border-color: var(--accent); }
button.active { background: #1e3d32; border-color: var(--accent); }
#stats { color: var(--muted); font-size: 12px; min-height: 2.8em; }
#log {
flex: 1;
min-height: 80px;
max-height: 160px;
overflow: auto;
background: #0e1014;
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px;
font: 11px/1.35 ui-monospace, monospace;
color: var(--muted);
}
.msg { display: flex; gap: 6px; }
.msg input { flex: 1; background: #0e1014; color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #666; display: inline-block; }
.status-dot.on { background: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<div class="stage" id="stage">
<img id="frame" alt="camera" width="320" height="240">
<canvas id="overlay" width="320" height="240"></canvas>
</div>
<aside class="side">
<h1>linefollower-eye <span class="status-dot" id="wsDot" title="WebSocket"></span></h1>
<div id="stats">connecting…</div>
<h2>Target colour</h2>
<div class="row">
<div class="swatch" id="swatch"></div>
<input type="color" id="colour" value="#ff8800" title="Pick colour">
<button type="button" id="eyedrop">Eyedropper</button>
</div>
<div id="hsvLabel" style="color:var(--muted);font-size:12px;font-family:ui-monospace,monospace">HSV: —</div>
<h2>Detection</h2>
<div class="row">
<label>Method</label>
<select id="method">
<option value="contour">Contour</option>
<option value="rect">Rectangle</option>
</select>
</div>
<label class="chk"><input type="checkbox" id="showMask"> Show mask</label>
<label class="chk"><input type="checkbox" id="maskFull"> Full-frame mask (slow)</label>
<label class="chk"><input type="checkbox" id="morphRoi" checked> ROI morph</label>
<div class="row">
<label>Stride</label>
<select id="stride"><option>2</option><option selected>4</option></select>
</div>
<div class="row">
<label>Tolerance</label>
<input type="range" id="tol" min="2" max="40" value="10">
<span id="tolVal">10</span>
</div>
<h2>Terminal</h2>
<div id="log"></div>
<div class="msg">
<input id="msg" placeholder="message…" autocomplete="off">
<button type="button" id="sendMsg">Send</button>
</div>
</aside>
</div>
<script>
(function () {
const img = document.getElementById("frame");
const canvas = document.getElementById("overlay");
const ctx = canvas.getContext("2d");
const stage = document.getElementById("stage");
const swatch = document.getElementById("swatch");
const colour = document.getElementById("colour");
const eyedropBtn = document.getElementById("eyedrop");
const hsvLabel = document.getElementById("hsvLabel");
const methodEl = document.getElementById("method");
const showMask = document.getElementById("showMask");
const maskFull = document.getElementById("maskFull");
const morphRoi = document.getElementById("morphRoi");
const strideEl = document.getElementById("stride");
const tolEl = document.getElementById("tol");
const tolVal = document.getElementById("tolVal");
const statsEl = document.getElementById("stats");
const logEl = document.getElementById("log");
const wsDot = document.getElementById("wsDot");
const msgEl = document.getElementById("msg");
let ws = null;
let busy = false;
let prevUrl = null;
let applying = false;
let pickMode = false;
let pendingPick = null;
let syncTimer = null;
let lastPayload = "";
let settings = {
local: true,
show_mask: false,
method: "contour",
h: 15, s: 180, v: 200,
b: 0, g: 136, r: 255,
h_tol: 10, s_tol: 60, v_tol: 60,
coarse_stride: 4,
min_area: 80,
mask_full: false,
morph_roi: true,
};
function log(line) {
const t = new Date().toLocaleTimeString();
logEl.textContent += "[" + t + "] " + line + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function setSwatch(b, g, r) {
const hex = "#" + [r, g, b].map(function (n) {
return ("0" + (n & 255).toString(16)).slice(-2);
}).join("");
swatch.style.background = hex;
colour.value = hex;
}
function setHsvLabel(h, s, v) {
if (h == null || s == null || v == null) {
hsvLabel.textContent = "HSV: —";
return;
}
hsvLabel.textContent = "HSV: (" + h + ", " + s + ", " + v + ")";
}
function hexToBgr(hex) {
const n = parseInt(hex.slice(1), 16);
return { b: n & 255, g: (n >> 8) & 255, r: (n >> 16) & 255 };
}
function applySettings(data) {
if (!data || !data.ok) return;
applying = true;
try {
settings = Object.assign(settings, data);
settings.local = true;
if ("b" in data && "g" in data && "r" in data) {
setSwatch(+data.b, +data.g, +data.r);
}
if ("h" in data && "s" in data && "v" in data) {
setHsvLabel(+data.h, +data.s, +data.v);
}
methodEl.value = data.method === "rect" ? "rect" : "contour";
showMask.checked = !!data.show_mask;
maskFull.checked = !!data.mask_full;
morphRoi.checked = data.morph_roi !== false;
strideEl.value = String(data.coarse_stride === 2 ? 2 : 4);
if ("h_tol" in data) {
tolEl.value = String(data.h_tol);
tolVal.textContent = String(data.h_tol);
}
lastPayload = JSON.stringify(buildPayload());
log("settings loaded HSV=(" + data.h + "," + data.s + "," + data.v + ")");
} finally {
applying = false;
}
}
function buildPayload() {
const tol = +tolEl.value;
const payload = {
type: "vision",
local: true,
show_mask: showMask.checked,
method: methodEl.value,
h_tol: tol,
s_tol: tol * 6,
v_tol: tol * 6,
coarse_stride: +strideEl.value,
min_area: settings.min_area || 80,
mask_full: maskFull.checked,
morph_roi: morphRoi.checked,
};
if (pendingPick) {
payload.pick_x = pendingPick.x;
payload.pick_y = pendingPick.y;
pendingPick = null;
} else if (settings.h != null) {
payload.h = settings.h;
payload.s = settings.s;
payload.v = settings.v;
}
return payload;
}
function syncVision(delay) {
if (applying) return;
clearTimeout(syncTimer);
syncTimer = setTimeout(function () {
const payload = buildPayload();
const blob = JSON.stringify(payload);
if (blob === lastPayload && !("pick_x" in payload)) return;
lastPayload = blob.replace(/,"pick_x":\d+,"pick_y":\d+/, "");
wsSend(payload);
}, delay == null ? 150 : delay);
}
function wsSend(obj) {
if (!ws || ws.readyState !== 1) {
log("! WS not connected");
return false;
}
ws.send(JSON.stringify(obj));
return true;
}
function connectWs() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(proto + "//" + location.host + "/ws");
ws.onopen = function () {
wsDot.classList.add("on");
log("WS connected");
fetch("/vision/settings")
.then(function (r) { return r.json(); })
.then(applySettings)
.catch(function (e) { log("settings: " + e); wsSend({ type: "vision", get: true }); });
};
ws.onclose = function () {
wsDot.classList.remove("on");
log("WS closed — retrying…");
setTimeout(connectWs, 1500);
};
ws.onerror = function () { log("WS error"); };
ws.onmessage = function (ev) {
try {
const data = JSON.parse(ev.data);
if (data.type === "vision") applySettings(data);
else log("< " + ev.data);
} catch (e) {
log("< " + ev.data);
}
};
}
function parseVisionHeader(raw) {
const out = {};
if (!raw) return out;
raw.split(";").forEach(function (part) {
const i = part.indexOf("=");
if (i < 0) return;
const k = part.slice(0, i);
const v = part.slice(i + 1);
out[k] = k === "found" ? v === "1" : +v;
});
return out;
}
function drawOverlay(stats) {
const w = img.naturalWidth || 320;
const h = img.naturalHeight || 240;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!stats || !stats.found) return;
const cx = stats.cx, cy = stats.cy, r = Math.max(4, stats.r || 8);
ctx.strokeStyle = "#3ddc97";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx - 8, cy); ctx.lineTo(cx + 8, cy);
ctx.moveTo(cx, cy - 8); ctx.lineTo(cx, cy + 8);
ctx.stroke();
}
function formatStats(s) {
if (!s || s.total == null) return "timing: —";
let line = "timing: total " + Math.round(s.total) + "ms det " + Math.round(s.detect || 0);
if (s.found) line += " | blob @(" + Math.round(s.cx) + "," + Math.round(s.cy) + ") r=" + Math.round(s.r || 0);
return line;
}
async function tick() {
if (busy) return;
busy = true;
try {
const r = await fetch("/capture?t=" + Date.now(), { cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
const stats = parseVisionHeader(r.headers.get("X-Vision-Data"));
statsEl.textContent = formatStats(stats);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
await new Promise(function (resolve) {
img.onload = resolve;
img.src = url;
});
if (prevUrl) URL.revokeObjectURL(prevUrl);
prevUrl = url;
drawOverlay(stats);
} catch (e) {
statsEl.textContent = "video: " + e.message;
}
busy = false;
}
stage.addEventListener("click", function (ev) {
if (!pickMode) return;
const rect = img.getBoundingClientRect();
const nw = img.naturalWidth || 320;
const nh = img.naturalHeight || 240;
const x = Math.round((ev.clientX - rect.left) * nw / rect.width);
const y = Math.round((ev.clientY - rect.top) * nh / rect.height);
if (x < 0 || y < 0 || x >= nw || y >= nh) return;
if (showMask.checked) {
log("! turn off Show mask to pick colour");
return;
}
pendingPick = { x: x, y: y };
pickMode = false;
stage.classList.remove("pick");
eyedropBtn.classList.remove("active");
log("eyedrop @ (" + x + "," + y + ")");
syncVision(0);
});
eyedropBtn.addEventListener("click", function () {
pickMode = !pickMode;
stage.classList.toggle("pick", pickMode);
eyedropBtn.classList.toggle("active", pickMode);
log(pickMode ? "Eyedropper on — click the video" : "Eyedropper off");
});
colour.addEventListener("input", function () {
if (applying) return;
const c = hexToBgr(colour.value);
setSwatch(c.b, c.g, c.r);
settings.b = c.b; settings.g = c.g; settings.r = c.r;
// Send BGR so ESP derives HSV
const tol = +tolEl.value;
wsSend({
type: "vision",
local: true,
b: c.b, g: c.g, r: c.r,
show_mask: showMask.checked,
method: methodEl.value,
h_tol: tol, s_tol: tol * 6, v_tol: tol * 6,
coarse_stride: +strideEl.value,
min_area: settings.min_area || 80,
mask_full: maskFull.checked,
morph_roi: morphRoi.checked,
});
});
["change", "input"].forEach(function (ev) {
methodEl.addEventListener(ev, function () { if (!applying) syncVision(); });
showMask.addEventListener(ev, function () { if (!applying) syncVision(); });
maskFull.addEventListener(ev, function () { if (!applying) syncVision(); });
morphRoi.addEventListener(ev, function () { if (!applying) syncVision(); });
strideEl.addEventListener(ev, function () { if (!applying) syncVision(); });
});
tolEl.addEventListener("input", function () {
tolVal.textContent = tolEl.value;
if (!applying) syncVision(200);
});
document.getElementById("sendMsg").addEventListener("click", sendMsg);
msgEl.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") sendMsg();
});
function sendMsg() {
const text = msgEl.value.trim();
if (!text) return;
msgEl.value = "";
wsSend({ type: "msg", text: text });
}
connectWs();
setInterval(tick, 100);
tick();
})();
</script>
</body>
</html>