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/__init__.py
ADDED
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
|
+
|