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,1052 @@
1
+ import argparse
2
+ import glob
3
+ import json
4
+ import os
5
+ import threading
6
+ import time
7
+
8
+ import cv2
9
+ import numpy as np
10
+
11
+ from control_panel import RobotWebServer
12
+ from decision_log import DecisionLogger
13
+ from drive_state import StateMachine
14
+ from lap_direction import find_round_dir
15
+ from settings import ConfigLoader, parse_override_value
16
+ from vision_pipeline import Pipeline
17
+ from vision_types import Pillar, extract_ROI
18
+
19
+
20
+ def clamp(value, low, high):
21
+ return max(low, min(high, value))
22
+
23
+
24
+ def rect_roi(image, rect):
25
+ h, w = image.shape[:2]
26
+ x = int(clamp(rect.get("x", 0), 0, max(0, w - 1)))
27
+ y = int(clamp(rect.get("y", 0), 0, max(0, h - 1)))
28
+ rw = int(max(1, rect.get("w", w)))
29
+ rh = int(max(1, rect.get("h", h)))
30
+ return extract_ROI(image, [x, y], [min(w, x + rw), min(h, y + rh)])
31
+
32
+
33
+ def portion(mask):
34
+ if mask is None or mask.size == 0:
35
+ return 0.0
36
+ return cv2.countNonZero(mask) / float(mask.shape[0] * mask.shape[1])
37
+
38
+
39
+ def steering_offset_to_servo_angle(offset, control):
40
+ center = int(control.get("servo_center_deg", 90))
41
+ min_angle = int(control.get("servo_min_deg", 15))
42
+ max_angle = int(control.get("servo_max_deg", 165))
43
+ if min_angle > max_angle:
44
+ min_angle, max_angle = max_angle, min_angle
45
+ offset = int(round(offset))
46
+ offset_limit = min(center - min_angle, max_angle - center)
47
+ offset = int(clamp(offset, -offset_limit, offset_limit))
48
+ return int(clamp(center + offset, min_angle, max_angle))
49
+
50
+
51
+ def pillar_snapshot(pillar):
52
+ if pillar is None:
53
+ return None
54
+ return {
55
+ "color": pillar.color,
56
+ "x": int(pillar.screen_x),
57
+ "y": int(pillar.y),
58
+ "width": int(pillar.width),
59
+ "height": int(pillar.height),
60
+ "area": int(pillar.area),
61
+ "raw_area": int(pillar.raw_area),
62
+ "contour_area": round(float(pillar.contour_area), 1),
63
+ "seen_frames": int(pillar.seen_frames),
64
+ "held": bool(pillar.held),
65
+ "ignored": bool(pillar.ignore),
66
+ }
67
+
68
+
69
+ def detect_serial_port(config):
70
+ configured = str(config.get("port", "/dev/ttyACM0"))
71
+ if not config.get("auto_detect", True):
72
+ return configured
73
+ for pattern in ("/dev/ttyACM*", "/dev/ttyUSB*"):
74
+ matches = sorted(glob.glob(pattern))
75
+ if matches:
76
+ return matches[0]
77
+ try:
78
+ import serial.tools.list_ports
79
+ ports = sorted(port.device for port in serial.tools.list_ports.comports())
80
+ if ports:
81
+ return ports[0]
82
+ except Exception:
83
+ pass
84
+ return configured
85
+
86
+
87
+ class ArduinoLink:
88
+ def __init__(self, serial_config, dry_run=False):
89
+ self.config = serial_config
90
+ self.dry_run = dry_run or not bool(serial_config.get("enabled", True))
91
+ self.ser = None
92
+ self.last_command = {}
93
+ self.last_command_at = {}
94
+ self.port = detect_serial_port(serial_config)
95
+
96
+ def connect(self):
97
+ if self.dry_run:
98
+ print("Serial disabled; running without Arduino output.")
99
+ return
100
+ try:
101
+ import serial
102
+ except ImportError:
103
+ print("pyserial is not installed; running without Arduino output.")
104
+ self.dry_run = True
105
+ return
106
+
107
+ self.ser = serial.Serial(
108
+ self.port,
109
+ int(self.config.get("baud_rate", 9600)),
110
+ timeout=0.2,
111
+ write_timeout=float(self.config.get("write_timeout_seconds", 1.0)),
112
+ )
113
+ try:
114
+ self.ser.reset_input_buffer()
115
+ except Exception:
116
+ pass
117
+ ready = self.wait_ready(float(self.config.get("ready_timeout_seconds", 6.0)))
118
+ print(f"Arduino {'ready' if ready else 'connected'} on {self.port}")
119
+
120
+ def wait_ready(self, timeout_seconds):
121
+ deadline = time.monotonic() + timeout_seconds
122
+ while self.ser and time.monotonic() < deadline:
123
+ line = self.ser.readline().decode("ascii", errors="ignore").strip()
124
+ if line == "READY":
125
+ return True
126
+ return False
127
+
128
+ def send(self, command, value=None, force=False):
129
+ if value is None:
130
+ text = command
131
+ key = command
132
+ else:
133
+ text = f"{command}:{int(value)}"
134
+ key = command
135
+
136
+ now = time.monotonic()
137
+ send_interval = float(self.config.get("send_interval_seconds", 0.02))
138
+ keepalive_interval = float(self.config.get("keepalive_interval_seconds", 0.15))
139
+ last_at = self.last_command_at.get(key, 0.0)
140
+ if not force and self.last_command.get(key) == text and now - last_at < keepalive_interval:
141
+ return
142
+ if not force and self.last_command.get(key) != text and now - last_at < send_interval:
143
+ return
144
+
145
+ if self.dry_run:
146
+ self.last_command[key] = text
147
+ self.last_command_at[key] = now
148
+ return
149
+ if not self.ser:
150
+ return
151
+ self.ser.write((text + "\n").encode("ascii"))
152
+ self.ser.flush()
153
+ self.last_command[key] = text
154
+ self.last_command_at[key] = now
155
+ self.drain_input()
156
+
157
+ def drain_input(self):
158
+ if not self.ser:
159
+ return
160
+ try:
161
+ while self.ser.in_waiting:
162
+ self.ser.read(self.ser.in_waiting)
163
+ except Exception:
164
+ pass
165
+
166
+ def apply(self, servo_angle, drive_speed):
167
+ self.send("STEER", servo_angle)
168
+ self.send("DRIVE", drive_speed)
169
+
170
+ def stop(self):
171
+ self.send("STOP", force=True)
172
+
173
+ def close(self):
174
+ try:
175
+ self.stop()
176
+ finally:
177
+ if self.ser and self.ser.is_open:
178
+ self.ser.close()
179
+
180
+
181
+ class PiCamera:
182
+ def __init__(self, camera_config):
183
+ self.config = camera_config
184
+ self.picam2 = None
185
+
186
+ def start(self):
187
+ try:
188
+ from picamera2 import Picamera2
189
+ except ImportError as exc:
190
+ raise RuntimeError("Picamera2 is not installed. Run this on the Raspberry Pi camera environment.") from exc
191
+
192
+ self.picam2 = Picamera2()
193
+ size = (
194
+ int(self.config.get("frame_width", 640)),
195
+ int(self.config.get("frame_height", 480)),
196
+ )
197
+ fmt = self.config.get("format", "RGB888")
198
+ self.picam2.configure(self.picam2.create_preview_configuration(main={"size": size, "format": fmt}))
199
+ self.picam2.start()
200
+ try:
201
+ self.picam2.set_controls({"AwbEnable": bool(self.config.get("awb_enable", False))})
202
+ except Exception:
203
+ pass
204
+
205
+ def capture_bgr(self):
206
+ frame = self.picam2.capture_array()
207
+ frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
208
+ if self.config.get("flip_180", True):
209
+ frame = cv2.rotate(frame, cv2.ROTATE_180)
210
+ return frame
211
+
212
+ def stop(self):
213
+ if self.picam2:
214
+ self.picam2.stop()
215
+
216
+
217
+ class RobotRunner:
218
+ def __init__(self, configloader, no_serial=False):
219
+ self.configloader = configloader
220
+ self.config = configloader.config
221
+ self.pipeline = Pipeline(configloader)
222
+ self.sm = StateMachine(self.config["behavior"])
223
+ self.last_error = 0.0
224
+ self.last_log = 0.0
225
+ self.last_frame_save = 0.0
226
+ self.latest_jpeg = None
227
+ self.latest_sample_image = None
228
+ self.latest_status = {}
229
+ self.closest_pillar = None
230
+ self._pillar_track = None
231
+ self.manual_until = 0.0
232
+ self.manual_servo_angle = None
233
+ self.manual_drive_speed = None
234
+ self._lock = threading.RLock()
235
+ self.arduino = ArduinoLink(self.config["serial"], dry_run=no_serial)
236
+ self.camera = PiCamera(self.config["camera"])
237
+ self.decision_logger = DecisionLogger.from_config(self.config, configloader.file_path)
238
+
239
+ def start(self):
240
+ self.arduino.connect()
241
+ self.camera.start()
242
+ self.decision_logger.record_decision(
243
+ "runtime_start",
244
+ inputs={
245
+ "config_path": self.configloader.file_path,
246
+ "serial_enabled": bool(self.config["serial"].get("enabled", True)),
247
+ "web_enabled": bool(self.config["web"].get("enabled", True)),
248
+ },
249
+ selected={
250
+ "serial_port": self.arduino.port,
251
+ "initial_state": self.sm.current_state,
252
+ "round_dir": self.sm.round_dir,
253
+ "pillars_enabled": self.sm.is_pillar_round,
254
+ },
255
+ force=True,
256
+ )
257
+
258
+ def stop(self):
259
+ try:
260
+ self.arduino.close()
261
+ self.camera.stop()
262
+ finally:
263
+ self.decision_logger.close()
264
+
265
+ def cycle(self):
266
+ color_image = self.pipeline.crop(self.camera.capture_bgr())
267
+ hsv_image = cv2.cvtColor(color_image, cv2.COLOR_BGR2HSV)
268
+ preview = color_image.copy()
269
+ with self._lock:
270
+ self.latest_sample_image = color_image.copy()
271
+
272
+ line_roi = rect_roi(hsv_image, self.config["roi"]["line"])
273
+ orange_blue = self.pipeline.filter_OB(line_roi)
274
+ full_orange_blue = self.pipeline.filter_OB(hsv_image)
275
+ portion_orange = portion(orange_blue["orange"])
276
+ portion_blue = portion(orange_blue["blue"])
277
+
278
+ rgbl = self.pipeline.filter_RG_Bl(hsv_image, color_image)
279
+ left_wall = rect_roi(rgbl["black"], self.config["roi"]["left_wall"])
280
+ right_wall = rect_roi(rgbl["black"], self.config["roi"]["right_wall"])
281
+ portion_black_l = portion(left_wall)
282
+ portion_black_r = portion(right_wall)
283
+
284
+ pillars = self.detect_pillars(hsv_image, rgbl)
285
+ state_before = self.state_snapshot()
286
+ transitioned = self.sm.should_transition_state(portion_orange, portion_blue, pillars)
287
+ round_vote = 0
288
+ if self.sm.search_for_dir:
289
+ round_votes_before = self.sm._round_dir_votes
290
+ round_vote = find_round_dir(rgbl["black"])
291
+ self.sm.add_round_dir_vote(round_vote)
292
+ if round_vote != 0 and not self.sm.search_for_dir:
293
+ self.decision_logger.record_decision(
294
+ "round_direction_selected",
295
+ inputs={
296
+ "last_vote": round_vote,
297
+ "vote_sum_before": round_votes_before,
298
+ "vote_threshold": int(self.config["behavior"].get("round_dir_vote_threshold", 10)),
299
+ },
300
+ selected={"round_dir": self.sm.round_dir},
301
+ reasoning={"vote_sum": self.sm._round_dir_votes},
302
+ force=True,
303
+ )
304
+ state_after = self.state_snapshot()
305
+ if transitioned or state_after != state_before:
306
+ self.decision_logger.record_decision(
307
+ "state_update",
308
+ inputs={
309
+ "state_before": state_before,
310
+ "line_orange": round(float(portion_orange), 4),
311
+ "line_blue": round(float(portion_blue), 4),
312
+ "pillar_count": len(pillars),
313
+ "closest_pillar": pillar_snapshot(pillars[0] if pillars else None),
314
+ "round_vote": round_vote,
315
+ },
316
+ selected=state_after,
317
+ reasoning={
318
+ "transitioned": bool(transitioned),
319
+ "state_time_seconds": round(float(self.sm.time_diff), 3),
320
+ "is_pillar_round": bool(self.sm.is_pillar_round),
321
+ },
322
+ force=True,
323
+ )
324
+
325
+ previous_error = self.last_error
326
+ correction, error = self.compute_correction(pillars, portion_black_l, portion_black_r)
327
+ self.last_error = error
328
+
329
+ control = self.config["control"]
330
+ max_correction = float(control.get("max_correction", 1.0))
331
+ correction = clamp(correction, -max_correction, max_correction)
332
+ steering_offset = correction * float(control.get("max_steering_offset", 55)) * int(control.get("steering_sign", -1))
333
+ servo_angle = steering_offset_to_servo_angle(steering_offset, control)
334
+ speed = self.speed_for_state()
335
+
336
+ if self.sm.current_state == "DONE":
337
+ servo_angle = steering_offset_to_servo_angle(0, control)
338
+ speed = 0
339
+
340
+ drive_speed = int(speed * int(control.get("drive_sign", 1)))
341
+ manual_active = False
342
+ with self._lock:
343
+ manual_active = time.monotonic() < self.manual_until
344
+ if manual_active:
345
+ if self.manual_servo_angle is not None:
346
+ servo_angle = self.manual_servo_angle
347
+ if self.manual_drive_speed is not None:
348
+ drive_speed = self.manual_drive_speed
349
+ self.arduino.apply(servo_angle, drive_speed)
350
+ self.decision_logger.record_decision(
351
+ "drive_output",
352
+ inputs={
353
+ "state": self.sm.current_state,
354
+ "round_dir": self.sm.round_dir,
355
+ "turns_left": self.sm.turns_left,
356
+ "wall_left": round(float(portion_black_l), 4),
357
+ "wall_right": round(float(portion_black_r), 4),
358
+ "line_orange": round(float(portion_orange), 4),
359
+ "line_blue": round(float(portion_blue), 4),
360
+ "pillar_count": len(pillars),
361
+ "closest_pillar": pillar_snapshot(pillars[0] if pillars else None),
362
+ },
363
+ selected={
364
+ "servo_angle": int(servo_angle),
365
+ "drive_speed": int(drive_speed),
366
+ "correction": round(float(correction), 4),
367
+ },
368
+ reasoning={
369
+ "error": round(float(error), 4),
370
+ "previous_error": round(float(previous_error), 4),
371
+ "kp": float(self.config["pd"].get("kp", 4.5)),
372
+ "kd": float(self.config["pd"].get("kd", 8.0)),
373
+ "max_correction": float(max_correction),
374
+ "manual_override": bool(manual_active),
375
+ },
376
+ sampled=True,
377
+ )
378
+ self.update_preview(
379
+ preview,
380
+ rgbl,
381
+ full_orange_blue,
382
+ pillars,
383
+ servo_angle,
384
+ drive_speed,
385
+ correction,
386
+ portion_black_l,
387
+ portion_black_r,
388
+ portion_orange,
389
+ portion_blue,
390
+ )
391
+ self.update_status(servo_angle, drive_speed, correction, portion_black_l, portion_black_r, portion_orange, portion_blue, len(pillars))
392
+ self.log_status(servo_angle, drive_speed, correction, portion_black_l, portion_black_r, portion_orange, portion_blue, len(pillars))
393
+ self.save_debug_frame(color_image)
394
+
395
+ return self.sm.current_state != "DONE"
396
+
397
+ def state_snapshot(self):
398
+ scheduled = self.sm._scheduled_state[0] if self.sm._scheduled_state else None
399
+ return {
400
+ "state": self.sm.current_state,
401
+ "round_dir": self.sm.round_dir,
402
+ "turns_left": self.sm.turns_left,
403
+ "search_for_dir": bool(self.sm.search_for_dir),
404
+ "scheduled_state": scheduled,
405
+ }
406
+
407
+ def detect_pillars(self, hsv_image, rgbl):
408
+ pillars = self.pipeline.get_pillars(rgbl["red"], "RED") + self.pipeline.get_pillars(rgbl["green"], "GREEN")
409
+ pillars.sort(key=lambda p: p.raw_area, reverse=True)
410
+ if pillars:
411
+ next_pillar = pillars[0]
412
+ check_width = max(next_pillar.width, 12)
413
+ x1 = int(next_pillar.screen_x - check_width * 0.5)
414
+ x2 = int(next_pillar.screen_x + check_width * 0.5)
415
+ pillar_roi = extract_ROI(hsv_image, [max(0, x1), 0], [min(hsv_image.shape[1], x2), hsv_image.shape[0]])
416
+ if pillar_roi.size:
417
+ masks = self.pipeline.filter_OB(pillar_roi)
418
+ if portion(masks["orange"]) > 0.00005 or portion(masks["blue"]) > 0.00005:
419
+ next_pillar.ignore = True
420
+ pillars = self.stabilize_pillars(pillars)
421
+ return pillars
422
+
423
+ def stabilize_pillars(self, pillars):
424
+ behavior = self.config["behavior"]
425
+ alpha = float(clamp(float(behavior.get("pillar_area_ema_alpha", 0.35)), 0.0, 1.0))
426
+ hold_frames = int(max(0, behavior.get("pillar_lost_hold_frames", 3)))
427
+ match_dx = float(max(0.0, behavior.get("pillar_match_max_dx", 90)))
428
+
429
+ if not pillars:
430
+ if self._pillar_track and self._pillar_track["missing"] < hold_frames:
431
+ self._pillar_track["missing"] += 1
432
+ held = Pillar(
433
+ self._pillar_track["x"],
434
+ self._pillar_track["width"],
435
+ self._pillar_track["height"],
436
+ self._pillar_track["color"],
437
+ self._pillar_track["y"],
438
+ self._pillar_track.get("contour_area", 0.0),
439
+ )
440
+ held.stable_area = self._pillar_track["stable_area"]
441
+ held.seen_frames = self._pillar_track["seen_frames"]
442
+ held.ignore = self._pillar_track.get("ignore", False)
443
+ held.held = True
444
+ self.closest_pillar = held
445
+ return [held]
446
+ self._pillar_track = None
447
+ self.closest_pillar = None
448
+ return []
449
+
450
+ closest = pillars[0]
451
+ raw_area = closest.raw_area
452
+ matched = (
453
+ self._pillar_track is not None
454
+ and self._pillar_track["color"] == closest.color
455
+ and abs(self._pillar_track["x"] - closest.screen_x) <= match_dx
456
+ )
457
+ if matched:
458
+ stable_area = self._pillar_track["stable_area"] * (1.0 - alpha) + raw_area * alpha
459
+ seen_frames = self._pillar_track["seen_frames"] + 1
460
+ else:
461
+ stable_area = float(raw_area)
462
+ seen_frames = 1
463
+
464
+ closest.stable_area = stable_area
465
+ closest.seen_frames = seen_frames
466
+ self._pillar_track = {
467
+ "x": closest.screen_x,
468
+ "width": closest.width,
469
+ "height": closest.height,
470
+ "y": closest.y,
471
+ "color": closest.color,
472
+ "stable_area": stable_area,
473
+ "seen_frames": seen_frames,
474
+ "missing": 0,
475
+ "ignore": closest.ignore,
476
+ "contour_area": closest.contour_area,
477
+ }
478
+ pillars.sort(key=lambda p: p.area, reverse=True)
479
+ self.closest_pillar = closest
480
+ return pillars
481
+
482
+ def compute_correction(self, pillars, portion_black_l, portion_black_r):
483
+ state = self.sm.current_state
484
+ pd = self.config["pd"]
485
+ behavior = self.config["behavior"]
486
+ error = 0.0
487
+ correction = 0.0
488
+
489
+ if state == "TRACKING-PILLAR" and pillars:
490
+ frame_width = int(self.config["camera"].get("frame_width", 640))
491
+ error = (pillars[0].screen_x - frame_width / 2.0) / frame_width
492
+ elif state in ("PD-CENTER", "PD-RIGHT", "PD-LEFT"):
493
+ target = float(pd.get("target_black_portion", 0.45))
494
+ if self.sm.round_dir < 0:
495
+ error = target - portion_black_r
496
+ elif self.sm.round_dir > 0:
497
+ error = portion_black_l - target
498
+
499
+ correction = error * float(pd.get("kp", 4.5)) + (error - self.last_error) * float(pd.get("kd", 8.0))
500
+
501
+ if state == "TURNING-L":
502
+ correction = -1.0
503
+ elif state == "TURNING-R":
504
+ correction = 1.0
505
+ elif state == "AVOIDING-R":
506
+ correction = self.pillar_avoid_correction(
507
+ behavior.get("pillar_red_first_correction", 0.95),
508
+ behavior.get("pillar_red_second_correction", -0.72),
509
+ portion_black_l,
510
+ portion_black_r,
511
+ )
512
+ elif state == "AVOIDING-G":
513
+ correction = self.pillar_avoid_correction(
514
+ behavior.get("pillar_green_first_correction", -0.95),
515
+ behavior.get("pillar_green_second_correction", 0.72),
516
+ portion_black_l,
517
+ portion_black_r,
518
+ )
519
+ elif state in ("STARTING", "DONE"):
520
+ correction = 0.0
521
+
522
+ return correction, error
523
+
524
+ def pillar_avoid_correction(self, first_correction, second_correction, portion_black_l, portion_black_r):
525
+ behavior = self.config["behavior"]
526
+ phase_correction = (
527
+ float(first_correction)
528
+ if self.sm.time_diff < float(behavior.get("pillar_first_phase_seconds", 0.95))
529
+ else float(second_correction)
530
+ )
531
+
532
+ if not behavior.get("pillar_wall_guard", True):
533
+ return phase_correction
534
+
535
+ threshold = float(behavior.get("pillar_wall_guard_threshold", 0.62))
536
+ guard = float(behavior.get("pillar_wall_guard_correction", 1.0))
537
+ if portion_black_l > threshold and portion_black_l > portion_black_r:
538
+ return guard
539
+ if portion_black_r > threshold and portion_black_r > portion_black_l:
540
+ return -guard
541
+ return phase_correction
542
+
543
+ def speed_for_state(self):
544
+ control = self.config["control"]
545
+ state = self.sm.current_state
546
+ if state in ("TURNING-L", "TURNING-R"):
547
+ return int(control.get("turn_speed", control.get("speed", 100)))
548
+ if state in ("AVOIDING-R", "AVOIDING-G", "TRACKING-PILLAR"):
549
+ return int(control.get("avoid_speed", control.get("speed", 100)))
550
+ if state == "DONE":
551
+ return 0
552
+ return int(control.get("speed", 100))
553
+
554
+ def update_status(self, servo_angle, drive_speed, correction, black_l, black_r, orange, blue, pillar_count):
555
+ with self._lock:
556
+ self.latest_status = {
557
+ "state": self.sm.current_state,
558
+ "round_dir": self.sm.round_dir,
559
+ "turns_left": self.sm.turns_left,
560
+ "servo_angle": int(servo_angle),
561
+ "drive_speed": int(drive_speed),
562
+ "correction": round(float(correction), 3),
563
+ "wall_left": round(float(black_l), 3),
564
+ "wall_right": round(float(black_r), 3),
565
+ "orange": round(float(orange), 3),
566
+ "blue": round(float(blue), 3),
567
+ "pillars": int(pillar_count),
568
+ "serial_port": self.arduino.port,
569
+ }
570
+ if self.sm.current_state in ("AVOIDING-R", "AVOIDING-G"):
571
+ first_phase = float(self.config["behavior"].get("pillar_first_phase_seconds", 0.95))
572
+ self.latest_status["avoid_phase"] = "first" if self.sm.time_diff < first_phase else "second"
573
+ if self.closest_pillar:
574
+ self.latest_status.update({
575
+ "closest_pillar_color": self.closest_pillar.color,
576
+ "closest_pillar_area": int(self.closest_pillar.area),
577
+ "closest_pillar_raw_area": int(self.closest_pillar.raw_area),
578
+ "closest_pillar_x": int(self.closest_pillar.screen_x),
579
+ "closest_pillar_seen": int(self.closest_pillar.seen_frames),
580
+ "closest_pillar_held": bool(self.closest_pillar.held),
581
+ })
582
+
583
+ def status_snapshot(self):
584
+ with self._lock:
585
+ return dict(self.latest_status)
586
+
587
+ def update_preview(self, image, rgbl, orange_blue, pillars, servo_angle, drive_speed, correction, black_l, black_r, orange, blue):
588
+ self.draw_preview_overlay(image, rgbl, orange_blue, pillars, servo_angle, drive_speed, correction, black_l, black_r, orange, blue)
589
+ ok, encoded = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 75])
590
+ if ok:
591
+ with self._lock:
592
+ self.latest_jpeg = encoded.tobytes()
593
+
594
+ def preview_jpeg(self):
595
+ with self._lock:
596
+ return self.latest_jpeg
597
+
598
+ def sample_color(self, x, y, radius=4):
599
+ with self._lock:
600
+ if self.latest_sample_image is None:
601
+ raise ValueError("no camera frame available yet")
602
+ image = self.latest_sample_image.copy()
603
+
604
+ h, w = image.shape[:2]
605
+ x = int(clamp(int(round(float(x))), 0, w - 1))
606
+ y = int(clamp(int(round(float(y))), 0, h - 1))
607
+ radius = int(clamp(int(radius), 0, 30))
608
+ x1 = max(0, x - radius)
609
+ x2 = min(w, x + radius + 1)
610
+ y1 = max(0, y - radius)
611
+ y2 = min(h, y + radius + 1)
612
+ patch = image[y1:y2, x1:x2]
613
+ hsv = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV).reshape(-1, 3)
614
+ gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY).reshape(-1)
615
+ bgr = patch.reshape(-1, 3)
616
+ median_hsv = np.median(hsv, axis=0).round().astype(int)
617
+ low_hsv = np.percentile(hsv, 10, axis=0).round().astype(int)
618
+ high_hsv = np.percentile(hsv, 90, axis=0).round().astype(int)
619
+ median_gray = int(round(float(np.median(gray))))
620
+ low_gray = int(round(float(np.percentile(gray, 10))))
621
+ high_gray = int(round(float(np.percentile(gray, 90))))
622
+ median_bgr = np.median(bgr, axis=0).round().astype(int)
623
+ rgb = [int(median_bgr[2]), int(median_bgr[1]), int(median_bgr[0])]
624
+ return {
625
+ "x": x,
626
+ "y": y,
627
+ "radius": radius,
628
+ "image_width": int(w),
629
+ "image_height": int(h),
630
+ "hsv": [int(v) for v in median_hsv],
631
+ "hsv_low": [int(v) for v in low_hsv],
632
+ "hsv_high": [int(v) for v in high_hsv],
633
+ "gray": median_gray,
634
+ "gray_low": low_gray,
635
+ "gray_high": high_gray,
636
+ "rgb": rgb,
637
+ "hex": "#{:02x}{:02x}{:02x}".format(*rgb),
638
+ }
639
+
640
+ def filter_bounds_from_sample(self, sample, hue_margin, sv_margin):
641
+ return self.filter_bounds_from_samples([sample], hue_margin, sv_margin)
642
+
643
+ def filter_bounds_from_samples(self, samples, hue_margin, sv_margin):
644
+ hue_margin = int(clamp(int(hue_margin), 0, 60))
645
+ sv_margin = int(clamp(int(sv_margin), 0, 160))
646
+ samples = [self.normalize_sample(sample) for sample in samples]
647
+ if not samples:
648
+ raise ValueError("at least one sample is required")
649
+
650
+ hues = sorted(int(sample["hsv"][0]) % 180 for sample in samples)
651
+ if len(hues) == 1:
652
+ start_h = end_h = hues[0]
653
+ else:
654
+ largest_gap = -1
655
+ largest_gap_index = 0
656
+ for index, hue in enumerate(hues):
657
+ next_hue = hues[(index + 1) % len(hues)]
658
+ if index == len(hues) - 1:
659
+ next_hue += 180
660
+ gap = next_hue - hue
661
+ if gap > largest_gap:
662
+ largest_gap = gap
663
+ largest_gap_index = index
664
+ start_h = hues[(largest_gap_index + 1) % len(hues)]
665
+ end_h = hues[largest_gap_index]
666
+
667
+ hue_span = (end_h - start_h) % 180
668
+ if hue_span + hue_margin * 2 >= 179:
669
+ lo_h = 0
670
+ hi_h = 179
671
+ else:
672
+ lo_h = (start_h - hue_margin) % 180
673
+ hi_h = (end_h + hue_margin) % 180
674
+
675
+ s_low = min(sample["hsv_low"][1] for sample in samples)
676
+ v_low = min(sample["hsv_low"][2] for sample in samples)
677
+ s_high = max(sample["hsv_high"][1] for sample in samples)
678
+ v_high = max(sample["hsv_high"][2] for sample in samples)
679
+ return (
680
+ [
681
+ int(clamp(lo_h, 0, 179)),
682
+ int(clamp(s_low - sv_margin, 0, 255)),
683
+ int(clamp(v_low - sv_margin, 0, 255)),
684
+ ],
685
+ [
686
+ int(clamp(hi_h, 0, 179)),
687
+ int(clamp(s_high + sv_margin, 0, 255)),
688
+ int(clamp(v_high + sv_margin, 0, 255)),
689
+ ],
690
+ )
691
+
692
+ def gray_threshold_from_samples(self, samples, gray_margin):
693
+ gray_margin = int(clamp(int(gray_margin), 0, 120))
694
+ samples = [self.normalize_sample(sample) for sample in samples]
695
+ if not samples:
696
+ raise ValueError("at least one sample is required")
697
+ gray_high = max(sample["gray_high"] for sample in samples)
698
+ return int(clamp(gray_high + gray_margin, 0, 255))
699
+
700
+ def wall_saturation_from_samples(self, samples, sat_margin):
701
+ sat_margin = int(clamp(int(sat_margin), 0, 160))
702
+ samples = [self.normalize_sample(sample) for sample in samples]
703
+ if not samples:
704
+ raise ValueError("at least one sample is required")
705
+ sat_high = max(sample["hsv_high"][1] for sample in samples)
706
+ return int(clamp(sat_high + sat_margin, 0, 255))
707
+
708
+ def normalize_sample(self, sample):
709
+ if not isinstance(sample, dict):
710
+ raise ValueError("sample must be an object")
711
+
712
+ def hsv_triplet(key):
713
+ values = sample.get(key)
714
+ if not isinstance(values, (list, tuple)) or len(values) != 3:
715
+ raise ValueError(f"sample.{key} must be an HSV triplet")
716
+ return [
717
+ int(clamp(int(values[0]), 0, 179)),
718
+ int(clamp(int(values[1]), 0, 255)),
719
+ int(clamp(int(values[2]), 0, 255)),
720
+ ]
721
+
722
+ hsv = hsv_triplet("hsv")
723
+ hsv_low = hsv_triplet("hsv_low")
724
+ hsv_high = hsv_triplet("hsv_high")
725
+ gray_value = int(clamp(int(sample.get("gray", hsv[2])), 0, 255))
726
+ gray_low = int(clamp(int(sample.get("gray_low", gray_value)), 0, 255))
727
+ gray_high = int(clamp(int(sample.get("gray_high", gray_value)), 0, 255))
728
+ return {
729
+ "hsv": hsv,
730
+ "hsv_low": hsv_low,
731
+ "hsv_high": hsv_high,
732
+ "gray": gray_value,
733
+ "gray_low": gray_low,
734
+ "gray_high": gray_high,
735
+ }
736
+
737
+ def sample_settings(self, radius=None, hue_margin=None, sv_margin=None, gray_margin=None):
738
+ debug = self.config["debug"]
739
+ return (
740
+ debug.get("sample_radius", 4) if radius is None else radius,
741
+ debug.get("sample_hue_margin", 8) if hue_margin is None else hue_margin,
742
+ debug.get("sample_sv_margin", 35) if sv_margin is None else sv_margin,
743
+ debug.get("sample_gray_margin", 18) if gray_margin is None else gray_margin,
744
+ )
745
+
746
+ def apply_color_sample(self, target, action, x, y, radius=None, hue_margin=None, sv_margin=None, gray_margin=None):
747
+ radius, hue_margin, sv_margin, gray_margin = self.sample_settings(radius, hue_margin, sv_margin, gray_margin)
748
+ target = str(target or "").upper()
749
+ if target not in ("RED", "GREEN", "ORANGE", "BLUE", "PINK", "WALL", "BLACK"):
750
+ raise ValueError("target must be RED, GREEN, ORANGE, BLUE, PINK, or WALL")
751
+ action = str(action or "range").lower()
752
+ sample = self.sample_color(x, y, radius)
753
+ if target in ("WALL", "BLACK"):
754
+ gray = self.gray_threshold_from_samples([sample], gray_margin)
755
+ wall_sat = self.wall_saturation_from_samples([sample], sv_margin)
756
+ with self._lock:
757
+ self.configloader.set_path("filters.GRAY", gray)
758
+ self.configloader.set_path("filters.WALL_MAX_SAT", wall_sat)
759
+ self.config = self.configloader.config
760
+ return {
761
+ "sample": sample,
762
+ "target": "WALL",
763
+ "action": "gray",
764
+ "gray": gray,
765
+ "wall_max_sat": wall_sat,
766
+ "filters": self.config["filters"],
767
+ }
768
+
769
+ lo, hi = self.filter_bounds_from_sample(sample, hue_margin, sv_margin)
770
+ with self._lock:
771
+ if action in ("lo", "low"):
772
+ self.configloader.set_path(f"filters.{target}LO", lo)
773
+ elif action in ("hi", "high"):
774
+ self.configloader.set_path(f"filters.{target}HI", hi)
775
+ elif action in ("range", "both"):
776
+ self.configloader.set_path(f"filters.{target}LO", lo)
777
+ self.configloader.set_path(f"filters.{target}HI", hi)
778
+ else:
779
+ raise ValueError("action must be range, lo, or hi")
780
+ self.config = self.configloader.config
781
+ actual_lo = self.config["filters"][f"{target}LO"]
782
+ actual_hi = self.config["filters"][f"{target}HI"]
783
+ return {
784
+ "sample": sample,
785
+ "target": target,
786
+ "action": action,
787
+ "lo": actual_lo,
788
+ "hi": actual_hi,
789
+ "filters": self.config["filters"],
790
+ }
791
+
792
+ def apply_color_samples(self, target, samples, hue_margin=None, sv_margin=None, gray_margin=None):
793
+ _, hue_margin, sv_margin, gray_margin = self.sample_settings(None, hue_margin, sv_margin, gray_margin)
794
+ target = str(target or "").upper()
795
+ if target not in ("RED", "GREEN", "ORANGE", "BLUE", "PINK", "WALL", "BLACK"):
796
+ raise ValueError("target must be RED, GREEN, ORANGE, BLUE, PINK, or WALL")
797
+ if not isinstance(samples, list) or not samples:
798
+ raise ValueError("samples must be a non-empty list")
799
+ samples = [self.normalize_sample(sample) for sample in samples[:32]]
800
+
801
+ with self._lock:
802
+ if target in ("WALL", "BLACK"):
803
+ gray = self.gray_threshold_from_samples(samples, gray_margin)
804
+ wall_sat = self.wall_saturation_from_samples(samples, sv_margin)
805
+ self.configloader.set_path("filters.GRAY", gray)
806
+ self.configloader.set_path("filters.WALL_MAX_SAT", wall_sat)
807
+ self.config = self.configloader.config
808
+ return {
809
+ "samples": samples,
810
+ "target": "WALL",
811
+ "action": "gray",
812
+ "gray": gray,
813
+ "wall_max_sat": wall_sat,
814
+ "filters": self.config["filters"],
815
+ }
816
+
817
+ lo, hi = self.filter_bounds_from_samples(samples, hue_margin, sv_margin)
818
+ self.configloader.set_path(f"filters.{target}LO", lo)
819
+ self.configloader.set_path(f"filters.{target}HI", hi)
820
+ self.config = self.configloader.config
821
+ return {
822
+ "samples": samples,
823
+ "target": target,
824
+ "action": "samples",
825
+ "lo": lo,
826
+ "hi": hi,
827
+ "filters": self.config["filters"],
828
+ }
829
+
830
+ def tint_mask(self, image, mask, color, alpha=0.45):
831
+ if mask is None or mask.shape[:2] != image.shape[:2]:
832
+ return
833
+ pixels = mask > 0
834
+ if not np.any(pixels):
835
+ return
836
+ tint = np.zeros_like(image)
837
+ tint[:, :] = color
838
+ blended = cv2.addWeighted(image, 1.0 - alpha, tint, alpha, 0)
839
+ image[pixels] = blended[pixels]
840
+
841
+ def draw_preview_overlay(self, image, rgbl, orange_blue, pillars, servo_angle, drive_speed, correction, black_l, black_r, orange, blue):
842
+ if self.config["debug"].get("preview_masks", True):
843
+ self.tint_mask(image, rgbl.get("black"), (255, 210, 60), 0.38)
844
+ self.tint_mask(image, rgbl.get("red"), (40, 40, 255), 0.52)
845
+ self.tint_mask(image, rgbl.get("green"), (40, 230, 60), 0.52)
846
+ self.tint_mask(image, orange_blue.get("orange"), (0, 150, 255), 0.52)
847
+ self.tint_mask(image, orange_blue.get("blue"), (255, 120, 20), 0.52)
848
+
849
+ for color, key in (((0, 255, 255), "line"), ((255, 220, 0), "left_wall"), ((255, 220, 0), "right_wall")):
850
+ rect = self.config["roi"].get(key, {})
851
+ x, y = int(rect.get("x", 0)), int(rect.get("y", 0))
852
+ w, h = int(rect.get("w", 0)), int(rect.get("h", 0))
853
+ cv2.rectangle(image, (x, y), (x + w, y + h), color, 2)
854
+ cv2.putText(image, key, (x + 4, max(14, y - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA)
855
+
856
+ for pillar in pillars:
857
+ x, y, w, h = pillar.rect
858
+ color = (0, 0, 255) if pillar.color == "RED" else (0, 255, 0)
859
+ cv2.rectangle(image, (x, y), (x + w, y + h), color, 2)
860
+ label = f"{pillar.color.lower()} area={pillar.area}"
861
+ if pillar.stable_area is not None and abs(pillar.area - pillar.raw_area) > 20:
862
+ label += f" raw={pillar.raw_area}"
863
+ if pillar.held:
864
+ label += " held"
865
+ if pillar.ignore:
866
+ label += " ignored"
867
+ cv2.putText(image, label, (x, max(14, y - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA)
868
+
869
+ pillar_count = len(pillars)
870
+ if self.closest_pillar:
871
+ closest_text = (
872
+ f"closest {self.closest_pillar.color} area={self.closest_pillar.area} "
873
+ f"raw={self.closest_pillar.raw_area} seen={self.closest_pillar.seen_frames}"
874
+ )
875
+ else:
876
+ closest_text = "closest pillar area=0"
877
+ lines = [
878
+ f"{self.sm.current_state} dir={self.sm.round_dir} turns={self.sm.turns_left}",
879
+ f"servo={servo_angle} speed={drive_speed} corr={correction:.2f}",
880
+ f"walls L={black_l:.2f} R={black_r:.2f} line O={orange:.2f} B={blue:.2f} pillars={pillar_count}",
881
+ closest_text,
882
+ ]
883
+ y = 22
884
+ for text in lines:
885
+ cv2.putText(image, text, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 0, 0), 3, cv2.LINE_AA)
886
+ cv2.putText(image, text, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.52, (230, 255, 230), 1, cv2.LINE_AA)
887
+ y += 24
888
+
889
+ def update_config(self, path, value):
890
+ with self._lock:
891
+ self.configloader.set_path(path, value)
892
+ self.config = self.configloader.config
893
+ self.arduino.config = self.config["serial"]
894
+ self.camera.config = self.config["camera"]
895
+ self.sm.behavior = self.config["behavior"]
896
+ self.sm.is_pillar_round = bool(self.config["behavior"].get("pillars", False))
897
+ self.decision_logger.record_decision(
898
+ "config_update",
899
+ inputs={"path": path, "value": value},
900
+ selected={"path": path},
901
+ force=True,
902
+ )
903
+
904
+ def manual_command(self, command, value=None):
905
+ command = str(command or "").upper()
906
+ with self._lock:
907
+ self.manual_until = time.monotonic() + 1.0
908
+ if command == "STOP":
909
+ self.manual_servo_angle = steering_offset_to_servo_angle(0, self.config["control"])
910
+ self.manual_drive_speed = 0
911
+ self.arduino.stop()
912
+ elif command == "STEER":
913
+ if value is None:
914
+ raise ValueError("value is required")
915
+ self.manual_servo_angle = int(value)
916
+ self.arduino.send(command, int(value), force=True)
917
+ elif command == "DRIVE":
918
+ if value is None:
919
+ raise ValueError("value is required")
920
+ self.manual_drive_speed = int(value)
921
+ self.arduino.send(command, int(value), force=True)
922
+ else:
923
+ raise ValueError(f"unknown command: {command}")
924
+ self.decision_logger.record_decision(
925
+ "manual_command",
926
+ inputs={"command": command, "value": value},
927
+ selected={
928
+ "manual_servo_angle": self.manual_servo_angle,
929
+ "manual_drive_speed": self.manual_drive_speed,
930
+ },
931
+ reasoning={"override_seconds": 1.0},
932
+ force=True,
933
+ )
934
+
935
+ def log_status(self, servo_angle, drive_speed, correction, black_l, black_r, orange, blue, pillar_count):
936
+ now = time.monotonic()
937
+ every = float(self.config["debug"].get("log_every_seconds", 0.5))
938
+ if every <= 0 or now - self.last_log < every:
939
+ return
940
+ self.last_log = now
941
+ print(
942
+ f"state={self.sm.current_state} dir={self.sm.round_dir} turns_left={self.sm.turns_left} "
943
+ f"servo={servo_angle} speed={drive_speed} corr={correction:.2f} "
944
+ f"wallL={black_l:.2f} wallR={black_r:.2f} orange={orange:.2f} blue={blue:.2f} pillars={pillar_count}"
945
+ )
946
+
947
+ def save_debug_frame(self, image):
948
+ debug = self.config["debug"]
949
+ if not debug.get("save_frames", False):
950
+ return
951
+ now = time.monotonic()
952
+ if now - self.last_frame_save < float(debug.get("frame_every_seconds", 1.0)):
953
+ return
954
+ self.last_frame_save = now
955
+ frame_dir = os.path.abspath(debug.get("frame_dir", "debug_frames"))
956
+ os.makedirs(frame_dir, exist_ok=True)
957
+ cv2.imwrite(os.path.join(frame_dir, "latest.jpg"), image)
958
+
959
+
960
+ def build_parser():
961
+ default_config = os.path.join(os.path.dirname(__file__), "settings.json")
962
+ parser = argparse.ArgumentParser(description="Headless WRO Future Engineers robot runner.")
963
+ parser.add_argument("--config", default=default_config, help="Path to config JSON.")
964
+ parser.add_argument("--set", action="append", default=[], metavar="PATH=VALUE", help="Override config, e.g. --set control.speed=90")
965
+ parser.add_argument("--save-config", action="store_true", help="Save config after applying overrides.")
966
+ parser.add_argument("--print-config", action="store_true", help="Print effective config and exit.")
967
+ parser.add_argument("--no-serial", action="store_true", help="Run without sending Arduino commands.")
968
+ parser.add_argument("--pillars", action="store_true", help="Enable pillar round behavior.")
969
+ parser.add_argument("--no-pillars", action="store_true", help="Disable pillar round behavior.")
970
+ parser.add_argument("--fixed-dir", type=int, choices=(-1, 0, 1), help="-1 clockwise vote, 1 counter-clockwise vote, 0 auto.")
971
+ parser.add_argument("--max-frames", type=int, default=0, help="Stop after N frames; 0 means run forever.")
972
+ parser.add_argument("--log-every", type=float, help="Seconds between status lines.")
973
+ parser.add_argument("--web", action="store_true", help="Start preview/config webpage.")
974
+ parser.add_argument("--no-web", action="store_true", help="Disable preview/config webpage.")
975
+ parser.add_argument("--host", help="Webpage host bind address.")
976
+ parser.add_argument("--port", type=int, help="Webpage port.")
977
+ return parser
978
+
979
+
980
+ def apply_cli_overrides(configloader, args):
981
+ if args.pillars:
982
+ configloader.set_path("behavior.pillars", True)
983
+ if args.no_pillars:
984
+ configloader.set_path("behavior.pillars", False)
985
+ if args.fixed_dir is not None:
986
+ configloader.set_path("behavior.fixed_round_dir", args.fixed_dir)
987
+ if args.log_every is not None:
988
+ configloader.set_path("debug.log_every_seconds", args.log_every)
989
+ if args.web:
990
+ configloader.set_path("web.enabled", True)
991
+ if args.no_web:
992
+ configloader.set_path("web.enabled", False)
993
+ if args.host:
994
+ configloader.set_path("web.host", args.host)
995
+ if args.port is not None:
996
+ configloader.set_path("web.port", args.port)
997
+ for item in args.set:
998
+ if "=" not in item:
999
+ raise ValueError(f"invalid --set override: {item}")
1000
+ key, value = item.split("=", 1)
1001
+ configloader.set_path(key, parse_override_value(value))
1002
+
1003
+
1004
+ def main(argv=None):
1005
+ args = build_parser().parse_args(argv)
1006
+ configloader = ConfigLoader(args.config)
1007
+ apply_cli_overrides(configloader, args)
1008
+ if args.save_config:
1009
+ configloader.save_config()
1010
+ if args.print_config:
1011
+ print(json.dumps(configloader.config, indent=2))
1012
+ return 0
1013
+
1014
+ runner = RobotRunner(configloader, no_serial=args.no_serial)
1015
+ web_server = None
1016
+ frame_count = 0
1017
+ try:
1018
+ runner.start()
1019
+ if configloader.get_path("web.enabled", True):
1020
+ web_server = RobotWebServer(
1021
+ runner,
1022
+ configloader.get_path("web.host", "0.0.0.0"),
1023
+ configloader.get_path("web.port", 5000),
1024
+ )
1025
+ web_server.start()
1026
+ while True:
1027
+ keep_running = runner.cycle()
1028
+ frame_count += 1
1029
+ if args.max_frames and frame_count >= args.max_frames:
1030
+ break
1031
+ if not keep_running:
1032
+ time.sleep(float(configloader.get_path("control.done_sleep_seconds", 1.0)))
1033
+ break
1034
+ except KeyboardInterrupt:
1035
+ print("Interrupted; stopping robot.")
1036
+ except Exception as exc:
1037
+ print(f"Robot runtime error: {exc}")
1038
+ runner.decision_logger.record_decision(
1039
+ "runtime_error",
1040
+ selected={"message": str(exc)},
1041
+ force=True,
1042
+ )
1043
+ return 1
1044
+ finally:
1045
+ if web_server:
1046
+ web_server.stop()
1047
+ runner.stop()
1048
+ return 0
1049
+
1050
+
1051
+ if __name__ == "__main__":
1052
+ raise SystemExit(main())