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/__init__.py +2 -0
- core/candidates.py +511 -0
- core/config_loader.py +305 -0
- core/greedy.py +470 -0
- core/room.py +447 -0
- core/scoring.py +248 -0
- core/visualize.py +517 -0
- lab_camera_optimizer-1.0.0.dist-info/METADATA +416 -0
- lab_camera_optimizer-1.0.0.dist-info/RECORD +13 -0
- lab_camera_optimizer-1.0.0.dist-info/WHEEL +5 -0
- lab_camera_optimizer-1.0.0.dist-info/entry_points.txt +3 -0
- lab_camera_optimizer-1.0.0.dist-info/licenses/LICENSE +22 -0
- lab_camera_optimizer-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
|