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