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/visualize.py ADDED
@@ -0,0 +1,517 @@
1
+ """
2
+ visualize.py
3
+ All visualisation functions for the camera optimiser.
4
+ Receives explicit cfg/state — no global state.
5
+ """
6
+
7
+ import math
8
+ import numpy as np
9
+ import matplotlib.pyplot as plt
10
+ import matplotlib.colors as mcolors
11
+ from matplotlib.patches import Wedge, Rectangle, Polygon
12
+
13
+ from .room import (obs_poly_vertices, obs_label, obs_centroid, obs_height,
14
+ cam_d_min, cam_side, point_in_room, point_in_wedge,
15
+ vertical_body_coverage, cam_fixed_tilt)
16
+ from .scoring import get_fov, count_cameras_3d
17
+
18
+
19
+ # =============================================================
20
+ # HELPERS
21
+ # =============================================================
22
+
23
+ def _min_dist_full_coverage(cam_z, fov_v_deg, human_h, human_fz, human_hz,
24
+ v_thresh=0.9, fixed_tilt_rad=None):
25
+ return cam_d_min(cam_z, fov_v_deg, fixed_tilt_rad,
26
+ human_h, human_fz, human_hz, v_thresh=v_thresh)
27
+
28
+
29
+ # =============================================================
30
+ # TOP-DOWN VIEW (cones)
31
+ # =============================================================
32
+
33
+ def draw_top_view(ax, cam_A_list, cam_B_list, score, cfg, state):
34
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
35
+ cam_B = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
36
+ human_h = cfg.HUMAN_HEIGHT
37
+ human_fz = cfg.HUMAN_FOOT_Z
38
+ human_hz = cfg.HUMAN_HEAD_Z
39
+
40
+ walk_y = state["walk_y"]
41
+ walk_x_start = state["walk_x_start"]
42
+ walk_x_end = state["walk_x_end"]
43
+ analysis_x_start = state["analysis_x_start"]
44
+ analysis_x_end = state["analysis_x_end"]
45
+ sts_x = state["sts_x"]
46
+ sts_y = state["sts_y"]
47
+ sts_radius = cfg.STS_RADIUS
48
+
49
+ ax.set_facecolor('white')
50
+
51
+ # Room
52
+ corners = cfg.ROOM_CORNERS + [cfg.ROOM_CORNERS[0]]
53
+ ax.fill([c[0] for c in corners], [c[1] for c in corners], color='#f5f5f5', zorder=0)
54
+ ax.plot([c[0] for c in corners], [c[1] for c in corners], 'k-', lw=2.5, zorder=5)
55
+
56
+ # Corridor & zones
57
+ ax.plot([walk_x_start, walk_x_end], [walk_y, walk_y],
58
+ color='#2ca02c', lw=1.5, ls='--', zorder=4, alpha=0.7)
59
+ ax.add_patch(Rectangle((analysis_x_start, walk_y - 0.55),
60
+ analysis_x_end - analysis_x_start, 1.1,
61
+ fill=True, facecolor='#d5f5d5', edgecolor='#2ca02c',
62
+ lw=2, ls='--', zorder=2, alpha=0.6))
63
+ ax.text((analysis_x_start + analysis_x_end) / 2, walk_y + 0.68,
64
+ "Analysis zone", ha='center', fontsize=8, color='#1a7a1a', fontweight='bold')
65
+ ax.add_patch(plt.Circle((sts_x, sts_y), sts_radius,
66
+ facecolor='#ffe0b2', edgecolor='darkorange', lw=2, zorder=3, alpha=0.8))
67
+ ax.text(sts_x, sts_y, "STS", ha='center', va='center',
68
+ fontsize=8, color='darkorange', fontweight='bold')
69
+
70
+ # ── Polygon capture zones ─────────────────────────────────────────────
71
+ for zone in cfg.capture_zones:
72
+ if zone.type != "polygon":
73
+ continue
74
+ verts = getattr(zone, "_translated_vertices", None) or zone.vertices
75
+ if len(verts) < 3:
76
+ continue
77
+ ax.add_patch(Polygon(verts, closed=True,
78
+ facecolor='#d5f5d5', edgecolor='#2ca02c',
79
+ lw=2, ls='-.', zorder=2, alpha=0.5))
80
+ cx_z = sum(v[0] for v in verts) / len(verts)
81
+ cy_z = sum(v[1] for v in verts) / len(verts)
82
+ ax.text(cx_z, cy_z, zone.id, ha='center', va='center',
83
+ fontsize=7, color='#1a7a1a', fontweight='bold', zorder=3)
84
+
85
+ # Obstacles
86
+ for obs in cfg.obstacles:
87
+ oh = obs_height(obs)
88
+ alpha = 0.85 if oh >= cfg.ROOM_HEIGHT else 0.45
89
+ color = '#555555' if oh >= cfg.ROOM_HEIGHT else '#aaaaaa'
90
+ verts = obs_poly_vertices(obs)
91
+ ax.add_patch(Polygon(verts, closed=True, facecolor=color,
92
+ edgecolor='black', lw=1.5, zorder=6, alpha=alpha))
93
+ cx_obs, cy_obs = obs_centroid(obs)
94
+ ax.text(cx_obs, cy_obs, obs_label(obs), ha='center', va='center',
95
+ fontsize=5.5, color='white', fontweight='bold', zorder=7)
96
+
97
+ # Cam A cones
98
+ for idx, (cx, cy, angle, orient, zh) in enumerate(cam_A_list):
99
+ fov_h, fov_v = get_fov(cam_A, orient)
100
+ theta1, theta2 = angle - fov_h/2, angle + fov_h/2
101
+
102
+ angle_rad = math.radians(angle)
103
+ dy_dir = math.sin(angle_rad)
104
+ if abs(dy_dir) > 0.01:
105
+ dtw = (walk_y - cy) / dy_dir
106
+ dist_perp = max(dtw, 0.1) if dtw > 0 else max(abs(cy - walk_y), 0.1)
107
+ else:
108
+ dist_perp = max(abs(cy - walk_y), 0.1)
109
+ fixed_tilt = math.atan2(human_h / 2.0 - zh, dist_perp)
110
+ d_min_draw = max(_min_dist_full_coverage(zh, fov_v, human_h, human_fz, human_hz,
111
+ fixed_tilt_rad=fixed_tilt),
112
+ cam_A.min_range)
113
+
114
+ ax.add_patch(Wedge((cx, cy), d_min_draw, theta1, theta2,
115
+ facecolor='#888888', alpha=0.35, linewidth=0, zorder=3))
116
+ ax.add_patch(Wedge((cx, cy), d_min_draw, theta1, theta2,
117
+ width=0.04, facecolor='white', alpha=0.8, linewidth=0, zorder=4))
118
+ n_steps = 25
119
+ radii = np.linspace(d_min_draw, cam_A.max_range, n_steps + 1)
120
+ for k in range(n_steps):
121
+ ri = radii[k]; ro = radii[k+1]
122
+ a_step = 0.30 * (1.0 / (1.0 + 0.025 * ((ri+ro)/2)**2))
123
+ ax.add_patch(Wedge((cx, cy), ro, theta1, theta2, width=ro-ri,
124
+ color=cam_A.color, alpha=a_step, linewidth=0, zorder=3))
125
+ ax.add_patch(Wedge((cx, cy), cam_A.max_range, theta1, theta2,
126
+ fill=False, edgecolor=cam_A.color, lw=0.8, alpha=0.6, zorder=4))
127
+ marker = 'o' if orient == 'L' else '^'
128
+ ax.plot(cx, cy, marker=marker, color=cam_A.color, ms=10,
129
+ markeredgecolor='white', markeredgewidth=1.0, zorder=7)
130
+ ax.text(cx, cy+0.18, f"A{idx+1}{'P' if orient=='P' else ''}\n{zh:.1f}m",
131
+ fontsize=5.5, ha='center', color=cam_A.color, fontweight='bold', zorder=8)
132
+
133
+ # Cam B cones
134
+ if cam_B:
135
+ for idx, (cx, cy, angle, orient, ih) in enumerate(cam_B_list):
136
+ fov_h, fov_v = get_fov(cam_B, orient)
137
+ theta1, theta2 = angle - fov_h/2, angle + fov_h/2
138
+ d_min_draw = max(_min_dist_full_coverage(ih, fov_v, human_h, human_fz, human_hz),
139
+ cam_B.min_range)
140
+ ax.add_patch(Wedge((cx, cy), d_min_draw, theta1, theta2,
141
+ facecolor='#888888', alpha=0.35, linewidth=0, zorder=3))
142
+ ax.add_patch(Wedge((cx, cy), d_min_draw, theta1, theta2,
143
+ width=0.04, facecolor='white', alpha=0.8, linewidth=0, zorder=4))
144
+ radii = np.linspace(d_min_draw, cam_B.max_range, 26)
145
+ for k in range(25):
146
+ ri = radii[k]; ro = radii[k+1]
147
+ a_step = 0.30 * (1.0 / (1.0 + 0.025 * ((ri+ro)/2)**2))
148
+ ax.add_patch(Wedge((cx, cy), ro, theta1, theta2, width=ro-ri,
149
+ color=cam_B.color, alpha=a_step, linewidth=0, zorder=3))
150
+ ax.add_patch(Wedge((cx, cy), cam_B.max_range, theta1, theta2,
151
+ fill=False, edgecolor=cam_B.color, lw=0.8, alpha=0.6, zorder=4))
152
+ marker_b = 's' if orient == 'P' else 'D'
153
+ ax.plot(cx, cy, marker=marker_b, color=cam_B.color, ms=10,
154
+ markeredgecolor='white', markeredgewidth=1.0, zorder=7)
155
+ ax.text(cx, cy+0.18, f"B{idx+1}{'P' if orient=='P' else ''}\n{ih:.1f}m",
156
+ fontsize=5.5, ha='center', color=cam_B.color, fontweight='bold', zorder=8)
157
+
158
+ ax.set_aspect('equal')
159
+ _xs = [c[0] for c in cfg.ROOM_CORNERS]; _ys = [c[1] for c in cfg.ROOM_CORNERS]
160
+ _pad = max((max(_xs)-min(_xs)), (max(_ys)-min(_ys))) * 0.08 + 0.5
161
+ ax.set_xlim(min(_xs) - _pad, max(_xs) + _pad)
162
+ ax.set_ylim(min(_ys) - _pad, max(_ys) + _pad)
163
+ ax.set_xlabel("X (m)", fontsize=9); ax.set_ylabel("Y (m)", fontsize=9)
164
+ ax.set_title(f"Top view — Score: {score:.1f} | bilateral={cfg.BILATERAL_WEIGHT}",
165
+ fontsize=9, fontweight='bold')
166
+ ax.grid(True, ls='--', alpha=0.25, color='gray')
167
+
168
+
169
+ # =============================================================
170
+ # XZ SIDE VIEW
171
+ # =============================================================
172
+
173
+ def draw_side_view_xz(ax, cam_A_list, cam_B_list, cfg, state):
174
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
175
+ cam_B = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
176
+ walk_y = state["walk_y"]
177
+ walk_x_start = state["walk_x_start"]; walk_x_end = state["walk_x_end"]
178
+ analysis_x_start = state["analysis_x_start"]; analysis_x_end = state["analysis_x_end"]
179
+ sts_x = state["sts_x"]
180
+ human_h = cfg.HUMAN_HEIGHT
181
+ obstacles = cfg.obstacles
182
+ wall_segs = state["wall_segments"]
183
+ room_h = cfg.ROOM_HEIGHT
184
+
185
+ CELL = 0.25
186
+ MAX_CAM = 10
187
+ base_cmap = plt.get_cmap('YlOrRd')
188
+ colors_d = ['#1a1a2e'] + [base_cmap(i / MAX_CAM) for i in range(1, MAX_CAM + 1)]
189
+ cmap = mcolors.ListedColormap(colors_d)
190
+ norm = mcolors.BoundaryNorm(list(range(0, MAX_CAM + 2)), cmap.N)
191
+
192
+ ax.set_facecolor('#1a1a2e')
193
+ xs = np.arange(min(c[0] for c in cfg.ROOM_CORNERS), max(c[0] for c in cfg.ROOM_CORNERS), CELL)
194
+ zs = np.arange(0.0, room_h, CELL)
195
+
196
+ for xi in xs:
197
+ xm = xi + CELL / 2
198
+ for zi in zs:
199
+ zm = zi + CELL / 2
200
+ count = 0.0
201
+ for (cx, cy, angle, orient, zh) in cam_A_list:
202
+ fov_h, fov_v = get_fov(cam_A, orient)
203
+ in_h, _ = point_in_wedge(xm, walk_y, cx, cy, angle, fov_h,
204
+ cam_A.max_range, cam_A.min_range,
205
+ wall_segs, obstacles, room_h, human_h, cam_z=zh)
206
+ if not in_h: continue
207
+ horiz_d = math.sqrt((xm-cx)**2 + (walk_y-cy)**2)
208
+ if horiz_d < 0.01: continue
209
+ dp = max(abs(cy - walk_y), 0.1)
210
+ ft = math.atan2(human_h / 2.0 - zh, dp)
211
+ hfv = math.radians(fov_v / 2.0)
212
+ if ft - hfv <= math.atan2(zm - zh, horiz_d) <= ft + hfv:
213
+ count += 1.0
214
+ if cam_B:
215
+ for (cx, cy, angle, orient, ih) in cam_B_list:
216
+ fov_h, fov_v = get_fov(cam_B, orient)
217
+ in_h, _ = point_in_wedge(xm, walk_y, cx, cy, angle, fov_h,
218
+ cam_B.max_range, cam_B.min_range,
219
+ wall_segs, obstacles, room_h, human_h, cam_z=ih)
220
+ if not in_h: continue
221
+ horiz_d = math.sqrt((xm-cx)**2 + (walk_y-cy)**2)
222
+ if horiz_d < 0.01: continue
223
+ tilt_i = math.atan2(human_h / 2.0 - ih, horiz_d)
224
+ hfv = math.radians(fov_v / 2.0)
225
+ if tilt_i - hfv <= math.atan2(zm - ih, horiz_d) <= tilt_i + hfv:
226
+ count += 0.5
227
+ color = cmap(norm(int(min(count, MAX_CAM))))
228
+ ax.add_patch(Rectangle((xi, zi), CELL, CELL, facecolor=color, edgecolor='none', zorder=2))
229
+
230
+ ax.axhline(0, color='saddlebrown', lw=2.5, zorder=6)
231
+ ax.axhline(room_h, color='white', lw=1.5, ls='--', alpha=0.5, zorder=6)
232
+ ax.axvline(walk_x_start, color='#74b9ff', lw=2, zorder=7)
233
+ ax.axvline(walk_x_end, color='#74b9ff', lw=2, zorder=7)
234
+ ax.axvline(analysis_x_start, color='#55efc4', lw=1.5, ls='--', zorder=7)
235
+ ax.axvline(analysis_x_end, color='#55efc4', lw=1.5, ls='--', zorder=7)
236
+ ax.axvline(sts_x, color='#fdcb6e', lw=1.5, ls=':', zorder=7)
237
+
238
+ for obs in obstacles:
239
+ oh = obs_height(obs)
240
+ verts = obs_poly_vertices(obs)
241
+ ox0 = min(v[0] for v in verts); ox1 = max(v[0] for v in verts)
242
+ ax.add_patch(Rectangle((ox0, 0), ox1-ox0, oh,
243
+ facecolor='#666666', edgecolor='white', lw=1, zorder=8, alpha=0.75))
244
+ ax.text((ox0+ox1)/2, oh/2, obs_label(obs), ha='center', va='center',
245
+ fontsize=5, color='white', fontweight='bold', zorder=9,
246
+ rotation=90 if (ox1-ox0) < 0.5 else 0)
247
+
248
+ colors_z = plt.cm.tab20(np.linspace(0, 0.9, max(len(cam_A_list), 1)))
249
+ for idx, (cx, cy, _, orient, zh) in enumerate(cam_A_list):
250
+ ax.plot(cx, zh, 'o', color=colors_z[idx], ms=8, markeredgecolor='white', markeredgewidth=1, zorder=9)
251
+ ax.text(cx, zh+0.12, f"A{idx+1}\n{zh:.1f}m", ha='center', fontsize=5.5, color=colors_z[idx], fontweight='bold', zorder=10)
252
+ if cam_B:
253
+ for idx, (cx, cy, _, orient, ih) in enumerate(cam_B_list):
254
+ ax.plot(cx, ih, 's', color=cam_B.color, ms=8, markeredgecolor='white', markeredgewidth=1, zorder=9)
255
+ ax.text(cx, ih+0.12, f"B{idx+1}\n{ih:.1f}m", ha='center', fontsize=5.5, color=cam_B.color, fontweight='bold', zorder=10)
256
+
257
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
258
+ sm.set_array([])
259
+ cbar = plt.colorbar(sm, ax=ax, orientation='vertical', pad=0.01, fraction=0.03,
260
+ ticks=[i + 0.5 for i in range(0, MAX_CAM + 1)])
261
+ cbar.set_ticklabels([f"{i} cam{'s' if i > 1 else ''}" for i in range(0, MAX_CAM + 1)])
262
+ cbar.set_label("Nb cameras (≥90% body)", fontsize=8, color='white')
263
+ cbar.ax.yaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
264
+ for y in range(1, MAX_CAM + 1):
265
+ cbar.ax.axhline(y, color='white', lw=0.5, alpha=0.6)
266
+
267
+ _xs_r = [c[0] for c in cfg.ROOM_CORNERS]
268
+ ax.set_xlim(min(_xs_r) - 0.2, max(_xs_r) + 0.2); ax.set_ylim(-0.25, room_h + 0.25)
269
+ ax.set_xlabel("X (m)", fontsize=9, color='white'); ax.set_ylabel("Z (m) height", fontsize=9, color='white')
270
+ ax.tick_params(colors='white')
271
+ ax.set_title("XZ side view — cameras seeing each height point (≥90% body)", fontsize=9, fontweight='bold', color='white')
272
+ ax.set_facecolor('#1a1a2e')
273
+ for spine in ax.spines.values(): spine.set_edgecolor('white')
274
+ ax.grid(False)
275
+
276
+
277
+ # =============================================================
278
+ # XY TOP-DOWN HEATMAP
279
+ # =============================================================
280
+
281
+ def draw_top_heatmap(ax, cam_A_list, cam_B_list, cfg, state):
282
+ CELL = 0.2
283
+ MAX_CAM = 10
284
+ base_cmap = plt.get_cmap('YlOrRd')
285
+ colors_d = ['#1a1a2e'] + [base_cmap(i / MAX_CAM) for i in range(1, MAX_CAM + 1)]
286
+ cmap = mcolors.ListedColormap(colors_d)
287
+ norm = mcolors.BoundaryNorm(list(range(0, MAX_CAM + 2)), cmap.N)
288
+
289
+ walk_y = state["walk_y"]
290
+ analysis_x_start = state["analysis_x_start"]; analysis_x_end = state["analysis_x_end"]
291
+ sts_x = state["sts_x"]; sts_y = state["sts_y"]
292
+
293
+ ax.set_facecolor('#1a1a2e')
294
+ corners = cfg.ROOM_CORNERS + [cfg.ROOM_CORNERS[0]]
295
+ ax.fill([c[0] for c in corners], [c[1] for c in corners], color='#16213e', zorder=1)
296
+ ax.plot([c[0] for c in corners], [c[1] for c in corners], color='white', lw=2, zorder=8)
297
+
298
+ for xi in np.arange(min(c[0] for c in cfg.ROOM_CORNERS),
299
+ max(c[0] for c in cfg.ROOM_CORNERS) + CELL, CELL):
300
+ xm = xi + CELL / 2
301
+ for yi in np.arange(min(c[1] for c in cfg.ROOM_CORNERS),
302
+ max(c[1] for c in cfg.ROOM_CORNERS) + CELL, CELL):
303
+ ym = yi + CELL / 2
304
+ if not point_in_room(xm, ym, cfg.ROOM_CORNERS, cfg.obstacles, cfg.ROOM_HEIGHT):
305
+ continue
306
+ count = count_cameras_3d(xm, ym, cam_A_list, cam_B_list, cfg, state)
307
+ if count <= 0: continue
308
+ color = cmap(norm(int(min(count, MAX_CAM))))
309
+ ax.add_patch(Rectangle((xi, yi), CELL, CELL, facecolor=color, edgecolor='none', alpha=0.85, zorder=3))
310
+
311
+ ax.plot([state["walk_x_start"], state["walk_x_end"]], [walk_y, walk_y],
312
+ color='#74b9ff', lw=1.5, ls='--', zorder=9, alpha=0.8)
313
+ ax.add_patch(Rectangle((analysis_x_start, walk_y - 0.5),
314
+ analysis_x_end - analysis_x_start, 1.0,
315
+ fill=False, edgecolor='#55efc4', lw=2, ls='--', zorder=9))
316
+ ax.add_patch(plt.Circle((sts_x, sts_y), cfg.STS_RADIUS,
317
+ facecolor='none', edgecolor='#fdcb6e', lw=2, zorder=9))
318
+ ax.text(sts_x, sts_y, "STS", ha='center', va='center', fontsize=7, color='#fdcb6e', fontweight='bold', zorder=10)
319
+
320
+ # ── Polygon capture zones ─────────────────────────────────────────────
321
+ for zone in cfg.capture_zones:
322
+ if zone.type != "polygon":
323
+ continue
324
+ verts = getattr(zone, "_translated_vertices", None) or zone.vertices
325
+ if len(verts) < 3:
326
+ continue
327
+ ax.add_patch(Polygon(verts, closed=True,
328
+ facecolor='none', edgecolor='#55efc4',
329
+ lw=2, ls='-.', zorder=9, alpha=0.9))
330
+ cx_z = sum(v[0] for v in verts) / len(verts)
331
+ cy_z = sum(v[1] for v in verts) / len(verts)
332
+ ax.text(cx_z, cy_z, zone.id, ha='center', va='center',
333
+ fontsize=6, color='#55efc4', fontweight='bold', zorder=10)
334
+
335
+ for obs in cfg.obstacles:
336
+ verts = obs_poly_vertices(obs)
337
+ ax.add_patch(Polygon(verts, closed=True, facecolor='#444444', edgecolor='white', lw=1.2, zorder=10, alpha=0.9))
338
+ cx_obs, cy_obs = obs_centroid(obs)
339
+ ax.text(cx_obs, cy_obs, obs_label(obs), ha='center', va='center', fontsize=5, color='white', fontweight='bold', zorder=11)
340
+
341
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
342
+ cam_B = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
343
+ colors_z = plt.cm.tab20(np.linspace(0, 0.9, max(len(cam_A_list), 1)))
344
+ for idx, (cx, cy, angle, orient, zh) in enumerate(cam_A_list):
345
+ ax.plot(cx, cy, 'o', color=colors_z[idx], ms=9, markeredgecolor='white', markeredgewidth=1.2, zorder=11)
346
+ ax.text(cx, cy+0.15, f"A{idx+1}\n{zh:.1f}m", ha='center', fontsize=5.5, color=colors_z[idx], fontweight='bold', zorder=12)
347
+ if cam_B:
348
+ for idx, (cx, cy, angle, orient, ih) in enumerate(cam_B_list):
349
+ ax.plot(cx, cy, 's', color=cam_B.color, ms=9, markeredgecolor='white', markeredgewidth=1.2, zorder=11)
350
+ ax.text(cx, cy+0.15, f"B{idx+1}\n{ih:.1f}m", ha='center', fontsize=5.5, color=cam_B.color, fontweight='bold', zorder=12)
351
+
352
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm); sm.set_array([])
353
+ cbar = plt.colorbar(sm, ax=ax, orientation='vertical', pad=0.01, fraction=0.03,
354
+ ticks=[i + 0.5 for i in range(0, MAX_CAM + 1)])
355
+ cbar.set_ticklabels([f"{i} cam{'s' if i > 1 else ''}" for i in range(0, MAX_CAM + 1)])
356
+ cbar.set_label("Nb cameras (≥90% body)", fontsize=8, color='white')
357
+ cbar.ax.yaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
358
+ for y in range(1, MAX_CAM + 1): cbar.ax.axhline(y, color='white', lw=0.5, alpha=0.6)
359
+
360
+ ax.set_aspect('equal')
361
+ _xs_r = [c[0] for c in cfg.ROOM_CORNERS]; _ys_r = [c[1] for c in cfg.ROOM_CORNERS]
362
+ ax.set_xlim(min(_xs_r) - 0.5, max(_xs_r) + 0.5)
363
+ ax.set_ylim(min(_ys_r) - 0.5, max(_ys_r) + 0.5)
364
+ ax.set_xlabel("X (m)", fontsize=9, color='white'); ax.set_ylabel("Y (m)", fontsize=9, color='white')
365
+ ax.tick_params(colors='white')
366
+ for spine in ax.spines.values(): spine.set_edgecolor('white')
367
+ ax.set_title("XY heatmap — 3D coverage (≥90% body)", fontsize=9, fontweight='bold', color='white')
368
+ ax.grid(False)
369
+
370
+
371
+ # =============================================================
372
+ # COVERAGE BAR CHART
373
+ # =============================================================
374
+
375
+ def draw_coverage_bar_chart(ax, cam_A_list, cam_B_list, score, cfg, state):
376
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
377
+ cam_B = next((c for c in cfg.camera_sets if c.mounting == "tripod"), None)
378
+ wall_segs = state["wall_segments"]
379
+ obstacles = cfg.obstacles
380
+ room_h = cfg.ROOM_HEIGHT
381
+ human_h = cfg.HUMAN_HEIGHT; human_fz = cfg.HUMAN_FOOT_Z; human_hz = cfg.HUMAN_HEAD_Z
382
+ walk_y = state["walk_y"]
383
+ walk_x_start = state["walk_x_start"]; walk_x_end = state["walk_x_end"]
384
+ analysis_x_start = state["analysis_x_start"]; analysis_x_end = state["analysis_x_end"]
385
+ sts_x = state["sts_x"]
386
+ target_coverage = cfg.TARGET_COVERAGE
387
+ v_thresh = cfg.opt.vertical_coverage_threshold
388
+ k_dist = cfg.DIST_QUALITY_K
389
+
390
+ _x_min_r = min(c[0] for c in cfg.ROOM_CORNERS)
391
+ _x_max_r = max(c[0] for c in cfg.ROOM_CORNERS)
392
+ x_full = np.arange(_x_min_r, _x_max_r + 0.01, 0.1)
393
+ cov_total, cov_south, cov_north = [], [], []
394
+
395
+ for x in x_full:
396
+ tot = 0; s_v = 0.0; n_v = 0.0
397
+ for (cx, cy, angle, orient, zh) in cam_A_list:
398
+ fov_h, fov_v = get_fov(cam_A, orient)
399
+ in_c, _ = point_in_wedge(x, walk_y, cx, cy, angle, fov_h,
400
+ cam_A.max_range, cam_A.min_range,
401
+ wall_segs, obstacles, room_h, human_h, cam_z=zh)
402
+ if in_c:
403
+ dp = max(abs(cy - walk_y), 0.1)
404
+ ft = math.atan2(human_h / 2.0 - zh, dp)
405
+ v = vertical_body_coverage(cx, cy, zh, x, walk_y, fov_v,
406
+ human_h, human_fz, human_hz,
407
+ v_thresh=v_thresh, fixed_tilt_rad=ft)
408
+ if v >= v_thresh:
409
+ tot += 1
410
+ if cam_side(cy, walk_y) == 'S': s_v = max(s_v, v)
411
+ else: n_v = max(n_v, v)
412
+ if cam_B:
413
+ for (cx, cy, angle, orient, ih) in cam_B_list:
414
+ fov_h, fov_v = get_fov(cam_B, orient)
415
+ in_c, _ = point_in_wedge(x, walk_y, cx, cy, angle, fov_h,
416
+ cam_B.max_range, cam_B.min_range,
417
+ wall_segs, obstacles, room_h, human_h, cam_z=ih)
418
+ if in_c:
419
+ v = vertical_body_coverage(cx, cy, ih, x, walk_y, fov_v,
420
+ human_h, human_fz, human_hz,
421
+ v_thresh=v_thresh)
422
+ if v >= v_thresh:
423
+ tot += 0.5
424
+ if cam_side(cy, walk_y) == 'S': s_v = max(s_v, v * 0.6)
425
+ else: n_v = max(n_v, v * 0.6)
426
+ cov_total.append(tot); cov_south.append(s_v); cov_north.append(n_v)
427
+
428
+ cov = np.array(cov_total)
429
+ ax.axvspan(0, walk_x_start, alpha=0.06, color='gray', zorder=1)
430
+ ax.axvspan(walk_x_end, 13.0, alpha=0.06, color='gray', zorder=1)
431
+ ax.axvspan(walk_x_start, walk_x_end, alpha=0.05, color='steelblue', zorder=1)
432
+ ax.axvspan(analysis_x_start, analysis_x_end, alpha=0.09, color='green', zorder=1)
433
+
434
+ norm_bar = mcolors.Normalize(vmin=0, vmax=target_coverage + 1)
435
+ for i in range(len(x_full) - 1):
436
+ ax.bar(x_full[i], cov[i], width=0.1,
437
+ color=plt.cm.RdYlGn(norm_bar(cov[i])), align='edge', zorder=2)
438
+
439
+ ax.axvline(walk_x_start, color='steelblue', lw=2, ls='-', zorder=5)
440
+ ax.axvline(walk_x_end, color='steelblue', lw=2, ls='-', zorder=5)
441
+ ax.axvline(analysis_x_start, color='#2ca02c', lw=1.5, ls='--', zorder=5)
442
+ ax.axvline(analysis_x_end, color='#2ca02c', lw=1.5, ls='--', zorder=5)
443
+ ax.axhline(target_coverage, color='#1a7a1a', lw=2, ls='--', zorder=5,
444
+ label=f"Target {target_coverage} cams")
445
+ ax.axvline(sts_x, color='darkorange', lw=1.5, ls=':', zorder=5, label="STS")
446
+
447
+ y_top = max(cov) + 1.5
448
+ ax.text((walk_x_start + walk_x_end) / 2, y_top * 0.95, "Corridor", ha='center', fontsize=8, color='steelblue', fontweight='bold')
449
+ ax.text((analysis_x_start + analysis_x_end) / 2, y_top * 0.78, "Analysis zone", ha='center', fontsize=8, color='#1a7a1a', fontweight='bold')
450
+
451
+ am6 = (x_full >= analysis_x_start) & (x_full <= analysis_x_end)
452
+ am10 = (x_full >= walk_x_start) & (x_full <= walk_x_end)
453
+ cs = np.array(cov_south); cn = np.array(cov_north)
454
+ bk6 = np.mean((cs[am6] > 0) & (cn[am6] > 0)) * 100
455
+ bk10 = np.mean((cs[am10] > 0) & (cn[am10] > 0)) * 100
456
+ stats = (f"Zone: >={target_coverage} cams = {100*np.mean(cov[am6]>=target_coverage):.0f}% | Bilateral = {bk6:.0f}%\n"
457
+ f"Corridor: >={target_coverage} cams = {100*np.mean(cov[am10]>=target_coverage):.0f}% | Bilateral = {bk10:.0f}%\n"
458
+ f"Score: {score:.2f}")
459
+ ax.text(0.99, 0.99, stats, transform=ax.transAxes, fontsize=8, va='top', ha='right',
460
+ bbox=dict(boxstyle='round', facecolor='white', edgecolor='gray', alpha=0.92))
461
+
462
+ _xs_r = [c[0] for c in cfg.ROOM_CORNERS]
463
+ _x_min_r, _x_max_r = min(_xs_r), max(_xs_r)
464
+ ax.set_xlim(_x_min_r - 0.3, _x_max_r + 0.3)
465
+ ax.set_ylim(0, max(cov) + 2.2)
466
+ ax.set_xlabel(f"X (m) — room length ({_x_min_r:.0f} → {_x_max_r:.0f}m)", fontsize=9)
467
+ ax.set_ylabel("Nb cameras (≥90% body)", fontsize=9)
468
+ ax.set_title("Coverage along the room — Blue: corridor | Green: analysis zone", fontsize=9, fontweight='bold')
469
+ ax.legend(loc='upper left', fontsize=7, ncol=2, framealpha=0.9)
470
+ ax.grid(True, ls='--', alpha=0.25, axis='y')
471
+ ax.set_xticks(range(int(_x_min_r), int(_x_max_r) + 1))
472
+
473
+
474
+ # =============================================================
475
+ # MAIN VISUALISATION ENTRY POINT
476
+ # =============================================================
477
+
478
+ def visualize_solution(cam_A_list, cam_B_list, score, cfg, state,
479
+ show_window=True, save_path=None):
480
+ """
481
+ Builds and saves the 4-panel visualisation figure.
482
+ """
483
+ fig = plt.figure(figsize=(30, 18), facecolor='#111122')
484
+ gs = fig.add_gridspec(2, 2, hspace=0.38, wspace=0.32)
485
+ ax_top = fig.add_subplot(gs[0, 0])
486
+ ax_heatmap = fig.add_subplot(gs[0, 1])
487
+ ax_side = fig.add_subplot(gs[1, 0])
488
+ ax_bar = fig.add_subplot(gs[1, 1])
489
+ ax_bar.set_facecolor('white')
490
+
491
+ draw_top_view(ax_top, cam_A_list, cam_B_list, score, cfg, state)
492
+ draw_top_heatmap(ax_heatmap, cam_A_list, cam_B_list, cfg, state)
493
+ draw_side_view_xz(ax_side, cam_A_list, cam_B_list, cfg, state)
494
+ draw_coverage_bar_chart(ax_bar, cam_A_list, cam_B_list, score, cfg, state)
495
+
496
+ cam_A = next(c for c in cfg.camera_sets if c.mounting == "wall")
497
+ walk_y = state["walk_y"]
498
+ n_S = sum(1 for c in cam_A_list if c[1] < walk_y)
499
+ n_N = sum(1 for c in cam_A_list if c[1] >= walk_y)
500
+ n_L = sum(1 for c in cam_A_list if c[3] == 'L')
501
+ n_P = sum(1 for c in cam_A_list if c[3] == 'P')
502
+ fig.suptitle(
503
+ f"Camera optimisation — Markerless Biomechanics Lab\n"
504
+ f"{cam_A.name}: {n_S} SOUTH + {n_N} NORTH | "
505
+ f"{n_L}× Landscape + {n_P}× Portrait | "
506
+ f"bilateral={cfg.BILATERAL_WEIGHT}",
507
+ fontsize=12, fontweight='bold')
508
+
509
+ out_path = save_path if save_path else "optimisation_cameras_resultat.png"
510
+ if out_path and __import__('os').path.dirname(out_path):
511
+ __import__('os').makedirs(__import__('os').path.dirname(out_path), exist_ok=True)
512
+ plt.savefig(out_path, dpi=150, bbox_inches='tight', facecolor='white')
513
+ if show_window:
514
+ plt.show()
515
+ else:
516
+ plt.close(fig)
517
+