469 lines
14 KiB
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>
|