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 ADDED
@@ -0,0 +1,2 @@
1
+ # lab-camera-optimizer core package
2
+
core/candidates.py ADDED
@@ -0,0 +1,511 @@
1
+ """
2
+ candidates.py
3
+ Generates all candidate camera placements (wall-mounted and tripod).
4
+ """
5
+
6
+ import math
7
+ import numpy as np
8
+ from .room import (point_in_room, point_in_polygon, has_line_of_sight,
9
+ wall_angular_limits)
10
+ from .scoring import get_fov
11
+
12
+
13
+ # =============================================================
14
+ # ANGLE HELPERS (mirrors optimize_camera_placement.py logic)
15
+ # =============================================================
16
+
17
+ def _aperture_midpoint(wall_lim):
18
+ """Circular mean of lim_left / lim_right → ideal inward direction."""
19
+ (lim_left, _), (lim_right, _) = wall_lim
20
+ if lim_left is None:
21
+ return None
22
+ sin_m = (math.sin(math.radians(lim_left)) + math.sin(math.radians(lim_right))) / 2.0
23
+ cos_m = (math.cos(math.radians(lim_left)) + math.cos(math.radians(lim_right))) / 2.0
24
+ return math.degrees(math.atan2(sin_m, cos_m))
25
+
26
+
27
+ def _aperture_span(wall_lim, ref_angle):
28
+ """
29
+ Returns (lo, hi) degrees RELATIVE to ref_angle so that lo ≤ 0 ≤ hi.
30
+ Falls back to (-180, 180) when the aperture wraps across ±180°.
31
+ """
32
+ (lim_left, _), (lim_right, _) = wall_lim
33
+ if lim_left is None:
34
+ return -180.0, 180.0
35
+
36
+ def _rel(a):
37
+ return (a - ref_angle + 180) % 360 - 180
38
+
39
+ lo = min(_rel(lim_left), _rel(lim_right))
40
+ hi = max(_rel(lim_left), _rel(lim_right))
41
+ if not (lo <= 0 <= hi):
42
+ return -180.0, 180.0
43
+ return lo, hi
44
+
45
+
46
+ def _clamp_angle_to_aperture(cam_angle, fov_h, wall_lim):
47
+ """
48
+ Returns the angle closest to cam_angle such that the cone
49
+ [angle ± fov_h/2] stays within the aperture.
50
+
51
+ When fov_h < aperture → standard clamp: keep as close to cam_angle as possible.
52
+ When fov_h >= aperture → push cone to butt against its own wall on the side
53
+ of the NEAREST adjacent wall, so the overflow is
54
+ toward the FAR adjacent wall.
55
+ This maximises the in-room fraction of the cone.
56
+
57
+ Returns (clamped_angle, residual_overflow_degrees).
58
+ """
59
+ mid = _aperture_midpoint(wall_lim)
60
+ if mid is None:
61
+ return cam_angle, 0.0
62
+
63
+ lo, hi = _aperture_span(wall_lim, mid)
64
+ aperture = hi - lo
65
+ half = fov_h / 2.0
66
+
67
+ if aperture <= 0:
68
+ return mid, half
69
+
70
+ if fov_h >= aperture:
71
+ # FOV is wider than the available aperture — some overflow is unavoidable.
72
+ # Strategy: push the cone so its edge butts against the own wall on the
73
+ # side of the NEAREST adjacent wall.
74
+ # dist_left = distance to the adjacent wall at lim_left
75
+ # dist_right = distance to the adjacent wall at lim_right
76
+ # Nearest adjacent wall → push cone edge against that side of own wall
77
+ # → overflow goes toward the FAR adjacent wall (less problematic).
78
+ (_, dist_left), (_, dist_right) = wall_lim
79
+ d_left = dist_left if dist_left > 0 else 1e6
80
+ d_right = dist_right if dist_right > 0 else 1e6
81
+
82
+ if d_left <= d_right:
83
+ # Left adjacent wall is closer → push cone left edge to lim_left
84
+ # cone_left = mid + offset - half = lo → offset = lo + half
85
+ offset = lo + half
86
+ else:
87
+ # Right adjacent wall is closer → push cone right edge to lim_right
88
+ # cone_right = mid + offset + half = hi → offset = hi - half
89
+ offset = hi - half
90
+
91
+ overflow = fov_h - aperture
92
+ return mid + offset, overflow / 2.0
93
+
94
+ # Standard clamp: fov fits inside aperture
95
+ offset = (cam_angle - mid + 180) % 360 - 180
96
+ offset = max(offset, lo + half)
97
+ offset = min(offset, hi - half)
98
+ return mid + offset, 0.0
99
+
100
+
101
+ def _aim_quality(cam_angle, fov_h, cam_x, cam_y, targets):
102
+ """
103
+ Score in [0,1]: how well cam_angle covers the target zone.
104
+ targets: list of (tx, ty, weight)
105
+ """
106
+ if not targets:
107
+ return 0.0
108
+ half = fov_h / 2.0
109
+ total_w = sum(w for (_, _, w) in targets)
110
+ if total_w <= 0:
111
+ return 0.0
112
+ score = 0.0
113
+ for (tx, ty, w) in targets:
114
+ angle_to = math.degrees(math.atan2(ty - cam_y, tx - cam_x))
115
+ delta = abs((angle_to - cam_angle + 180) % 360 - 180)
116
+ if delta <= half:
117
+ score += w * (1.0 - delta / half)
118
+ return score / total_w
119
+
120
+
121
+ # =============================================================
122
+ # SAMPLE POINTS
123
+ # =============================================================
124
+
125
+ def build_sample_points(cfg, state):
126
+ """
127
+ Returns the list of (px, py, weight) evaluation points for the current
128
+ zone configuration stored in state.
129
+ """
130
+ walk_y = state["walk_y"]
131
+ walk_x_start = state["walk_x_start"]
132
+ walk_x_end = state["walk_x_end"]
133
+ analysis_x_start = state["analysis_x_start"]
134
+ analysis_x_end = state["analysis_x_end"]
135
+ sts_x = state["sts_x"]
136
+ sts_y = state["sts_y"]
137
+ sts_radius = cfg.STS_RADIUS
138
+ obstacles = cfg.obstacles
139
+ room_corners = cfg.ROOM_CORNERS
140
+ room_height = cfg.ROOM_HEIGHT
141
+
142
+ zone_w = next((z.priority for z in cfg.capture_zones if z.type == "sub_zone"), 1.0)
143
+ corr_w = next((z.priority for z in cfg.capture_zones if z.type == "corridor"), 0.5)
144
+ sts_w = next((z.priority for z in cfg.capture_zones if z.type == "point"), 2.0)
145
+
146
+ pts = []
147
+
148
+ for x in np.arange(analysis_x_start, analysis_x_end + 0.01, 0.15):
149
+ for dy in np.arange(-0.5, 0.51, 0.2):
150
+ py = walk_y + dy
151
+ if point_in_room(x, py, room_corners, obstacles, room_height):
152
+ pts.append((x, py, zone_w))
153
+
154
+ for x in np.arange(walk_x_start, walk_x_end + 0.01, 0.2):
155
+ if x < analysis_x_start or x > analysis_x_end:
156
+ if point_in_room(x, walk_y, room_corners, obstacles, room_height):
157
+ pts.append((x, walk_y, corr_w))
158
+
159
+ for angle_deg in range(0, 360, 20):
160
+ for r in [0.2, 0.4, min(0.6, sts_radius)]:
161
+ px = sts_x + r * math.cos(math.radians(angle_deg))
162
+ py = sts_y + r * math.sin(math.radians(angle_deg))
163
+ if point_in_room(px, py, room_corners, obstacles, room_height):
164
+ pts.append((px, py, sts_w))
165
+
166
+ for zone in cfg.capture_zones:
167
+ if zone.type != "polygon":
168
+ continue
169
+ verts = getattr(zone, "_translated_vertices", None) or zone.vertices
170
+ if len(verts) < 3:
171
+ continue
172
+ xs_v = [v[0] for v in verts]; ys_v = [v[1] for v in verts]
173
+ step = zone.grid_step
174
+ for px in np.arange(min(xs_v), max(xs_v) + step, step):
175
+ for py in np.arange(min(ys_v), max(ys_v) + step, step):
176
+ if (point_in_polygon(px, py, verts) and
177
+ point_in_room(px, py, room_corners, obstacles, room_height)):
178
+ pts.append((px, py, zone.priority))
179
+
180
+ return pts
181
+
182
+
183
+ # =============================================================
184
+ # CANDIDATE GENERATION
185
+ # =============================================================
186
+
187
+ def generate_candidates(cfg, state, wall_step=0.35, angle_steps=24,
188
+ tripod_grid_step=0.7, sample_points=None):
189
+ """
190
+ Generates all camera placement candidates.
191
+
192
+ Parameters
193
+ ----------
194
+ sample_points : list of (px, py, weight), optional
195
+ If provided, used to compute realistic zone_targets for each wall
196
+ position (camera aims toward the weighted centroid of nearby points
197
+ it can plausibly reach). Falls back to a fixed centre point if None.
198
+
199
+ Returns
200
+ -------
201
+ cam_A_cands : list of (x, y, angle, orient, height) — wall-mounted cameras
202
+ cam_B_cands : list of (x, y, angle, orient, height) — tripod cameras
203
+ """
204
+ walk_y = state["walk_y"]
205
+ analysis_x_start = state["analysis_x_start"]
206
+ analysis_x_end = state["analysis_x_end"]
207
+ sts_x = state["sts_x"]
208
+ sts_y = state["sts_y"]
209
+ wall_segs = state["wall_segments"]
210
+ room_corners = cfg.ROOM_CORNERS
211
+ obstacles = cfg.obstacles
212
+ room_height = cfg.ROOM_HEIGHT
213
+ human_height = cfg.HUMAN_HEIGHT
214
+
215
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
216
+ cam_B = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
217
+
218
+ tgt_x = (analysis_x_start + analysis_x_end) / 2
219
+ tgt_y = walk_y
220
+
221
+ # Build zone_targets from real sample points (subsampled for speed).
222
+ # Each wall candidate will use only the sample points it can plausibly
223
+ # reach (within max_range), giving a much more realistic aim direction
224
+ # than a single fixed centre point.
225
+ if sample_points:
226
+ # Subsample: take every 3rd point to limit compute
227
+ _sp_sub = sample_points[::3]
228
+ zone_targets_global = [(px, py, w) for (px, py, w) in _sp_sub]
229
+ else:
230
+ zone_targets_global = [
231
+ (tgt_x, tgt_y, 1.0),
232
+ ((analysis_x_start + analysis_x_end) / 2.0, walk_y, 0.5),
233
+ ]
234
+
235
+ corners_loop = room_corners + [room_corners[0]]
236
+
237
+ cam_A_cands = []
238
+ cam_B_cands = []
239
+ seen_A = set()
240
+ seen_B = set()
241
+
242
+ def _adjacent_wall_penalty(cam_angle, fov_h, wall_lim):
243
+ """
244
+ Returns a multiplier in (0, 1] that penalises orientations whose cone
245
+ overlaps nearby adjacent walls.
246
+
247
+ Logic:
248
+ - The cone spans [cam_angle - fov_h/2, cam_angle + fov_h/2].
249
+ - wall_lim gives the angular limits of the open aperture, with the
250
+ distance to each adjacent wall (dist_left, dist_right).
251
+ - If the cone extends beyond the aperture limit toward an adjacent wall,
252
+ the penalty is proportional to (overlap_deg / fov_h) and inversely
253
+ proportional to the distance to that wall (closer = worse).
254
+ - A camera in a corner (very small aperture) is handled gracefully:
255
+ we just return the least-bad multiplier.
256
+
257
+ Returns float in (0, 1]:
258
+ 1.0 → no overlap with adjacent walls
259
+ → 0 → cone fully buried in adjacent wall
260
+ """
261
+ (lim_left, dist_left), (lim_right, dist_right) = wall_lim
262
+ if lim_left is None:
263
+ return 1.0
264
+
265
+ half = fov_h / 2.0
266
+ cone_left = cam_angle - half
267
+ cone_right = cam_angle + half
268
+
269
+ penalty = 0.0
270
+
271
+ # ── Left adjacent wall ────────────────────────────────────────────
272
+ # cone extends past lim_left → overlap on the left side
273
+ overlap_left = lim_left - cone_left # positive if cone pokes left
274
+ if overlap_left > 0:
275
+ d_left = max(dist_left, 0.3) # cap at 0.3m to avoid /0
276
+ # weight: close wall matters more (1/d), normalise by fov
277
+ penalty += (overlap_left / fov_h) * min(1.0, 1.5 / d_left)
278
+
279
+ # ── Right adjacent wall ───────────────────────────────────────────
280
+ overlap_right = cone_right - lim_right
281
+ if overlap_right > 0:
282
+ d_right = max(dist_right, 0.3)
283
+ penalty += (overlap_right / fov_h) * min(1.0, 1.5 / d_right)
284
+
285
+ return max(0.05, 1.0 - penalty)
286
+
287
+
288
+ def _best_angle_in_aperture(x, y, normal_angle, zone_targets):
289
+ """
290
+ For each (orient, height): sweep angles that fit entirely inside the
291
+ wall aperture, rank by aim_quality penalised by adjacent-wall overlap,
292
+ return best clamped candidate.
293
+ Yields (orient, zh, clamped_angle).
294
+ """
295
+ wall_lim = wall_angular_limits(x, y, wall_segs)
296
+ mid = _aperture_midpoint(wall_lim)
297
+ if mid is None:
298
+ return
299
+ lo, hi = _aperture_span(wall_lim, mid)
300
+
301
+ for orient in ("L", "P"):
302
+ fov_h_ori, _ = get_fov(cam_A, orient)
303
+ half_ori = fov_h_ori / 2.0
304
+
305
+ if fov_h_ori >= (hi - lo):
306
+ # FOV wider than aperture: test BOTH butt positions.
307
+ # Butt-left : push cone so its left edge touches lim_left
308
+ # Butt-right : push cone so its right edge touches lim_right
309
+ # Pick whichever gives better (aim_quality × adj_wall_penalty).
310
+ valid_offsets = [lo + half_ori, hi - half_ori]
311
+ else:
312
+ valid_offsets = list(np.arange(lo + half_ori,
313
+ hi - half_ori + 0.1, 5.0))
314
+ if not valid_offsets:
315
+ valid_offsets = [0.0]
316
+
317
+ best_angle = None
318
+ best_q = -1.0
319
+ for offset in valid_offsets:
320
+ ta = mid + offset
321
+ dn = (ta - normal_angle + 180) % 360 - 180
322
+ if abs(dn) + half_ori > 92:
323
+ continue
324
+ # aim quality: how well the cone covers the target zone
325
+ q = _aim_quality(ta, fov_h_ori, x, y, zone_targets)
326
+ # adjacent wall penalty: penalise cones pointing into nearby walls
327
+ adj_mult = _adjacent_wall_penalty(ta, fov_h_ori, wall_lim)
328
+ q *= adj_mult
329
+ if q > best_q:
330
+ best_q = q
331
+ best_angle = ta
332
+
333
+ if best_angle is None:
334
+ best_angle = mid
335
+
336
+ clamped_ta, _ = _clamp_angle_to_aperture(best_angle, fov_h_ori, wall_lim)
337
+
338
+ for zh in cam_A.height_options:
339
+ yield (orient, zh, clamped_ta)
340
+
341
+ def _add_wall_cands(x, y, normal_angle, cands, seen):
342
+ """Generate candidates at wall position (x,y)."""
343
+ # Filter zone_targets to those within max_range of this camera position
344
+ # → the camera aims toward what it can actually see, not a fixed centre
345
+ max_r = cam_A.max_range
346
+ reachable = [(px, py, w) for (px, py, w) in zone_targets_global
347
+ if math.sqrt((px-x)**2 + (py-y)**2) <= max_r]
348
+ zone_targets = reachable if reachable else zone_targets_global
349
+ for (orient, zh, clamped_ta) in _best_angle_in_aperture(x, y, normal_angle, zone_targets):
350
+ key = (round(x, 2), round(y, 2), round(clamped_ta % 360, 1), orient, zh)
351
+ if key not in seen:
352
+ seen.add(key)
353
+ cands.append((x, y, clamped_ta, orient, zh))
354
+
355
+ def _add_corner_cands(xc, yc, cands, seen):
356
+ """Generate candidates at corner position (xc,yc)."""
357
+ tgt_x_c = float(np.clip(xc, analysis_x_start + 1.0, analysis_x_end - 1.0))
358
+ # Check LOS to at least one target
359
+ targets_c = [(tgt_x_c, walk_y),
360
+ ((analysis_x_start + analysis_x_end) / 2.0, walk_y)]
361
+ has_los = any(
362
+ has_line_of_sight(xc, yc, tx, ty, wall_segs, obstacles, room_height,
363
+ cam_z=max(cam_A.height_options),
364
+ target_z=human_height / 2.0)
365
+ for (tx, ty) in targets_c
366
+ )
367
+ if not has_los:
368
+ return
369
+
370
+ wall_lim = wall_angular_limits(xc, yc, wall_segs)
371
+ mid = _aperture_midpoint(wall_lim)
372
+ if mid is None:
373
+ return
374
+ lo, hi = _aperture_span(wall_lim, mid)
375
+
376
+ max_r = cam_A.max_range
377
+ reachable_c = [(px, py, w) for (px, py, w) in zone_targets_global
378
+ if math.sqrt((px-xc)**2 + (py-yc)**2) <= max_r]
379
+ zone_targets = reachable_c if reachable_c else zone_targets_global
380
+
381
+ for orient in ("L", "P"):
382
+ fov_h_ori, _ = get_fov(cam_A, orient)
383
+ half_ori = fov_h_ori / 2.0
384
+
385
+ if fov_h_ori >= (hi - lo):
386
+ # Test both butt positions — pick the one with best score
387
+ valid_offsets = [lo + half_ori, hi - half_ori]
388
+ else:
389
+ valid_offsets = list(np.arange(lo + half_ori,
390
+ hi - half_ori + 0.1, 5.0))
391
+ if not valid_offsets:
392
+ valid_offsets = [0.0]
393
+
394
+ best_angle = None
395
+ best_q = -1.0
396
+ for offset in valid_offsets:
397
+ ta = mid + offset
398
+ q = _aim_quality(ta, fov_h_ori, xc, yc, zone_targets)
399
+ q *= _adjacent_wall_penalty(ta, fov_h_ori, wall_lim)
400
+ if q > best_q:
401
+ best_q = q
402
+ best_angle = ta
403
+
404
+ if best_angle is None:
405
+ best_angle = mid
406
+
407
+ clamped_ta, _ = _clamp_angle_to_aperture(best_angle, fov_h_ori, wall_lim)
408
+
409
+ for zh in cam_A.height_options:
410
+ key = (round(xc, 2), round(yc, 2),
411
+ round(clamped_ta % 360, 1), orient, zh)
412
+ if key not in seen:
413
+ seen.add(key)
414
+ cands.append((xc, yc, clamped_ta, orient, zh))
415
+
416
+ # ── Room wall segments ────────────────────────────────────────────────
417
+ for i in range(len(corners_loop) - 1):
418
+ x0, y0 = corners_loop[i]
419
+ x1, y1 = corners_loop[i+1]
420
+ seg_len = math.sqrt((x1-x0)**2 + (y1-y0)**2)
421
+ if seg_len < 0.01:
422
+ continue
423
+
424
+ dx_w, dy_w = (x1-x0)/seg_len, (y1-y0)/seg_len
425
+ xm, ym = (x0+x1)/2, (y0+y1)/2
426
+ nx, ny = -dy_w, dx_w
427
+ if not point_in_room(xm + nx*0.1, ym + ny*0.1, room_corners, obstacles, room_height):
428
+ nx, ny = dy_w, -dx_w
429
+ normal_angle = math.degrees(math.atan2(ny, nx))
430
+ n_pts = max(2, int(seg_len / wall_step))
431
+
432
+ for j in range(n_pts + 1):
433
+ t = j / n_pts
434
+ x = x0 + t*(x1-x0)
435
+ y = y0 + t*(y1-y0)
436
+ _add_wall_cands(x, y, normal_angle, cam_A_cands, seen_A)
437
+
438
+ # Corner at start of segment
439
+ _add_corner_cands(x0, y0, cam_A_cands, seen_A)
440
+
441
+ # ── Obstacle walls (can_mount_camera=True, floor-to-ceiling) ─────────
442
+ for obs in obstacles:
443
+ if obs.get("height", 0) < room_height:
444
+ continue
445
+ if not obs.get("can_mount_camera", False):
446
+ continue
447
+ verts_obs = obs["vertices"]
448
+ for i in range(len(verts_obs)):
449
+ w0 = verts_obs[i]
450
+ w1 = verts_obs[(i+1) % len(verts_obs)]
451
+ x0, y0 = w0; x1, y1 = w1
452
+ seg_len = math.sqrt((x1-x0)**2 + (y1-y0)**2)
453
+ if seg_len < 0.05:
454
+ continue
455
+ dx_w, dy_w = (x1-x0)/seg_len, (y1-y0)/seg_len
456
+ xm, ym = (x0+x1)/2, (y0+y1)/2
457
+ nx, ny = -dy_w, dx_w
458
+ test_a = point_in_room(xm + nx*0.15, ym + ny*0.15, room_corners, obstacles, room_height)
459
+ test_b = point_in_room(xm - nx*0.15, ym - ny*0.15, room_corners, obstacles, room_height)
460
+ if test_a and not test_b:
461
+ pass # normal already correct
462
+ elif test_b and not test_a:
463
+ nx, ny = -nx, -ny
464
+ elif test_a and test_b:
465
+ # Both sides inside room (e.g. a partition wall).
466
+ # Pick the normal that points toward the capture zone centre.
467
+ dot = nx*(tgt_x - xm) + ny*(tgt_y - ym)
468
+ if dot < 0:
469
+ nx, ny = -nx, -ny
470
+ else:
471
+ continue # neither side is in the room — skip
472
+ normal_angle = math.degrees(math.atan2(ny, nx))
473
+ n_pts = max(2, int(seg_len / wall_step))
474
+ for j in range(n_pts + 1):
475
+ t = j / n_pts
476
+ x = x0 + t*(x1-x0)
477
+ y = y0 + t*(y1-y0)
478
+ _add_wall_cands(x, y, normal_angle, cam_A_cands, seen_A)
479
+
480
+ # ── Tripod cameras (cam_B) ────────────────────────────────────────────
481
+ if cam_B and cam_B.max_count > 0:
482
+ xs_r = [c[0] for c in room_corners]
483
+ ys_r = [c[1] for c in room_corners]
484
+ walk_margin = cam_B.walk_axis_margin
485
+
486
+ for xi in np.arange(min(xs_r)+0.4, max(xs_r), tripod_grid_step):
487
+ for yi in np.arange(min(ys_r)+0.4, max(ys_r), tripod_grid_step):
488
+ if not point_in_room(xi, yi, room_corners, obstacles, room_height):
489
+ continue
490
+ if abs(yi - walk_y) < walk_margin:
491
+ continue
492
+ if not has_line_of_sight(xi, yi, tgt_x, tgt_y,
493
+ wall_segs, obstacles, room_height,
494
+ cam_z=max(cam_B.height_options),
495
+ target_z=human_height / 2.0):
496
+ continue
497
+ for a_deg in range(0, 360, 20):
498
+ sts_ang = math.degrees(math.atan2(sts_y - yi, sts_x - xi))
499
+ delta_sts = (sts_ang - a_deg + 180) % 360 - 180
500
+ for orient in ("L", "P"):
501
+ fov_h_b, _ = get_fov(cam_B, orient)
502
+ if abs(delta_sts) > (fov_h_b / 2.0 - 10):
503
+ continue
504
+ for ih in cam_B.height_options:
505
+ key = (round(xi, 1), round(yi, 1), a_deg, orient, ih)
506
+ if key not in seen_B:
507
+ seen_B.add(key)
508
+ cam_B_cands.append((xi, yi, float(a_deg), orient, ih))
509
+
510
+ return cam_A_cands, cam_B_cands
511
+