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,155 @@
|
|
|
1
|
+
"""DANDI NWB video player widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Optional
|
|
8
|
+
|
|
9
|
+
import anywidget
|
|
10
|
+
import traitlets
|
|
11
|
+
from pynwb import NWBFile
|
|
12
|
+
|
|
13
|
+
from nwb_video_widgets._utils import discover_video_series, get_video_info, get_video_timestamps
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from dandi.dandiapi import RemoteAsset
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NWBDANDIVideoPlayer(anywidget.AnyWidget):
|
|
20
|
+
"""Display videos from a DANDI-hosted NWB file with synchronized playback.
|
|
21
|
+
|
|
22
|
+
This widget discovers ImageSeries with external_file references in the NWB
|
|
23
|
+
file and resolves their paths to S3 URLs via the DANDI API. An interactive
|
|
24
|
+
settings panel allows users to select which videos to display and choose
|
|
25
|
+
between Row, Column, or Grid layouts.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
asset : RemoteAsset
|
|
30
|
+
DANDI asset object (from dandiset.get_asset_by_path() or similar).
|
|
31
|
+
The dandiset_id and asset path are extracted from this object.
|
|
32
|
+
nwbfile : pynwb.NWBFile, optional
|
|
33
|
+
Pre-loaded NWB file to avoid re-loading. If not provided, the widget
|
|
34
|
+
will load the NWB file via streaming.
|
|
35
|
+
|
|
36
|
+
Example
|
|
37
|
+
-------
|
|
38
|
+
>>> from dandi.dandiapi import DandiAPIClient
|
|
39
|
+
>>> client = DandiAPIClient()
|
|
40
|
+
>>> dandiset = client.get_dandiset("000409", "draft")
|
|
41
|
+
>>> asset = dandiset.get_asset_by_path("sub-NYU-39/sub-NYU-39_ses-...nwb")
|
|
42
|
+
>>> widget = NWBDANDIVideoPlayer(asset=asset)
|
|
43
|
+
>>> display(widget)
|
|
44
|
+
|
|
45
|
+
With pre-loaded NWB file (avoids re-loading):
|
|
46
|
+
|
|
47
|
+
>>> widget = NWBDANDIVideoPlayer(
|
|
48
|
+
... asset=asset,
|
|
49
|
+
... nwbfile=already_loaded_nwbfile,
|
|
50
|
+
... )
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
video_urls = traitlets.Dict({}).tag(sync=True)
|
|
54
|
+
video_timestamps = traitlets.Dict({}).tag(sync=True)
|
|
55
|
+
available_videos = traitlets.Dict({}).tag(sync=True)
|
|
56
|
+
selected_videos = traitlets.List([]).tag(sync=True)
|
|
57
|
+
layout_mode = traitlets.Unicode("row").tag(sync=True)
|
|
58
|
+
settings_open = traitlets.Bool(False).tag(sync=True)
|
|
59
|
+
|
|
60
|
+
_esm = pathlib.Path(__file__).parent / "video_widget.js"
|
|
61
|
+
_css = pathlib.Path(__file__).parent / "video_widget.css"
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
asset: RemoteAsset,
|
|
66
|
+
nwbfile: Optional[NWBFile] = None,
|
|
67
|
+
**kwargs,
|
|
68
|
+
):
|
|
69
|
+
# Load NWB file if not provided
|
|
70
|
+
if nwbfile is None:
|
|
71
|
+
nwbfile = self._load_nwbfile_from_dandi(asset)
|
|
72
|
+
|
|
73
|
+
video_urls = self.get_video_urls_from_dandi(nwbfile, asset)
|
|
74
|
+
video_timestamps = get_video_timestamps(nwbfile)
|
|
75
|
+
available_videos = get_video_info(nwbfile)
|
|
76
|
+
|
|
77
|
+
# Start with no videos selected to avoid initial buffering
|
|
78
|
+
super().__init__(
|
|
79
|
+
video_urls=video_urls,
|
|
80
|
+
video_timestamps=video_timestamps,
|
|
81
|
+
available_videos=available_videos,
|
|
82
|
+
selected_videos=[],
|
|
83
|
+
layout_mode="grid",
|
|
84
|
+
settings_open=True,
|
|
85
|
+
**kwargs,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _load_nwbfile_from_dandi(asset: RemoteAsset) -> NWBFile:
|
|
90
|
+
"""Load an NWB file from DANDI via streaming.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
asset : RemoteAsset
|
|
95
|
+
DANDI asset object
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
NWBFile
|
|
100
|
+
Loaded NWB file
|
|
101
|
+
"""
|
|
102
|
+
import h5py
|
|
103
|
+
import remfile
|
|
104
|
+
from pynwb import NWBHDF5IO
|
|
105
|
+
|
|
106
|
+
s3_url = asset.get_content_url(follow_redirects=1, strip_query=True)
|
|
107
|
+
|
|
108
|
+
remote_file = remfile.File(s3_url)
|
|
109
|
+
h5_file = h5py.File(remote_file, "r")
|
|
110
|
+
io = NWBHDF5IO(file=h5_file, load_namespaces=True)
|
|
111
|
+
return io.read()
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def get_video_urls_from_dandi(
|
|
115
|
+
nwbfile: NWBFile,
|
|
116
|
+
asset: RemoteAsset,
|
|
117
|
+
) -> dict[str, str]:
|
|
118
|
+
"""Extract video S3 URLs from NWB file using DANDI API.
|
|
119
|
+
|
|
120
|
+
Videos in NWB files are stored as ImageSeries with external_file paths.
|
|
121
|
+
This function finds all ImageSeries with external files and resolves
|
|
122
|
+
their relative paths to full S3 URLs using the DANDI API.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
nwbfile : pynwb.NWBFile
|
|
127
|
+
NWB file containing video ImageSeries in acquisition
|
|
128
|
+
asset : RemoteAsset
|
|
129
|
+
DANDI asset object for the NWB file
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
dict[str, str]
|
|
134
|
+
Mapping of video names to S3 URLs
|
|
135
|
+
"""
|
|
136
|
+
from dandi.dandiapi import DandiAPIClient
|
|
137
|
+
|
|
138
|
+
client = DandiAPIClient()
|
|
139
|
+
dandiset = client.get_dandiset(asset.dandiset_id, asset.version_id)
|
|
140
|
+
|
|
141
|
+
nwb_parent = Path(asset.path).parent
|
|
142
|
+
video_series = discover_video_series(nwbfile)
|
|
143
|
+
video_urls = {}
|
|
144
|
+
|
|
145
|
+
for name, series in video_series.items():
|
|
146
|
+
relative_path = series.external_file[0].lstrip("./")
|
|
147
|
+
full_path = str(nwb_parent / relative_path)
|
|
148
|
+
|
|
149
|
+
video_asset = dandiset.get_asset_by_path(full_path)
|
|
150
|
+
if video_asset is not None:
|
|
151
|
+
video_urls[name] = video_asset.get_content_url(
|
|
152
|
+
follow_redirects=1, strip_query=True
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return video_urls
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Local NWB pose estimation video overlay widget."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import anywidget
|
|
8
|
+
import matplotlib.colors as mcolors
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
import numpy as np
|
|
11
|
+
import traitlets
|
|
12
|
+
from pynwb import NWBFile
|
|
13
|
+
|
|
14
|
+
from nwb_video_widgets._utils import (
|
|
15
|
+
discover_pose_estimation_cameras,
|
|
16
|
+
discover_video_series,
|
|
17
|
+
get_pose_estimation_info,
|
|
18
|
+
start_video_server,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NWBLocalPoseEstimationWidget(anywidget.AnyWidget):
|
|
23
|
+
"""Video player with pose estimation overlay for local NWB files.
|
|
24
|
+
|
|
25
|
+
Overlays DeepLabCut keypoints on streaming video with support for
|
|
26
|
+
camera selection via a settings panel.
|
|
27
|
+
|
|
28
|
+
This widget discovers PoseEstimation containers in processing['pose_estimation']
|
|
29
|
+
and resolves video paths relative to the NWB file location. An interactive
|
|
30
|
+
settings panel allows users to select which camera to display.
|
|
31
|
+
|
|
32
|
+
Supports two common NWB patterns:
|
|
33
|
+
1. Single file: both videos and pose estimation in same NWB file
|
|
34
|
+
2. Split files: videos in raw NWB file, pose estimation in processed file
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
nwbfile : pynwb.NWBFile
|
|
39
|
+
NWB file containing pose estimation in processing['pose_estimation'].
|
|
40
|
+
Must have been loaded from disk.
|
|
41
|
+
video_nwbfile : pynwb.NWBFile, optional
|
|
42
|
+
NWB file containing video ImageSeries in acquisition. If not provided,
|
|
43
|
+
videos are assumed to be in `nwbfile`. Use this when videos are in a
|
|
44
|
+
separate raw file from the processed pose data.
|
|
45
|
+
keypoint_colors : str or dict, default 'tab10'
|
|
46
|
+
Either a matplotlib colormap name (e.g., 'tab10', 'Set1', 'Paired') for
|
|
47
|
+
automatic color assignment, or a dict mapping keypoint names to hex colors
|
|
48
|
+
(e.g., {'LeftPaw': '#FF0000', 'RightPaw': '#00FF00'}).
|
|
49
|
+
default_camera : str, optional
|
|
50
|
+
Camera to display initially. Falls back to first available if not found.
|
|
51
|
+
|
|
52
|
+
Example
|
|
53
|
+
-------
|
|
54
|
+
Single file (videos + pose in same file):
|
|
55
|
+
|
|
56
|
+
>>> from pynwb import NWBHDF5IO
|
|
57
|
+
>>> with NWBHDF5IO("experiment.nwb", "r") as io:
|
|
58
|
+
... nwbfile = io.read()
|
|
59
|
+
... widget = NWBLocalPoseEstimationWidget(nwbfile)
|
|
60
|
+
... display(widget)
|
|
61
|
+
|
|
62
|
+
Split files (videos in raw, pose in processed):
|
|
63
|
+
|
|
64
|
+
>>> io_raw = NWBHDF5IO("raw.nwb", "r")
|
|
65
|
+
>>> io_processed = NWBHDF5IO("processed.nwb", "r")
|
|
66
|
+
>>> nwbfile_raw = io_raw.read()
|
|
67
|
+
>>> nwbfile_processed = io_processed.read()
|
|
68
|
+
>>> widget = NWBLocalPoseEstimationWidget(
|
|
69
|
+
... nwbfile=nwbfile_processed,
|
|
70
|
+
... video_nwbfile=nwbfile_raw,
|
|
71
|
+
... )
|
|
72
|
+
>>> display(widget)
|
|
73
|
+
|
|
74
|
+
Raises
|
|
75
|
+
------
|
|
76
|
+
ValueError
|
|
77
|
+
If the NWB file was not loaded from disk (read_io is None) or
|
|
78
|
+
if no cameras have both pose data and video.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
selected_camera = traitlets.Unicode("").tag(sync=True)
|
|
82
|
+
available_cameras = traitlets.List([]).tag(sync=True)
|
|
83
|
+
available_cameras_info = traitlets.Dict({}).tag(sync=True)
|
|
84
|
+
|
|
85
|
+
# Video selection - users explicitly match cameras to videos
|
|
86
|
+
available_videos = traitlets.List([]).tag(sync=True)
|
|
87
|
+
available_videos_info = traitlets.Dict({}).tag(sync=True)
|
|
88
|
+
video_name_to_url = traitlets.Dict({}).tag(sync=True) # Video name -> URL mapping
|
|
89
|
+
camera_to_video = traitlets.Dict({}).tag(sync=True) # Camera -> video name mapping
|
|
90
|
+
|
|
91
|
+
settings_open = traitlets.Bool(True).tag(sync=True)
|
|
92
|
+
|
|
93
|
+
# Pose data for cameras - loaded lazily when selected
|
|
94
|
+
all_camera_data = traitlets.Dict({}).tag(sync=True)
|
|
95
|
+
|
|
96
|
+
# Loading state for progress indicator
|
|
97
|
+
loading = traitlets.Bool(False).tag(sync=True)
|
|
98
|
+
|
|
99
|
+
show_labels = traitlets.Bool(True).tag(sync=True)
|
|
100
|
+
visible_keypoints = traitlets.Dict({}).tag(sync=True)
|
|
101
|
+
|
|
102
|
+
_esm = pathlib.Path(__file__).parent / "pose_widget.js"
|
|
103
|
+
_css = pathlib.Path(__file__).parent / "pose_widget.css"
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
nwbfile: NWBFile,
|
|
108
|
+
video_nwbfile: Optional[NWBFile] = None,
|
|
109
|
+
keypoint_colors: str | dict[str, str] = "tab10",
|
|
110
|
+
default_camera: Optional[str] = None,
|
|
111
|
+
**kwargs,
|
|
112
|
+
):
|
|
113
|
+
# Use video_nwbfile for videos if provided, otherwise use nwbfile
|
|
114
|
+
video_source = video_nwbfile if video_nwbfile is not None else nwbfile
|
|
115
|
+
|
|
116
|
+
# Compute video URLs from local files
|
|
117
|
+
video_urls = self._get_video_urls_from_local(video_source)
|
|
118
|
+
|
|
119
|
+
# Parse keypoint_colors
|
|
120
|
+
if isinstance(keypoint_colors, str):
|
|
121
|
+
colormap_name = keypoint_colors
|
|
122
|
+
custom_colors = {}
|
|
123
|
+
else:
|
|
124
|
+
colormap_name = "tab10"
|
|
125
|
+
custom_colors = keypoint_colors
|
|
126
|
+
|
|
127
|
+
# Get pose estimation container
|
|
128
|
+
if "pose_estimation" not in nwbfile.processing:
|
|
129
|
+
raise ValueError("NWB file does not contain pose_estimation processing module")
|
|
130
|
+
pose_estimation = nwbfile.processing["pose_estimation"]
|
|
131
|
+
|
|
132
|
+
# Get all PoseEstimation containers (excludes Skeletons and other metadata)
|
|
133
|
+
pose_containers = discover_pose_estimation_cameras(nwbfile)
|
|
134
|
+
available_cameras = list(pose_containers.keys())
|
|
135
|
+
|
|
136
|
+
# Get camera info for settings panel display
|
|
137
|
+
available_cameras_info = get_pose_estimation_info(nwbfile)
|
|
138
|
+
|
|
139
|
+
# Get ALL available videos (sorted alphabetically)
|
|
140
|
+
available_videos = sorted(video_urls.keys())
|
|
141
|
+
available_videos_info = self._get_video_info(video_source)
|
|
142
|
+
|
|
143
|
+
# Video name to URL mapping (sent to JS for URL resolution)
|
|
144
|
+
video_name_to_url = video_urls
|
|
145
|
+
|
|
146
|
+
# Start with empty mapping - users explicitly select videos
|
|
147
|
+
camera_to_video = {}
|
|
148
|
+
|
|
149
|
+
# Select default camera - start with empty to show settings
|
|
150
|
+
if default_camera and default_camera in available_cameras:
|
|
151
|
+
selected_camera = default_camera
|
|
152
|
+
else:
|
|
153
|
+
selected_camera = ""
|
|
154
|
+
|
|
155
|
+
# Store references for lazy loading (not synced to JS)
|
|
156
|
+
self._pose_estimation = pose_estimation
|
|
157
|
+
self._cmap = plt.get_cmap(colormap_name)
|
|
158
|
+
self._custom_colors = custom_colors
|
|
159
|
+
|
|
160
|
+
super().__init__(
|
|
161
|
+
selected_camera=selected_camera,
|
|
162
|
+
available_cameras=available_cameras,
|
|
163
|
+
available_cameras_info=available_cameras_info,
|
|
164
|
+
available_videos=available_videos,
|
|
165
|
+
available_videos_info=available_videos_info,
|
|
166
|
+
video_name_to_url=video_name_to_url,
|
|
167
|
+
camera_to_video=camera_to_video,
|
|
168
|
+
all_camera_data={}, # Start empty, load lazily
|
|
169
|
+
visible_keypoints={}, # Populated as cameras are loaded
|
|
170
|
+
settings_open=True,
|
|
171
|
+
**kwargs,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@traitlets.observe("selected_camera")
|
|
175
|
+
def _on_camera_selected(self, change):
|
|
176
|
+
"""Load pose data lazily when a camera is selected."""
|
|
177
|
+
camera_name = change["new"]
|
|
178
|
+
if not camera_name or camera_name in self.all_camera_data:
|
|
179
|
+
return # Already loaded or no camera selected
|
|
180
|
+
|
|
181
|
+
# Signal loading start
|
|
182
|
+
self.loading = True
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
# Load pose data for this camera
|
|
186
|
+
camera_data = self._load_camera_pose_data(
|
|
187
|
+
self._pose_estimation, camera_name, self._cmap, self._custom_colors
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Update all_camera_data (must create new dict for traitlets to detect change)
|
|
191
|
+
self.all_camera_data = {**self.all_camera_data, camera_name: camera_data}
|
|
192
|
+
|
|
193
|
+
# Add any new keypoints to visible_keypoints
|
|
194
|
+
new_keypoints = {**self.visible_keypoints}
|
|
195
|
+
for name in camera_data["keypoint_metadata"].keys():
|
|
196
|
+
if name not in new_keypoints:
|
|
197
|
+
new_keypoints[name] = True
|
|
198
|
+
if new_keypoints != self.visible_keypoints:
|
|
199
|
+
self.visible_keypoints = new_keypoints
|
|
200
|
+
finally:
|
|
201
|
+
# Signal loading complete
|
|
202
|
+
self.loading = False
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _get_video_info(nwbfile: NWBFile) -> dict[str, dict]:
|
|
206
|
+
"""Get metadata for all video series."""
|
|
207
|
+
video_series = discover_video_series(nwbfile)
|
|
208
|
+
info = {}
|
|
209
|
+
|
|
210
|
+
for name, series in video_series.items():
|
|
211
|
+
timestamps = None
|
|
212
|
+
if series.timestamps is not None:
|
|
213
|
+
timestamps = series.timestamps[:]
|
|
214
|
+
elif series.starting_time is not None and series.rate is not None:
|
|
215
|
+
n_frames = series.data.shape[0] if hasattr(series.data, "shape") else 0
|
|
216
|
+
timestamps = np.arange(n_frames) / series.rate + series.starting_time
|
|
217
|
+
|
|
218
|
+
if timestamps is not None and len(timestamps) > 0:
|
|
219
|
+
info[name] = {
|
|
220
|
+
"start": float(timestamps[0]),
|
|
221
|
+
"end": float(timestamps[-1]),
|
|
222
|
+
"frames": len(timestamps),
|
|
223
|
+
}
|
|
224
|
+
else:
|
|
225
|
+
info[name] = {"start": 0, "end": 0, "frames": 0}
|
|
226
|
+
|
|
227
|
+
return info
|
|
228
|
+
|
|
229
|
+
@traitlets.observe("camera_to_video")
|
|
230
|
+
def _on_camera_to_video_changed(self, change):
|
|
231
|
+
"""Update video URL when user changes camera-to-video mapping."""
|
|
232
|
+
# The camera_to_video dict now stores video URLs directly
|
|
233
|
+
# This observer can be used for any side effects needed
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _get_video_urls_from_local(nwbfile: NWBFile) -> dict[str, str]:
|
|
238
|
+
"""Extract video file URLs from a local NWB file.
|
|
239
|
+
|
|
240
|
+
Resolves external_file paths relative to the NWB file location and
|
|
241
|
+
serves them via a local HTTP server for browser playback.
|
|
242
|
+
"""
|
|
243
|
+
if nwbfile.read_io is None or not hasattr(nwbfile.read_io, "source"):
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"NWB file must be loaded from disk to resolve video paths. "
|
|
246
|
+
"The nwbfile.read_io attribute is None."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
nwbfile_path = Path(nwbfile.read_io.source)
|
|
250
|
+
base_dir = nwbfile_path.parent
|
|
251
|
+
video_series = discover_video_series(nwbfile)
|
|
252
|
+
video_urls = {}
|
|
253
|
+
|
|
254
|
+
if not video_series:
|
|
255
|
+
return video_urls
|
|
256
|
+
|
|
257
|
+
# Collect all video directories and start servers
|
|
258
|
+
video_dirs: set[Path] = set()
|
|
259
|
+
for series in video_series.values():
|
|
260
|
+
relative_path = series.external_file[0].lstrip("./")
|
|
261
|
+
video_path = (base_dir / relative_path).resolve()
|
|
262
|
+
video_dirs.add(video_path.parent)
|
|
263
|
+
|
|
264
|
+
# Start servers for each unique directory
|
|
265
|
+
dir_to_port: dict[Path, int] = {}
|
|
266
|
+
for video_dir in video_dirs:
|
|
267
|
+
port = start_video_server(video_dir)
|
|
268
|
+
dir_to_port[video_dir] = port
|
|
269
|
+
|
|
270
|
+
# Build URLs using the local HTTP server
|
|
271
|
+
for name, series in video_series.items():
|
|
272
|
+
relative_path = series.external_file[0].lstrip("./")
|
|
273
|
+
video_path = (base_dir / relative_path).resolve()
|
|
274
|
+
video_dir = video_path.parent
|
|
275
|
+
port = dir_to_port[video_dir]
|
|
276
|
+
video_urls[name] = f"http://127.0.0.1:{port}/{video_path.name}"
|
|
277
|
+
|
|
278
|
+
return video_urls
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _load_camera_pose_data(
|
|
282
|
+
pose_estimation, camera_name: str, cmap, custom_colors: dict
|
|
283
|
+
) -> dict:
|
|
284
|
+
"""Load pose data for a single camera.
|
|
285
|
+
|
|
286
|
+
Returns a dict with:
|
|
287
|
+
- keypoint_metadata: {name: {color, label}}
|
|
288
|
+
- pose_coordinates: {name: [[x, y], ...]} as JSON-serializable lists
|
|
289
|
+
- timestamps: [t0, t1, ...] as JSON-serializable list
|
|
290
|
+
"""
|
|
291
|
+
camera_pose = pose_estimation[camera_name]
|
|
292
|
+
|
|
293
|
+
keypoint_names = list(camera_pose.pose_estimation_series.keys())
|
|
294
|
+
n_kp = len(keypoint_names)
|
|
295
|
+
|
|
296
|
+
metadata = {}
|
|
297
|
+
coordinates = {}
|
|
298
|
+
timestamps = None
|
|
299
|
+
|
|
300
|
+
for index, (series_name, series) in enumerate(
|
|
301
|
+
camera_pose.pose_estimation_series.items()
|
|
302
|
+
):
|
|
303
|
+
short_name = series_name.replace("PoseEstimationSeries", "")
|
|
304
|
+
|
|
305
|
+
# Get coordinates - iterate to build list without memory duplication
|
|
306
|
+
data = series.data[:]
|
|
307
|
+
coords_list = []
|
|
308
|
+
for x, y in data:
|
|
309
|
+
if np.isnan(x) or np.isnan(y):
|
|
310
|
+
coords_list.append(None)
|
|
311
|
+
else:
|
|
312
|
+
coords_list.append([float(x), float(y)])
|
|
313
|
+
coordinates[short_name] = coords_list
|
|
314
|
+
|
|
315
|
+
if timestamps is None:
|
|
316
|
+
timestamps = series.timestamps[:].tolist()
|
|
317
|
+
|
|
318
|
+
# Assign color from custom dict or colormap
|
|
319
|
+
if short_name in custom_colors:
|
|
320
|
+
color = custom_colors[short_name]
|
|
321
|
+
else:
|
|
322
|
+
if hasattr(cmap, "N") and cmap.N < 256:
|
|
323
|
+
rgba = cmap(index % cmap.N)
|
|
324
|
+
else:
|
|
325
|
+
rgba = cmap(index / max(n_kp - 1, 1))
|
|
326
|
+
color = mcolors.to_hex(rgba)
|
|
327
|
+
|
|
328
|
+
metadata[short_name] = {"color": color, "label": short_name}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
"keypoint_metadata": metadata,
|
|
332
|
+
"pose_coordinates": coordinates,
|
|
333
|
+
"timestamps": timestamps,
|
|
334
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Local NWB video player widget."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import anywidget
|
|
7
|
+
import traitlets
|
|
8
|
+
from pynwb import NWBFile
|
|
9
|
+
|
|
10
|
+
from nwb_video_widgets._utils import (
|
|
11
|
+
discover_video_series,
|
|
12
|
+
get_video_info,
|
|
13
|
+
get_video_timestamps,
|
|
14
|
+
start_video_server,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NWBLocalVideoPlayer(anywidget.AnyWidget):
|
|
19
|
+
"""Display local videos from an NWB file with synchronized playback.
|
|
20
|
+
|
|
21
|
+
This widget discovers ImageSeries with external_file references in the NWB
|
|
22
|
+
file and resolves their paths relative to the NWB file location. An interactive
|
|
23
|
+
settings panel allows users to select which videos to display and choose
|
|
24
|
+
between Row, Column, or Grid layouts.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
nwbfile : pynwb.NWBFile
|
|
29
|
+
NWB file containing video ImageSeries in acquisition. Must have been
|
|
30
|
+
loaded from disk (i.e., nwbfile.read_io must not be None).
|
|
31
|
+
|
|
32
|
+
Example
|
|
33
|
+
-------
|
|
34
|
+
>>> from pynwb import NWBHDF5IO
|
|
35
|
+
>>> with NWBHDF5IO("experiment.nwb", "r") as io:
|
|
36
|
+
... nwbfile = io.read()
|
|
37
|
+
... widget = NWBLocalVideoPlayer(nwbfile)
|
|
38
|
+
... display(widget)
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
ValueError
|
|
43
|
+
If the NWB file was not loaded from disk (read_io is None)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
video_urls = traitlets.Dict({}).tag(sync=True)
|
|
47
|
+
video_timestamps = traitlets.Dict({}).tag(sync=True)
|
|
48
|
+
available_videos = traitlets.Dict({}).tag(sync=True)
|
|
49
|
+
selected_videos = traitlets.List([]).tag(sync=True)
|
|
50
|
+
layout_mode = traitlets.Unicode("row").tag(sync=True)
|
|
51
|
+
settings_open = traitlets.Bool(False).tag(sync=True)
|
|
52
|
+
|
|
53
|
+
_esm = pathlib.Path(__file__).parent / "video_widget.js"
|
|
54
|
+
_css = pathlib.Path(__file__).parent / "video_widget.css"
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
nwbfile: NWBFile,
|
|
59
|
+
**kwargs,
|
|
60
|
+
):
|
|
61
|
+
video_urls = self.get_video_urls_from_local(nwbfile)
|
|
62
|
+
video_timestamps = get_video_timestamps(nwbfile)
|
|
63
|
+
available_videos = get_video_info(nwbfile)
|
|
64
|
+
|
|
65
|
+
# Start with no videos selected to avoid initial buffering
|
|
66
|
+
super().__init__(
|
|
67
|
+
video_urls=video_urls,
|
|
68
|
+
video_timestamps=video_timestamps,
|
|
69
|
+
available_videos=available_videos,
|
|
70
|
+
selected_videos=[],
|
|
71
|
+
layout_mode="grid",
|
|
72
|
+
settings_open=True,
|
|
73
|
+
**kwargs,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def get_video_urls_from_local(nwbfile: NWBFile) -> dict[str, str]:
|
|
78
|
+
"""Extract video file URLs from a local NWB file.
|
|
79
|
+
|
|
80
|
+
Resolves external_file paths relative to the NWB file location and
|
|
81
|
+
serves them via a local HTTP server for browser playback.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
nwbfile : pynwb.NWBFile
|
|
86
|
+
NWB file containing video ImageSeries in acquisition
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
dict[str, str]
|
|
91
|
+
Mapping of video names to HTTP URLs served locally
|
|
92
|
+
|
|
93
|
+
Raises
|
|
94
|
+
------
|
|
95
|
+
ValueError
|
|
96
|
+
If the NWB file was not loaded from disk
|
|
97
|
+
"""
|
|
98
|
+
if nwbfile.read_io is None or not hasattr(nwbfile.read_io, "source"):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"NWB file must be loaded from disk to resolve video paths. "
|
|
101
|
+
"The nwbfile.read_io attribute is None."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
nwbfile_path = Path(nwbfile.read_io.source)
|
|
105
|
+
base_dir = nwbfile_path.parent
|
|
106
|
+
video_series = discover_video_series(nwbfile)
|
|
107
|
+
video_urls = {}
|
|
108
|
+
|
|
109
|
+
# Collect all video directories and start servers
|
|
110
|
+
video_dirs: set[Path] = set()
|
|
111
|
+
for series in video_series.values():
|
|
112
|
+
relative_path = series.external_file[0].lstrip("./")
|
|
113
|
+
video_path = (base_dir / relative_path).resolve()
|
|
114
|
+
video_dirs.add(video_path.parent)
|
|
115
|
+
|
|
116
|
+
# Start servers for each unique directory
|
|
117
|
+
dir_to_port: dict[Path, int] = {}
|
|
118
|
+
for video_dir in video_dirs:
|
|
119
|
+
port = start_video_server(video_dir)
|
|
120
|
+
dir_to_port[video_dir] = port
|
|
121
|
+
|
|
122
|
+
# Build URLs using the local HTTP server
|
|
123
|
+
for name, series in video_series.items():
|
|
124
|
+
relative_path = series.external_file[0].lstrip("./")
|
|
125
|
+
video_path = (base_dir / relative_path).resolve()
|
|
126
|
+
video_dir = video_path.parent
|
|
127
|
+
port = dir_to_port[video_dir]
|
|
128
|
+
video_urls[name] = f"http://127.0.0.1:{port}/{video_path.name}"
|
|
129
|
+
|
|
130
|
+
return video_urls
|