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,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())
|