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.
@@ -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)