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,198 @@
1
+ {
2
+ "version": 2,
3
+ "filters": {
4
+ "GRAY": 56,
5
+ "WALL_MAX_SAT": 102,
6
+ "WALL_EXCLUDE_COLORS": true,
7
+ "WALL_EXCLUDE_DILATE": 1,
8
+ "REDLO": [
9
+ 111,
10
+ 149,
11
+ 48
12
+ ],
13
+ "REDHI": [
14
+ 130,
15
+ 239,
16
+ 167
17
+ ],
18
+ "GREENLO": [
19
+ 51,
20
+ 66,
21
+ 33
22
+ ],
23
+ "GREENHI": [
24
+ 73,
25
+ 171,
26
+ 136
27
+ ],
28
+ "ORANGELO": [
29
+ 98,
30
+ 0,
31
+ 88
32
+ ],
33
+ "ORANGEHI": [
34
+ 118,
35
+ 234,
36
+ 208
37
+ ],
38
+ "BLUELO": [
39
+ 173,
40
+ 0,
41
+ 54
42
+ ],
43
+ "BLUEHI": [
44
+ 14,
45
+ 255,
46
+ 240
47
+ ],
48
+ "PINKLO": [
49
+ 158,
50
+ 160,
51
+ 100
52
+ ],
53
+ "PINKHI": [
54
+ 170,
55
+ 255,
56
+ 255
57
+ ]
58
+ },
59
+ "contours": {
60
+ "minSize": 80.0,
61
+ "pillar_morph_kernel": 5,
62
+ "pillar_min_width": 4,
63
+ "pillar_min_height": 8,
64
+ "pillar_min_fill_ratio": 0.18
65
+ },
66
+ "camera": {
67
+ "frame_width": 640,
68
+ "frame_height": 400,
69
+ "format": "RGB888",
70
+ "crop_height": 190,
71
+ "flip_180": true,
72
+ "awb_enable": false,
73
+ "mtx": [
74
+ [
75
+ 495.44408865,
76
+ 0.0,
77
+ 294.45510998
78
+ ],
79
+ [
80
+ 0.0,
81
+ 504.36905411,
82
+ 259.77204181
83
+ ],
84
+ [
85
+ 0.0,
86
+ 0.0,
87
+ 1.0
88
+ ]
89
+ ],
90
+ "dist": [
91
+ [
92
+ -0.64066312,
93
+ 0.41901729,
94
+ -0.00366722,
95
+ 0.01957175,
96
+ -0.14404429
97
+ ]
98
+ ]
99
+ },
100
+ "roi": {
101
+ "line": {
102
+ "x": 270,
103
+ "y": 180,
104
+ "w": 100,
105
+ "h": 30
106
+ },
107
+ "left_wall": {
108
+ "x": 0,
109
+ "y": 0,
110
+ "w": 100,
111
+ "h": 150
112
+ },
113
+ "right_wall": {
114
+ "x": 540,
115
+ "y": 0,
116
+ "w": 100,
117
+ "h": 150
118
+ }
119
+ },
120
+ "pd": {
121
+ "kp": 4.5,
122
+ "kd": 8.0,
123
+ "target_black_portion": 0.45
124
+ },
125
+ "control": {
126
+ "speed": 160,
127
+ "turn_speed": 155,
128
+ "avoid_speed": 115,
129
+ "done_sleep_seconds": 1.0,
130
+ "max_correction": 1,
131
+ "max_steering_offset": 75,
132
+ "steering_sign": -1,
133
+ "drive_sign": -1,
134
+ "servo_center_deg": 90,
135
+ "servo_min_deg": 15,
136
+ "servo_max_deg": 165
137
+ },
138
+ "behavior": {
139
+ "pillars": true,
140
+ "turns": 120,
141
+ "fixed_round_dir": 0,
142
+ "round_dir_vote_threshold": 10,
143
+ "line_marker_min_portion": 0.25,
144
+ "turn_delay_seconds": 0,
145
+ "pillar_turn_delay_seconds": 0.1,
146
+ "turn_timeout_seconds": 1.1,
147
+ "state_hold_seconds": 0.25,
148
+ "finish_delay_seconds": 1.5,
149
+ "pillar_track_area": 600,
150
+ "pillar_avoid_area": 2800,
151
+ "pillar_avoid_cooldown_seconds": 0.55,
152
+ "pillar_avoid_seconds": 2.2,
153
+ "pillar_first_phase_seconds": 0.85,
154
+ "pillar_red_first_correction": 0.95,
155
+ "pillar_red_second_correction": -0.72,
156
+ "pillar_green_first_correction": -0.95,
157
+ "pillar_green_second_correction": 0.72,
158
+ "pillar_wall_guard": true,
159
+ "pillar_wall_guard_threshold": 0.62,
160
+ "pillar_wall_guard_correction": 1.0,
161
+ "pillar_area_ema_alpha": 0.5,
162
+ "pillar_lost_hold_frames": 4,
163
+ "pillar_match_max_dx": 90,
164
+ "pillar_track_confirm_frames": 1,
165
+ "pillar_avoid_confirm_frames": 1
166
+ },
167
+ "serial": {
168
+ "enabled": true,
169
+ "port": "/dev/ttyACM0",
170
+ "auto_detect": true,
171
+ "baud_rate": 9600,
172
+ "ready_timeout_seconds": 6.0,
173
+ "write_timeout_seconds": 1.0,
174
+ "send_interval_seconds": 0.02,
175
+ "keepalive_interval_seconds": 0.15
176
+ },
177
+ "web": {
178
+ "enabled": true,
179
+ "host": "0.0.0.0",
180
+ "port": 5000
181
+ },
182
+ "decision_log": {
183
+ "enabled": true,
184
+ "path": "decision_log.jsonl",
185
+ "cycle_interval_seconds": 0.25
186
+ },
187
+ "debug": {
188
+ "log_every_seconds": 0.5,
189
+ "preview_masks": true,
190
+ "sample_radius": 4,
191
+ "sample_hue_margin": 8,
192
+ "sample_sv_margin": 35,
193
+ "sample_gray_margin": 18,
194
+ "save_frames": false,
195
+ "frame_dir": "debug_frames",
196
+ "frame_every_seconds": 1.0
197
+ }
198
+ }
@@ -0,0 +1,206 @@
1
+ import json
2
+ import os
3
+ from copy import deepcopy
4
+
5
+
6
+ DEFAULT_CONFIG = {
7
+ "version": 2,
8
+ "filters": {
9
+ "GRAY": 110,
10
+ "WALL_MAX_SAT": 90,
11
+ "WALL_EXCLUDE_COLORS": True,
12
+ "WALL_EXCLUDE_DILATE": 7,
13
+ "REDLO": [0, 100, 80],
14
+ "REDHI": [4, 255, 255],
15
+ "GREENLO": [30, 80, 30],
16
+ "GREENHI": [90, 255, 255],
17
+ "ORANGELO": [7, 20, 10],
18
+ "ORANGEHI": [37, 255, 255],
19
+ "BLUELO": [100, 85, 90],
20
+ "BLUEHI": [130, 255, 255],
21
+ "PINKLO": [158, 160, 100],
22
+ "PINKHI": [170, 255, 255]
23
+ },
24
+ "contours": {
25
+ "minSize": 80.0,
26
+ "pillar_morph_kernel": 5,
27
+ "pillar_min_width": 4,
28
+ "pillar_min_height": 8,
29
+ "pillar_min_fill_ratio": 0.18
30
+ },
31
+ "camera": {
32
+ "frame_width": 640,
33
+ "frame_height": 480,
34
+ "format": "RGB888",
35
+ "crop_height": 258,
36
+ "flip_180": True,
37
+ "awb_enable": False,
38
+ "mtx": [
39
+ [495.44408865, 0.0, 294.45510998],
40
+ [0.0, 504.36905411, 259.77204181],
41
+ [0.0, 0.0, 1.0]
42
+ ],
43
+ "dist": [[-0.64066312, 0.41901729, -0.00366722, 0.01957175, -0.14404429]]
44
+ },
45
+ "roi": {
46
+ "line": {"x": 270, "y": 180, "w": 100, "h": 30},
47
+ "left_wall": {"x": 0, "y": 0, "w": 100, "h": 150},
48
+ "right_wall": {"x": 540, "y": 0, "w": 100, "h": 150}
49
+ },
50
+ "pd": {
51
+ "kp": 4.5,
52
+ "kd": 8.0,
53
+ "target_black_portion": 0.45
54
+ },
55
+ "control": {
56
+ "speed": 100,
57
+ "turn_speed": 100,
58
+ "avoid_speed": 100,
59
+ "done_sleep_seconds": 1.0,
60
+ "max_correction": 1.0,
61
+ "max_steering_offset": 55,
62
+ "steering_sign": -1,
63
+ "drive_sign": 1,
64
+ "servo_center_deg": 90,
65
+ "servo_min_deg": 15,
66
+ "servo_max_deg": 165
67
+ },
68
+ "behavior": {
69
+ "pillars": False,
70
+ "turns": 12,
71
+ "fixed_round_dir": 0,
72
+ "round_dir_vote_threshold": 10,
73
+ "line_marker_min_portion": 0.25,
74
+ "turn_delay_seconds": 0.7,
75
+ "pillar_turn_delay_seconds": 0.3,
76
+ "turn_timeout_seconds": 0.85,
77
+ "state_hold_seconds": 0.4,
78
+ "finish_delay_seconds": 3.1,
79
+ "pillar_track_area": 190,
80
+ "pillar_avoid_area": 530,
81
+ "pillar_avoid_cooldown_seconds": 1.0,
82
+ "pillar_avoid_seconds": 2.5,
83
+ "pillar_first_phase_seconds": 0.95,
84
+ "pillar_red_first_correction": 0.95,
85
+ "pillar_red_second_correction": -0.72,
86
+ "pillar_green_first_correction": -0.95,
87
+ "pillar_green_second_correction": 0.72,
88
+ "pillar_wall_guard": True,
89
+ "pillar_wall_guard_threshold": 0.62,
90
+ "pillar_wall_guard_correction": 1.0,
91
+ "pillar_area_ema_alpha": 0.35,
92
+ "pillar_lost_hold_frames": 3,
93
+ "pillar_match_max_dx": 90,
94
+ "pillar_track_confirm_frames": 2,
95
+ "pillar_avoid_confirm_frames": 2
96
+ },
97
+ "serial": {
98
+ "enabled": True,
99
+ "port": "/dev/ttyACM0",
100
+ "auto_detect": True,
101
+ "baud_rate": 9600,
102
+ "ready_timeout_seconds": 6.0,
103
+ "write_timeout_seconds": 1.0,
104
+ "send_interval_seconds": 0.02,
105
+ "keepalive_interval_seconds": 0.15
106
+ },
107
+ "web": {
108
+ "enabled": True,
109
+ "host": "0.0.0.0",
110
+ "port": 5000
111
+ },
112
+ "decision_log": {
113
+ "enabled": True,
114
+ "path": "decision_log.jsonl",
115
+ "cycle_interval_seconds": 0.25
116
+ },
117
+ "debug": {
118
+ "log_every_seconds": 0.5,
119
+ "preview_masks": True,
120
+ "sample_radius": 4,
121
+ "sample_hue_margin": 8,
122
+ "sample_sv_margin": 35,
123
+ "sample_gray_margin": 18,
124
+ "save_frames": False,
125
+ "frame_dir": "debug_frames",
126
+ "frame_every_seconds": 1.0
127
+ }
128
+ }
129
+
130
+
131
+ class ConfigLoader:
132
+ def __init__(self, file_path):
133
+ self.file_path = file_path
134
+ self.config = deepcopy(DEFAULT_CONFIG)
135
+ self.load_config()
136
+
137
+ def load_config(self):
138
+ if not os.path.exists(self.file_path):
139
+ return
140
+ with open(self.file_path, "r", encoding="utf-8") as file:
141
+ loaded = json.load(file)
142
+ if not isinstance(loaded, dict):
143
+ raise ValueError("config root must be an object")
144
+ self.config = merge_dicts(deepcopy(DEFAULT_CONFIG), loaded)
145
+ self._apply_legacy_keys(loaded)
146
+
147
+ def _apply_legacy_keys(self, loaded):
148
+ if "ArduinoSerialPort" in loaded and not loaded.get("serial", {}).get("port"):
149
+ self.config["serial"]["port"] = loaded["ArduinoSerialPort"]
150
+ if "PD" in loaded:
151
+ pd = loaded["PD"]
152
+ if "kp" in pd:
153
+ self.config["pd"]["kp"] = pd["kp"]
154
+ if "kd" in pd:
155
+ self.config["pd"]["kd"] = pd["kd"]
156
+ if "ROI" in loaded and isinstance(loaded["ROI"], dict):
157
+ self.config["roi"] = merge_dicts(self.config["roi"], loaded["ROI"])
158
+
159
+ def get_property(self, key, default=None):
160
+ return self.config.get(key, default)
161
+
162
+ def get_path(self, path, default=None):
163
+ current = self.config
164
+ for part in str(path).split("."):
165
+ if not isinstance(current, dict) or part not in current:
166
+ return default
167
+ current = current[part]
168
+ return current
169
+
170
+ def set_path(self, path, value):
171
+ parts = str(path).split(".")
172
+ current = self.config
173
+ for part in parts[:-1]:
174
+ if part not in current or not isinstance(current[part], dict):
175
+ current[part] = {}
176
+ current = current[part]
177
+ current[parts[-1]] = value
178
+
179
+ def save_config(self, path=None):
180
+ target = path or self.file_path
181
+ with open(target, "w", encoding="utf-8") as file:
182
+ json.dump(self.config, file, indent=2)
183
+ file.write("\n")
184
+
185
+
186
+ def merge_dicts(base, overrides):
187
+ for key, value in overrides.items():
188
+ if isinstance(value, dict) and isinstance(base.get(key), dict):
189
+ base[key] = merge_dicts(base[key], value)
190
+ else:
191
+ base[key] = value
192
+ return base
193
+
194
+
195
+ def parse_override_value(text):
196
+ try:
197
+ return json.loads(text)
198
+ except json.JSONDecodeError:
199
+ lowered = text.lower()
200
+ if lowered == "true":
201
+ return True
202
+ if lowered == "false":
203
+ return False
204
+ if lowered == "null":
205
+ return None
206
+ return text
@@ -0,0 +1,12 @@
1
+ [Unit]
2
+ Description=Run WRO vehicle runtime
3
+ After=multi-user.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory=/home/pi/program/onboard
8
+ ExecStart=/usr/bin/python3 /home/pi/program/onboard/robot_runtime.py --pillars
9
+ Restart=on-failure
10
+
11
+ [Install]
12
+ WantedBy=multi-user.target
@@ -0,0 +1,142 @@
1
+ import cv2
2
+ import numpy as np
3
+
4
+ from settings import ConfigLoader
5
+ from vision_types import Pillar
6
+
7
+ class Pipeline:
8
+ def __init__(self, configloader: ConfigLoader):
9
+ self.configloader = configloader
10
+
11
+ def undistort(self, image: np.ndarray):
12
+ mtx, dist = np.array(self.configloader.get_property("camera")['mtx']), np.array(self.configloader.get_property("camera")['dist'])
13
+ return cv2.undistort(image, mtx, dist, None, mtx)
14
+
15
+ def crop(self, image: np.ndarray):
16
+ crop_height = int(self.configloader.get_property("camera")['crop_height'])
17
+ return image[crop_height:,:]
18
+
19
+ @staticmethod
20
+ def hsv_range_mask(hsv: np.ndarray, lower, upper):
21
+ lower = tuple(lower)
22
+ upper = tuple(upper)
23
+ if lower[0] <= upper[0]:
24
+ return cv2.inRange(hsv, lower, upper)
25
+
26
+ low_wrap = (0, lower[1], lower[2])
27
+ high_wrap = upper
28
+ low_direct = lower
29
+ high_direct = (179, upper[1], upper[2])
30
+ return cv2.bitwise_or(
31
+ cv2.inRange(hsv, low_direct, high_direct),
32
+ cv2.inRange(hsv, low_wrap, high_wrap),
33
+ )
34
+
35
+ def filter_RG_Bl(self, hsv: np.ndarray, color_image: np.ndarray):
36
+ """
37
+ Extracts red/green pillars and the dark wall mask.
38
+ """
39
+ filters = self.configloader.get_property("filters")
40
+ redMin = tuple(filters['REDLO'])
41
+ redMax = tuple(filters['REDHI'])
42
+ greenMin = tuple(filters['GREENLO'])
43
+ greenMax = tuple(filters['GREENHI'])
44
+ orangeMin = tuple(filters['ORANGELO'])
45
+ orangeMax = tuple(filters['ORANGEHI'])
46
+ blueMin = tuple(filters['BLUELO'])
47
+ blueMax = tuple(filters['BLUEHI'])
48
+ pinkMin = tuple(filters['PINKLO'])
49
+ pinkMax = tuple(filters['PINKHI'])
50
+ grayThresh = int(filters['GRAY'])
51
+ wallMaxSat = int(filters.get('WALL_MAX_SAT', 90))
52
+ excludeColors = bool(filters.get('WALL_EXCLUDE_COLORS', True))
53
+ excludeDilate = int(filters.get('WALL_EXCLUDE_DILATE', 7))
54
+
55
+ rMask = self.hsv_range_mask(hsv, redMin, redMax)
56
+ gMask = self.hsv_range_mask(hsv, greenMin, greenMax)
57
+ oMask = self.hsv_range_mask(hsv, orangeMin, orangeMax)
58
+ bMask = self.hsv_range_mask(hsv, blueMin, blueMax)
59
+ pMask = self.hsv_range_mask(hsv, pinkMin, pinkMax)
60
+
61
+ blurredR = cv2.medianBlur(rMask, 5)
62
+ blurredG = cv2.medianBlur(gMask, 5)
63
+ grayImage = cv2.cvtColor(color_image, cv2.COLOR_BGR2GRAY)
64
+ blurredImg = cv2.GaussianBlur(grayImage, (3, 3), 0)
65
+ darkMask = cv2.inRange(blurredImg, 0, grayThresh)
66
+ lowSatMask = cv2.inRange(hsv[:, :, 1], 0, wallMaxSat)
67
+ blackimg = cv2.bitwise_and(darkMask, lowSatMask)
68
+
69
+ if excludeColors:
70
+ colorMask = cv2.bitwise_or(rMask, gMask)
71
+ colorMask = cv2.bitwise_or(colorMask, oMask)
72
+ colorMask = cv2.bitwise_or(colorMask, bMask)
73
+ colorMask = cv2.bitwise_or(colorMask, pMask)
74
+ if excludeDilate > 0:
75
+ kernelSize = excludeDilate if excludeDilate % 2 == 1 else excludeDilate + 1
76
+ kernel = np.ones((kernelSize, kernelSize), np.uint8)
77
+ colorMask = cv2.dilate(colorMask, kernel)
78
+ blackimg = cv2.bitwise_and(blackimg, cv2.bitwise_not(colorMask))
79
+
80
+ blackimg = cv2.medianBlur(blackimg, 3)
81
+ return {"green": blurredG, "red": blurredR, "black": blackimg}
82
+
83
+
84
+ def filter_OB(self, hsv: np.ndarray):
85
+ """
86
+ Extracts the orange and blue colors from the image -> this is used to detect the turn markers
87
+ """
88
+ orangeMin = tuple(self.configloader.get_property("filters")['ORANGELO'])
89
+ orangeMax = tuple(self.configloader.get_property("filters")['ORANGEHI'])
90
+ blueMin = tuple(self.configloader.get_property("filters")['BLUELO'])
91
+ blueMax = tuple(self.configloader.get_property("filters")['BLUEHI'])
92
+ oMask = self.hsv_range_mask(hsv, orangeMin, orangeMax)
93
+ bMask = self.hsv_range_mask(hsv, blueMin, blueMax)
94
+ # blur images to remove noise
95
+ blurredO = cv2.medianBlur(oMask, 5)
96
+ blurredB = cv2.medianBlur(bMask, 5)
97
+ # return [blurredO, blurredB]
98
+ return {"orange": blurredO, "blue": blurredB}
99
+
100
+ def filter_parking(self, hsv: np.ndarray):
101
+ pinkMin = tuple(self.configloader.get_property("filters")['PINKLO'])
102
+ pinkMax = tuple(self.configloader.get_property("filters")['PINKHI'])
103
+ pMask = cv2.inRange(hsv, pinkMin, pinkMax)
104
+ blurredP = cv2.medianBlur(pMask, 5)
105
+ return blurredP
106
+
107
+ def get_pillars(self, imgIn: np.ndarray, type = "RED") -> list[Pillar]:
108
+ """
109
+ Extracts pillars from filtered image
110
+ """
111
+ contours_config = self.configloader.get_property("contours")
112
+ minSize = float(contours_config['minSize'])
113
+ minWidth = int(contours_config.get("pillar_min_width", 4))
114
+ minHeight = int(contours_config.get("pillar_min_height", 8))
115
+ minFillRatio = float(contours_config.get("pillar_min_fill_ratio", 0.18))
116
+ kernelSize = int(contours_config.get("pillar_morph_kernel", 5))
117
+
118
+ mask = cv2.medianBlur(imgIn, 5)
119
+ if kernelSize > 1:
120
+ kernelSize = kernelSize if kernelSize % 2 == 1 else kernelSize + 1
121
+ kernel = np.ones((kernelSize, kernelSize), np.uint8)
122
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
123
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
124
+
125
+ contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
126
+
127
+ processedContours = []
128
+ for contour in contours:
129
+ size = cv2.contourArea(contour)
130
+ if size > minSize:
131
+ rx, ry, w, h = cv2.boundingRect(contour)
132
+ width = int(w)
133
+ height = int(h)
134
+ bboxArea = max(1, width * height)
135
+ fillRatio = float(size) / float(bboxArea)
136
+
137
+ if width < minWidth or height < minHeight or fillRatio < minFillRatio:
138
+ continue
139
+
140
+ x = int(rx + width / 2)
141
+ processedContours.append(Pillar(x, width, height, type, ry, size))
142
+ return processedContours
@@ -0,0 +1,42 @@
1
+ import numpy as np
2
+
3
+
4
+ class Pillar:
5
+ ignore = False
6
+ big_correction = False
7
+ def __init__(self, screen_x: int, width: int, height: int, color: str, y: int = 0, contour_area: float = 0.0):
8
+ self.screen_x = screen_x
9
+ self.width = width
10
+ self.height = height
11
+ self.color = color
12
+ self.y = y
13
+ self.contour_area = float(contour_area)
14
+ self.stable_area = None
15
+ self.seen_frames = 1
16
+ self.held = False
17
+
18
+ @property
19
+ def x(self):
20
+ return int(self.screen_x - self.width / 2)
21
+
22
+ @property
23
+ def rect(self):
24
+ return (self.x, self.y, self.width, self.height)
25
+
26
+ @property
27
+ def raw_area(self):
28
+ return int(self.width * self.height)
29
+
30
+ @property
31
+ def area(self):
32
+ if self.stable_area is not None:
33
+ return int(round(self.stable_area))
34
+ return self.raw_area
35
+
36
+
37
+ def extract_ROI(image: np.ndarray, startxy: list, endxy: list) -> np.ndarray:
38
+ """
39
+ Extracts the ROIs from the image
40
+ """
41
+
42
+ return image[startxy[1]:endxy[1], startxy[0]:endxy[0]]