nwb-video-widgets 0.1.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.
- nwb_video_widgets/__init__.py +16 -0
- nwb_video_widgets/_utils.py +328 -0
- nwb_video_widgets/dandi_pose_widget.py +356 -0
- nwb_video_widgets/dandi_video_widget.py +155 -0
- nwb_video_widgets/local_pose_widget.py +334 -0
- nwb_video_widgets/local_video_widget.py +130 -0
- nwb_video_widgets/pose_widget.css +624 -0
- nwb_video_widgets/pose_widget.js +798 -0
- nwb_video_widgets/video_widget.css +484 -0
- nwb_video_widgets/video_widget.js +566 -0
- nwb_video_widgets/video_widget.py +170 -0
- nwb_video_widgets-0.1.0.dist-info/METADATA +174 -0
- nwb_video_widgets-0.1.0.dist-info/RECORD +15 -0
- nwb_video_widgets-0.1.0.dist-info/WHEEL +4 -0
- nwb_video_widgets-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pose estimation video player widget.
|
|
3
|
+
* Overlays keypoints on streaming video with pose estimation selection.
|
|
4
|
+
*
|
|
5
|
+
* Data format (from Python via JSON):
|
|
6
|
+
* - all_camera_data: {pose_name: {keypoint_metadata, pose_coordinates, timestamps}}
|
|
7
|
+
* - pose_coordinates: {keypoint_name: [[x, y], null, [x, y], ...]}
|
|
8
|
+
* - timestamps: [t0, t1, t2, ...] array of frame timestamps
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DISPLAY_WIDTH = 640;
|
|
12
|
+
const DISPLAY_HEIGHT = 512;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format seconds as MM:SS.ms string for session time display.
|
|
16
|
+
*/
|
|
17
|
+
function formatTime(seconds) {
|
|
18
|
+
const mins = Math.floor(seconds / 60);
|
|
19
|
+
const secs = Math.floor(seconds % 60);
|
|
20
|
+
const ms = Math.floor((seconds % 1) * 10);
|
|
21
|
+
return mins + ":" + secs.toString().padStart(2, "0") + "." + ms;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Binary search for frame index closest to target time.
|
|
26
|
+
*/
|
|
27
|
+
function findFrameIndex(timestamps, targetTime) {
|
|
28
|
+
if (!timestamps || timestamps.length === 0) return 0;
|
|
29
|
+
let left = 0;
|
|
30
|
+
let right = timestamps.length - 1;
|
|
31
|
+
while (left < right) {
|
|
32
|
+
const mid = Math.floor((left + right) / 2);
|
|
33
|
+
if (timestamps[mid] < targetTime) {
|
|
34
|
+
left = mid + 1;
|
|
35
|
+
} else {
|
|
36
|
+
right = mid;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return left;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create an SVG icon element.
|
|
44
|
+
*/
|
|
45
|
+
function createIcon(type) {
|
|
46
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
47
|
+
svg.setAttribute("width", "16");
|
|
48
|
+
svg.setAttribute("height", "16");
|
|
49
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
50
|
+
svg.setAttribute("fill", "currentColor");
|
|
51
|
+
|
|
52
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
53
|
+
if (type === "play") {
|
|
54
|
+
path.setAttribute("d", "M8 5v14l11-7z");
|
|
55
|
+
} else if (type === "pause") {
|
|
56
|
+
path.setAttribute("d", "M6 19h4V5H6v14zm8-14v14h4V5h-4z");
|
|
57
|
+
} else if (type === "settings") {
|
|
58
|
+
path.setAttribute(
|
|
59
|
+
"d",
|
|
60
|
+
"M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
|
|
61
|
+
);
|
|
62
|
+
} else if (type === "chevron-down") {
|
|
63
|
+
path.setAttribute("d", "M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z");
|
|
64
|
+
} else if (type === "chevron-up") {
|
|
65
|
+
path.setAttribute("d", "M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z");
|
|
66
|
+
}
|
|
67
|
+
svg.appendChild(path);
|
|
68
|
+
return svg;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render the pose video player widget.
|
|
73
|
+
*/
|
|
74
|
+
function render({ model, el }) {
|
|
75
|
+
const wrapper = document.createElement("div");
|
|
76
|
+
wrapper.classList.add("pose-widget");
|
|
77
|
+
|
|
78
|
+
// ============ POSE ESTIMATION SELECTION SECTION ============
|
|
79
|
+
const poseSection = document.createElement("div");
|
|
80
|
+
poseSection.classList.add("pose-widget__section", "pose-widget__section--pose");
|
|
81
|
+
|
|
82
|
+
const poseHeader = document.createElement("div");
|
|
83
|
+
poseHeader.classList.add("pose-widget__section-header");
|
|
84
|
+
|
|
85
|
+
const poseTitleWrapper = document.createElement("div");
|
|
86
|
+
poseTitleWrapper.classList.add("pose-widget__section-title-wrapper");
|
|
87
|
+
|
|
88
|
+
const poseTitle = document.createElement("span");
|
|
89
|
+
poseTitle.classList.add("pose-widget__section-title");
|
|
90
|
+
poseTitle.textContent = "Pose Estimation";
|
|
91
|
+
|
|
92
|
+
const poseSelectedLabel = document.createElement("span");
|
|
93
|
+
poseSelectedLabel.classList.add("pose-widget__section-selected");
|
|
94
|
+
|
|
95
|
+
poseTitleWrapper.appendChild(poseTitle);
|
|
96
|
+
poseTitleWrapper.appendChild(poseSelectedLabel);
|
|
97
|
+
|
|
98
|
+
const poseToggleIcon = document.createElement("span");
|
|
99
|
+
poseToggleIcon.classList.add("pose-widget__section-toggle");
|
|
100
|
+
poseToggleIcon.appendChild(createIcon("chevron-down"));
|
|
101
|
+
|
|
102
|
+
poseHeader.appendChild(poseTitleWrapper);
|
|
103
|
+
poseHeader.appendChild(poseToggleIcon);
|
|
104
|
+
|
|
105
|
+
const poseContent = document.createElement("div");
|
|
106
|
+
poseContent.classList.add("pose-widget__section-content");
|
|
107
|
+
|
|
108
|
+
const poseHint = document.createElement("p");
|
|
109
|
+
poseHint.classList.add("pose-widget__section-hint");
|
|
110
|
+
poseHint.textContent = "Select a pose estimation to display.";
|
|
111
|
+
|
|
112
|
+
const poseList = document.createElement("div");
|
|
113
|
+
poseList.classList.add("pose-widget__pose-list");
|
|
114
|
+
|
|
115
|
+
poseContent.appendChild(poseHint);
|
|
116
|
+
poseContent.appendChild(poseList);
|
|
117
|
+
|
|
118
|
+
poseSection.appendChild(poseHeader);
|
|
119
|
+
poseSection.appendChild(poseContent);
|
|
120
|
+
|
|
121
|
+
// Toggle pose section collapse
|
|
122
|
+
poseHeader.addEventListener("click", () => {
|
|
123
|
+
poseSection.classList.toggle("pose-widget__section--collapsed");
|
|
124
|
+
poseToggleIcon.innerHTML = "";
|
|
125
|
+
poseToggleIcon.appendChild(
|
|
126
|
+
createIcon(
|
|
127
|
+
poseSection.classList.contains("pose-widget__section--collapsed")
|
|
128
|
+
? "chevron-down"
|
|
129
|
+
: "chevron-up"
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ============ VIDEO SELECTION SECTION ============
|
|
135
|
+
const videoSection = document.createElement("div");
|
|
136
|
+
videoSection.classList.add(
|
|
137
|
+
"pose-widget__section",
|
|
138
|
+
"pose-widget__section--video",
|
|
139
|
+
"pose-widget__section--hidden"
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const videoHeader = document.createElement("div");
|
|
143
|
+
videoHeader.classList.add("pose-widget__section-header");
|
|
144
|
+
|
|
145
|
+
const videoTitleWrapper = document.createElement("div");
|
|
146
|
+
videoTitleWrapper.classList.add("pose-widget__section-title-wrapper");
|
|
147
|
+
|
|
148
|
+
const videoTitle = document.createElement("span");
|
|
149
|
+
videoTitle.classList.add("pose-widget__section-title");
|
|
150
|
+
videoTitle.textContent = "Video Selection";
|
|
151
|
+
|
|
152
|
+
const videoSelectedLabel = document.createElement("span");
|
|
153
|
+
videoSelectedLabel.classList.add("pose-widget__section-selected");
|
|
154
|
+
|
|
155
|
+
videoTitleWrapper.appendChild(videoTitle);
|
|
156
|
+
videoTitleWrapper.appendChild(videoSelectedLabel);
|
|
157
|
+
|
|
158
|
+
const videoToggleIcon = document.createElement("span");
|
|
159
|
+
videoToggleIcon.classList.add("pose-widget__section-toggle");
|
|
160
|
+
videoToggleIcon.appendChild(createIcon("chevron-down"));
|
|
161
|
+
|
|
162
|
+
videoHeader.appendChild(videoTitleWrapper);
|
|
163
|
+
videoHeader.appendChild(videoToggleIcon);
|
|
164
|
+
|
|
165
|
+
// Toggle video section collapse
|
|
166
|
+
videoHeader.addEventListener("click", () => {
|
|
167
|
+
videoSection.classList.toggle("pose-widget__section--collapsed");
|
|
168
|
+
videoToggleIcon.innerHTML = "";
|
|
169
|
+
videoToggleIcon.appendChild(
|
|
170
|
+
createIcon(
|
|
171
|
+
videoSection.classList.contains("pose-widget__section--collapsed")
|
|
172
|
+
? "chevron-down"
|
|
173
|
+
: "chevron-up"
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const videoContent = document.createElement("div");
|
|
179
|
+
videoContent.classList.add("pose-widget__section-content");
|
|
180
|
+
|
|
181
|
+
const videoHint = document.createElement("p");
|
|
182
|
+
videoHint.classList.add("pose-widget__section-hint");
|
|
183
|
+
videoHint.textContent = "Select the video to overlay the pose estimation on.";
|
|
184
|
+
|
|
185
|
+
const videoSelect = document.createElement("select");
|
|
186
|
+
videoSelect.classList.add("pose-widget__video-select");
|
|
187
|
+
|
|
188
|
+
videoContent.appendChild(videoHint);
|
|
189
|
+
videoContent.appendChild(videoSelect);
|
|
190
|
+
|
|
191
|
+
videoSection.appendChild(videoHeader);
|
|
192
|
+
videoSection.appendChild(videoContent);
|
|
193
|
+
|
|
194
|
+
// ============ VIDEO CONTAINER ============
|
|
195
|
+
const videoContainer = document.createElement("div");
|
|
196
|
+
videoContainer.classList.add("pose-widget__video-container");
|
|
197
|
+
|
|
198
|
+
const video = document.createElement("video");
|
|
199
|
+
video.classList.add("pose-widget__video");
|
|
200
|
+
video.muted = true;
|
|
201
|
+
video.playsInline = true;
|
|
202
|
+
|
|
203
|
+
const canvas = document.createElement("canvas");
|
|
204
|
+
canvas.width = DISPLAY_WIDTH;
|
|
205
|
+
canvas.height = DISPLAY_HEIGHT;
|
|
206
|
+
canvas.classList.add("pose-widget__canvas");
|
|
207
|
+
|
|
208
|
+
const emptyMsg = document.createElement("div");
|
|
209
|
+
emptyMsg.classList.add("pose-widget__empty-msg");
|
|
210
|
+
emptyMsg.textContent = "Select a pose estimation and video to begin.";
|
|
211
|
+
|
|
212
|
+
const loadingOverlay = document.createElement("div");
|
|
213
|
+
loadingOverlay.classList.add("pose-widget__loading-overlay");
|
|
214
|
+
|
|
215
|
+
const loadingSpinner = document.createElement("div");
|
|
216
|
+
loadingSpinner.classList.add("pose-widget__loading-spinner");
|
|
217
|
+
|
|
218
|
+
const loadingText = document.createElement("div");
|
|
219
|
+
loadingText.classList.add("pose-widget__loading-text");
|
|
220
|
+
loadingText.textContent = "Loading pose data...";
|
|
221
|
+
|
|
222
|
+
loadingOverlay.appendChild(loadingSpinner);
|
|
223
|
+
loadingOverlay.appendChild(loadingText);
|
|
224
|
+
|
|
225
|
+
videoContainer.appendChild(video);
|
|
226
|
+
videoContainer.appendChild(canvas);
|
|
227
|
+
videoContainer.appendChild(emptyMsg);
|
|
228
|
+
videoContainer.appendChild(loadingOverlay);
|
|
229
|
+
|
|
230
|
+
// ============ CONTROLS ============
|
|
231
|
+
const controls = document.createElement("div");
|
|
232
|
+
controls.classList.add("pose-widget__controls");
|
|
233
|
+
|
|
234
|
+
const playPauseBtn = document.createElement("button");
|
|
235
|
+
playPauseBtn.classList.add("pose-widget__button");
|
|
236
|
+
playPauseBtn.appendChild(createIcon("play"));
|
|
237
|
+
|
|
238
|
+
const seekBar = document.createElement("input");
|
|
239
|
+
seekBar.type = "range";
|
|
240
|
+
seekBar.min = 0;
|
|
241
|
+
seekBar.max = 100;
|
|
242
|
+
seekBar.value = 0;
|
|
243
|
+
seekBar.classList.add("pose-widget__seekbar");
|
|
244
|
+
|
|
245
|
+
const timeLabel = document.createElement("span");
|
|
246
|
+
timeLabel.classList.add("pose-widget__time-label");
|
|
247
|
+
timeLabel.textContent = "0:00.0 / 0:00.0";
|
|
248
|
+
|
|
249
|
+
controls.appendChild(playPauseBtn);
|
|
250
|
+
controls.appendChild(seekBar);
|
|
251
|
+
controls.appendChild(timeLabel);
|
|
252
|
+
|
|
253
|
+
// ============ KEYPOINT VISIBILITY SECTION (after controls) ============
|
|
254
|
+
const keypointSection = document.createElement("div");
|
|
255
|
+
keypointSection.classList.add(
|
|
256
|
+
"pose-widget__section",
|
|
257
|
+
"pose-widget__section--keypoints",
|
|
258
|
+
"pose-widget__section--hidden"
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const keypointHeader = document.createElement("div");
|
|
262
|
+
keypointHeader.classList.add("pose-widget__section-header");
|
|
263
|
+
|
|
264
|
+
const keypointTitle = document.createElement("span");
|
|
265
|
+
keypointTitle.classList.add("pose-widget__section-title");
|
|
266
|
+
keypointTitle.textContent = "Keypoint Visibility";
|
|
267
|
+
|
|
268
|
+
keypointHeader.appendChild(keypointTitle);
|
|
269
|
+
|
|
270
|
+
const keypointContent = document.createElement("div");
|
|
271
|
+
keypointContent.classList.add("pose-widget__section-content");
|
|
272
|
+
|
|
273
|
+
const keypointTogglesWrapper = document.createElement("div");
|
|
274
|
+
keypointTogglesWrapper.classList.add("pose-widget__keypoint-toggles-wrapper");
|
|
275
|
+
|
|
276
|
+
const utilityRow = document.createElement("div");
|
|
277
|
+
utilityRow.classList.add("pose-widget__keypoint-toggles");
|
|
278
|
+
|
|
279
|
+
const keypointRow = document.createElement("div");
|
|
280
|
+
keypointRow.classList.add("pose-widget__keypoint-toggles");
|
|
281
|
+
|
|
282
|
+
keypointTogglesWrapper.appendChild(utilityRow);
|
|
283
|
+
keypointTogglesWrapper.appendChild(keypointRow);
|
|
284
|
+
keypointContent.appendChild(keypointTogglesWrapper);
|
|
285
|
+
|
|
286
|
+
keypointSection.appendChild(keypointHeader);
|
|
287
|
+
keypointSection.appendChild(keypointContent);
|
|
288
|
+
|
|
289
|
+
// ============ DISPLAY OPTIONS SECTION (after keypoints) ============
|
|
290
|
+
const displaySection = document.createElement("div");
|
|
291
|
+
displaySection.classList.add(
|
|
292
|
+
"pose-widget__section",
|
|
293
|
+
"pose-widget__section--display",
|
|
294
|
+
"pose-widget__section--hidden"
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const displayHeader = document.createElement("div");
|
|
298
|
+
displayHeader.classList.add("pose-widget__section-header");
|
|
299
|
+
|
|
300
|
+
const displayTitle = document.createElement("span");
|
|
301
|
+
displayTitle.classList.add("pose-widget__section-title");
|
|
302
|
+
displayTitle.textContent = "Display Options";
|
|
303
|
+
|
|
304
|
+
displayHeader.appendChild(displayTitle);
|
|
305
|
+
|
|
306
|
+
const displayContent = document.createElement("div");
|
|
307
|
+
displayContent.classList.add("pose-widget__section-content");
|
|
308
|
+
|
|
309
|
+
const labelToggle = document.createElement("label");
|
|
310
|
+
labelToggle.classList.add("pose-widget__label-toggle");
|
|
311
|
+
const checkbox = document.createElement("input");
|
|
312
|
+
checkbox.type = "checkbox";
|
|
313
|
+
checkbox.checked = model.get("show_labels");
|
|
314
|
+
labelToggle.appendChild(checkbox);
|
|
315
|
+
labelToggle.appendChild(document.createTextNode(" Show keypoint labels"));
|
|
316
|
+
displayContent.appendChild(labelToggle);
|
|
317
|
+
|
|
318
|
+
displaySection.appendChild(displayHeader);
|
|
319
|
+
displaySection.appendChild(displayContent);
|
|
320
|
+
|
|
321
|
+
// ============ STATE ============
|
|
322
|
+
let isPlaying = false;
|
|
323
|
+
let animationId = null;
|
|
324
|
+
let visibleKeypoints = { ...model.get("visible_keypoints") };
|
|
325
|
+
|
|
326
|
+
// ============ FUNCTIONS ============
|
|
327
|
+
|
|
328
|
+
function getCurrentCameraData() {
|
|
329
|
+
const camera = model.get("selected_camera");
|
|
330
|
+
const allData = model.get("all_camera_data");
|
|
331
|
+
return allData[camera] || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function updateTimeLabel(frameIdx) {
|
|
335
|
+
const data = getCurrentCameraData();
|
|
336
|
+
const timestamps = data?.timestamps;
|
|
337
|
+
if (!timestamps || timestamps.length === 0) {
|
|
338
|
+
timeLabel.textContent = "0:00.0 / 0:00.0";
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const currentTime = timestamps[frameIdx] || timestamps[0];
|
|
342
|
+
const endTime = timestamps[timestamps.length - 1];
|
|
343
|
+
timeLabel.textContent = formatTime(currentTime) + " / " + formatTime(endTime);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function updatePoseList() {
|
|
347
|
+
const poses = model.get("available_cameras") || [];
|
|
348
|
+
const posesInfo = model.get("available_cameras_info") || {};
|
|
349
|
+
const selectedPose = model.get("selected_camera");
|
|
350
|
+
|
|
351
|
+
poseList.innerHTML = "";
|
|
352
|
+
|
|
353
|
+
if (poses.length === 0) {
|
|
354
|
+
const emptyText = document.createElement("p");
|
|
355
|
+
emptyText.classList.add("pose-widget__empty-text");
|
|
356
|
+
emptyText.textContent = "No pose estimations available.";
|
|
357
|
+
poseList.appendChild(emptyText);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
poses.forEach((pose) => {
|
|
362
|
+
const info = posesInfo[pose] || {};
|
|
363
|
+
const isSelected = pose === selectedPose;
|
|
364
|
+
|
|
365
|
+
const poseItem = document.createElement("div");
|
|
366
|
+
poseItem.classList.add("pose-widget__pose-item");
|
|
367
|
+
if (isSelected) {
|
|
368
|
+
poseItem.classList.add("pose-widget__pose-item--selected");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const radio = document.createElement("input");
|
|
372
|
+
radio.type = "radio";
|
|
373
|
+
radio.name = "pose-select";
|
|
374
|
+
radio.id = "pose-" + pose;
|
|
375
|
+
radio.value = pose;
|
|
376
|
+
radio.checked = isSelected;
|
|
377
|
+
radio.addEventListener("change", () => {
|
|
378
|
+
if (radio.checked) {
|
|
379
|
+
model.set("selected_camera", pose);
|
|
380
|
+
model.save_changes();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const label = document.createElement("label");
|
|
385
|
+
label.htmlFor = "pose-" + pose;
|
|
386
|
+
label.classList.add("pose-widget__pose-item-label");
|
|
387
|
+
label.textContent = pose;
|
|
388
|
+
|
|
389
|
+
const infoSpan = document.createElement("span");
|
|
390
|
+
infoSpan.classList.add("pose-widget__pose-item-info");
|
|
391
|
+
if (info.start !== undefined && info.end !== undefined) {
|
|
392
|
+
infoSpan.textContent =
|
|
393
|
+
formatTime(info.start) +
|
|
394
|
+
" - " +
|
|
395
|
+
formatTime(info.end) +
|
|
396
|
+
(info.keypoints ? " | " + info.keypoints.length + " keypoints" : "");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
poseItem.appendChild(radio);
|
|
400
|
+
poseItem.appendChild(label);
|
|
401
|
+
poseItem.appendChild(infoSpan);
|
|
402
|
+
|
|
403
|
+
poseList.appendChild(poseItem);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function updateVideoSelect() {
|
|
408
|
+
const videos = model.get("available_videos") || [];
|
|
409
|
+
const videosInfo = model.get("available_videos_info") || {};
|
|
410
|
+
const selectedPose = model.get("selected_camera");
|
|
411
|
+
const cameraToVideo = model.get("camera_to_video") || {};
|
|
412
|
+
const currentVideoName = cameraToVideo[selectedPose] || "";
|
|
413
|
+
|
|
414
|
+
videoSelect.innerHTML = "";
|
|
415
|
+
|
|
416
|
+
// Add empty option
|
|
417
|
+
const emptyOption = document.createElement("option");
|
|
418
|
+
emptyOption.value = "";
|
|
419
|
+
emptyOption.textContent = "-- Select video --";
|
|
420
|
+
videoSelect.appendChild(emptyOption);
|
|
421
|
+
|
|
422
|
+
// Add video options
|
|
423
|
+
videos.forEach((videoName) => {
|
|
424
|
+
const option = document.createElement("option");
|
|
425
|
+
const videoInfo = videosInfo[videoName] || {};
|
|
426
|
+
option.value = videoName;
|
|
427
|
+
option.textContent = videoName;
|
|
428
|
+
if (videoInfo.start !== undefined && videoInfo.end !== undefined) {
|
|
429
|
+
option.textContent +=
|
|
430
|
+
" (" + formatTime(videoInfo.start) + " - " + formatTime(videoInfo.end) + ")";
|
|
431
|
+
}
|
|
432
|
+
videoSelect.appendChild(option);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
videoSelect.value = currentVideoName;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function updateSectionVisibility() {
|
|
439
|
+
const selectedPose = model.get("selected_camera");
|
|
440
|
+
const cameraToVideo = model.get("camera_to_video") || {};
|
|
441
|
+
const currentVideoName = cameraToVideo[selectedPose] || "";
|
|
442
|
+
const data = getCurrentCameraData();
|
|
443
|
+
|
|
444
|
+
// Update selected labels
|
|
445
|
+
poseSelectedLabel.textContent = selectedPose ? selectedPose : "";
|
|
446
|
+
videoSelectedLabel.textContent = currentVideoName ? currentVideoName : "";
|
|
447
|
+
|
|
448
|
+
// Show video section when pose is selected
|
|
449
|
+
if (selectedPose) {
|
|
450
|
+
videoSection.classList.remove("pose-widget__section--hidden");
|
|
451
|
+
// Collapse pose section
|
|
452
|
+
poseSection.classList.add("pose-widget__section--collapsed");
|
|
453
|
+
poseToggleIcon.innerHTML = "";
|
|
454
|
+
poseToggleIcon.appendChild(createIcon("chevron-down"));
|
|
455
|
+
} else {
|
|
456
|
+
videoSection.classList.add("pose-widget__section--hidden");
|
|
457
|
+
poseSection.classList.remove("pose-widget__section--collapsed");
|
|
458
|
+
poseToggleIcon.innerHTML = "";
|
|
459
|
+
poseToggleIcon.appendChild(createIcon("chevron-up"));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Collapse video section when video is selected
|
|
463
|
+
if (currentVideoName) {
|
|
464
|
+
videoSection.classList.add("pose-widget__section--collapsed");
|
|
465
|
+
videoToggleIcon.innerHTML = "";
|
|
466
|
+
videoToggleIcon.appendChild(createIcon("chevron-down"));
|
|
467
|
+
} else {
|
|
468
|
+
videoSection.classList.remove("pose-widget__section--collapsed");
|
|
469
|
+
videoToggleIcon.innerHTML = "";
|
|
470
|
+
videoToggleIcon.appendChild(createIcon("chevron-up"));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Show keypoint and display sections when we have data and video
|
|
474
|
+
if (selectedPose && currentVideoName && data) {
|
|
475
|
+
keypointSection.classList.remove("pose-widget__section--hidden");
|
|
476
|
+
displaySection.classList.remove("pose-widget__section--hidden");
|
|
477
|
+
} else {
|
|
478
|
+
keypointSection.classList.add("pose-widget__section--hidden");
|
|
479
|
+
displaySection.classList.add("pose-widget__section--hidden");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Update video container empty state
|
|
483
|
+
if (!selectedPose || !currentVideoName) {
|
|
484
|
+
videoContainer.classList.add("pose-widget__video-container--empty");
|
|
485
|
+
} else {
|
|
486
|
+
videoContainer.classList.remove("pose-widget__video-container--empty");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function updateToggleStyles() {
|
|
491
|
+
const buttons = keypointRow.querySelectorAll("button[data-keypoint]");
|
|
492
|
+
const data = getCurrentCameraData();
|
|
493
|
+
const metadata = data?.keypoint_metadata || {};
|
|
494
|
+
buttons.forEach((btn) => {
|
|
495
|
+
const name = btn.dataset.keypoint;
|
|
496
|
+
const isVisible = visibleKeypoints[name] !== false;
|
|
497
|
+
const color = metadata[name]?.color || "#999";
|
|
498
|
+
|
|
499
|
+
if (isVisible) {
|
|
500
|
+
btn.classList.add("pose-widget__keypoint-toggle--active");
|
|
501
|
+
btn.style.backgroundColor = color;
|
|
502
|
+
btn.style.borderColor = color;
|
|
503
|
+
} else {
|
|
504
|
+
btn.classList.remove("pose-widget__keypoint-toggle--active");
|
|
505
|
+
btn.style.backgroundColor = "";
|
|
506
|
+
btn.style.borderColor = color;
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function createKeypointToggles() {
|
|
512
|
+
utilityRow.innerHTML = "";
|
|
513
|
+
keypointRow.innerHTML = "";
|
|
514
|
+
const data = getCurrentCameraData();
|
|
515
|
+
const metadata = data?.keypoint_metadata || {};
|
|
516
|
+
if (Object.keys(metadata).length === 0) return;
|
|
517
|
+
|
|
518
|
+
const allBtn = document.createElement("button");
|
|
519
|
+
allBtn.textContent = "All";
|
|
520
|
+
allBtn.classList.add(
|
|
521
|
+
"pose-widget__keypoint-toggle",
|
|
522
|
+
"pose-widget__keypoint-toggle--utility"
|
|
523
|
+
);
|
|
524
|
+
allBtn.addEventListener("click", () => {
|
|
525
|
+
for (const name of Object.keys(metadata)) visibleKeypoints[name] = true;
|
|
526
|
+
model.set("visible_keypoints", { ...visibleKeypoints });
|
|
527
|
+
model.save_changes();
|
|
528
|
+
updateToggleStyles();
|
|
529
|
+
drawPose();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const noneBtn = document.createElement("button");
|
|
533
|
+
noneBtn.textContent = "None";
|
|
534
|
+
noneBtn.classList.add(
|
|
535
|
+
"pose-widget__keypoint-toggle",
|
|
536
|
+
"pose-widget__keypoint-toggle--utility"
|
|
537
|
+
);
|
|
538
|
+
noneBtn.addEventListener("click", () => {
|
|
539
|
+
for (const name of Object.keys(metadata)) visibleKeypoints[name] = false;
|
|
540
|
+
model.set("visible_keypoints", { ...visibleKeypoints });
|
|
541
|
+
model.save_changes();
|
|
542
|
+
updateToggleStyles();
|
|
543
|
+
drawPose();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
utilityRow.appendChild(allBtn);
|
|
547
|
+
utilityRow.appendChild(noneBtn);
|
|
548
|
+
|
|
549
|
+
for (const [name, kp] of Object.entries(metadata)) {
|
|
550
|
+
const btn = document.createElement("button");
|
|
551
|
+
btn.textContent = name;
|
|
552
|
+
btn.dataset.keypoint = name;
|
|
553
|
+
btn.classList.add("pose-widget__keypoint-toggle");
|
|
554
|
+
btn.style.borderColor = kp.color;
|
|
555
|
+
btn.addEventListener("click", () => {
|
|
556
|
+
visibleKeypoints[name] = !visibleKeypoints[name];
|
|
557
|
+
model.set("visible_keypoints", { ...visibleKeypoints });
|
|
558
|
+
model.save_changes();
|
|
559
|
+
updateToggleStyles();
|
|
560
|
+
drawPose();
|
|
561
|
+
});
|
|
562
|
+
keypointRow.appendChild(btn);
|
|
563
|
+
}
|
|
564
|
+
updateToggleStyles();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function getFrameIndex() {
|
|
568
|
+
const data = getCurrentCameraData();
|
|
569
|
+
const timestamps = data?.timestamps;
|
|
570
|
+
if (!timestamps || timestamps.length === 0) return 0;
|
|
571
|
+
return findFrameIndex(timestamps, timestamps[0] + video.currentTime);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function drawPose() {
|
|
575
|
+
const ctx = canvas.getContext("2d");
|
|
576
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
577
|
+
|
|
578
|
+
const selectedCamera = model.get("selected_camera");
|
|
579
|
+
if (!selectedCamera) return;
|
|
580
|
+
|
|
581
|
+
const data = getCurrentCameraData();
|
|
582
|
+
if (!data) return;
|
|
583
|
+
|
|
584
|
+
const metadata = data.keypoint_metadata;
|
|
585
|
+
const coordinates = data.pose_coordinates;
|
|
586
|
+
const timestamps = data.timestamps;
|
|
587
|
+
const showLabels = model.get("show_labels");
|
|
588
|
+
|
|
589
|
+
if (!coordinates || !timestamps || timestamps.length === 0) return;
|
|
590
|
+
|
|
591
|
+
const frameIdx = getFrameIndex();
|
|
592
|
+
|
|
593
|
+
if (!video.videoWidth || !video.videoHeight) return;
|
|
594
|
+
|
|
595
|
+
const scaleX = DISPLAY_WIDTH / video.videoWidth;
|
|
596
|
+
const scaleY = DISPLAY_HEIGHT / video.videoHeight;
|
|
597
|
+
|
|
598
|
+
for (const [name, coords] of Object.entries(coordinates)) {
|
|
599
|
+
if (visibleKeypoints[name] === false) continue;
|
|
600
|
+
|
|
601
|
+
const coord = coords[frameIdx];
|
|
602
|
+
if (!coord) continue;
|
|
603
|
+
|
|
604
|
+
const x = coord[0] * scaleX;
|
|
605
|
+
const y = coord[1] * scaleY;
|
|
606
|
+
const kp = metadata[name];
|
|
607
|
+
|
|
608
|
+
ctx.beginPath();
|
|
609
|
+
ctx.arc(x, y, 5, 0, 2 * Math.PI);
|
|
610
|
+
ctx.fillStyle = kp?.color || "#fff";
|
|
611
|
+
ctx.fill();
|
|
612
|
+
ctx.strokeStyle = "#000";
|
|
613
|
+
ctx.lineWidth = 1.5;
|
|
614
|
+
ctx.stroke();
|
|
615
|
+
|
|
616
|
+
if (showLabels && kp) {
|
|
617
|
+
ctx.font = "bold 10px sans-serif";
|
|
618
|
+
ctx.fillStyle = "#fff";
|
|
619
|
+
ctx.strokeStyle = "#000";
|
|
620
|
+
ctx.lineWidth = 2;
|
|
621
|
+
ctx.strokeText(kp.label, x + 6, y + 3);
|
|
622
|
+
ctx.fillText(kp.label, x + 6, y + 3);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
updateTimeLabel(frameIdx);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function animate() {
|
|
629
|
+
drawPose();
|
|
630
|
+
if (isPlaying) animationId = requestAnimationFrame(animate);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function updateLoadingState() {
|
|
634
|
+
const isLoading = model.get("loading");
|
|
635
|
+
const camera = model.get("selected_camera");
|
|
636
|
+
const cameraToVideo = model.get("camera_to_video") || {};
|
|
637
|
+
const currentVideoName = cameraToVideo[camera] || "";
|
|
638
|
+
const data = getCurrentCameraData();
|
|
639
|
+
|
|
640
|
+
if (isLoading || (camera && currentVideoName && !data)) {
|
|
641
|
+
loadingOverlay.classList.add("pose-widget__loading-overlay--visible");
|
|
642
|
+
video.style.visibility = "hidden";
|
|
643
|
+
canvas.style.visibility = "hidden";
|
|
644
|
+
} else {
|
|
645
|
+
loadingOverlay.classList.remove("pose-widget__loading-overlay--visible");
|
|
646
|
+
video.style.visibility = "visible";
|
|
647
|
+
canvas.style.visibility = "visible";
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function loadVideo() {
|
|
652
|
+
const camera = model.get("selected_camera");
|
|
653
|
+
const cameraToVideo = model.get("camera_to_video") || {};
|
|
654
|
+
const videoName = cameraToVideo[camera];
|
|
655
|
+
|
|
656
|
+
if (!camera || !videoName) {
|
|
657
|
+
video.src = "";
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const videoNameToUrl = model.get("video_name_to_url") || {};
|
|
662
|
+
const videoUrl = videoNameToUrl[videoName];
|
|
663
|
+
|
|
664
|
+
if (videoUrl && video.src !== videoUrl) {
|
|
665
|
+
video.src = videoUrl;
|
|
666
|
+
}
|
|
667
|
+
updateLoadingState();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function updatePlayPauseIcon(playing) {
|
|
671
|
+
playPauseBtn.innerHTML = "";
|
|
672
|
+
playPauseBtn.appendChild(createIcon(playing ? "pause" : "play"));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function switchCamera() {
|
|
676
|
+
const data = getCurrentCameraData();
|
|
677
|
+
seekBar.max = data?.timestamps?.length - 1 || 100;
|
|
678
|
+
createKeypointToggles();
|
|
679
|
+
loadVideo();
|
|
680
|
+
drawPose();
|
|
681
|
+
updateSectionVisibility();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ============ INITIALIZE ============
|
|
685
|
+
updatePoseList();
|
|
686
|
+
updateVideoSelect();
|
|
687
|
+
updateSectionVisibility();
|
|
688
|
+
loadVideo();
|
|
689
|
+
|
|
690
|
+
const initialData = getCurrentCameraData();
|
|
691
|
+
if (initialData?.timestamps) {
|
|
692
|
+
seekBar.max = initialData.timestamps.length - 1;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ============ EVENT LISTENERS ============
|
|
696
|
+
|
|
697
|
+
video.addEventListener("loadedmetadata", drawPose);
|
|
698
|
+
video.addEventListener("seeked", drawPose);
|
|
699
|
+
video.addEventListener("timeupdate", drawPose);
|
|
700
|
+
|
|
701
|
+
model.on("change:selected_camera", () => {
|
|
702
|
+
if (isPlaying) {
|
|
703
|
+
video.pause();
|
|
704
|
+
updatePlayPauseIcon(false);
|
|
705
|
+
if (animationId) cancelAnimationFrame(animationId);
|
|
706
|
+
isPlaying = false;
|
|
707
|
+
}
|
|
708
|
+
updatePoseList();
|
|
709
|
+
updateVideoSelect();
|
|
710
|
+
switchCamera();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
model.on("change:available_cameras", updatePoseList);
|
|
714
|
+
model.on("change:available_videos", updateVideoSelect);
|
|
715
|
+
|
|
716
|
+
model.on("change:camera_to_video", () => {
|
|
717
|
+
updateSectionVisibility();
|
|
718
|
+
loadVideo();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
model.on("change:all_camera_data", () => {
|
|
722
|
+
updateLoadingState();
|
|
723
|
+
createKeypointToggles();
|
|
724
|
+
updateSectionVisibility();
|
|
725
|
+
drawPose();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
model.on("change:visible_keypoints", () => {
|
|
729
|
+
visibleKeypoints = { ...model.get("visible_keypoints") };
|
|
730
|
+
updateToggleStyles();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
model.on("change:loading", updateLoadingState);
|
|
734
|
+
|
|
735
|
+
videoSelect.addEventListener("change", () => {
|
|
736
|
+
const selectedVideo = videoSelect.value;
|
|
737
|
+
const selectedPose = model.get("selected_camera");
|
|
738
|
+
const newMapping = { ...model.get("camera_to_video") };
|
|
739
|
+
|
|
740
|
+
if (selectedVideo) {
|
|
741
|
+
newMapping[selectedPose] = selectedVideo;
|
|
742
|
+
} else {
|
|
743
|
+
delete newMapping[selectedPose];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
model.set("camera_to_video", newMapping);
|
|
747
|
+
model.save_changes();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
playPauseBtn.addEventListener("click", () => {
|
|
751
|
+
const selectedCamera = model.get("selected_camera");
|
|
752
|
+
const cameraToVideo = model.get("camera_to_video") || {};
|
|
753
|
+
if (!selectedCamera || !cameraToVideo[selectedCamera]) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (isPlaying) {
|
|
757
|
+
video.pause();
|
|
758
|
+
if (animationId) cancelAnimationFrame(animationId);
|
|
759
|
+
} else {
|
|
760
|
+
video.play();
|
|
761
|
+
animate();
|
|
762
|
+
}
|
|
763
|
+
isPlaying = !isPlaying;
|
|
764
|
+
updatePlayPauseIcon(isPlaying);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
seekBar.addEventListener("input", () => {
|
|
768
|
+
const frameIdx = parseInt(seekBar.value);
|
|
769
|
+
const data = getCurrentCameraData();
|
|
770
|
+
const timestamps = data?.timestamps;
|
|
771
|
+
if (timestamps && timestamps.length > 0) {
|
|
772
|
+
video.currentTime = timestamps[frameIdx] - timestamps[0];
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
checkbox.addEventListener("change", () => {
|
|
777
|
+
model.set("show_labels", checkbox.checked);
|
|
778
|
+
model.save_changes();
|
|
779
|
+
drawPose();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// ============ LAYOUT ============
|
|
783
|
+
wrapper.appendChild(poseSection);
|
|
784
|
+
wrapper.appendChild(videoSection);
|
|
785
|
+
wrapper.appendChild(videoContainer);
|
|
786
|
+
wrapper.appendChild(controls);
|
|
787
|
+
wrapper.appendChild(keypointSection);
|
|
788
|
+
wrapper.appendChild(displaySection);
|
|
789
|
+
el.appendChild(wrapper);
|
|
790
|
+
|
|
791
|
+
return () => {
|
|
792
|
+
if (animationId) {
|
|
793
|
+
cancelAnimationFrame(animationId);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export default { render };
|