lab-camera-optimizer 1.0.0__py3-none-any.whl

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.
core/config_loader.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ config_loader.py
3
+ Reads a YAML configuration file and exposes a Config object used by
4
+ optimize_camera_placement.py.
5
+
6
+ All hard-coded constants in the main script are replaced by attributes
7
+ of the Config object returned by load_config().
8
+ """
9
+
10
+ import yaml
11
+ import os
12
+ import math
13
+
14
+
15
+ # ─────────────────────────────────────────────────────────────────────────────
16
+ # Helper dataclasses (simple namespaces — no external dependency)
17
+ # ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ class _Ns:
20
+ """Generic namespace: _Ns(a=1, b=2) → obj.a, obj.b"""
21
+ def __init__(self, **kwargs):
22
+ self.__dict__.update(kwargs)
23
+
24
+ def __repr__(self):
25
+ return f"{self.__class__.__name__}({self.__dict__})"
26
+
27
+
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ # Public entry point
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ def load_config(yaml_path: str) -> "_Ns":
33
+ """
34
+ Loads a YAML config file and returns a Config namespace with all
35
+ parameters needed by the optimiser.
36
+
37
+ Parameters
38
+ ----------
39
+ yaml_path : str
40
+ Path to the .yaml configuration file.
41
+
42
+ Returns
43
+ -------
44
+ cfg : _Ns
45
+ Namespace with the following top-level attributes:
46
+ cfg.room — room geometry
47
+ cfg.obstacles — list of obstacle dicts (normalised)
48
+ cfg.subject — subject height / foot_z
49
+ cfg.camera_sets — list of CameraSet namespaces
50
+ cfg.capture_zones — list of CaptureZone namespaces
51
+ cfg.opt — optimisation parameters
52
+ """
53
+ if not os.path.isfile(yaml_path):
54
+ raise FileNotFoundError(f"Config file not found: {yaml_path}")
55
+
56
+ with open(yaml_path, "r", encoding="utf-8") as f:
57
+ raw = yaml.safe_load(f)
58
+
59
+ cfg = _Ns()
60
+
61
+ # ── Room ──────────────────────────────────────────────────────────────
62
+ r = raw["room"]
63
+ cfg.room = _Ns(
64
+ corners = [tuple(c) for c in r["corners"]],
65
+ height = float(r["height"]),
66
+ )
67
+
68
+ # ── Obstacles ─────────────────────────────────────────────────────────
69
+ cfg.obstacles = []
70
+ for obs in raw.get("obstacles", []):
71
+ o = dict(obs) # shallow copy
72
+ o["height"] = float(o["height"])
73
+ o["can_mount_camera"] = bool(o.get("can_mount_camera", False))
74
+ # Normalise to always have a "vertices" key
75
+ if o["type"] == "rect":
76
+ x0, y0, x1, y1 = o["bounds"]
77
+ o["vertices"] = [(x0,y0),(x1,y0),(x1,y1),(x0,y1)]
78
+ elif o["type"] == "polygon":
79
+ o["vertices"] = [tuple(v) for v in o["vertices"]]
80
+ cfg.obstacles.append(o)
81
+
82
+ # ── Subject ───────────────────────────────────────────────────────────
83
+ s = raw.get("subject", {})
84
+ cfg.subject = _Ns(
85
+ height = float(s.get("height", 1.9)),
86
+ foot_z = float(s.get("foot_z", 0.0)),
87
+ )
88
+ cfg.subject.head_z = cfg.subject.height # alias
89
+
90
+ # ── Camera sets ───────────────────────────────────────────────────────
91
+ cfg.camera_sets = []
92
+ for cs in raw.get("camera_sets", []):
93
+ cam = _Ns(
94
+ id = cs["id"],
95
+ name = cs.get("name", cs["id"]),
96
+ mounting = cs.get("mounting", "wall"),
97
+ optional = bool(cs.get("optional", False)),
98
+
99
+ fov_h_L = float(cs.get("fov_h_landscape", 110.0)),
100
+ fov_v_L = float(cs.get("fov_v_landscape", 70.0)),
101
+ fov_h_P = float(cs.get("fov_h_portrait", 70.0)),
102
+ fov_v_P = float(cs.get("fov_v_portrait", 110.0)),
103
+
104
+ max_range = float(cs.get("max_range", 15.0)),
105
+ min_range = float(cs.get("min_range", 0.5)),
106
+
107
+ height_options = [float(h) for h in cs.get("height_options", [2.0])],
108
+ max_count = int(cs.get("max_count", 10)),
109
+ min_spacing = float(cs.get("min_spacing", 1.5)),
110
+
111
+ score_weight = float(cs.get("score_weight", 1.0)),
112
+ walk_axis_margin = float(cs.get("walk_axis_margin", 0.0)),
113
+
114
+ color = str(cs.get("color", "#1f77b4")),
115
+ )
116
+ # Derived: is this set effectively disabled?
117
+ cam.enabled = (not cam.optional) or (cam.max_count > 0)
118
+ cfg.camera_sets.append(cam)
119
+
120
+ # ── Capture zones ─────────────────────────────────────────────────────
121
+ cfg.capture_zones = []
122
+ zone_by_id = {}
123
+ for z in raw.get("capture_zones", []):
124
+ pl = z.get("placement", {})
125
+ zone = _Ns(
126
+ id = z["id"],
127
+ type = z.get("type", "corridor"),
128
+ priority = float(z.get("priority", 1.0)),
129
+ contained_in = z.get("contained_in", None),
130
+ auto_optimize = bool(z.get("auto_optimize", False)),
131
+
132
+ # corridor / sub_zone
133
+ length = float(z.get("length", 10.0)),
134
+ width = float(z.get("width", 1.0)),
135
+ offset_options = [float(o) for o in z.get("offset_options", [0.0])],
136
+
137
+ # corridor placement sweep
138
+ x_start_options = [float(x) for x in pl.get("x_start_options", [0.0])],
139
+ y_options = [float(y) for y in pl.get("y_options", [1.4])],
140
+
141
+ # point zone
142
+ radius = float(z.get("radius", 0.5)),
143
+
144
+ # polygon zone — arbitrary shape (L, T, U, cross…)
145
+ # vertices are in RELATIVE coordinates (origin = 0,0)
146
+ # placement.x_offsets / y_offsets define the sweep translations
147
+ vertices = [tuple(v) for v in z.get("vertices", [])],
148
+ grid_step = float(z.get("grid_step", 0.20)),
149
+ x_offsets = [float(v) for v in pl.get("x_offsets", [0.0])],
150
+ y_offsets = [float(v) for v in pl.get("y_offsets", [0.0])],
151
+ )
152
+ cfg.capture_zones.append(zone)
153
+ zone_by_id[zone.id] = zone
154
+
155
+ # Resolve parent references
156
+ for zone in cfg.capture_zones:
157
+ zone.parent = zone_by_id.get(zone.contained_in, None)
158
+
159
+ # ── Optimisation parameters ───────────────────────────────────────────
160
+ opt = raw.get("optimization", {})
161
+ cfg.opt = _Ns(
162
+ target_coverage = int(opt.get("target_coverage", 3)),
163
+ bilateral_weight = float(opt.get("bilateral_weight", 0.8)),
164
+ vertical_coverage_threshold = float(opt.get("vertical_coverage_threshold", 0.9)),
165
+ restarts_per_combo = int(opt.get("restarts_per_combo", 20)),
166
+ wall_step = float(opt.get("wall_step", 0.35)),
167
+ angle_steps = int(opt.get("angle_steps", 24)),
168
+ tripod_grid_step = float(opt.get("tripod_grid_step", 0.70)),
169
+ distance_quality_factor = float(opt.get("distance_quality_factor", 0.01)),
170
+ graph_mode = str(opt.get("graph_mode", "records_only")),
171
+ algo = str(opt.get("algo", "greedy_1opt")),
172
+ early_stop = int(opt.get("early_stop", 0)), # 0 = auto (n_restarts//3)
173
+ )
174
+
175
+ # ── Convenience shortcuts (mirrors old global constants) ──────────────
176
+ # These allow the main script to reference cfg.ROOM_CORNERS etc.
177
+ # during the transition period before full refactor.
178
+ cfg.ROOM_CORNERS = cfg.room.corners
179
+ cfg.ROOM_HEIGHT = cfg.room.height
180
+
181
+ cfg.HUMAN_HEIGHT = cfg.subject.height
182
+ cfg.HUMAN_FOOT_Z = cfg.subject.foot_z
183
+ cfg.HUMAN_HEAD_Z = cfg.subject.head_z
184
+
185
+ cfg.TARGET_COVERAGE = cfg.opt.target_coverage
186
+ cfg.BILATERAL_WEIGHT = cfg.opt.bilateral_weight
187
+
188
+ # Camera set A (first wall-mounted set) — backward-compat shortcuts
189
+ cam_A = next((c for c in cfg.camera_sets if c.mounting == "wall"), None)
190
+ if cam_A:
191
+ cfg.ZED_FOV_H_L = cam_A.fov_h_L
192
+ cfg.ZED_FOV_V_L = cam_A.fov_v_L
193
+ cfg.ZED_FOV_H_P = cam_A.fov_h_P
194
+ cfg.ZED_FOV_V_P = cam_A.fov_v_P
195
+ cfg.ZED_RADIUS = cam_A.max_range
196
+ cfg.ZED_MIN_DIST = cam_A.min_range
197
+ cfg.ZED_HEIGHT = cam_A.height_options[0]
198
+ cfg.ZED_HEIGHT_OPTIONS = cam_A.height_options
199
+ cfg.MIN_DIST_BETWEEN_ZED = cam_A.min_spacing
200
+ cfg.COLOR_ZED = cam_A.color
201
+ cfg.COLOR_CONE_ZED = cam_A.color
202
+ cfg.N_ZED = cam_A.max_count
203
+
204
+ # Camera set B (first tripod set) — backward-compat shortcuts
205
+ cam_B = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
206
+ if cam_B:
207
+ cfg.IPAD_FOV_H_L = cam_B.fov_h_L
208
+ cfg.IPAD_FOV_V_L = cam_B.fov_v_L
209
+ cfg.IPAD_FOV_H_P = cam_B.fov_h_P
210
+ cfg.IPAD_FOV_V_P = cam_B.fov_v_P
211
+ cfg.IPAD_RADIUS = cam_B.max_range
212
+ cfg.IPAD_HEIGHT = cam_B.height_options[0]
213
+ cfg.IPAD_HEIGHT_OPTIONS = cam_B.height_options
214
+ cfg.MIN_DIST_BETWEEN_IPAD = cam_B.min_spacing
215
+ cfg.IPAD_WALK_MARGIN = cam_B.walk_axis_margin
216
+ cfg.COLOR_IPAD = cam_B.color
217
+ cfg.COLOR_CONE_IPAD = cam_B.color
218
+ cfg.N_IPAD = cam_B.max_count
219
+ else:
220
+ # No tripod set defined → safe defaults (tripod cameras disabled)
221
+ cfg.IPAD_FOV_H_L = cfg.IPAD_FOV_V_L = 80.0
222
+ cfg.IPAD_FOV_H_P = cfg.IPAD_FOV_V_P = 58.0
223
+ cfg.IPAD_RADIUS = 10.0
224
+ cfg.IPAD_HEIGHT = 1.5
225
+ cfg.IPAD_HEIGHT_OPTIONS = [1.5]
226
+ cfg.MIN_DIST_BETWEEN_IPAD = 1.5
227
+ cfg.IPAD_WALK_MARGIN = 0.7
228
+ cfg.COLOR_IPAD = "#d62728"
229
+ cfg.COLOR_CONE_IPAD = "#d62728"
230
+ cfg.N_IPAD = 0
231
+
232
+ # Capture zone shortcuts
233
+ corridor = next((z for z in cfg.capture_zones if z.type == "corridor"), None)
234
+ analysis = next((z for z in cfg.capture_zones if z.type == "sub_zone"), None)
235
+ sts = next((z for z in cfg.capture_zones if z.type == "point"), None)
236
+
237
+ # Room centre — used as fallback for all positional defaults
238
+ _cx = sum(c[0] for c in cfg.ROOM_CORNERS) / len(cfg.ROOM_CORNERS)
239
+ _cy = sum(c[1] for c in cfg.ROOM_CORNERS) / len(cfg.ROOM_CORNERS)
240
+
241
+ if corridor:
242
+ cfg.CORRIDOR_LENGTH = corridor.length
243
+ cfg.CORRIDOR_X_STARTS = corridor.x_start_options
244
+ cfg.ZONE_Y_WALKS = corridor.y_options
245
+ cfg.WALK_X_START = corridor.x_start_options[0]
246
+ cfg.WALK_X_END = corridor.x_start_options[0] + corridor.length
247
+ cfg.WALK_Y = corridor.y_options[0]
248
+ else:
249
+ # No corridor — fall back to room centre; polygon sweep drives everything
250
+ cfg.CORRIDOR_LENGTH = 0.0
251
+ cfg.CORRIDOR_X_STARTS = [_cx]
252
+ cfg.ZONE_Y_WALKS = [_cy]
253
+ cfg.WALK_X_START = _cx
254
+ cfg.WALK_X_END = _cx
255
+ cfg.WALK_Y = _cy
256
+
257
+ if analysis:
258
+ cfg.ZONE_LENGTH = analysis.length
259
+ cfg.ZONE_OFFSETS = analysis.offset_options
260
+ cfg.ANALYSIS_X_START = cfg.WALK_X_START + analysis.offset_options[0]
261
+ cfg.ANALYSIS_X_END = cfg.ANALYSIS_X_START + analysis.length
262
+ else:
263
+ cfg.ZONE_LENGTH = 0.0
264
+ cfg.ZONE_OFFSETS = [0.0]
265
+ cfg.ANALYSIS_X_START = cfg.WALK_X_START
266
+ cfg.ANALYSIS_X_END = cfg.WALK_X_END
267
+
268
+ if sts:
269
+ cfg.STS_RADIUS = sts.radius
270
+ cfg.STS_X = cfg.ANALYSIS_X_START + cfg.ZONE_LENGTH / 2.0 if analysis else _cx
271
+ cfg.STS_Y = cfg.WALK_Y
272
+ else:
273
+ # No STS point — put it at the room centre (used only as a fallback reference)
274
+ cfg.STS_RADIUS = 0.5
275
+ cfg.STS_X = _cx
276
+ cfg.STS_Y = _cy
277
+
278
+ # Optimisation resolution shortcuts
279
+ cfg.WALL_STEP = cfg.opt.wall_step
280
+ cfg.ANGLE_STEPS = cfg.opt.angle_steps
281
+ cfg.TRIPOD_GRID_STEP = cfg.opt.tripod_grid_step
282
+ cfg.DIST_QUALITY_K = cfg.opt.distance_quality_factor
283
+
284
+ return cfg
285
+
286
+
287
+ # ─────────────────────────────────────────────────────────────────────────────
288
+ # Quick validation (run as script: python config_loader.py <config.yaml>)
289
+ # ─────────────────────────────────────────────────────────────────────────────
290
+ if __name__ == "__main__":
291
+ import sys
292
+ path = sys.argv[1] if len(sys.argv) > 1 else "configs/labo_CHU.yaml"
293
+ cfg = load_config(path)
294
+ print("=== Config loaded successfully ===")
295
+ print(f"Room corners : {cfg.ROOM_CORNERS}")
296
+ print(f"Room height : {cfg.ROOM_HEIGHT} m")
297
+ print(f"Obstacles : {len(cfg.obstacles)}")
298
+ print(f"Camera sets : {[c.name for c in cfg.camera_sets]}")
299
+ print(f"Capture zones : {[z.id for z in cfg.capture_zones]}")
300
+ print(f"ZED heights : {cfg.ZED_HEIGHT_OPTIONS}")
301
+ print(f"iPad heights : {cfg.IPAD_HEIGHT_OPTIONS}")
302
+ print(f"Target coverage: {cfg.TARGET_COVERAGE}")
303
+ print(f"Bilateral w : {cfg.BILATERAL_WEIGHT}")
304
+ print(f"Restarts/combo : {cfg.opt.restarts_per_combo}")
305
+