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/room.py ADDED
@@ -0,0 +1,447 @@
1
+ """
2
+ room.py
3
+ Room geometry, obstacle helpers, line-of-sight, coverage geometry.
4
+ All functions are pure (no global state) — they receive cfg as argument.
5
+ """
6
+
7
+ import math
8
+ import numpy as np
9
+
10
+
11
+ # =============================================================
12
+ # OBSTACLE HELPERS
13
+ # =============================================================
14
+
15
+ def obs_height(obs):
16
+ return obs["height"]
17
+
18
+ def obs_label(obs):
19
+ return obs.get("label", "")
20
+
21
+ def obs_poly_vertices(obs):
22
+ """Returns the list of (x,y) vertices of the obstacle footprint."""
23
+ return obs["vertices"]
24
+
25
+ def obstacle_segments(obs):
26
+ """Returns all border segments of an obstacle footprint."""
27
+ verts = obs_poly_vertices(obs)
28
+ n = len(verts)
29
+ return [(verts[i], verts[(i+1) % n]) for i in range(n)]
30
+
31
+ def obs_centroid(obs):
32
+ """Returns the visual centroid (average of vertices)."""
33
+ verts = obs_poly_vertices(obs)
34
+ return (sum(v[0] for v in verts) / len(verts),
35
+ sum(v[1] for v in verts) / len(verts))
36
+
37
+
38
+ # =============================================================
39
+ # POINT-IN-POLYGON
40
+ # =============================================================
41
+
42
+ def point_in_polygon(x, y, verts):
43
+ """Ray-casting point-in-polygon test."""
44
+ n = len(verts); inside = False; j = n - 1
45
+ for i in range(n):
46
+ xi, yi = verts[i]; xj, yj = verts[j]
47
+ if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
48
+ inside = not inside
49
+ j = i
50
+ return inside
51
+
52
+
53
+ def point_in_obstacle(x, y, obstacles, room_height):
54
+ """Returns True if (x,y) is inside any floor-to-ceiling obstacle footprint."""
55
+ for obs in obstacles:
56
+ if obs_height(obs) >= room_height:
57
+ if point_in_polygon(x, y, obs_poly_vertices(obs)):
58
+ return True
59
+ return False
60
+
61
+
62
+ def point_in_room(x, y, room_corners, obstacles, room_height):
63
+ """Returns True if (x,y) is inside the room polygon and not inside an obstacle."""
64
+ poly = room_corners; n = len(poly); inside = False; j = n - 1
65
+ for i in range(n):
66
+ xi, yi = poly[i]; xj, yj = poly[j]
67
+ if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
68
+ inside = not inside
69
+ j = i
70
+ if inside and point_in_obstacle(x, y, obstacles, room_height):
71
+ return False
72
+ return inside
73
+
74
+
75
+ # =============================================================
76
+ # WALL SEGMENTS (room perimeter + floor-to-ceiling obstacles)
77
+ # =============================================================
78
+
79
+ def build_wall_segments(room_corners, obstacles, room_height):
80
+ """
81
+ Returns the list of all wall segments that block line-of-sight:
82
+ - Room perimeter edges
83
+ - Floor-to-ceiling obstacle border edges
84
+ """
85
+ segments = []
86
+ corners = room_corners
87
+ for i in range(len(corners)):
88
+ segments.append((corners[i], corners[(i + 1) % len(corners)]))
89
+ for obs in obstacles:
90
+ if obs_height(obs) >= room_height:
91
+ for seg in obstacle_segments(obs):
92
+ segments.append(seg)
93
+ return segments
94
+
95
+
96
+ # =============================================================
97
+ # LINE-OF-SIGHT
98
+ # =============================================================
99
+
100
+ def _seg_intersect(p1, p2, p3, p4):
101
+ """Returns True if segment p1-p2 strictly intersects p3-p4."""
102
+ def cross(o, a, b):
103
+ return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0])
104
+ d1 = cross(p3, p4, p1); d2 = cross(p3, p4, p2)
105
+ d3 = cross(p1, p2, p3); d4 = cross(p1, p2, p4)
106
+ if ((d1 > 0 and d2 < 0) or (d1 < 0 and d2 > 0)) and \
107
+ ((d3 > 0 and d4 < 0) or (d3 < 0 and d4 > 0)):
108
+ return True
109
+ return False
110
+
111
+
112
+ def has_line_of_sight(x1, y1, x2, y2,
113
+ wall_segments, obstacles, room_height,
114
+ cam_z, target_z):
115
+ """
116
+ Checks that the line (x1,y1,cam_z) → (x2,y2,target_z) is not blocked.
117
+ Stage 1: 2D check against wall_segments (room + floor-to-ceiling obstacles).
118
+ Stage 2: 3D height check for partial-height obstacles.
119
+ """
120
+ EPS = 0.05
121
+ dx, dy = x2 - x1, y2 - y1
122
+ dist = math.sqrt(dx*dx + dy*dy)
123
+ if dist < 1e-6:
124
+ return True
125
+ nx, ny = dx / dist, dy / dist
126
+ p1 = (x1 + nx*EPS, y1 + ny*EPS)
127
+ p2 = (x2 - nx*EPS, y2 - ny*EPS)
128
+
129
+ # Stage 1: room walls + floor-to-ceiling obstacles
130
+ for (w1, w2) in wall_segments:
131
+ if _seg_intersect(p1, p2, w1, w2):
132
+ return False
133
+
134
+ # Stage 2: partial-height obstacles (3D check)
135
+ for obs in obstacles:
136
+ oh = obs_height(obs)
137
+ if oh >= room_height:
138
+ continue
139
+ verts = obs_poly_vertices(obs)
140
+ segs = obstacle_segments(obs)
141
+ hits = any(_seg_intersect(p1, p2, w1, w2) for (w1, w2) in segs)
142
+ if not hits:
143
+ if point_in_polygon((x1+x2)/2, (y1+y2)/2, verts):
144
+ hits = True
145
+ if not hits:
146
+ continue
147
+ t_vals = []
148
+ for (w1, w2) in segs:
149
+ rx, ry = p2[0]-p1[0], p2[1]-p1[1]
150
+ sx, sy = w2[0]-w1[0], w2[1]-w1[1]
151
+ denom = rx*sy - ry*sx
152
+ if abs(denom) < 1e-9:
153
+ continue
154
+ t = ((w1[0]-p1[0])*sy - (w1[1]-p1[1])*sx) / denom
155
+ u = ((w1[0]-p1[0])*ry - (w1[1]-p1[1])*rx) / denom
156
+ if -EPS <= t <= 1+EPS and -EPS <= u <= 1+EPS:
157
+ t_vals.append(max(0.0, min(1.0, t)))
158
+ if len(t_vals) < 2:
159
+ if not t_vals:
160
+ continue
161
+ t_vals = [0.0, t_vals[0]] if t_vals[0] > 0.5 else [t_vals[0], 1.0]
162
+ t_mid = (min(t_vals) + max(t_vals)) / 2.0
163
+ z_at_mid = cam_z + t_mid * (target_z - cam_z)
164
+ if z_at_mid <= oh:
165
+ return False
166
+ return True
167
+
168
+
169
+ # =============================================================
170
+ # WALL GEOMETRY HELPERS (for candidate generation)
171
+ # =============================================================
172
+
173
+ def wall_normal_at(cx, cy, wall_segments, room_corners,
174
+ room_height, obstacles, tol=0.08):
175
+ """
176
+ Returns the inward normal angle (degrees) of the wall the camera is on.
177
+ Falls back to 90° if no surface found within tol.
178
+ """
179
+ best_dist = tol
180
+ best_angle = 90.0
181
+
182
+ for (w0, w1) in wall_segments:
183
+ x0, y0 = w0; x1, y1 = w1
184
+ seg_dx, seg_dy = x1-x0, y1-y0
185
+ seg_len2 = seg_dx**2 + seg_dy**2
186
+ if seg_len2 < 1e-9:
187
+ continue
188
+ t = max(0.0, min(1.0, ((cx-x0)*seg_dx + (cy-y0)*seg_dy) / seg_len2))
189
+ px, py = x0 + t*seg_dx, y0 + t*seg_dy
190
+ d = math.sqrt((cx-px)**2 + (cy-py)**2)
191
+ if d < best_dist:
192
+ best_dist = d
193
+ seg_len = math.sqrt(seg_len2)
194
+ nx, ny = -seg_dy/seg_len, seg_dx/seg_len
195
+ mid_x, mid_y = (x0+x1)/2, (y0+y1)/2
196
+ if not point_in_room(mid_x + nx*0.1, mid_y + ny*0.1,
197
+ room_corners, obstacles, room_height):
198
+ nx, ny = -nx, -ny
199
+ best_angle = math.degrees(math.atan2(ny, nx))
200
+ return best_angle
201
+
202
+
203
+ def wall_angular_limits(cx, cy, wall_segments, tol=0.12):
204
+ """
205
+ Returns ((lim_left, dist_left), (lim_right, dist_right)):
206
+ angular limits of the open inward aperture from (cx,cy) on its wall.
207
+ Handles corners (two segments meeting) correctly.
208
+ """
209
+ from collections import Counter
210
+
211
+ close_segs = []
212
+ for (w0, w1) in wall_segments:
213
+ x0, y0 = w0; x1, y1 = w1
214
+ seg_dx, seg_dy = x1-x0, y1-y0
215
+ seg_len2 = seg_dx**2 + seg_dy**2
216
+ if seg_len2 < 1e-9:
217
+ continue
218
+ t = max(0.0, min(1.0, ((cx-x0)*seg_dx + (cy-y0)*seg_dy) / seg_len2))
219
+ px, py = x0 + t*seg_dx, y0 + t*seg_dy
220
+ d = math.sqrt((cx-px)**2 + (cy-py)**2)
221
+ if d < tol:
222
+ close_segs.append((d, w0, w1))
223
+
224
+ if not close_segs:
225
+ return ((None, 0.0), (None, 0.0))
226
+
227
+ min_d = min(d for (d, _, _) in close_segs)
228
+ CORNER_TOL = 0.04
229
+ active = [(d, w0, w1) for (d, w0, w1) in close_segs if d <= min_d + CORNER_TOL]
230
+
231
+ all_ep = []
232
+ for (d, w0, w1) in active:
233
+ all_ep.append(w0); all_ep.append(w1)
234
+
235
+ def _key(pt): return (round(pt[0], 3), round(pt[1], 3))
236
+
237
+ cnt = Counter(_key(ep) for ep in all_ep)
238
+
239
+ ep_info = []
240
+ for (d_seg, w0, w1) in active:
241
+ for ep in (w0, w1):
242
+ if len(active) >= 2 and cnt[_key(ep)] > 1:
243
+ continue
244
+ ang = math.degrees(math.atan2(ep[1]-cy, ep[0]-cx))
245
+ dist_adj = _dist_to_adjacent_wall(cx, cy, ang, wall_segments)
246
+ ep_info.append((ang, dist_adj))
247
+
248
+ if len(ep_info) < 2:
249
+ return ((None, 0.0), (None, 0.0))
250
+
251
+ # ── Sort by angle relative to inward wall normal ──────────────────────
252
+ # Raw degree sort (e.g. 350° vs 10°) gives wrong left/right assignment.
253
+ # Sort relative to the inward normal so "left" and "right" are geometrically
254
+ # correct from the camera's perspective looking into the room.
255
+ normal_angles = []
256
+ # Centroid of ALL wall segment endpoints ≈ room centroid (always inside)
257
+ all_wall_pts = [p for (w0, w1) in wall_segments for p in (w0, w1)]
258
+ room_cx = sum(p[0] for p in all_wall_pts) / len(all_wall_pts)
259
+ room_cy = sum(p[1] for p in all_wall_pts) / len(all_wall_pts)
260
+ for (d_seg, w0, w1) in active:
261
+ seg_dx = w1[0]-w0[0]; seg_dy = w1[1]-w0[1]
262
+ seg_len = math.sqrt(seg_dx**2 + seg_dy**2)
263
+ if seg_len < 1e-6:
264
+ continue
265
+ nx, ny = -seg_dy/seg_len, seg_dx/seg_len
266
+ mid_x, mid_y = (w0[0]+w1[0])/2, (w0[1]+w1[1])/2
267
+ # Point normal toward the room centroid
268
+ dot = nx*(room_cx - mid_x) + ny*(room_cy - mid_y)
269
+ if dot < 0:
270
+ nx, ny = -nx, -ny
271
+ normal_angles.append(math.degrees(math.atan2(ny, nx)))
272
+
273
+ if not normal_angles:
274
+ ep_info.sort(key=lambda v: v[0])
275
+ return (ep_info[0], ep_info[-1])
276
+
277
+ sin_avg = sum(math.sin(math.radians(a)) for a in normal_angles) / len(normal_angles)
278
+ cos_avg = sum(math.cos(math.radians(a)) for a in normal_angles) / len(normal_angles)
279
+ ref_angle = math.degrees(math.atan2(sin_avg, cos_avg))
280
+
281
+ def _rel_to_normal(ang_abs):
282
+ return (ang_abs - ref_angle + 180) % 360 - 180
283
+
284
+ ep_info_rel = [(ang, dist, _rel_to_normal(ang)) for (ang, dist) in ep_info]
285
+ ep_info_rel.sort(key=lambda v: v[2]) # sort by relative angle
286
+ lim_left, dist_left = ep_info_rel[0][0], ep_info_rel[0][1]
287
+ lim_right, dist_right = ep_info_rel[-1][0], ep_info_rel[-1][1]
288
+ return ((lim_left, dist_left), (lim_right, dist_right))
289
+
290
+
291
+ def _dist_to_adjacent_wall(cx, cy, limit_angle_deg, wall_segments, own_seg_tol=0.12):
292
+ """
293
+ Ray-cast from (cx,cy) in the given direction; return distance to the
294
+ nearest wall segment that is NOT the camera's own mounting surface.
295
+ """
296
+ best = 999.0
297
+ rad = math.radians(limit_angle_deg)
298
+ dx, dy = math.cos(rad), math.sin(rad)
299
+
300
+ for (w0, w1) in wall_segments:
301
+ x0, y0 = w0; x1, y1 = w1
302
+ seg_dx, seg_dy = x1-x0, y1-y0
303
+ seg_len2 = seg_dx**2 + seg_dy**2
304
+ if seg_len2 < 1e-9:
305
+ continue
306
+ t_own = max(0.0, min(1.0, ((cx-x0)*seg_dx + (cy-y0)*seg_dy) / seg_len2))
307
+ px_own = x0 + t_own*seg_dx; py_own = y0 + t_own*seg_dy
308
+ if math.sqrt((cx-px_own)**2 + (cy-py_own)**2) < own_seg_tol:
309
+ continue
310
+ denom = dx*seg_dy - dy*seg_dx
311
+ if abs(denom) < 1e-9:
312
+ continue
313
+ t_ray = ((x0-cx)*seg_dy - (y0-cy)*seg_dx) / denom
314
+ s_seg = ((x0-cx)*dy - (y0-cy)*dx) / denom
315
+ if t_ray > 0.01 and 0.0 <= s_seg <= 1.0:
316
+ best = min(best, t_ray)
317
+ return best
318
+
319
+
320
+ def cone_wall_spill(cam_angle, fov_h, wall_lim):
321
+ """
322
+ Weighted spill of cone [cam_angle ± fov_h/2] beyond the wall aperture.
323
+ Spill on each side weighted by 1/distance_to_adjacent_wall.
324
+ Returns 0.0 if cone fits within the aperture.
325
+ """
326
+ (lim_left, dist_left), (lim_right, dist_right) = wall_lim
327
+ if lim_left is None:
328
+ return 0.0
329
+
330
+ half = fov_h / 2.0
331
+ def rel(a, ref): return (a - ref + 180) % 360 - 180
332
+
333
+ rl_left = rel(lim_left, cam_angle)
334
+ rl_right = rel(lim_right, cam_angle)
335
+ lo = min(rl_left, rl_right)
336
+ hi = max(rl_left, rl_right)
337
+
338
+ if not (lo <= 0 <= hi):
339
+ spill_left = max(0.0, lo - (-half)) if (-half) > lo else 0.0
340
+ spill_right = max(0.0, half - hi) if half > hi else 0.0
341
+ else:
342
+ spill_left = max(0.0, lo - (-half))
343
+ spill_right = max(0.0, half - hi)
344
+
345
+ w_left = 1.0 / max(dist_left, 0.3)
346
+ w_right = 1.0 / max(dist_right, 0.3)
347
+ return spill_left * w_left + spill_right * w_right
348
+
349
+
350
+ # =============================================================
351
+ # COVERAGE GEOMETRY
352
+ # =============================================================
353
+
354
+ def cam_d_min(cam_z, fov_v_deg, fixed_tilt_rad,
355
+ human_height, human_foot_z, human_head_z,
356
+ v_thresh=0.9, step=0.01, max_search=15.0):
357
+ """
358
+ Minimum horizontal distance at which this camera can see v_thresh of the body.
359
+ """
360
+ half_fov = math.radians(fov_v_deg / 2.0)
361
+ for d in np.arange(step, max_search, step):
362
+ tilt = fixed_tilt_rad if fixed_tilt_rad is not None \
363
+ else math.atan2(human_height / 2.0 - cam_z, d)
364
+ cone_top = tilt + half_fov
365
+ cone_bot = tilt - half_fov
366
+ angle_feet = math.atan2(human_foot_z - cam_z, d)
367
+ angle_head = math.atan2(human_head_z - cam_z, d)
368
+ body_span = angle_head - angle_feet
369
+ if body_span <= 0:
370
+ continue
371
+ vis = max(0.0, min(angle_head, cone_top) - max(angle_feet, cone_bot))
372
+ if vis / body_span >= v_thresh:
373
+ return d
374
+ return max_search
375
+
376
+
377
+ def vertical_body_coverage(cam_x, cam_y, cam_z, pt_x, pt_y, fov_v_deg,
378
+ human_height, human_foot_z, human_head_z,
379
+ v_thresh=0.9, fixed_tilt_rad=None):
380
+ """
381
+ Fraction of the human body (foot→head) seen by the camera at (pt_x, pt_y).
382
+ Returns 0.0 if point is closer than d_min (hard geometry filter).
383
+ """
384
+ horiz_dist = math.sqrt((pt_x - cam_x)**2 + (pt_y - cam_y)**2)
385
+ if horiz_dist < 0.01:
386
+ return 0.0
387
+ d_min = cam_d_min(cam_z, fov_v_deg, fixed_tilt_rad,
388
+ human_height, human_foot_z, human_head_z,
389
+ v_thresh=v_thresh)
390
+ if horiz_dist < d_min:
391
+ return 0.0
392
+ tilt = fixed_tilt_rad if fixed_tilt_rad is not None \
393
+ else math.atan2(human_height / 2.0 - cam_z, horiz_dist)
394
+ half_fov = math.radians(fov_v_deg / 2.0)
395
+ cone_top = tilt + half_fov
396
+ cone_bot = tilt - half_fov
397
+ angle_feet = math.atan2(human_foot_z - cam_z, horiz_dist)
398
+ angle_head = math.atan2(human_head_z - cam_z, horiz_dist)
399
+ vis_min = max(angle_feet, cone_bot)
400
+ vis_max = min(angle_head, cone_top)
401
+ if vis_max <= vis_min:
402
+ return 0.0
403
+ body_span = angle_head - angle_feet
404
+ return min(1.0, (vis_max - vis_min) / body_span) if body_span > 0 else 0.0
405
+
406
+
407
+ def point_in_wedge(px, py, cx, cy, angle_deg, fov_h, radius, min_range,
408
+ wall_segments, obstacles, room_height,
409
+ human_height, cam_z):
410
+ """
411
+ Returns (in_wedge: bool, distance: float).
412
+ True if (px,py) is inside the horizontal cone AND has line-of-sight.
413
+ """
414
+ dx, dy = px - cx, py - cy
415
+ dist = math.sqrt(dx*dx + dy*dy)
416
+ if dist > radius or dist < min_range:
417
+ return False, 0.0
418
+ pt_angle = math.degrees(math.atan2(dy, dx))
419
+ delta = (pt_angle - angle_deg + 180) % 360 - 180
420
+ if abs(delta) > fov_h / 2:
421
+ return False, 0.0
422
+ if not has_line_of_sight(cx, cy, px, py,
423
+ wall_segments, obstacles, room_height,
424
+ cam_z=cam_z, target_z=human_height / 2.0):
425
+ return False, 0.0
426
+ return True, dist
427
+
428
+
429
+ def cam_fixed_tilt(cx, cy, cam_z, angle_deg, walk_y, human_height):
430
+ """
431
+ Fixed tilt of a wall-mounted camera pointing toward the corridor at walk_y.
432
+ """
433
+ angle_rad = math.radians(angle_deg)
434
+ dy_dir = math.sin(angle_rad)
435
+ if abs(dy_dir) > 0.01:
436
+ dist_to_walk = (walk_y - cy) / dy_dir
437
+ dist_ref = max(dist_to_walk, 0.1) if dist_to_walk > 0 \
438
+ else max(abs(cy - walk_y), 0.1)
439
+ else:
440
+ dist_ref = max(abs(cy - walk_y), 0.1)
441
+ return math.atan2(human_height / 2.0 - cam_z, dist_ref)
442
+
443
+
444
+ def cam_side(cy, walk_y):
445
+ """Returns 'S' if camera is south of the corridor, 'N' otherwise."""
446
+ return 'S' if cy < walk_y else 'N'
447
+
core/scoring.py ADDED
@@ -0,0 +1,248 @@
1
+ """
2
+ scoring.py
3
+ Score configuration: coverage, bilateral constraint, angular diversity.
4
+ All functions receive explicit context (cfg, state) — no global state.
5
+ """
6
+
7
+ import math
8
+ from .room import (vertical_body_coverage, point_in_wedge,
9
+ cam_fixed_tilt, cam_side)
10
+
11
+
12
+ def get_fov(cam_set, orientation):
13
+ """Returns (fov_h, fov_v) for a camera set and orientation ('L' or 'P')."""
14
+ if orientation == "P":
15
+ return cam_set.fov_h_P, cam_set.fov_v_P
16
+ return cam_set.fov_h_L, cam_set.fov_v_L
17
+
18
+
19
+ def dist_quality(d, k):
20
+ """Distance quality multiplier: 1 / (1 + k*d²)."""
21
+ return 1.0 / (1.0 + k * d * d)
22
+
23
+
24
+ def score_configuration(cam_A_list, cam_B_list,
25
+ sample_points, cfg, state):
26
+ """
27
+ Computes the global score for a camera configuration.
28
+
29
+ Improvements over original:
30
+ 1. sum_v with diminishing returns instead of avg_v
31
+ → rewards more cameras covering a point, not just quality of best one
32
+ 2. bilateral uses sum-per-side with saturation (not max)
33
+ → rewards having SEVERAL cameras on each side, not just one good one
34
+ 3. angular proximity penalty for cameras that are BOTH positionally close
35
+ AND pointing in the same direction (close + opposite angle = OK,
36
+ far + same angle = OK, close + same angle = penalised)
37
+
38
+ Returns (total_score, coverage_list, south_total, north_total)
39
+ """
40
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
41
+ cam_B_set = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
42
+
43
+ walk_y = state["walk_y"]
44
+ analysis_x_start = state["analysis_x_start"]
45
+ analysis_x_end = state["analysis_x_end"]
46
+ v_thresh = cfg.opt.vertical_coverage_threshold
47
+ target_coverage = cfg.TARGET_COVERAGE
48
+ bilateral_weight = cfg.BILATERAL_WEIGHT
49
+ k_dist = cfg.DIST_QUALITY_K
50
+
51
+ wall_segs = state["wall_segments"]
52
+ obstacles = cfg.obstacles
53
+ room_h = cfg.ROOM_HEIGHT
54
+ human_h = cfg.HUMAN_HEIGHT
55
+ human_fz = cfg.HUMAN_FOOT_Z
56
+ human_hz = cfg.HUMAN_HEAD_Z
57
+
58
+ total_score = 0.0
59
+ coverage_list = []
60
+ south_total = 0.0
61
+ north_total = 0.0
62
+
63
+ for (px, py, weight) in sample_points:
64
+ # Per-camera contributions at this point
65
+ # Each entry: (angle, score_v, side)
66
+ cam_contribs = [] # (angle, score_v, side, cx, cy)
67
+
68
+ # ── Wall cameras (cam_A) ─────────────────────────────────────────
69
+ for (cx, cy, angle, orient, zh) in cam_A_list:
70
+ fov_h, fov_v = get_fov(cam_A, orient)
71
+ in_cone, dist = point_in_wedge(
72
+ px, py, cx, cy, angle, fov_h, cam_A.max_range, cam_A.min_range,
73
+ wall_segs, obstacles, room_h, human_h, cam_z=zh)
74
+ if not in_cone:
75
+ continue
76
+ fixed_tilt = cam_fixed_tilt(cx, cy, zh, angle, walk_y, human_h)
77
+ v = vertical_body_coverage(cx, cy, zh, px, py, fov_v,
78
+ human_h, human_fz, human_hz,
79
+ v_thresh=v_thresh,
80
+ fixed_tilt_rad=fixed_tilt)
81
+ if v >= v_thresh:
82
+ q = dist_quality(dist, k_dist)
83
+ score_v = v * v * q * cam_A.score_weight
84
+ side = cam_side(cy, walk_y)
85
+ cam_contribs.append((angle, score_v, side, cx, cy))
86
+
87
+ # ── Tripod cameras (cam_B) ───────────────────────────────────────
88
+ if cam_B_set and cam_B_list:
89
+ for (cx, cy, angle, orient, ih) in cam_B_list:
90
+ fov_h, fov_v = get_fov(cam_B_set, orient)
91
+ in_cone, dist = point_in_wedge(
92
+ px, py, cx, cy, angle, fov_h,
93
+ cam_B_set.max_range, cam_B_set.min_range,
94
+ wall_segs, obstacles, room_h, human_h, cam_z=ih)
95
+ if not in_cone:
96
+ continue
97
+ v = vertical_body_coverage(cx, cy, ih, px, py, fov_v,
98
+ human_h, human_fz, human_hz,
99
+ v_thresh=v_thresh,
100
+ fixed_tilt_rad=None)
101
+ if v >= v_thresh:
102
+ q = dist_quality(dist, k_dist)
103
+ score_v = v * v * q * cam_B_set.score_weight * 0.5
104
+ cam_contribs.append((angle, score_v, 'S', cx, cy))
105
+ cam_contribs.append((angle, score_v, 'N', cx, cy))
106
+
107
+ if not cam_contribs:
108
+ coverage_list.append(0)
109
+ continue
110
+
111
+ # ── 1. Effective camera count with angular+positional diversity ──
112
+ # Sort by angle, then apply two diversity rules:
113
+ # a) Angular rule: cameras <20° apart count as 0.3 (unchanged)
114
+ # b) Proximity+angle rule: if two cameras are close in XY AND
115
+ # similar in angle, the second one is heavily discounted.
116
+ # But close + opposite angle = full credit (different view).
117
+ sa = sorted(cam_contribs, key=lambda c: c[0]) # sort by angle
118
+
119
+ effective_n = 0.0
120
+ last_angle = sa[0][0]
121
+ last_x, last_y = sa[0][3], sa[0][4]
122
+ effective_n = 1.0
123
+
124
+ for i in range(1, len(sa)):
125
+ angle_i = sa[i][0]
126
+ cx_i, cy_i = sa[i][3], sa[i][4]
127
+
128
+ ang_diff = abs((angle_i - last_angle + 180) % 360 - 180)
129
+ pos_dist = math.sqrt((cx_i - last_x)**2 + (cy_i - last_y)**2)
130
+
131
+ if ang_diff < 20:
132
+ # Angularly near-identical → weak contribution
133
+ effective_n += 0.3
134
+ else:
135
+ # Check position+angle redundancy:
136
+ # Close in position AND similar angle → penalise
137
+ # Close in position but opposite angle → OK (good diversity)
138
+ # Far in position → full credit regardless of angle
139
+ if pos_dist < 2.0 and ang_diff < 60:
140
+ # Close AND same general direction → partially redundant
141
+ proximity_factor = (pos_dist / 2.0) # 0=touching → 1=far
142
+ angle_factor = (ang_diff / 60.0) # 0=same → 1=different
143
+ effective_n += 0.3 + 0.7 * max(proximity_factor, angle_factor)
144
+ else:
145
+ effective_n += 1.0
146
+ last_angle = angle_i
147
+ last_x, last_y = cx_i, cy_i
148
+
149
+ # ── 2. sum_v with diminishing returns (replaces avg_v) ───────────
150
+ # Each additional camera at this point adds less and less.
151
+ # Uses sqrt-like saturation: sum_v = Σ score_v_i / sqrt(i)
152
+ # This rewards 3 cameras over 1, but avoids 10 cameras being 10× better.
153
+ sorted_scores = sorted([c[1] for c in cam_contribs], reverse=True)
154
+ sum_v = sum(s / math.sqrt(i + 1) for i, s in enumerate(sorted_scores))
155
+ # Normalise so a single perfect camera (score_v=1.0) gives base=1.0
156
+ sum_v_norm = sum_v / math.sqrt(target_coverage)
157
+
158
+ base = min(effective_n, target_coverage) / target_coverage * sum_v_norm
159
+
160
+ # ── 3. Angular bonus (unchanged) ─────────────────────────────────
161
+ angles_only = [c[0] for c in sa]
162
+ angular_bonus = 0.0
163
+ if len(angles_only) >= 2:
164
+ all_gaps = []
165
+ for i in range(len(angles_only)):
166
+ nxt = (i + 1) % len(angles_only)
167
+ all_gaps.append((angles_only[nxt] - angles_only[i] + 360) % 360)
168
+ if max(all_gaps) < 120:
169
+ angular_bonus += 0.15
170
+ if sum(1 for g in all_gaps if g > 60) >= 2:
171
+ angular_bonus += 0.15
172
+
173
+ # ── 4. Bilateral factor — sum per side, not max ───────────────────
174
+ # Accumulate contributions per side with sqrt saturation.
175
+ # → one good camera per side gives bilateral ≈ same as before
176
+ # → two good cameras per side gives a meaningfully better bilateral
177
+ south_scores = sorted([c[1] for c in cam_contribs if c[2] == 'S'], reverse=True)
178
+ north_scores = sorted([c[1] for c in cam_contribs if c[2] == 'N'], reverse=True)
179
+
180
+ south_v = sum(s / math.sqrt(i + 1) for i, s in enumerate(south_scores)) if south_scores else 0.0
181
+ north_v = sum(s / math.sqrt(i + 1) for i, s in enumerate(north_scores)) if north_scores else 0.0
182
+
183
+ denom = max(south_v, north_v, 0.01)
184
+ bilateral_pt = math.sqrt(south_v * north_v) / denom if bilateral_weight > 0 else 1.0
185
+
186
+ point_score = weight * (
187
+ (1.0 - bilateral_weight) * (base + angular_bonus) +
188
+ bilateral_weight * (base + angular_bonus) * bilateral_pt
189
+ )
190
+ total_score += point_score
191
+ coverage_list.append(len(cam_contribs))
192
+
193
+ if analysis_x_start <= px <= analysis_x_end:
194
+ south_total += south_v
195
+ north_total += north_v
196
+
197
+ return total_score, coverage_list, south_total, north_total
198
+
199
+
200
+ def count_cameras_3d(px, py, cam_A_list, cam_B_list,
201
+ cfg, state, v_thresh=None):
202
+ """
203
+ Counts cameras truly seeing (px, py) in 3D (FOV_H + FOV_V ≥ threshold).
204
+ Tripod cameras count as 0.5.
205
+ """
206
+ if v_thresh is None:
207
+ v_thresh = cfg.opt.vertical_coverage_threshold
208
+
209
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
210
+ cam_B = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
211
+ walk_y = state["walk_y"]
212
+ wall_segs = state["wall_segments"]
213
+ obstacles = cfg.obstacles
214
+ room_h = cfg.ROOM_HEIGHT
215
+ human_h = cfg.HUMAN_HEIGHT
216
+ human_fz = cfg.HUMAN_FOOT_Z
217
+ human_hz = cfg.HUMAN_HEAD_Z
218
+ k_dist = cfg.DIST_QUALITY_K
219
+
220
+ count = 0.0
221
+ for (cx, cy, angle, orient, zh) in cam_A_list:
222
+ fov_h, fov_v = get_fov(cam_A, orient)
223
+ in_c, _ = point_in_wedge(px, py, cx, cy, angle, fov_h,
224
+ cam_A.max_range, cam_A.min_range,
225
+ wall_segs, obstacles, room_h, human_h, cam_z=zh)
226
+ if in_c:
227
+ dp = max(abs(cy - walk_y), 0.1)
228
+ ft = math.atan2(human_h / 2.0 - zh, dp)
229
+ if vertical_body_coverage(cx, cy, zh, px, py, fov_v,
230
+ human_h, human_fz, human_hz,
231
+ v_thresh=v_thresh,
232
+ fixed_tilt_rad=ft) >= v_thresh:
233
+ count += 1.0
234
+
235
+ if cam_B and cam_B_list:
236
+ for (cx, cy, angle, orient, ih) in cam_B_list:
237
+ fov_h, fov_v = get_fov(cam_B, orient)
238
+ in_c, _ = point_in_wedge(px, py, cx, cy, angle, fov_h,
239
+ cam_B.max_range, cam_B.min_range,
240
+ wall_segs, obstacles, room_h, human_h, cam_z=ih)
241
+ if in_c:
242
+ if vertical_body_coverage(cx, cy, ih, px, py, fov_v,
243
+ human_h, human_fz, human_hz,
244
+ v_thresh=v_thresh,
245
+ fixed_tilt_rad=None) >= v_thresh:
246
+ count += 0.5
247
+ return count
248
+