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/greedy.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""
|
|
2
|
+
greedy.py
|
|
3
|
+
Greedy optimisation with random restarts for camera placement.
|
|
4
|
+
Two algorithms available (set via cfg.opt.algo):
|
|
5
|
+
- "greedy" : pure greedy (original behaviour)
|
|
6
|
+
- "greedy_1opt" : greedy init + 1-opt local search (better quality)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import random
|
|
11
|
+
import numpy as np
|
|
12
|
+
from tqdm import tqdm
|
|
13
|
+
|
|
14
|
+
from .room import (point_in_room, point_in_wedge, vertical_body_coverage,
|
|
15
|
+
wall_normal_at)
|
|
16
|
+
from .scoring import score_configuration, get_fov, dist_quality
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
# 1-OPT LOCAL SEARCH
|
|
21
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def _coverage_ratio(cam, sample_points, cam_set, wall_segs, obstacles, room_h, human_h,
|
|
24
|
+
subsample=6):
|
|
25
|
+
"""
|
|
26
|
+
Returns the fraction of sample_points (subsampled) that this camera sees.
|
|
27
|
+
Used to penalise candidates whose footprint covers very little of the zone,
|
|
28
|
+
regardless of how close they are (counteracts distance_quality bias).
|
|
29
|
+
"""
|
|
30
|
+
cx, cy, angle, orient, zh = cam
|
|
31
|
+
fov_h, _ = get_fov(cam_set, orient)
|
|
32
|
+
pts = sample_points[::subsample]
|
|
33
|
+
if not pts:
|
|
34
|
+
return 0.0
|
|
35
|
+
seen = sum(
|
|
36
|
+
1 for (px, py, w) in pts
|
|
37
|
+
if point_in_wedge(px, py, cx, cy, angle, fov_h,
|
|
38
|
+
cam_set.max_range, cam_set.min_range,
|
|
39
|
+
wall_segs, obstacles, room_h, human_h,
|
|
40
|
+
cam_z=zh)[0]
|
|
41
|
+
)
|
|
42
|
+
return seen / len(pts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _1opt(cur_A, cur_B, pool_A, sample_points, cfg, state,
|
|
46
|
+
current_score, min_spacing_A, max_passes=3, pbar=None):
|
|
47
|
+
"""
|
|
48
|
+
1-opt local search on cur_A.
|
|
49
|
+
|
|
50
|
+
Each pass: iterate over cameras in RANDOM order (so different restarts
|
|
51
|
+
explore different swap sequences), find the best replacement for each
|
|
52
|
+
camera from pool_A. Accept if score improves.
|
|
53
|
+
Repeat until no pass improves or max_passes reached.
|
|
54
|
+
|
|
55
|
+
Returns (improved_cam_A, improved_score).
|
|
56
|
+
"""
|
|
57
|
+
cam_A_set = next(c for c in cfg.camera_sets if c.mounting == "wall")
|
|
58
|
+
wall_segs = state["wall_segments"]
|
|
59
|
+
obstacles = cfg.obstacles
|
|
60
|
+
room_h = cfg.ROOM_HEIGHT
|
|
61
|
+
human_h = cfg.HUMAN_HEIGHT
|
|
62
|
+
|
|
63
|
+
best_A = list(cur_A)
|
|
64
|
+
best_score = current_score
|
|
65
|
+
|
|
66
|
+
for _pass in range(max_passes):
|
|
67
|
+
improved = False
|
|
68
|
+
# Randomise camera order each pass → different restarts explore differently
|
|
69
|
+
indices = list(range(len(best_A)))
|
|
70
|
+
random.shuffle(indices)
|
|
71
|
+
|
|
72
|
+
for i in indices:
|
|
73
|
+
others = [c for j, c in enumerate(best_A) if j != i]
|
|
74
|
+
|
|
75
|
+
best_cand = None
|
|
76
|
+
best_cand_score = best_score # only strict improvements
|
|
77
|
+
|
|
78
|
+
for cand in tqdm(pool_A,
|
|
79
|
+
desc=f" 1-opt pass {_pass+1} cam {i+1}/{len(best_A)}",
|
|
80
|
+
leave=False,
|
|
81
|
+
ncols=80,
|
|
82
|
+
unit="cand"):
|
|
83
|
+
cx, cy, angle, orient, zh = cand
|
|
84
|
+
|
|
85
|
+
if any(math.sqrt((cx-c[0])**2 + (cy-c[1])**2) < min_spacing_A
|
|
86
|
+
for c in others):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
fov_h, _ = get_fov(cam_A_set, orient)
|
|
90
|
+
if not any(point_in_wedge(px, py, cx, cy, angle, fov_h,
|
|
91
|
+
cam_A_set.max_range, cam_A_set.min_range,
|
|
92
|
+
wall_segs, obstacles, room_h, human_h,
|
|
93
|
+
cam_z=zh)[0]
|
|
94
|
+
for (px, py, w) in sample_points[::8]):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
s, *_ = score_configuration(
|
|
98
|
+
others + [cand], cur_B, sample_points, cfg, state)
|
|
99
|
+
|
|
100
|
+
# Penalise candidates with tiny footprint on the zone
|
|
101
|
+
cov = _coverage_ratio(cand, sample_points, cam_A_set,
|
|
102
|
+
wall_segs, obstacles, room_h, human_h)
|
|
103
|
+
s *= cov # zero if sees nothing, proportional otherwise
|
|
104
|
+
|
|
105
|
+
if s > best_cand_score:
|
|
106
|
+
best_cand_score = s
|
|
107
|
+
best_cand = cand
|
|
108
|
+
|
|
109
|
+
if best_cand is not None:
|
|
110
|
+
best_A = others + [best_cand]
|
|
111
|
+
best_score = best_cand_score
|
|
112
|
+
improved = True
|
|
113
|
+
if pbar:
|
|
114
|
+
pbar.set_postfix_str(f"score={best_score:.1f}", refresh=True)
|
|
115
|
+
|
|
116
|
+
if not improved:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
return best_A, best_score
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
# DIVERSE INITIALISATION (farthest-point sampling)
|
|
124
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def _init_diverse(n_A, pool_A, sample_points, cfg, state, min_spacing_A):
|
|
127
|
+
"""
|
|
128
|
+
Place n_A cameras using farthest-point sampling:
|
|
129
|
+
- 1st camera: random valid candidate
|
|
130
|
+
- Each next camera: the candidate that maximises the minimum
|
|
131
|
+
distance to all already-placed cameras
|
|
132
|
+
(and still passes the visibility pre-filter)
|
|
133
|
+
|
|
134
|
+
This guarantees cameras are spread spatially around the room
|
|
135
|
+
before any score-based refinement.
|
|
136
|
+
|
|
137
|
+
Returns list of (x, y, angle, orient, height).
|
|
138
|
+
"""
|
|
139
|
+
cam_A_set = next(c for c in cfg.camera_sets if c.mounting == "wall")
|
|
140
|
+
wall_segs = state["wall_segments"]
|
|
141
|
+
obstacles = cfg.obstacles
|
|
142
|
+
room_h = cfg.ROOM_HEIGHT
|
|
143
|
+
human_h = cfg.HUMAN_HEIGHT
|
|
144
|
+
|
|
145
|
+
# Pre-filter: keep only candidates that see at least one sample point
|
|
146
|
+
visible = [
|
|
147
|
+
cam for cam in pool_A
|
|
148
|
+
if any(point_in_wedge(px, py, cam[0], cam[1], cam[2],
|
|
149
|
+
get_fov(cam_A_set, cam[3])[0],
|
|
150
|
+
cam_A_set.max_range, cam_A_set.min_range,
|
|
151
|
+
wall_segs, obstacles, room_h, human_h,
|
|
152
|
+
cam_z=cam[4])[0]
|
|
153
|
+
for (px, py, w) in sample_points[::6])
|
|
154
|
+
]
|
|
155
|
+
if not visible:
|
|
156
|
+
visible = pool_A
|
|
157
|
+
|
|
158
|
+
placed = []
|
|
159
|
+
|
|
160
|
+
# 1st camera: random pick from visible candidates
|
|
161
|
+
placed.append(random.choice(visible))
|
|
162
|
+
|
|
163
|
+
# Subsequent cameras: farthest from all placed so far
|
|
164
|
+
for _ in range(n_A - 1):
|
|
165
|
+
best_cand = None
|
|
166
|
+
best_min_d = -1.0
|
|
167
|
+
|
|
168
|
+
for cand in visible:
|
|
169
|
+
cx, cy = cand[0], cand[1]
|
|
170
|
+
|
|
171
|
+
# Spacing constraint
|
|
172
|
+
if any(math.sqrt((cx-c[0])**2 + (cy-c[1])**2) < min_spacing_A
|
|
173
|
+
for c in placed):
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Minimum distance to any already-placed camera
|
|
177
|
+
min_d = min(math.sqrt((cx-c[0])**2 + (cy-c[1])**2) for c in placed)
|
|
178
|
+
|
|
179
|
+
if min_d > best_min_d:
|
|
180
|
+
best_min_d = min_d
|
|
181
|
+
best_cand = cand
|
|
182
|
+
|
|
183
|
+
if best_cand is None:
|
|
184
|
+
# All remaining candidates violate spacing — relax and take farthest
|
|
185
|
+
for cand in visible:
|
|
186
|
+
if cand in placed:
|
|
187
|
+
continue
|
|
188
|
+
min_d = min(math.sqrt((cand[0]-c[0])**2 + (cand[1]-c[1])**2)
|
|
189
|
+
for c in placed)
|
|
190
|
+
if min_d > best_min_d:
|
|
191
|
+
best_min_d = min_d
|
|
192
|
+
best_cand = cand
|
|
193
|
+
|
|
194
|
+
if best_cand:
|
|
195
|
+
placed.append(best_cand)
|
|
196
|
+
|
|
197
|
+
return placed
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def greedy_place_cameras(cam_A_cands, cam_B_cands,
|
|
202
|
+
sample_points, cfg, state,
|
|
203
|
+
n_restarts=20,
|
|
204
|
+
combo_label="combo",
|
|
205
|
+
log=None,
|
|
206
|
+
on_record=None):
|
|
207
|
+
"""
|
|
208
|
+
Greedy optimisation with random restarts.
|
|
209
|
+
|
|
210
|
+
Algorithm selected via cfg.opt.algo:
|
|
211
|
+
"greedy" — pure greedy (fast)
|
|
212
|
+
"greedy_1opt" — greedy init + 1-opt local search (better quality)
|
|
213
|
+
|
|
214
|
+
Returns (best_cam_A, best_cam_B, best_score, best_sts_pos)
|
|
215
|
+
"""
|
|
216
|
+
def _log(msg="", end="\n"):
|
|
217
|
+
if log: log(msg, end=end)
|
|
218
|
+
else: print(msg, end=end)
|
|
219
|
+
|
|
220
|
+
algo = getattr(cfg.opt, "algo", "greedy_1opt")
|
|
221
|
+
|
|
222
|
+
cam_A_set = next(c for c in cfg.camera_sets if c.mounting == "wall")
|
|
223
|
+
cam_B_set = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
|
|
224
|
+
n_A = cam_A_set.max_count
|
|
225
|
+
n_B = cam_B_set.max_count if cam_B_set else 0
|
|
226
|
+
|
|
227
|
+
walk_y = state["walk_y"]
|
|
228
|
+
analysis_x_start = state["analysis_x_start"]
|
|
229
|
+
analysis_x_end = state["analysis_x_end"]
|
|
230
|
+
sts_x_init = state["sts_x"]
|
|
231
|
+
sts_y_init = state["sts_y"]
|
|
232
|
+
wall_segs = state["wall_segments"]
|
|
233
|
+
obstacles = cfg.obstacles
|
|
234
|
+
room_h = cfg.ROOM_HEIGHT
|
|
235
|
+
human_h = cfg.HUMAN_HEIGHT
|
|
236
|
+
human_fz = cfg.HUMAN_FOOT_Z
|
|
237
|
+
human_hz = cfg.HUMAN_HEAD_Z
|
|
238
|
+
k_dist = cfg.DIST_QUALITY_K
|
|
239
|
+
v_thresh = cfg.opt.vertical_coverage_threshold
|
|
240
|
+
min_spacing_A = cam_A_set.min_spacing
|
|
241
|
+
min_spacing_B = cam_B_set.min_spacing if cam_B_set else 1.5
|
|
242
|
+
|
|
243
|
+
_log(f"\nGreedy optimisation ({n_restarts} restarts) — algo={algo} bilateral ON")
|
|
244
|
+
_log(f" Cam A (wall) candidates : {len(cam_A_cands)}")
|
|
245
|
+
_log(f" Cam B (tripod) candidates : {len(cam_B_cands)}")
|
|
246
|
+
_log(f" Eval points : {len(sample_points)}")
|
|
247
|
+
_log(f" Graph mode : {cfg.opt.graph_mode}")
|
|
248
|
+
|
|
249
|
+
best_score = -1.0
|
|
250
|
+
best_cam_A = []
|
|
251
|
+
best_cam_B = []
|
|
252
|
+
best_sts_pos = (sts_x_init, sts_y_init)
|
|
253
|
+
no_improvement = 0
|
|
254
|
+
|
|
255
|
+
# ── Outer progress bar — one tick per restart ─────────────────────────
|
|
256
|
+
pbar = tqdm(total=n_restarts,
|
|
257
|
+
desc=" Restarts",
|
|
258
|
+
unit="restart",
|
|
259
|
+
ncols=90,
|
|
260
|
+
colour="cyan")
|
|
261
|
+
|
|
262
|
+
for restart in range(n_restarts):
|
|
263
|
+
pbar.set_description(
|
|
264
|
+
f" Restart {restart+1:2d}/{n_restarts} best={best_score:.1f}")
|
|
265
|
+
|
|
266
|
+
# pool_A : subsample for greedy init (fast)
|
|
267
|
+
# pool_1opt drawn fresh per restart inside phase 1b (different each time)
|
|
268
|
+
pool_A = random.sample(cam_A_cands, min(250, len(cam_A_cands)))
|
|
269
|
+
pool_B = random.sample(cam_B_cands, min(150, len(cam_B_cands))) if cam_B_cands else []
|
|
270
|
+
cur_A = []
|
|
271
|
+
cur_B = []
|
|
272
|
+
|
|
273
|
+
# ── PHASE 1: initial camera placement ────────────────────────────
|
|
274
|
+
if algo == "greedy_1opt":
|
|
275
|
+
cur_A = _init_diverse(n_A, pool_A, sample_points, cfg, state, min_spacing_A)
|
|
276
|
+
else:
|
|
277
|
+
# Pure greedy: sequential score-maximising
|
|
278
|
+
for _ in range(n_A):
|
|
279
|
+
best_add = -1.0; best_cam = None
|
|
280
|
+
n_south = sum(1 for c in cur_A if c[1] < walk_y)
|
|
281
|
+
n_north = sum(1 for c in cur_A if c[1] >= walk_y)
|
|
282
|
+
n_placed = n_south + n_north
|
|
283
|
+
sub = random.sample(pool_A, min(100, len(pool_A)))
|
|
284
|
+
for cam in sub:
|
|
285
|
+
cx, cy, angle, orient, zh = cam
|
|
286
|
+
if any(math.sqrt((cx-c[0])**2+(cy-c[1])**2) < min_spacing_A
|
|
287
|
+
for c in cur_A):
|
|
288
|
+
continue
|
|
289
|
+
fov_h, _ = get_fov(cam_A_set, orient)
|
|
290
|
+
if not any(point_in_wedge(px, py, cx, cy, angle, fov_h,
|
|
291
|
+
cam_A_set.max_range, cam_A_set.min_range,
|
|
292
|
+
wall_segs, obstacles, room_h, human_h,
|
|
293
|
+
cam_z=zh)[0]
|
|
294
|
+
for (px, py, w) in sample_points[::6]):
|
|
295
|
+
continue
|
|
296
|
+
s, *_ = score_configuration(cur_A + [cam], [], sample_points, cfg, state)
|
|
297
|
+
# Penalise candidates with tiny footprint on the zone
|
|
298
|
+
cov = _coverage_ratio(cam, sample_points, cam_A_set,
|
|
299
|
+
wall_segs, obstacles, room_h, human_h)
|
|
300
|
+
s *= cov
|
|
301
|
+
if n_placed >= 2 and cfg.BILATERAL_WEIGHT > 0:
|
|
302
|
+
cam_side_this = 'S' if cy < walk_y else 'N'
|
|
303
|
+
n_this = n_south if cam_side_this == 'S' else n_north
|
|
304
|
+
n_other = n_north if cam_side_this == 'S' else n_south
|
|
305
|
+
if n_this > n_other:
|
|
306
|
+
ratio = (n_this + 1) / max(n_other + 1, 1)
|
|
307
|
+
s *= (1.0/math.sqrt(ratio))*cfg.BILATERAL_WEIGHT \
|
|
308
|
+
+ (1.0-cfg.BILATERAL_WEIGHT)
|
|
309
|
+
if s > best_add:
|
|
310
|
+
best_add = s; best_cam = cam
|
|
311
|
+
if best_cam:
|
|
312
|
+
cur_A.append(best_cam)
|
|
313
|
+
|
|
314
|
+
# ── PHASE 1b: 1-opt ───────────────────────────────────────────────
|
|
315
|
+
if algo == "greedy_1opt" and cur_A:
|
|
316
|
+
init_score, *_ = score_configuration(cur_A, [], sample_points, cfg, state)
|
|
317
|
+
pool_1opt = random.sample(cam_A_cands, min(80, len(cam_A_cands)))
|
|
318
|
+
cur_A, _ = _1opt(cur_A, cur_B, pool_1opt,
|
|
319
|
+
sample_points, cfg, state,
|
|
320
|
+
init_score, min_spacing_A, max_passes=2,
|
|
321
|
+
pbar=pbar)
|
|
322
|
+
|
|
323
|
+
# ── Pre-STS score (cam_A only) — used only to decide record ──────
|
|
324
|
+
pre_score, _, _, _ = score_configuration(
|
|
325
|
+
cur_A, [], sample_points, cfg, state)
|
|
326
|
+
is_record_pre = pre_score > best_score
|
|
327
|
+
|
|
328
|
+
if is_record_pre:
|
|
329
|
+
# ── FIND BEST STS POINT (only on new records — expensive grid search) ──
|
|
330
|
+
WALL_MARGIN_STS = 0.7
|
|
331
|
+
best_sts_score = -1.0
|
|
332
|
+
sts_x_opt, sts_y_opt = sts_x_init, sts_y_init
|
|
333
|
+
room_corners = cfg.ROOM_CORNERS
|
|
334
|
+
|
|
335
|
+
for sx in np.arange(analysis_x_start, analysis_x_end + 0.01, 0.25):
|
|
336
|
+
for sy in np.arange(WALL_MARGIN_STS,
|
|
337
|
+
room_h + WALL_MARGIN_STS + 0.01, 0.25):
|
|
338
|
+
if not point_in_room(sx, sy, room_corners, obstacles, room_h):
|
|
339
|
+
continue
|
|
340
|
+
too_close = False
|
|
341
|
+
for (wx1, wy1), (wx2, wy2) in wall_segs:
|
|
342
|
+
sdx, sdy = wx2-wx1, wy2-wy1
|
|
343
|
+
sl2 = sdx**2 + sdy**2
|
|
344
|
+
if sl2 < 1e-9: continue
|
|
345
|
+
t = max(0, min(1, ((sx-wx1)*sdx+(sy-wy1)*sdy)/sl2))
|
|
346
|
+
if math.sqrt((sx-(wx1+t*sdx))**2+(sy-(wy1+t*sdy))**2) < WALL_MARGIN_STS:
|
|
347
|
+
too_close = True; break
|
|
348
|
+
if too_close:
|
|
349
|
+
continue
|
|
350
|
+
sts_score_pt = 0.0
|
|
351
|
+
for (cx_z, cy_z, ang_z, ori_z, zh) in cur_A:
|
|
352
|
+
fov_h_z, fov_v_z = get_fov(cam_A_set, ori_z)
|
|
353
|
+
in_h, dist_z = point_in_wedge(
|
|
354
|
+
sx, sy, cx_z, cy_z, ang_z, fov_h_z,
|
|
355
|
+
cam_A_set.max_range, cam_A_set.min_range,
|
|
356
|
+
wall_segs, obstacles, room_h, human_h, cam_z=zh)
|
|
357
|
+
if not in_h: continue
|
|
358
|
+
dp = max(abs(cy_z - walk_y), 0.1)
|
|
359
|
+
ft = math.atan2(human_h/2.0 - zh, dp)
|
|
360
|
+
v = vertical_body_coverage(cx_z, cy_z, zh, sx, sy, fov_v_z,
|
|
361
|
+
human_h, human_fz, human_hz,
|
|
362
|
+
v_thresh=v_thresh,
|
|
363
|
+
fixed_tilt_rad=ft)
|
|
364
|
+
if v >= v_thresh:
|
|
365
|
+
sts_score_pt += v * v * dist_quality(dist_z, k_dist)
|
|
366
|
+
if sts_score_pt > best_sts_score:
|
|
367
|
+
best_sts_score = sts_score_pt
|
|
368
|
+
sts_x_opt, sts_y_opt = sx, sy
|
|
369
|
+
|
|
370
|
+
best_sts_pos = (sts_x_opt, sts_y_opt)
|
|
371
|
+
else:
|
|
372
|
+
# Reuse best STS position found so far
|
|
373
|
+
sts_x_opt, sts_y_opt = best_sts_pos
|
|
374
|
+
|
|
375
|
+
# ── PHASE 2: cam_B placement — always run (uses best known STS) ──
|
|
376
|
+
cur_B = []
|
|
377
|
+
if cam_B_set and pool_B:
|
|
378
|
+
for _ in range(n_B):
|
|
379
|
+
best_add = -1.0; best_cam = None
|
|
380
|
+
sub = random.sample(pool_B, min(150, len(pool_B)))
|
|
381
|
+
for cam in sub:
|
|
382
|
+
cx, cy, angle, orient, ih = cam
|
|
383
|
+
if any(math.sqrt((cx-c[0])**2+(cy-c[1])**2) < min_spacing_B
|
|
384
|
+
for c in cur_B):
|
|
385
|
+
continue
|
|
386
|
+
fov_h, fov_v = get_fov(cam_B_set, orient)
|
|
387
|
+
sees_sts, _ = point_in_wedge(
|
|
388
|
+
sts_x_opt, sts_y_opt, cx, cy, angle, fov_h,
|
|
389
|
+
cam_B_set.max_range, cam_B_set.min_range,
|
|
390
|
+
wall_segs, obstacles, room_h, human_h, cam_z=ih)
|
|
391
|
+
if not sees_sts: continue
|
|
392
|
+
v_sts = vertical_body_coverage(
|
|
393
|
+
cx, cy, ih, sts_x_opt, sts_y_opt, fov_v,
|
|
394
|
+
human_h, human_fz, human_hz, v_thresh=v_thresh)
|
|
395
|
+
if v_sts < 0.8: continue
|
|
396
|
+
tri_penalty = 1.0
|
|
397
|
+
if cur_B:
|
|
398
|
+
ang_to_sts = math.degrees(math.atan2(sts_y_opt-cy, sts_x_opt-cx))
|
|
399
|
+
for ec in cur_B:
|
|
400
|
+
ea = math.degrees(math.atan2(sts_y_opt-ec[1], sts_x_opt-ec[0]))
|
|
401
|
+
diff = abs(ang_to_sts - ea) % 360
|
|
402
|
+
if diff > 180: diff = 360 - diff
|
|
403
|
+
if diff < 30 or diff > 150:
|
|
404
|
+
tri_penalty = 0.1; break
|
|
405
|
+
s_b, *_ = score_configuration(
|
|
406
|
+
[], cur_B + [cam], sample_points, cfg, state)
|
|
407
|
+
if s_b * tri_penalty > best_add:
|
|
408
|
+
best_add = s_b * tri_penalty; best_cam = cam
|
|
409
|
+
if best_cam:
|
|
410
|
+
cur_B.append(best_cam)
|
|
411
|
+
|
|
412
|
+
# ── Recompute final score including cam_B ─────────────────────────
|
|
413
|
+
final_score, _, s_tot, n_tot = score_configuration(
|
|
414
|
+
cur_A, cur_B, sample_points, cfg, state)
|
|
415
|
+
bilat = min(s_tot, n_tot) / max(s_tot, n_tot, 1e-6) * 100
|
|
416
|
+
|
|
417
|
+
if final_score > best_score:
|
|
418
|
+
best_score = final_score
|
|
419
|
+
best_cam_A = list(cur_A)
|
|
420
|
+
best_cam_B = list(cur_B)
|
|
421
|
+
record_tag = " --> NEW RECORD"
|
|
422
|
+
is_new_record = True
|
|
423
|
+
no_improvement = 0
|
|
424
|
+
else:
|
|
425
|
+
record_tag = ""
|
|
426
|
+
is_new_record = False
|
|
427
|
+
|
|
428
|
+
# Update outer bar
|
|
429
|
+
pbar.set_postfix({
|
|
430
|
+
"score": f"{final_score:.1f}",
|
|
431
|
+
"best": f"{best_score:.1f}",
|
|
432
|
+
"bal": f"{bilat:.0f}%",
|
|
433
|
+
"rec": "★" if is_new_record else "",
|
|
434
|
+
})
|
|
435
|
+
pbar.update(1)
|
|
436
|
+
|
|
437
|
+
# ── Early stop ────────────────────────────────────────────────────
|
|
438
|
+
early_stop = getattr(cfg.opt, "early_stop", max(3, n_restarts // 3))
|
|
439
|
+
if early_stop > 0 and no_improvement >= early_stop:
|
|
440
|
+
_log(f" Early stop after {no_improvement} restarts without improvement.")
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
# ── Graph callback ────────────────────────────────────────────────
|
|
444
|
+
graph_mode = cfg.opt.graph_mode
|
|
445
|
+
save_graph = (graph_mode == "all" or
|
|
446
|
+
(graph_mode in ("records_only", "best_per_combo") and is_new_record))
|
|
447
|
+
if save_graph and on_record:
|
|
448
|
+
on_record(restart + 1, cur_A, cur_B, final_score,
|
|
449
|
+
(sts_x_opt, sts_y_opt), is_new_record)
|
|
450
|
+
|
|
451
|
+
_log(f" - Attempt {restart+1:2d}: Score={final_score:6.3f} "
|
|
452
|
+
f"(South={s_tot:.2f} | North={n_tot:.2f} | Bal={bilat:.0f}%)"
|
|
453
|
+
f" STS=({sts_x_opt:.1f},{sts_y_opt:.1f})"
|
|
454
|
+
f"{record_tag}")
|
|
455
|
+
_log(f" Cam A ({len(cur_A)} cameras):")
|
|
456
|
+
for zi, (zx, zy, za, zo, zh) in enumerate(cur_A):
|
|
457
|
+
side = 'S' if zy < walk_y else 'N'
|
|
458
|
+
d_perp = max(abs(zy - walk_y), 0.3)
|
|
459
|
+
tilt_deg = math.degrees(math.atan2(human_h/2.0 - zh, d_perp))
|
|
460
|
+
wn = wall_normal_at(zx, zy, wall_segs,
|
|
461
|
+
cfg.ROOM_CORNERS, cfg.ROOM_HEIGHT, obstacles)
|
|
462
|
+
pan = (za - wn + 180) % 360 - 180
|
|
463
|
+
pan_str = f"{abs(pan):.0f}deg {'RIGHT' if pan>1 else 'LEFT' if pan<-1 else 'STRAIGHT'}"
|
|
464
|
+
_log(f" Z{zi+1:2d} [{zo}][{side}] "
|
|
465
|
+
f"({zx:.2f}m,{zy:.2f}m) h={zh:.1f}m "
|
|
466
|
+
f"angle={za:.0f}deg pan={pan_str} tilt={abs(tilt_deg):.1f}deg down")
|
|
467
|
+
|
|
468
|
+
pbar.close()
|
|
469
|
+
return best_cam_A, best_cam_B, best_score, best_sts_pos
|
|
470
|
+
|