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