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/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
|
+
|