flame-wro-fe 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +1196 -0
- package/arduino/outputProxy/platformio.ini +16 -0
- package/arduino/outputProxy/src/main.cpp +276 -0
- package/index.js +8 -0
- package/package.json +31 -0
- package/raspySrc/main.py +12 -0
- package/raspySrc/raspy/control_panel.py +586 -0
- package/raspySrc/raspy/decision_log.py +85 -0
- package/raspySrc/raspy/drive_state.py +160 -0
- package/raspySrc/raspy/install_vehicle_service.sh +3 -0
- package/raspySrc/raspy/lap_direction.py +24 -0
- package/raspySrc/raspy/robot_runtime.py +1052 -0
- package/raspySrc/raspy/settings.json +198 -0
- package/raspySrc/raspy/settings.py +206 -0
- package/raspySrc/raspy/vehicle-runtime.service +12 -0
- package/raspySrc/raspy/vision_pipeline.py +142 -0
- package/raspySrc/raspy/vision_types.py +42 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
INDEX_HTML = """<!doctype html>
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<head>
|
|
10
|
+
<meta charset="utf-8">
|
|
11
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
12
|
+
<title>Robot Control</title>
|
|
13
|
+
<style>
|
|
14
|
+
:root { color-scheme: dark; --bg:#0f1215; --panel:#171d22; --line:#2c3740; --text:#edf4f7; --muted:#9fb0bb; --accent:#55b7ff; --bad:#ff6969; }
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.35 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
|
17
|
+
header, main { width:min(1500px, calc(100vw - 24px)); margin:0 auto; }
|
|
18
|
+
header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 0; border-bottom:1px solid var(--line); }
|
|
19
|
+
h1, h2, h3 { margin:0; letter-spacing:0; }
|
|
20
|
+
h1 { font-size:20px; }
|
|
21
|
+
h2 { font-size:15px; margin-bottom:10px; }
|
|
22
|
+
h3 { font-size:13px; color:var(--muted); margin:12px 0 8px; }
|
|
23
|
+
main { display:grid; grid-template-columns:minmax(360px,1.2fr) minmax(360px,1fr); gap:12px; padding:12px 0 20px; }
|
|
24
|
+
section { border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:12px; }
|
|
25
|
+
.stack { display:grid; gap:12px; align-content:start; }
|
|
26
|
+
.video { width:100%; aspect-ratio:4/3; object-fit:contain; background:#050607; border:1px solid var(--line); border-radius:6px; }
|
|
27
|
+
#previewImg { cursor: crosshair; }
|
|
28
|
+
.grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px 12px; }
|
|
29
|
+
.row { display:grid; grid-template-columns:minmax(150px,1fr) minmax(120px,.7fr); align-items:center; gap:8px; padding:4px 0; border-bottom:1px solid rgba(255,255,255,.05); }
|
|
30
|
+
label, .key { color:var(--muted); overflow-wrap:anywhere; }
|
|
31
|
+
input, select, button { border:1px solid var(--line); border-radius:6px; background:#0d1115; color:var(--text); padding:7px 8px; min-width:0; }
|
|
32
|
+
input[type="checkbox"] { width:18px; height:18px; justify-self:end; }
|
|
33
|
+
input[type="color"] { height:34px; padding:2px; }
|
|
34
|
+
button { cursor:pointer; background:#202a33; }
|
|
35
|
+
button:hover { border-color:var(--accent); }
|
|
36
|
+
.buttons { display:flex; flex-wrap:wrap; gap:8px; }
|
|
37
|
+
.value { text-align:right; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
|
38
|
+
.hint { color:var(--muted); font-size:13px; margin:0 0 10px; }
|
|
39
|
+
.color-cell { display:grid; grid-template-columns:48px 1fr; gap:8px; align-items:center; }
|
|
40
|
+
.hsv-text { color:var(--muted); font-variant-numeric:tabular-nums; font-size:12px; }
|
|
41
|
+
.calibration { display:grid; gap:8px; }
|
|
42
|
+
.cal-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
|
43
|
+
.sample-swatch { display:inline-block; width:16px; height:16px; border:1px solid var(--line); border-radius:4px; vertical-align:-3px; margin-right:6px; }
|
|
44
|
+
details { border-top:1px solid rgba(255,255,255,.08); padding-top:8px; margin-top:8px; }
|
|
45
|
+
details:first-child { border-top:0; padding-top:0; margin-top:0; }
|
|
46
|
+
summary { cursor:pointer; font-weight:650; margin-bottom:8px; }
|
|
47
|
+
#message { color:var(--muted); min-height:20px; }
|
|
48
|
+
.error { color:var(--bad); }
|
|
49
|
+
@media (max-width: 980px) { main { grid-template-columns:1fr; } .grid { grid-template-columns:1fr; } }
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<header>
|
|
54
|
+
<div>
|
|
55
|
+
<h1>Robot Control</h1>
|
|
56
|
+
<div id="message"></div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="buttons">
|
|
59
|
+
<button id="saveBtn" type="button">Save Config</button>
|
|
60
|
+
<button id="reloadBtn" type="button">Reload</button>
|
|
61
|
+
</div>
|
|
62
|
+
</header>
|
|
63
|
+
<main>
|
|
64
|
+
<div class="stack">
|
|
65
|
+
<section>
|
|
66
|
+
<h2>Preview</h2>
|
|
67
|
+
<img id="previewImg" class="video" src="/video_feed" alt="robot camera preview">
|
|
68
|
+
</section>
|
|
69
|
+
<section class="calibration">
|
|
70
|
+
<h2>Color Sampling</h2>
|
|
71
|
+
<p class="hint">Click the preview on a real line or pillar pixel. Then apply that camera sample to a filter range.</p>
|
|
72
|
+
<div class="cal-grid">
|
|
73
|
+
<label>Target
|
|
74
|
+
<select id="sampleTarget">
|
|
75
|
+
<option>ORANGE</option>
|
|
76
|
+
<option>BLUE</option>
|
|
77
|
+
<option>RED</option>
|
|
78
|
+
<option>GREEN</option>
|
|
79
|
+
<option>PINK</option>
|
|
80
|
+
<option>WALL</option>
|
|
81
|
+
</select>
|
|
82
|
+
</label>
|
|
83
|
+
<label>Radius
|
|
84
|
+
<input id="sampleRadius" type="number" min="0" max="30" value="4">
|
|
85
|
+
</label>
|
|
86
|
+
<label>Hue margin
|
|
87
|
+
<input id="sampleHueMargin" type="number" min="0" max="60" value="8">
|
|
88
|
+
</label>
|
|
89
|
+
<label>S/V margin
|
|
90
|
+
<input id="sampleSvMargin" type="number" min="0" max="160" value="35">
|
|
91
|
+
</label>
|
|
92
|
+
<label>Gray margin
|
|
93
|
+
<input id="sampleGrayMargin" type="number" min="0" max="120" value="18">
|
|
94
|
+
</label>
|
|
95
|
+
</div>
|
|
96
|
+
<div id="sampleReadout" class="hint">No sample yet.</div>
|
|
97
|
+
<div class="buttons">
|
|
98
|
+
<button data-sample-action="range" type="button">Apply Last</button>
|
|
99
|
+
<button id="applySamplesBtn" type="button">Apply Samples</button>
|
|
100
|
+
<button data-sample-action="lo" type="button">Set LO</button>
|
|
101
|
+
<button data-sample-action="hi" type="button">Set HI</button>
|
|
102
|
+
<button id="clearSamplesBtn" type="button">Clear Samples</button>
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
<section>
|
|
106
|
+
<h2>Status</h2>
|
|
107
|
+
<div id="statusGrid" class="grid"></div>
|
|
108
|
+
</section>
|
|
109
|
+
<section>
|
|
110
|
+
<h2>Manual Test</h2>
|
|
111
|
+
<div class="buttons">
|
|
112
|
+
<button data-command="STEER" data-value="35">Steer 35</button>
|
|
113
|
+
<button data-command="STEER" data-value="90">Center</button>
|
|
114
|
+
<button data-command="STEER" data-value="145">Steer 145</button>
|
|
115
|
+
<button data-command="DRIVE" data-value="80">Drive 80</button>
|
|
116
|
+
<button data-command="DRIVE" data-value="-80">Reverse 80</button>
|
|
117
|
+
<button data-command="STOP">Stop</button>
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
120
|
+
</div>
|
|
121
|
+
<section>
|
|
122
|
+
<h2>Config</h2>
|
|
123
|
+
<p class="hint">Use Color Sampling first. The color filters use camera HSV thresholds; walls use the grayscale GRAY threshold. Red can wrap around hue 0, so red low/high may look reversed in hue.</p>
|
|
124
|
+
<div id="configFields"></div>
|
|
125
|
+
</section>
|
|
126
|
+
</main>
|
|
127
|
+
<script>
|
|
128
|
+
const message = document.getElementById("message");
|
|
129
|
+
const previewImg = document.getElementById("previewImg");
|
|
130
|
+
const statusGrid = document.getElementById("statusGrid");
|
|
131
|
+
const configFields = document.getElementById("configFields");
|
|
132
|
+
const sampleTarget = document.getElementById("sampleTarget");
|
|
133
|
+
const sampleRadius = document.getElementById("sampleRadius");
|
|
134
|
+
const sampleHueMargin = document.getElementById("sampleHueMargin");
|
|
135
|
+
const sampleSvMargin = document.getElementById("sampleSvMargin");
|
|
136
|
+
const sampleGrayMargin = document.getElementById("sampleGrayMargin");
|
|
137
|
+
const sampleReadout = document.getElementById("sampleReadout");
|
|
138
|
+
let config = {};
|
|
139
|
+
let lastSample = null;
|
|
140
|
+
let sampleSets = {};
|
|
141
|
+
const groups = ["control", "behavior", "pd", "roi", "filters", "serial", "camera", "decision_log", "debug"];
|
|
142
|
+
const defaultOpenGroups = new Set(["control", "behavior", "pd", "serial", "filters"]);
|
|
143
|
+
const statusOrder = ["state", "avoid_phase", "round_dir", "turns_left", "servo_angle", "drive_speed", "correction", "wall_left", "wall_right", "orange", "blue", "pillars", "closest_pillar_color", "closest_pillar_area", "closest_pillar_raw_area", "closest_pillar_x", "closest_pillar_seen", "closest_pillar_held", "serial_port"];
|
|
144
|
+
let configOpenState = loadOpenState();
|
|
145
|
+
|
|
146
|
+
function loadOpenState() {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(localStorage.getItem("robotConfigOpenState") || "{}");
|
|
149
|
+
} catch {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function saveOpenState() {
|
|
155
|
+
try {
|
|
156
|
+
localStorage.setItem("robotConfigOpenState", JSON.stringify(configOpenState));
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function rememberOpenStateFromDom() {
|
|
162
|
+
document.querySelectorAll("#configFields details[data-group]").forEach((details) => {
|
|
163
|
+
configOpenState[details.dataset.group] = details.open;
|
|
164
|
+
});
|
|
165
|
+
saveOpenState();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function setMessage(text, error=false) {
|
|
169
|
+
message.textContent = text || "";
|
|
170
|
+
message.className = error ? "error" : "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function api(path, payload) {
|
|
174
|
+
const options = payload === undefined ? {} : {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: {"Content-Type": "application/json"},
|
|
177
|
+
body: JSON.stringify(payload),
|
|
178
|
+
};
|
|
179
|
+
const response = await fetch(path, options);
|
|
180
|
+
const data = await response.json();
|
|
181
|
+
if (!response.ok || data.status === "error") throw new Error(data.message || "Request failed");
|
|
182
|
+
return data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function flatten(obj, prefix="") {
|
|
186
|
+
const out = [];
|
|
187
|
+
Object.entries(obj || {}).forEach(([key, value]) => {
|
|
188
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
189
|
+
if (value && typeof value === "object" && !Array.isArray(value)) out.push(...flatten(value, path));
|
|
190
|
+
else out.push([path, value]);
|
|
191
|
+
});
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function hsvToRgb(h, s, v) {
|
|
196
|
+
h = ((Number(h) || 0) * 2) % 360;
|
|
197
|
+
s = Math.max(0, Math.min(255, Number(s) || 0)) / 255;
|
|
198
|
+
v = Math.max(0, Math.min(255, Number(v) || 0)) / 255;
|
|
199
|
+
const c = v * s;
|
|
200
|
+
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
|
201
|
+
const m = v - c;
|
|
202
|
+
let r = 0, g = 0, b = 0;
|
|
203
|
+
if (h < 60) [r, g, b] = [c, x, 0];
|
|
204
|
+
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
205
|
+
else if (h < 180) [r, g, b] = [0, c, x];
|
|
206
|
+
else if (h < 240) [r, g, b] = [0, x, c];
|
|
207
|
+
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
208
|
+
else [r, g, b] = [c, 0, x];
|
|
209
|
+
return [r, g, b].map((channel) => Math.round((channel + m) * 255));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function rgbToHsvOpenCv(r, g, b) {
|
|
213
|
+
r /= 255; g /= 255; b /= 255;
|
|
214
|
+
const max = Math.max(r, g, b);
|
|
215
|
+
const min = Math.min(r, g, b);
|
|
216
|
+
const d = max - min;
|
|
217
|
+
let h = 0;
|
|
218
|
+
if (d !== 0) {
|
|
219
|
+
if (max === r) h = 60 * (((g - b) / d) % 6);
|
|
220
|
+
else if (max === g) h = 60 * ((b - r) / d + 2);
|
|
221
|
+
else h = 60 * ((r - g) / d + 4);
|
|
222
|
+
}
|
|
223
|
+
if (h < 0) h += 360;
|
|
224
|
+
const s = max === 0 ? 0 : d / max;
|
|
225
|
+
return [Math.round(h / 2), Math.round(s * 255), Math.round(max * 255)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function rgbToHex([r, g, b]) {
|
|
229
|
+
return "#" + [r, g, b].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function hexToRgb(hex) {
|
|
233
|
+
const clean = hex.replace("#", "");
|
|
234
|
+
return [0, 2, 4].map((index) => parseInt(clean.slice(index, index + 2), 16));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isFilterColorPath(path, value) {
|
|
238
|
+
return /^filters\\.[A-Z]+(LO|HI)$/.test(path) && Array.isArray(value) && value.length === 3;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function previewCoordinates(event) {
|
|
242
|
+
const rect = previewImg.getBoundingClientRect();
|
|
243
|
+
const naturalW = previewImg.naturalWidth;
|
|
244
|
+
const naturalH = previewImg.naturalHeight;
|
|
245
|
+
if (!naturalW || !naturalH) return null;
|
|
246
|
+
const scale = Math.min(rect.width / naturalW, rect.height / naturalH);
|
|
247
|
+
const drawW = naturalW * scale;
|
|
248
|
+
const drawH = naturalH * scale;
|
|
249
|
+
const offsetX = (rect.width - drawW) / 2;
|
|
250
|
+
const offsetY = (rect.height - drawH) / 2;
|
|
251
|
+
const x = (event.clientX - rect.left - offsetX) / scale;
|
|
252
|
+
const y = (event.clientY - rect.top - offsetY) / scale;
|
|
253
|
+
if (x < 0 || y < 0 || x >= naturalW || y >= naturalH) return null;
|
|
254
|
+
return {x: Math.round(x), y: Math.round(y)};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function updateSampleReadout(sample) {
|
|
258
|
+
const target = sampleTarget.value;
|
|
259
|
+
if (!sampleSets[target]) sampleSets[target] = [];
|
|
260
|
+
if (sample) {
|
|
261
|
+
sample.target = target;
|
|
262
|
+
lastSample = sample;
|
|
263
|
+
sampleSets[target].push(sample);
|
|
264
|
+
sampleSets[target] = sampleSets[target].slice(-32);
|
|
265
|
+
}
|
|
266
|
+
const samples = sampleSets[target];
|
|
267
|
+
const latest = sample || samples[samples.length - 1];
|
|
268
|
+
if (!latest) {
|
|
269
|
+
lastSample = null;
|
|
270
|
+
sampleReadout.textContent = `${target}: no samples yet.`;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!sample) lastSample = latest;
|
|
274
|
+
sampleReadout.innerHTML = `<span class="sample-swatch" style="background:${latest.hex}"></span>${target} samples=${samples.length} latest x=${latest.x} y=${latest.y} HSV=${latest.hsv.join(", ")} gray=${latest.gray} low=${latest.hsv_low.join(", ")} high=${latest.hsv_high.join(", ")}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function inputFor(path, value) {
|
|
278
|
+
const row = document.createElement("div");
|
|
279
|
+
row.className = "row";
|
|
280
|
+
const label = document.createElement("label");
|
|
281
|
+
label.textContent = path.split(".").slice(1).join(".");
|
|
282
|
+
if (isFilterColorPath(path, value)) {
|
|
283
|
+
const cell = document.createElement("div");
|
|
284
|
+
cell.className = "color-cell";
|
|
285
|
+
const input = document.createElement("input");
|
|
286
|
+
input.type = "color";
|
|
287
|
+
input.value = rgbToHex(hsvToRgb(value[0], value[1], value[2]));
|
|
288
|
+
const readout = document.createElement("span");
|
|
289
|
+
readout.className = "hsv-text";
|
|
290
|
+
readout.textContent = `HSV ${value.join(", ")}`;
|
|
291
|
+
input.addEventListener("change", async () => {
|
|
292
|
+
const next = rgbToHsvOpenCv(...hexToRgb(input.value));
|
|
293
|
+
try {
|
|
294
|
+
await api("/api/config", {path, value: next});
|
|
295
|
+
setMessage(`${path} updated to HSV ${next.join(", ")}`);
|
|
296
|
+
await loadConfig();
|
|
297
|
+
} catch (error) {
|
|
298
|
+
setMessage(error.message, true);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
cell.append(input, readout);
|
|
302
|
+
row.append(label, cell);
|
|
303
|
+
return row;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let input = document.createElement("input");
|
|
307
|
+
if (typeof value === "boolean") {
|
|
308
|
+
input.type = "checkbox";
|
|
309
|
+
input.checked = value;
|
|
310
|
+
} else if (typeof value === "number") {
|
|
311
|
+
input.type = "number";
|
|
312
|
+
input.step = Number.isInteger(value) ? "1" : "0.01";
|
|
313
|
+
input.value = value;
|
|
314
|
+
} else if (Array.isArray(value)) {
|
|
315
|
+
input.value = JSON.stringify(value);
|
|
316
|
+
} else {
|
|
317
|
+
input.value = value ?? "";
|
|
318
|
+
}
|
|
319
|
+
input.addEventListener("change", async () => {
|
|
320
|
+
let next = input.type === "checkbox" ? input.checked : input.value;
|
|
321
|
+
if (typeof value === "number") next = Number(next);
|
|
322
|
+
else if (Array.isArray(value)) next = JSON.parse(next);
|
|
323
|
+
try {
|
|
324
|
+
await api("/api/config", {path, value: next});
|
|
325
|
+
setMessage(`${path} updated`);
|
|
326
|
+
await loadConfig();
|
|
327
|
+
} catch (error) {
|
|
328
|
+
setMessage(error.message, true);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
row.append(label, input);
|
|
332
|
+
return row;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderConfig() {
|
|
336
|
+
rememberOpenStateFromDom();
|
|
337
|
+
configFields.replaceChildren();
|
|
338
|
+
groups.forEach((group) => {
|
|
339
|
+
if (!(group in config)) return;
|
|
340
|
+
const details = document.createElement("details");
|
|
341
|
+
details.dataset.group = group;
|
|
342
|
+
details.open = Object.prototype.hasOwnProperty.call(configOpenState, group)
|
|
343
|
+
? Boolean(configOpenState[group])
|
|
344
|
+
: defaultOpenGroups.has(group);
|
|
345
|
+
details.addEventListener("toggle", () => {
|
|
346
|
+
if (!details.isConnected) return;
|
|
347
|
+
configOpenState[group] = details.open;
|
|
348
|
+
saveOpenState();
|
|
349
|
+
});
|
|
350
|
+
const summary = document.createElement("summary");
|
|
351
|
+
summary.textContent = group;
|
|
352
|
+
const fields = document.createElement("div");
|
|
353
|
+
flatten(config[group], group).forEach(([path, value]) => fields.appendChild(inputFor(path, value)));
|
|
354
|
+
details.append(summary, fields);
|
|
355
|
+
configFields.appendChild(details);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function loadConfig() {
|
|
360
|
+
config = await api("/api/config");
|
|
361
|
+
renderConfig();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function pollStatus() {
|
|
365
|
+
try {
|
|
366
|
+
const status = await api("/api/status");
|
|
367
|
+
statusGrid.replaceChildren();
|
|
368
|
+
const keys = statusOrder.filter((key) => key in status);
|
|
369
|
+
Object.keys(status).forEach((key) => { if (!keys.includes(key)) keys.push(key); });
|
|
370
|
+
keys.forEach((key) => {
|
|
371
|
+
const item = document.createElement("div");
|
|
372
|
+
item.className = "row";
|
|
373
|
+
const k = document.createElement("span");
|
|
374
|
+
k.className = "key";
|
|
375
|
+
k.textContent = key;
|
|
376
|
+
const v = document.createElement("span");
|
|
377
|
+
v.className = "value";
|
|
378
|
+
v.textContent = status[key] ?? "";
|
|
379
|
+
item.append(k, v);
|
|
380
|
+
statusGrid.appendChild(item);
|
|
381
|
+
});
|
|
382
|
+
} catch (error) {
|
|
383
|
+
setMessage(error.message, true);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
document.querySelectorAll("[data-command]").forEach((button) => {
|
|
388
|
+
button.addEventListener("click", async () => {
|
|
389
|
+
try {
|
|
390
|
+
const payload = {command: button.dataset.command};
|
|
391
|
+
if ("value" in button.dataset) payload.value = Number(button.dataset.value);
|
|
392
|
+
await api("/api/command", payload);
|
|
393
|
+
setMessage(`${payload.command}${payload.value === undefined ? "" : ":" + payload.value} sent`);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
setMessage(error.message, true);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
previewImg.addEventListener("click", async (event) => {
|
|
400
|
+
const coords = previewCoordinates(event);
|
|
401
|
+
if (!coords) return;
|
|
402
|
+
try {
|
|
403
|
+
const data = await api("/api/sample", {
|
|
404
|
+
x: coords.x,
|
|
405
|
+
y: coords.y,
|
|
406
|
+
radius: Number(sampleRadius.value || 4),
|
|
407
|
+
});
|
|
408
|
+
updateSampleReadout(data);
|
|
409
|
+
setMessage(`Sampled HSV ${data.hsv.join(", ")}`);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
setMessage(error.message, true);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
document.querySelectorAll("[data-sample-action]").forEach((button) => {
|
|
415
|
+
button.addEventListener("click", async () => {
|
|
416
|
+
if (!lastSample) {
|
|
417
|
+
setMessage("Click the preview first to take a sample", true);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (sampleTarget.value === "WALL" && button.dataset.sampleAction !== "range") {
|
|
421
|
+
setMessage("Walls use the GRAY threshold; use Apply Last or Apply Samples", true);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
const data = await api("/api/apply_sample", {
|
|
426
|
+
target: sampleTarget.value,
|
|
427
|
+
action: button.dataset.sampleAction,
|
|
428
|
+
x: lastSample.x,
|
|
429
|
+
y: lastSample.y,
|
|
430
|
+
radius: Number(sampleRadius.value || 4),
|
|
431
|
+
hue_margin: Number(sampleHueMargin.value || 8),
|
|
432
|
+
sv_margin: Number(sampleSvMargin.value || 35),
|
|
433
|
+
gray_margin: Number(sampleGrayMargin.value || 18),
|
|
434
|
+
});
|
|
435
|
+
if ("gray" in data) setMessage(`${data.target} threshold updated: GRAY ${data.gray}, SAT <= ${data.wall_max_sat}`);
|
|
436
|
+
else setMessage(`${data.target} ${data.action} updated: LO ${data.lo.join(", ")} HI ${data.hi.join(", ")}`);
|
|
437
|
+
await loadConfig();
|
|
438
|
+
} catch (error) {
|
|
439
|
+
setMessage(error.message, true);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
document.getElementById("applySamplesBtn").addEventListener("click", async () => {
|
|
444
|
+
const target = sampleTarget.value;
|
|
445
|
+
const samples = sampleSets[target] || [];
|
|
446
|
+
if (!samples.length) {
|
|
447
|
+
setMessage("Click the preview a few times first", true);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const data = await api("/api/apply_samples", {
|
|
452
|
+
target,
|
|
453
|
+
samples,
|
|
454
|
+
hue_margin: Number(sampleHueMargin.value || 8),
|
|
455
|
+
sv_margin: Number(sampleSvMargin.value || 35),
|
|
456
|
+
gray_margin: Number(sampleGrayMargin.value || 18),
|
|
457
|
+
});
|
|
458
|
+
if ("gray" in data) setMessage(`${data.target} threshold updated from ${samples.length} samples: GRAY ${data.gray}, SAT <= ${data.wall_max_sat}`);
|
|
459
|
+
else setMessage(`${data.target} range updated from ${samples.length} samples: LO ${data.lo.join(", ")} HI ${data.hi.join(", ")}`);
|
|
460
|
+
await loadConfig();
|
|
461
|
+
} catch (error) {
|
|
462
|
+
setMessage(error.message, true);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
document.getElementById("clearSamplesBtn").addEventListener("click", () => {
|
|
466
|
+
sampleSets[sampleTarget.value] = [];
|
|
467
|
+
if (lastSample && sampleTarget.value === lastSample.target) lastSample = null;
|
|
468
|
+
updateSampleReadout(null);
|
|
469
|
+
setMessage(`${sampleTarget.value} samples cleared`);
|
|
470
|
+
});
|
|
471
|
+
sampleTarget.addEventListener("change", () => updateSampleReadout(null));
|
|
472
|
+
document.getElementById("saveBtn").addEventListener("click", async () => {
|
|
473
|
+
try { await api("/api/save", {}); setMessage("Config saved"); }
|
|
474
|
+
catch (error) { setMessage(error.message, true); }
|
|
475
|
+
});
|
|
476
|
+
document.getElementById("reloadBtn").addEventListener("click", () => location.reload());
|
|
477
|
+
loadConfig();
|
|
478
|
+
pollStatus();
|
|
479
|
+
setInterval(pollStatus, 500);
|
|
480
|
+
</script>
|
|
481
|
+
</body>
|
|
482
|
+
</html>"""
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class RobotWebServer:
|
|
486
|
+
def __init__(self, runner, host="0.0.0.0", port=5000):
|
|
487
|
+
self.runner = runner
|
|
488
|
+
self.host = host
|
|
489
|
+
self.port = int(port)
|
|
490
|
+
self.httpd = None
|
|
491
|
+
self.thread = None
|
|
492
|
+
|
|
493
|
+
def start(self):
|
|
494
|
+
runner = self.runner
|
|
495
|
+
|
|
496
|
+
class Handler(BaseHTTPRequestHandler):
|
|
497
|
+
def log_message(self, fmt, *args):
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
def do_GET(self):
|
|
501
|
+
if self.path == "/":
|
|
502
|
+
self.send_bytes(INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8")
|
|
503
|
+
elif self.path == "/api/config":
|
|
504
|
+
self.send_json(runner.configloader.config)
|
|
505
|
+
elif self.path == "/api/status":
|
|
506
|
+
self.send_json(runner.status_snapshot())
|
|
507
|
+
elif self.path == "/video_feed":
|
|
508
|
+
self.video_feed()
|
|
509
|
+
else:
|
|
510
|
+
self.send_error(404)
|
|
511
|
+
|
|
512
|
+
def do_POST(self):
|
|
513
|
+
payload = self.read_json()
|
|
514
|
+
try:
|
|
515
|
+
if self.path == "/api/config":
|
|
516
|
+
runner.update_config(payload["path"], payload.get("value"))
|
|
517
|
+
self.send_json({"status": "ok"})
|
|
518
|
+
elif self.path == "/api/save":
|
|
519
|
+
runner.configloader.save_config()
|
|
520
|
+
self.send_json({"status": "ok"})
|
|
521
|
+
elif self.path == "/api/command":
|
|
522
|
+
runner.manual_command(payload.get("command"), payload.get("value"))
|
|
523
|
+
self.send_json({"status": "ok"})
|
|
524
|
+
elif self.path == "/api/sample":
|
|
525
|
+
self.send_json(runner.sample_color(payload.get("x"), payload.get("y"), payload.get("radius", 4)))
|
|
526
|
+
elif self.path == "/api/apply_sample":
|
|
527
|
+
self.send_json(runner.apply_color_sample(
|
|
528
|
+
payload.get("target"),
|
|
529
|
+
payload.get("action"),
|
|
530
|
+
payload.get("x"),
|
|
531
|
+
payload.get("y"),
|
|
532
|
+
payload.get("radius"),
|
|
533
|
+
payload.get("hue_margin"),
|
|
534
|
+
payload.get("sv_margin"),
|
|
535
|
+
payload.get("gray_margin"),
|
|
536
|
+
))
|
|
537
|
+
elif self.path == "/api/apply_samples":
|
|
538
|
+
self.send_json(runner.apply_color_samples(
|
|
539
|
+
payload.get("target"),
|
|
540
|
+
payload.get("samples"),
|
|
541
|
+
payload.get("hue_margin"),
|
|
542
|
+
payload.get("sv_margin"),
|
|
543
|
+
payload.get("gray_margin"),
|
|
544
|
+
))
|
|
545
|
+
else:
|
|
546
|
+
self.send_error(404)
|
|
547
|
+
except Exception as exc:
|
|
548
|
+
self.send_json({"status": "error", "message": str(exc)}, 400)
|
|
549
|
+
|
|
550
|
+
def read_json(self):
|
|
551
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
552
|
+
if not length:
|
|
553
|
+
return {}
|
|
554
|
+
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
555
|
+
|
|
556
|
+
def send_json(self, data, code=200):
|
|
557
|
+
self.send_bytes(json.dumps(data).encode("utf-8"), "application/json", code)
|
|
558
|
+
|
|
559
|
+
def send_bytes(self, data, content_type, code=200):
|
|
560
|
+
self.send_response(code)
|
|
561
|
+
self.send_header("Content-Type", content_type)
|
|
562
|
+
self.send_header("Content-Length", str(len(data)))
|
|
563
|
+
self.end_headers()
|
|
564
|
+
self.wfile.write(data)
|
|
565
|
+
|
|
566
|
+
def video_feed(self):
|
|
567
|
+
self.send_response(200)
|
|
568
|
+
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
|
569
|
+
self.end_headers()
|
|
570
|
+
while True:
|
|
571
|
+
frame = runner.preview_jpeg()
|
|
572
|
+
if frame:
|
|
573
|
+
try:
|
|
574
|
+
self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
|
|
575
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
576
|
+
break
|
|
577
|
+
time.sleep(0.05)
|
|
578
|
+
|
|
579
|
+
self.httpd = ThreadingHTTPServer((self.host, self.port), Handler)
|
|
580
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
581
|
+
self.thread.start()
|
|
582
|
+
print(f"Preview/config page: http://{self.host}:{self.port}")
|
|
583
|
+
|
|
584
|
+
def stop(self):
|
|
585
|
+
if self.httpd:
|
|
586
|
+
self.httpd.shutdown()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DecisionLogger:
|
|
8
|
+
def __init__(self, path, enabled=True, cycle_interval_seconds=0.25):
|
|
9
|
+
self.enabled = bool(enabled)
|
|
10
|
+
self.path = os.path.abspath(path)
|
|
11
|
+
self.cycle_interval_seconds = max(0.0, float(cycle_interval_seconds))
|
|
12
|
+
self._last_sampled_at = 0.0
|
|
13
|
+
self._file = None
|
|
14
|
+
|
|
15
|
+
if self.enabled:
|
|
16
|
+
directory = os.path.dirname(self.path)
|
|
17
|
+
if directory:
|
|
18
|
+
os.makedirs(directory, exist_ok=True)
|
|
19
|
+
self._file = open(self.path, "w", encoding="utf-8")
|
|
20
|
+
self.record_decision(
|
|
21
|
+
"session_start",
|
|
22
|
+
selected={"log_file": self.path},
|
|
23
|
+
reasoning={"mode": "replace"},
|
|
24
|
+
force=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_config(cls, config, config_path):
|
|
29
|
+
log_config = config.get("decision_log", {})
|
|
30
|
+
path = log_config.get("path", "decision_log.jsonl")
|
|
31
|
+
if not os.path.isabs(path):
|
|
32
|
+
path = os.path.join(os.path.dirname(os.path.abspath(config_path)), path)
|
|
33
|
+
return cls(
|
|
34
|
+
path,
|
|
35
|
+
log_config.get("enabled", True),
|
|
36
|
+
log_config.get("cycle_interval_seconds", 0.25),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def record_decision(self, name, inputs=None, selected=None, reasoning=None, sampled=False, force=False):
|
|
40
|
+
if not self.enabled or self._file is None:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
now = time.monotonic()
|
|
44
|
+
if sampled and not force:
|
|
45
|
+
if self.cycle_interval_seconds > 0 and now - self._last_sampled_at < self.cycle_interval_seconds:
|
|
46
|
+
return
|
|
47
|
+
self._last_sampled_at = now
|
|
48
|
+
|
|
49
|
+
entry = {
|
|
50
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
51
|
+
"monotonic_seconds": round(now, 3),
|
|
52
|
+
"decision": name,
|
|
53
|
+
}
|
|
54
|
+
if inputs is not None:
|
|
55
|
+
entry["inputs"] = self._safe_json(inputs)
|
|
56
|
+
if selected is not None:
|
|
57
|
+
entry["selected"] = self._safe_json(selected)
|
|
58
|
+
if reasoning is not None:
|
|
59
|
+
entry["reasoning"] = self._safe_json(reasoning)
|
|
60
|
+
|
|
61
|
+
json.dump(entry, self._file, separators=(",", ":"), allow_nan=False)
|
|
62
|
+
self._file.write("\n")
|
|
63
|
+
self._file.flush()
|
|
64
|
+
|
|
65
|
+
def close(self):
|
|
66
|
+
if self._file is None:
|
|
67
|
+
return
|
|
68
|
+
self.record_decision("session_stop", force=True)
|
|
69
|
+
self._file.close()
|
|
70
|
+
self._file = None
|
|
71
|
+
|
|
72
|
+
def _safe_json(self, value):
|
|
73
|
+
if value is None or isinstance(value, (str, bool)):
|
|
74
|
+
return value
|
|
75
|
+
if isinstance(value, int):
|
|
76
|
+
return value
|
|
77
|
+
if isinstance(value, float):
|
|
78
|
+
return value if math.isfinite(value) else None
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
return {str(key): self._safe_json(item) for key, item in value.items()}
|
|
81
|
+
if isinstance(value, (list, tuple)):
|
|
82
|
+
return [self._safe_json(item) for item in value]
|
|
83
|
+
if hasattr(value, "item"):
|
|
84
|
+
return self._safe_json(value.item())
|
|
85
|
+
return str(value)
|