From 1f940df1c90bfcdc52dfa20fa166452ec2a4412f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:29:23 +0000 Subject: [PATCH 01/12] Initial plan From 0464f6f21db7be597f3be15b6af2f0dc4831c911 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:34:02 +0000 Subject: [PATCH 02/12] Add comprehensive unit tests for PoseEstimation widgets Co-authored-by: pauladkisson <34703136+pauladkisson@users.noreply.github.com> --- tests/conftest.py | 65 +++++++- tests/fixtures/synthetic_nwb.py | 166 ++++++++++++++++++- tests/test_local_pose_widget.py | 280 ++++++++++++++++++++++++++++++++ 3 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 tests/test_local_pose_widget.py diff --git a/tests/conftest.py b/tests/conftest.py index 68bb51e..3f03416 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,11 @@ import pytest from pynwb import NWBHDF5IO, read_nwb -from tests.fixtures.synthetic_nwb import create_nwbfile_with_external_videos +from tests.fixtures.synthetic_nwb import ( + create_nwbfile_with_external_videos, + create_nwbfile_with_pose_estimation, + create_nwbfile_with_videos_and_pose, +) from tests.fixtures.synthetic_video import create_synthetic_video @@ -85,3 +89,62 @@ def nwbfile_with_explicit_timestamps(tmp_path, synthetic_video_path): io.write(nwbfile) return read_nwb(nwb_path) + + +@pytest.fixture +def nwbfile_with_single_camera_pose(tmp_path): + """Create an NWB file with pose estimation for a single camera.""" + nwbfile = create_nwbfile_with_pose_estimation( + camera_names=["LeftCamera"], + keypoint_names=["Nose", "LeftEar", "RightEar"], + num_frames=30, + ) + nwb_path = tmp_path / "test_pose.nwb" + + with NWBHDF5IO(nwb_path, "w") as io: + io.write(nwbfile) + + return read_nwb(nwb_path) + + +@pytest.fixture +def nwbfile_with_multiple_cameras_pose(tmp_path): + """Create an NWB file with pose estimation for multiple cameras.""" + nwbfile = create_nwbfile_with_pose_estimation( + camera_names=["LeftCamera", "RightCamera", "BodyCamera"], + keypoint_names=["Nose", "LeftEar", "RightEar", "LeftPaw", "RightPaw"], + num_frames=30, + ) + nwb_path = tmp_path / "test_multi_pose.nwb" + + with NWBHDF5IO(nwb_path, "w") as io: + io.write(nwbfile) + + return read_nwb(nwb_path) + + +@pytest.fixture +def nwbfile_with_videos_and_pose(tmp_path, synthetic_video_paths): + """Create an NWB file with both videos and pose estimation.""" + copied_paths = {} + for name, path in synthetic_video_paths.items(): + video_copy = tmp_path / path.name + shutil.copy(path, video_copy) + copied_paths[name] = video_copy + + # Create pose estimation for cameras that match video names + # VideoLeftCamera -> LeftCamera, etc. + camera_names = [name.replace("Video", "") for name in copied_paths.keys()] + + nwbfile = create_nwbfile_with_videos_and_pose( + video_paths=copied_paths, + camera_names=camera_names, + keypoint_names=["Nose", "LeftEar", "RightEar"], + num_frames=30, + ) + nwb_path = tmp_path / "test_combined.nwb" + + with NWBHDF5IO(nwb_path, "w") as io: + io.write(nwbfile) + + return read_nwb(nwb_path) diff --git a/tests/fixtures/synthetic_nwb.py b/tests/fixtures/synthetic_nwb.py index c3b700f..8ebad7e 100644 --- a/tests/fixtures/synthetic_nwb.py +++ b/tests/fixtures/synthetic_nwb.py @@ -3,7 +3,8 @@ from pathlib import Path import numpy as np -from pynwb import NWBFile +from ndx_pose import PoseEstimation, PoseEstimationSeries +from pynwb import NWBFile, ProcessingModule from pynwb.image import ImageSeries from pynwb.testing.mock.file import mock_NWBFile @@ -50,3 +51,166 @@ def create_nwbfile_with_external_videos( nwbfile.add_acquisition(image_series) return nwbfile + + +def create_nwbfile_with_pose_estimation( + camera_names: list[str], + keypoint_names: list[str], + num_frames: int = 30, + timestamps: np.ndarray | None = None, +) -> NWBFile: + """Create an NWBFile with PoseEstimation data. + + Parameters + ---------- + camera_names : list[str] + Names of cameras to create pose estimation data for + keypoint_names : list[str] + Names of keypoints to track + num_frames : int, optional + Number of frames of pose data, by default 30 + timestamps : np.ndarray, optional + Timestamps for the pose data. If None, uses evenly spaced from 0 to 1. + + Returns + ------- + NWBFile + NWB file with pose estimation processing module + """ + nwbfile = mock_NWBFile() + + # Create pose_estimation processing module + pose_module = ProcessingModule( + name="pose_estimation", + description="Pose estimation data from DeepLabCut or similar", + ) + nwbfile.add_processing_module(pose_module) + + # Default timestamps + if timestamps is None: + timestamps = np.linspace(0.0, 1.0, num_frames) + + # Create a PoseEstimation container for each camera + for camera_name in camera_names: + # Create PoseEstimationSeries for each keypoint + pose_series_list = [] + for keypoint_name in keypoint_names: + # Generate synthetic pose data (x, y coordinates) + # Add some variation to make it more realistic + base_x = 100 + keypoint_names.index(keypoint_name) * 50 + base_y = 200 + keypoint_names.index(keypoint_name) * 30 + x_coords = base_x + np.random.randn(num_frames) * 5 + y_coords = base_y + np.random.randn(num_frames) * 5 + data = np.column_stack([x_coords, y_coords]) + + series = PoseEstimationSeries( + name=f"{keypoint_name}PoseEstimationSeries", + data=data, + reference_frame="top-left corner", + timestamps=timestamps, + ) + pose_series_list.append(series) + + # Create PoseEstimation container for this camera + pose_estimation = PoseEstimation( + name=camera_name, + pose_estimation_series=pose_series_list, + description=f"Pose estimation for {camera_name}", + ) + pose_module.add(pose_estimation) + + return nwbfile + + +def create_nwbfile_with_videos_and_pose( + video_paths: dict[str, Path], + camera_names: list[str], + keypoint_names: list[str], + num_frames: int = 30, + timestamps: dict[str, np.ndarray] | None = None, +) -> NWBFile: + """Create an NWBFile with both external videos and pose estimation. + + Parameters + ---------- + video_paths : dict[str, Path] + Mapping of video names to file paths + camera_names : list[str] + Names of cameras for pose estimation (should match video names pattern) + keypoint_names : list[str] + Names of keypoints to track + num_frames : int, optional + Number of frames of pose data, by default 30 + timestamps : dict[str, np.ndarray], optional + Mapping of video names to timestamp arrays. If None, uses rate-based. + + Returns + ------- + NWBFile + NWB file with both videos and pose estimation + """ + nwbfile = mock_NWBFile() + + # Add videos + for name, path in video_paths.items(): + external_path = f"./{path.name}" + + if timestamps and name in timestamps: + image_series = ImageSeries( + name=name, + format="external", + external_file=[external_path], + timestamps=timestamps[name], + ) + else: + image_series = ImageSeries( + name=name, + format="external", + external_file=[external_path], + starting_time=0.0, + rate=30.0, + ) + + nwbfile.add_acquisition(image_series) + + # Add pose estimation + pose_module = ProcessingModule( + name="pose_estimation", + description="Pose estimation data from DeepLabCut or similar", + ) + nwbfile.add_processing_module(pose_module) + + # Use first video's timestamps if available + pose_timestamps = None + if timestamps: + first_video = next(iter(timestamps.values())) + pose_timestamps = first_video + else: + pose_timestamps = np.linspace(0.0, 1.0, num_frames) + + # Create pose estimation for each camera + for camera_name in camera_names: + pose_series_list = [] + for keypoint_name in keypoint_names: + base_x = 100 + keypoint_names.index(keypoint_name) * 50 + base_y = 200 + keypoint_names.index(keypoint_name) * 30 + x_coords = base_x + np.random.randn(len(pose_timestamps)) * 5 + y_coords = base_y + np.random.randn(len(pose_timestamps)) * 5 + data = np.column_stack([x_coords, y_coords]) + + series = PoseEstimationSeries( + name=f"{keypoint_name}PoseEstimationSeries", + data=data, + reference_frame="top-left corner", + timestamps=pose_timestamps, + ) + pose_series_list.append(series) + + pose_estimation = PoseEstimation( + name=camera_name, + pose_estimation_series=pose_series_list, + description=f"Pose estimation for {camera_name}", + ) + pose_module.add(pose_estimation) + + return nwbfile diff --git a/tests/test_local_pose_widget.py b/tests/test_local_pose_widget.py new file mode 100644 index 0000000..78c5241 --- /dev/null +++ b/tests/test_local_pose_widget.py @@ -0,0 +1,280 @@ +"""Unit tests for NWBLocalPoseEstimationWidget.""" + +import pytest + +from nwb_video_widgets import NWBLocalPoseEstimationWidget +from nwb_video_widgets._utils import ( + discover_pose_estimation_cameras, + get_pose_estimation_info, +) + + +class TestPoseEstimationDiscovery: + """Tests for discovering pose estimation data from NWB files.""" + + def test_discover_single_camera(self, nwbfile_with_single_camera_pose): + """Test discovering pose estimation from a single camera.""" + cameras = discover_pose_estimation_cameras(nwbfile_with_single_camera_pose) + + assert len(cameras) == 1 + assert "LeftCamera" in cameras + + # Check that the camera has pose estimation series + camera_pose = cameras["LeftCamera"] + assert len(camera_pose.pose_estimation_series) == 3 # Nose, LeftEar, RightEar + + def test_discover_multiple_cameras(self, nwbfile_with_multiple_cameras_pose): + """Test discovering pose estimation from multiple cameras.""" + cameras = discover_pose_estimation_cameras(nwbfile_with_multiple_cameras_pose) + + assert len(cameras) == 3 + assert "LeftCamera" in cameras + assert "RightCamera" in cameras + assert "BodyCamera" in cameras + + # Check that each camera has the correct number of keypoints + for camera_name, camera_pose in cameras.items(): + assert len(camera_pose.pose_estimation_series) == 5 # 5 keypoints + + def test_discover_cameras_with_videos(self, nwbfile_with_videos_and_pose): + """Test discovering pose estimation when videos are also present.""" + cameras = discover_pose_estimation_cameras(nwbfile_with_videos_and_pose) + + assert len(cameras) == 3 + assert "LeftCamera" in cameras + assert "RightCamera" in cameras + assert "BodyCamera" in cameras + + +class TestCameraInfoExtraction: + """Tests for extracting camera metadata.""" + + def test_camera_info_single(self, nwbfile_with_single_camera_pose): + """Test extracting info for a single camera.""" + info = get_pose_estimation_info(nwbfile_with_single_camera_pose) + + assert len(info) == 1 + assert "LeftCamera" in info + + camera_info = info["LeftCamera"] + assert "start" in camera_info + assert "end" in camera_info + assert "frames" in camera_info + assert "keypoints" in camera_info + + assert camera_info["frames"] == 30 + assert len(camera_info["keypoints"]) == 3 + assert "Nose" in camera_info["keypoints"] + assert "LeftEar" in camera_info["keypoints"] + assert "RightEar" in camera_info["keypoints"] + + def test_camera_info_multiple(self, nwbfile_with_multiple_cameras_pose): + """Test extracting info for multiple cameras.""" + info = get_pose_estimation_info(nwbfile_with_multiple_cameras_pose) + + assert len(info) == 3 + + for camera_name in ["LeftCamera", "RightCamera", "BodyCamera"]: + assert camera_name in info + camera_info = info[camera_name] + assert camera_info["frames"] == 30 + assert len(camera_info["keypoints"]) == 5 + + +class TestWidgetCreation: + """Tests for widget instantiation.""" + + def test_create_widget_single_camera(self, nwbfile_with_single_camera_pose): + """Test creating widget with single camera.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose) + + assert len(widget.available_cameras) == 1 + assert "LeftCamera" in widget.available_cameras + assert widget.settings_open is True + assert widget.selected_camera == "" # No camera selected by default + + def test_create_widget_multiple_cameras(self, nwbfile_with_multiple_cameras_pose): + """Test creating widget with multiple cameras.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_multiple_cameras_pose) + + assert len(widget.available_cameras) == 3 + assert "LeftCamera" in widget.available_cameras + assert "RightCamera" in widget.available_cameras + assert "BodyCamera" in widget.available_cameras + assert widget.settings_open is True + + def test_create_widget_with_videos(self, nwbfile_with_videos_and_pose): + """Test creating widget when videos are present.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_videos_and_pose) + + assert len(widget.available_cameras) == 3 + assert len(widget.available_videos) == 3 + assert "VideoLeftCamera" in widget.available_videos + assert "VideoBodyCamera" in widget.available_videos + assert "VideoRightCamera" in widget.available_videos + + def test_default_camera_selection(self, nwbfile_with_single_camera_pose): + """Test selecting a default camera.""" + widget = NWBLocalPoseEstimationWidget( + nwbfile_with_single_camera_pose, default_camera="LeftCamera" + ) + + assert widget.selected_camera == "LeftCamera" + + def test_invalid_default_camera(self, nwbfile_with_single_camera_pose): + """Test that invalid default camera falls back to no selection.""" + widget = NWBLocalPoseEstimationWidget( + nwbfile_with_single_camera_pose, default_camera="NonexistentCamera" + ) + + assert widget.selected_camera == "" + + +class TestLazyLoading: + """Tests for lazy loading of pose data.""" + + def test_initial_data_empty(self, nwbfile_with_multiple_cameras_pose): + """Test that pose data is not loaded initially.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_multiple_cameras_pose) + + assert len(widget.all_camera_data) == 0 + assert widget.loading is False + + def test_data_loads_on_selection(self, nwbfile_with_single_camera_pose): + """Test that pose data loads when camera is selected.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose) + + # Initially empty + assert len(widget.all_camera_data) == 0 + + # Select camera + widget.selected_camera = "LeftCamera" + + # Data should be loaded now + assert "LeftCamera" in widget.all_camera_data + camera_data = widget.all_camera_data["LeftCamera"] + + assert "keypoint_metadata" in camera_data + assert "pose_coordinates" in camera_data + assert "timestamps" in camera_data + + # Check keypoints + assert "Nose" in camera_data["keypoint_metadata"] + assert "LeftEar" in camera_data["keypoint_metadata"] + assert "RightEar" in camera_data["keypoint_metadata"] + + # Check coordinates structure + assert len(camera_data["pose_coordinates"]["Nose"]) == 30 + assert len(camera_data["timestamps"]) == 30 + + +class TestKeypointColors: + """Tests for keypoint color assignment.""" + + def test_default_colormap(self, nwbfile_with_single_camera_pose): + """Test that default colormap is applied.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose) + + widget.selected_camera = "LeftCamera" + camera_data = widget.all_camera_data["LeftCamera"] + + # Each keypoint should have a color + for keypoint_name, metadata in camera_data["keypoint_metadata"].items(): + assert "color" in metadata + assert metadata["color"].startswith("#") # Hex color + + def test_custom_colors(self, nwbfile_with_single_camera_pose): + """Test custom color assignment.""" + custom_colors = { + "Nose": "#FF0000", + "LeftEar": "#00FF00", + "RightEar": "#0000FF", + } + + widget = NWBLocalPoseEstimationWidget( + nwbfile_with_single_camera_pose, keypoint_colors=custom_colors + ) + + widget.selected_camera = "LeftCamera" + camera_data = widget.all_camera_data["LeftCamera"] + + # Check custom colors are applied + assert camera_data["keypoint_metadata"]["Nose"]["color"] == "#FF0000" + assert camera_data["keypoint_metadata"]["LeftEar"]["color"] == "#00FF00" + assert camera_data["keypoint_metadata"]["RightEar"]["color"] == "#0000FF" + + def test_different_colormap(self, nwbfile_with_single_camera_pose): + """Test using a different colormap.""" + widget = NWBLocalPoseEstimationWidget( + nwbfile_with_single_camera_pose, keypoint_colors="Set1" + ) + + widget.selected_camera = "LeftCamera" + camera_data = widget.all_camera_data["LeftCamera"] + + # Verify colors are assigned (just check they exist) + for keypoint_name in ["Nose", "LeftEar", "RightEar"]: + assert "color" in camera_data["keypoint_metadata"][keypoint_name] + + +class TestErrorHandling: + """Tests for error handling.""" + + def test_raises_for_missing_pose_module(self, nwbfile_with_single_video): + """Test that error is raised when pose_estimation module is missing.""" + with pytest.raises(ValueError, match="pose_estimation processing module"): + NWBLocalPoseEstimationWidget(nwbfile_with_single_video) + + def test_raises_for_in_memory_nwbfile(self): + """Test that error is raised for NWB files not loaded from disk.""" + from pynwb import ProcessingModule + from pynwb.testing.mock.file import mock_NWBFile + + from ndx_pose import PoseEstimation, PoseEstimationSeries + + nwbfile = mock_NWBFile() + + # Add pose estimation to in-memory file + pose_module = ProcessingModule( + name="pose_estimation", + description="Test pose estimation", + ) + nwbfile.add_processing_module(pose_module) + + # Create a simple pose estimation + series = PoseEstimationSeries( + name="NosePoseEstimationSeries", + data=[[100.0, 200.0], [101.0, 201.0]], + reference_frame="top-left", + timestamps=[0.0, 0.1], + ) + pose_estimation = PoseEstimation( + name="TestCamera", + pose_estimation_series=[series], + ) + pose_module.add(pose_estimation) + + with pytest.raises(ValueError, match="loaded from disk"): + NWBLocalPoseEstimationWidget(nwbfile) + + +class TestVideoNameMapping: + """Tests for video name to URL mapping.""" + + def test_video_urls_extracted(self, nwbfile_with_videos_and_pose): + """Test that video URLs are extracted correctly.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_videos_and_pose) + + assert len(widget.video_name_to_url) == 3 + + # Check that URLs are HTTP addresses + for video_name, url in widget.video_name_to_url.items(): + assert url.startswith("http://127.0.0.1:") + assert url.endswith(".mp4") + + def test_camera_to_video_initially_empty(self, nwbfile_with_videos_and_pose): + """Test that camera-to-video mapping starts empty.""" + widget = NWBLocalPoseEstimationWidget(nwbfile_with_videos_and_pose) + + # Should start empty - users need to explicitly map cameras to videos + assert len(widget.camera_to_video) == 0 From 7d63d0b5188b573a28fe42d2170bf09be6a7bb37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:34:43 +0000 Subject: [PATCH 03/12] Fix import ordering in test_local_pose_widget.py Co-authored-by: pauladkisson <34703136+pauladkisson@users.noreply.github.com> --- tests/test_local_pose_widget.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_local_pose_widget.py b/tests/test_local_pose_widget.py index 78c5241..b3e2043 100644 --- a/tests/test_local_pose_widget.py +++ b/tests/test_local_pose_widget.py @@ -227,11 +227,10 @@ def test_raises_for_missing_pose_module(self, nwbfile_with_single_video): def test_raises_for_in_memory_nwbfile(self): """Test that error is raised for NWB files not loaded from disk.""" + from ndx_pose import PoseEstimation, PoseEstimationSeries from pynwb import ProcessingModule from pynwb.testing.mock.file import mock_NWBFile - from ndx_pose import PoseEstimation, PoseEstimationSeries - nwbfile = mock_NWBFile() # Add pose estimation to in-memory file From bc0d5e9f7582f513e6231b8874ad107c1ab5242a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:44:02 +0000 Subject: [PATCH 04/12] Add ndx-pose to test dependencies to fix import error Co-authored-by: pauladkisson <34703136+pauladkisson@users.noreply.github.com> --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8b156f3..56b45b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dandi = ["dandi>=0.60.0", "remfile>=0.1.13", "h5py"] test = [ "pytest>=7.0", "pytest-cov", + "ndx-pose", ] dev = [ From 44d5125920c1be2d425f2e2b5f93d0cf5a2be4ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:51:11 +0000 Subject: [PATCH 05/12] Update CHANGELOG.md with PoseEstimation test additions Co-authored-by: pauladkisson <34703136+pauladkisson@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb64e6..0ce3090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Improvements +* Added comprehensive unit tests for PoseEstimation widgets covering discovery, widget creation, lazy loading, keypoint colors, error handling, and video mapping. [PR #15](https://github.com/catalystneuro/nwb-video-widgets/pull/15) + # v0.1.5 (2026-02-03) ## Removals, Deprecations and Changes From 10a8c6372ad6a7370f8b2a60ee43c07c68e6bab3 Mon Sep 17 00:00:00 2001 From: Paul Adkisson-Floro Date: Tue, 17 Feb 2026 15:10:50 -0800 Subject: [PATCH 06/12] Apply suggestion from @pauladkisson --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce3090..fdf85b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ## Improvements -* Added comprehensive unit tests for PoseEstimation widgets covering discovery, widget creation, lazy loading, keypoint colors, error handling, and video mapping. [PR #15](https://github.com/catalystneuro/nwb-video-widgets/pull/15) +* Added comprehensive unit tests for PoseEstimation widgets covering discovery, widget creation, lazy loading, keypoint colors, error handling, and video mapping. [PR #16](https://github.com/catalystneuro/nwb-video-widgets/pull/16) # v0.1.5 (2026-02-03) From cd8a7ccde4cf79119de2ab657cb02817ab06b823 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 18 Feb 2026 09:22:47 -0800 Subject: [PATCH 07/12] Updated synthetic_nwb --- tests/fixtures/synthetic_nwb.py | 51 +++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/tests/fixtures/synthetic_nwb.py b/tests/fixtures/synthetic_nwb.py index 8ebad7e..212fcde 100644 --- a/tests/fixtures/synthetic_nwb.py +++ b/tests/fixtures/synthetic_nwb.py @@ -58,6 +58,8 @@ def create_nwbfile_with_pose_estimation( keypoint_names: list[str], num_frames: int = 30, timestamps: np.ndarray | None = None, + video_width: int = 160, + video_height: int = 120, ) -> NWBFile: """Create an NWBFile with PoseEstimation data. @@ -71,6 +73,10 @@ def create_nwbfile_with_pose_estimation( Number of frames of pose data, by default 30 timestamps : np.ndarray, optional Timestamps for the pose data. If None, uses evenly spaced from 0 to 1. + video_width : int, optional + Width of the source video in pixels, by default 160 + video_height : int, optional + Height of the source video in pixels, by default 120 Returns ------- @@ -91,23 +97,28 @@ def create_nwbfile_with_pose_estimation( timestamps = np.linspace(0.0, 1.0, num_frames) # Create a PoseEstimation container for each camera + frame_indices = np.arange(num_frames) + circle_x = video_width * (0.2 + 0.6 * frame_indices / num_frames) + circle_y = np.full(num_frames, video_height / 2) + noise_scale = max(1, int(video_width * 0.01)) for camera_name in camera_names: # Create PoseEstimationSeries for each keypoint pose_series_list = [] - for keypoint_name in keypoint_names: - # Generate synthetic pose data (x, y coordinates) - # Add some variation to make it more realistic - base_x = 100 + keypoint_names.index(keypoint_name) * 50 - base_y = 200 + keypoint_names.index(keypoint_name) * 30 - x_coords = base_x + np.random.randn(num_frames) * 5 - y_coords = base_y + np.random.randn(num_frames) * 5 + for idx, keypoint_name in enumerate(keypoint_names): + # Generate synthetic pose data tracking the moving circle in synthetic_video.py + x_offset = idx * int(video_width * 0.05) + y_offset = idx * int(video_height * 0.05) + x_coords = circle_x + x_offset + np.random.randn(num_frames) * noise_scale + y_coords = circle_y + y_offset + np.random.randn(num_frames) * noise_scale data = np.column_stack([x_coords, y_coords]) series = PoseEstimationSeries( name=f"{keypoint_name}PoseEstimationSeries", data=data, + unit="pixels", reference_frame="top-left corner", timestamps=timestamps, + confidence=np.random.rand(num_frames), ) pose_series_list.append(series) @@ -116,6 +127,7 @@ def create_nwbfile_with_pose_estimation( name=camera_name, pose_estimation_series=pose_series_list, description=f"Pose estimation for {camera_name}", + dimensions=np.array([[video_width, video_height]], dtype="uint16"), ) pose_module.add(pose_estimation) @@ -128,6 +140,8 @@ def create_nwbfile_with_videos_and_pose( keypoint_names: list[str], num_frames: int = 30, timestamps: dict[str, np.ndarray] | None = None, + video_width: int = 160, + video_height: int = 120, ) -> NWBFile: """Create an NWBFile with both external videos and pose estimation. @@ -143,6 +157,10 @@ def create_nwbfile_with_videos_and_pose( Number of frames of pose data, by default 30 timestamps : dict[str, np.ndarray], optional Mapping of video names to timestamp arrays. If None, uses rate-based. + video_width : int, optional + Width of the source video in pixels, by default 160 + video_height : int, optional + Height of the source video in pixels, by default 120 Returns ------- @@ -189,20 +207,28 @@ def create_nwbfile_with_videos_and_pose( pose_timestamps = np.linspace(0.0, 1.0, num_frames) # Create pose estimation for each camera + n = len(pose_timestamps) + frame_indices = np.arange(n) + circle_x = video_width * (0.2 + 0.6 * frame_indices / n) + circle_y = np.full(n, video_height / 2) + noise_scale = max(1, int(video_width * 0.01)) for camera_name in camera_names: pose_series_list = [] - for keypoint_name in keypoint_names: - base_x = 100 + keypoint_names.index(keypoint_name) * 50 - base_y = 200 + keypoint_names.index(keypoint_name) * 30 - x_coords = base_x + np.random.randn(len(pose_timestamps)) * 5 - y_coords = base_y + np.random.randn(len(pose_timestamps)) * 5 + for idx, keypoint_name in enumerate(keypoint_names): + # Generate synthetic pose data tracking the moving circle in synthetic_video.py + x_offset = idx * int(video_width * 0.05) + y_offset = idx * int(video_height * 0.05) + x_coords = circle_x + x_offset + np.random.randn(n) * noise_scale + y_coords = circle_y + y_offset + np.random.randn(n) * noise_scale data = np.column_stack([x_coords, y_coords]) series = PoseEstimationSeries( name=f"{keypoint_name}PoseEstimationSeries", data=data, + unit="pixels", reference_frame="top-left corner", timestamps=pose_timestamps, + confidence=np.random.rand(len(pose_timestamps)), ) pose_series_list.append(series) @@ -210,6 +236,7 @@ def create_nwbfile_with_videos_and_pose( name=camera_name, pose_estimation_series=pose_series_list, description=f"Pose estimation for {camera_name}", + dimensions=np.array([[video_width, video_height]], dtype="uint16"), ) pose_module.add(pose_estimation) From 4664342f94178df731270b552bfad7712bd0a887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:19:38 +0000 Subject: [PATCH 08/12] Replace custom mock pose data with NeuroConv MockPoseEstimationInterface Co-authored-by: pauladkisson <34703136+pauladkisson@users.noreply.github.com> --- pyproject.toml | 2 +- tests/conftest.py | 6 +- tests/fixtures/synthetic_nwb.py | 168 +++++++++++++------------------- tests/test_local_pose_widget.py | 55 ++++------- 4 files changed, 91 insertions(+), 140 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56b45b0..31dbb1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dandi = ["dandi>=0.60.0", "remfile>=0.1.13", "h5py"] test = [ "pytest>=7.0", "pytest-cov", - "ndx-pose", + "neuroconv", ] dev = [ diff --git a/tests/conftest.py b/tests/conftest.py index 3f03416..1195481 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,7 +96,7 @@ def nwbfile_with_single_camera_pose(tmp_path): """Create an NWB file with pose estimation for a single camera.""" nwbfile = create_nwbfile_with_pose_estimation( camera_names=["LeftCamera"], - keypoint_names=["Nose", "LeftEar", "RightEar"], + num_nodes=3, num_frames=30, ) nwb_path = tmp_path / "test_pose.nwb" @@ -112,7 +112,7 @@ def nwbfile_with_multiple_cameras_pose(tmp_path): """Create an NWB file with pose estimation for multiple cameras.""" nwbfile = create_nwbfile_with_pose_estimation( camera_names=["LeftCamera", "RightCamera", "BodyCamera"], - keypoint_names=["Nose", "LeftEar", "RightEar", "LeftPaw", "RightPaw"], + num_nodes=5, num_frames=30, ) nwb_path = tmp_path / "test_multi_pose.nwb" @@ -139,7 +139,7 @@ def nwbfile_with_videos_and_pose(tmp_path, synthetic_video_paths): nwbfile = create_nwbfile_with_videos_and_pose( video_paths=copied_paths, camera_names=camera_names, - keypoint_names=["Nose", "LeftEar", "RightEar"], + num_nodes=3, num_frames=30, ) nwb_path = tmp_path / "test_combined.nwb" diff --git a/tests/fixtures/synthetic_nwb.py b/tests/fixtures/synthetic_nwb.py index 212fcde..01ff657 100644 --- a/tests/fixtures/synthetic_nwb.py +++ b/tests/fixtures/synthetic_nwb.py @@ -3,7 +3,7 @@ from pathlib import Path import numpy as np -from ndx_pose import PoseEstimation, PoseEstimationSeries +from neuroconv.tools.testing.mock_interfaces import MockPoseEstimationInterface from pynwb import NWBFile, ProcessingModule from pynwb.image import ImageSeries from pynwb.testing.mock.file import mock_NWBFile @@ -53,30 +53,69 @@ def create_nwbfile_with_external_videos( return nwbfile +def _add_pose_estimation_to_module( + pose_module: ProcessingModule, + camera_names: list[str], + mock_interface: MockPoseEstimationInterface, + timestamps: np.ndarray, +) -> None: + """Add PoseEstimation containers to a processing module using MockPoseEstimationInterface data. + + Parameters + ---------- + pose_module : ProcessingModule + The NWB processing module to add pose estimation containers to + camera_names : list[str] + Names for each PoseEstimation container + mock_interface : MockPoseEstimationInterface + NeuroConv mock interface providing pose data and node names + timestamps : np.ndarray + Timestamps for all pose estimation series + """ + from ndx_pose import PoseEstimation, PoseEstimationSeries + + num_frames = len(timestamps) + for camera_name in camera_names: + pose_series_list = [] + for idx, node_name in enumerate(mock_interface.nodes): + pascal_case_node = "".join(word.capitalize() for word in node_name.replace("_", " ").split()) + series = PoseEstimationSeries( + name=f"PoseEstimationSeries{pascal_case_node}", + data=mock_interface.pose_data[:num_frames, idx, :], + unit="pixels", + reference_frame="top-left corner", + timestamps=timestamps, + confidence=np.ones(num_frames), + confidence_definition="Mock confidence", + ) + pose_series_list.append(series) + + pose_estimation = PoseEstimation( + name=camera_name, + pose_estimation_series=pose_series_list, + description=f"Pose estimation for {camera_name}", + ) + pose_module.add(pose_estimation) + + def create_nwbfile_with_pose_estimation( camera_names: list[str], - keypoint_names: list[str], + num_nodes: int = 3, num_frames: int = 30, - timestamps: np.ndarray | None = None, - video_width: int = 160, - video_height: int = 120, + seed: int = 0, ) -> NWBFile: - """Create an NWBFile with PoseEstimation data. + """Create an NWBFile with PoseEstimation data using NeuroConv's MockPoseEstimationInterface. Parameters ---------- camera_names : list[str] Names of cameras to create pose estimation data for - keypoint_names : list[str] - Names of keypoints to track + num_nodes : int, optional + Number of keypoint nodes per camera, by default 3 num_frames : int, optional Number of frames of pose data, by default 30 - timestamps : np.ndarray, optional - Timestamps for the pose data. If None, uses evenly spaced from 0 to 1. - video_width : int, optional - Width of the source video in pixels, by default 160 - video_height : int, optional - Height of the source video in pixels, by default 120 + seed : int, optional + Random seed for reproducible data generation, by default 0 Returns ------- @@ -85,51 +124,16 @@ def create_nwbfile_with_pose_estimation( """ nwbfile = mock_NWBFile() - # Create pose_estimation processing module pose_module = ProcessingModule( name="pose_estimation", description="Pose estimation data from DeepLabCut or similar", ) nwbfile.add_processing_module(pose_module) - # Default timestamps - if timestamps is None: - timestamps = np.linspace(0.0, 1.0, num_frames) - - # Create a PoseEstimation container for each camera - frame_indices = np.arange(num_frames) - circle_x = video_width * (0.2 + 0.6 * frame_indices / num_frames) - circle_y = np.full(num_frames, video_height / 2) - noise_scale = max(1, int(video_width * 0.01)) - for camera_name in camera_names: - # Create PoseEstimationSeries for each keypoint - pose_series_list = [] - for idx, keypoint_name in enumerate(keypoint_names): - # Generate synthetic pose data tracking the moving circle in synthetic_video.py - x_offset = idx * int(video_width * 0.05) - y_offset = idx * int(video_height * 0.05) - x_coords = circle_x + x_offset + np.random.randn(num_frames) * noise_scale - y_coords = circle_y + y_offset + np.random.randn(num_frames) * noise_scale - data = np.column_stack([x_coords, y_coords]) - - series = PoseEstimationSeries( - name=f"{keypoint_name}PoseEstimationSeries", - data=data, - unit="pixels", - reference_frame="top-left corner", - timestamps=timestamps, - confidence=np.random.rand(num_frames), - ) - pose_series_list.append(series) + mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) + timestamps = mock_interface.get_timestamps() - # Create PoseEstimation container for this camera - pose_estimation = PoseEstimation( - name=camera_name, - pose_estimation_series=pose_series_list, - description=f"Pose estimation for {camera_name}", - dimensions=np.array([[video_width, video_height]], dtype="uint16"), - ) - pose_module.add(pose_estimation) + _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, timestamps) return nwbfile @@ -137,30 +141,29 @@ def create_nwbfile_with_pose_estimation( def create_nwbfile_with_videos_and_pose( video_paths: dict[str, Path], camera_names: list[str], - keypoint_names: list[str], + num_nodes: int = 3, num_frames: int = 30, timestamps: dict[str, np.ndarray] | None = None, - video_width: int = 160, - video_height: int = 120, + seed: int = 0, ) -> NWBFile: """Create an NWBFile with both external videos and pose estimation. + Uses NeuroConv's MockPoseEstimationInterface for synthetic pose data generation. + Parameters ---------- video_paths : dict[str, Path] Mapping of video names to file paths camera_names : list[str] Names of cameras for pose estimation (should match video names pattern) - keypoint_names : list[str] - Names of keypoints to track + num_nodes : int, optional + Number of keypoint nodes per camera, by default 3 num_frames : int, optional Number of frames of pose data, by default 30 timestamps : dict[str, np.ndarray], optional Mapping of video names to timestamp arrays. If None, uses rate-based. - video_width : int, optional - Width of the source video in pixels, by default 160 - video_height : int, optional - Height of the source video in pixels, by default 120 + seed : int, optional + Random seed for reproducible data generation, by default 0 Returns ------- @@ -198,46 +201,15 @@ def create_nwbfile_with_videos_and_pose( ) nwbfile.add_processing_module(pose_module) - # Use first video's timestamps if available - pose_timestamps = None + # Use first video's timestamps if provided, otherwise use mock's timestamps if timestamps: - first_video = next(iter(timestamps.values())) - pose_timestamps = first_video + pose_timestamps = next(iter(timestamps.values())) else: - pose_timestamps = np.linspace(0.0, 1.0, num_frames) - - # Create pose estimation for each camera - n = len(pose_timestamps) - frame_indices = np.arange(n) - circle_x = video_width * (0.2 + 0.6 * frame_indices / n) - circle_y = np.full(n, video_height / 2) - noise_scale = max(1, int(video_width * 0.01)) - for camera_name in camera_names: - pose_series_list = [] - for idx, keypoint_name in enumerate(keypoint_names): - # Generate synthetic pose data tracking the moving circle in synthetic_video.py - x_offset = idx * int(video_width * 0.05) - y_offset = idx * int(video_height * 0.05) - x_coords = circle_x + x_offset + np.random.randn(n) * noise_scale - y_coords = circle_y + y_offset + np.random.randn(n) * noise_scale - data = np.column_stack([x_coords, y_coords]) + mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) + pose_timestamps = mock_interface.get_timestamps() - series = PoseEstimationSeries( - name=f"{keypoint_name}PoseEstimationSeries", - data=data, - unit="pixels", - reference_frame="top-left corner", - timestamps=pose_timestamps, - confidence=np.random.rand(len(pose_timestamps)), - ) - pose_series_list.append(series) + mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=len(pose_timestamps), seed=seed) - pose_estimation = PoseEstimation( - name=camera_name, - pose_estimation_series=pose_series_list, - description=f"Pose estimation for {camera_name}", - dimensions=np.array([[video_width, video_height]], dtype="uint16"), - ) - pose_module.add(pose_estimation) + _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, pose_timestamps) return nwbfile diff --git a/tests/test_local_pose_widget.py b/tests/test_local_pose_widget.py index b3e2043..28f0f2f 100644 --- a/tests/test_local_pose_widget.py +++ b/tests/test_local_pose_widget.py @@ -64,9 +64,9 @@ def test_camera_info_single(self, nwbfile_with_single_camera_pose): assert camera_info["frames"] == 30 assert len(camera_info["keypoints"]) == 3 - assert "Nose" in camera_info["keypoints"] - assert "LeftEar" in camera_info["keypoints"] - assert "RightEar" in camera_info["keypoints"] + assert "Head" in camera_info["keypoints"] + assert "Neck" in camera_info["keypoints"] + assert "LeftShoulder" in camera_info["keypoints"] def test_camera_info_multiple(self, nwbfile_with_multiple_cameras_pose): """Test extracting info for multiple cameras.""" @@ -159,12 +159,12 @@ def test_data_loads_on_selection(self, nwbfile_with_single_camera_pose): assert "timestamps" in camera_data # Check keypoints - assert "Nose" in camera_data["keypoint_metadata"] - assert "LeftEar" in camera_data["keypoint_metadata"] - assert "RightEar" in camera_data["keypoint_metadata"] + assert "Head" in camera_data["keypoint_metadata"] + assert "Neck" in camera_data["keypoint_metadata"] + assert "LeftShoulder" in camera_data["keypoint_metadata"] # Check coordinates structure - assert len(camera_data["pose_coordinates"]["Nose"]) == 30 + assert len(camera_data["pose_coordinates"]["Head"]) == 30 assert len(camera_data["timestamps"]) == 30 @@ -186,9 +186,9 @@ def test_default_colormap(self, nwbfile_with_single_camera_pose): def test_custom_colors(self, nwbfile_with_single_camera_pose): """Test custom color assignment.""" custom_colors = { - "Nose": "#FF0000", - "LeftEar": "#00FF00", - "RightEar": "#0000FF", + "Head": "#FF0000", + "Neck": "#00FF00", + "LeftShoulder": "#0000FF", } widget = NWBLocalPoseEstimationWidget( @@ -199,9 +199,9 @@ def test_custom_colors(self, nwbfile_with_single_camera_pose): camera_data = widget.all_camera_data["LeftCamera"] # Check custom colors are applied - assert camera_data["keypoint_metadata"]["Nose"]["color"] == "#FF0000" - assert camera_data["keypoint_metadata"]["LeftEar"]["color"] == "#00FF00" - assert camera_data["keypoint_metadata"]["RightEar"]["color"] == "#0000FF" + assert camera_data["keypoint_metadata"]["Head"]["color"] == "#FF0000" + assert camera_data["keypoint_metadata"]["Neck"]["color"] == "#00FF00" + assert camera_data["keypoint_metadata"]["LeftShoulder"]["color"] == "#0000FF" def test_different_colormap(self, nwbfile_with_single_camera_pose): """Test using a different colormap.""" @@ -213,7 +213,7 @@ def test_different_colormap(self, nwbfile_with_single_camera_pose): camera_data = widget.all_camera_data["LeftCamera"] # Verify colors are assigned (just check they exist) - for keypoint_name in ["Nose", "LeftEar", "RightEar"]: + for keypoint_name in ["Head", "Neck", "LeftShoulder"]: assert "color" in camera_data["keypoint_metadata"][keypoint_name] @@ -227,31 +227,10 @@ def test_raises_for_missing_pose_module(self, nwbfile_with_single_video): def test_raises_for_in_memory_nwbfile(self): """Test that error is raised for NWB files not loaded from disk.""" - from ndx_pose import PoseEstimation, PoseEstimationSeries - from pynwb import ProcessingModule - from pynwb.testing.mock.file import mock_NWBFile + from tests.fixtures.synthetic_nwb import create_nwbfile_with_pose_estimation - nwbfile = mock_NWBFile() - - # Add pose estimation to in-memory file - pose_module = ProcessingModule( - name="pose_estimation", - description="Test pose estimation", - ) - nwbfile.add_processing_module(pose_module) - - # Create a simple pose estimation - series = PoseEstimationSeries( - name="NosePoseEstimationSeries", - data=[[100.0, 200.0], [101.0, 201.0]], - reference_frame="top-left", - timestamps=[0.0, 0.1], - ) - pose_estimation = PoseEstimation( - name="TestCamera", - pose_estimation_series=[series], - ) - pose_module.add(pose_estimation) + # Create an in-memory NWBFile (not written to disk and read back) + nwbfile = create_nwbfile_with_pose_estimation(camera_names=["TestCamera"]) with pytest.raises(ValueError, match="loaded from disk"): NWBLocalPoseEstimationWidget(nwbfile) From a148fa0cef957b8206327beb2b8b634e64b42b2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:46:25 +0000 Subject: [PATCH 09/12] Add ndx-pose as main dep; use add_to_nwbfile; search all modules for PoseEstimation Co-authored-by: pauladkisson <34703136+pauladkisson@users.noreply.github.com> --- pyproject.toml | 1 + src/nwb_video_widgets/_utils.py | 22 ++-- src/nwb_video_widgets/dandi_pose_widget.py | 17 ++- src/nwb_video_widgets/local_pose_widget.py | 17 ++- tests/conftest.py | 4 +- tests/fixtures/synthetic_nwb.py | 117 ++++++++++----------- tests/test_local_pose_widget.py | 47 ++++----- 7 files changed, 102 insertions(+), 123 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 31dbb1d..32cfcbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "numpy", "matplotlib", "ipykernel>=7.1.0", + "ndx-pose", ] [project.optional-dependencies] diff --git a/src/nwb_video_widgets/_utils.py b/src/nwb_video_widgets/_utils.py index 653eb76..693ff9c 100644 --- a/src/nwb_video_widgets/_utils.py +++ b/src/nwb_video_widgets/_utils.py @@ -223,6 +223,8 @@ def start_video_server(directory: Path) -> int: def discover_pose_estimation_cameras(nwbfile: NWBFile) -> dict: """Discover all PoseEstimation containers in an NWB file. + Searches all processing modules for PoseEstimation objects. + Parameters ---------- nwbfile : NWBFile @@ -231,20 +233,14 @@ def discover_pose_estimation_cameras(nwbfile: NWBFile) -> dict: Returns ------- dict - Mapping of camera names to PoseEstimation objects from - processing['pose_estimation']. + Mapping of camera names to PoseEstimation objects found across + all processing modules. """ - if "pose_estimation" not in nwbfile.processing: - return {} - - pose_module = nwbfile.processing["pose_estimation"] - - # Get only PoseEstimation objects (not Skeletons or other types) cameras = {} - for name, obj in pose_module.data_interfaces.items(): - if type(obj).__name__ == "PoseEstimation": - cameras[name] = obj - + for module in nwbfile.processing.values(): + for name, obj in module.data_interfaces.items(): + if type(obj).__name__ == "PoseEstimation": + cameras[name] = obj return cameras @@ -285,7 +281,7 @@ def get_pose_estimation_info(nwbfile: NWBFile) -> dict[str, dict]: Parameters ---------- nwbfile : NWBFile - NWB file containing pose estimation in processing['pose_estimation'] + NWB file containing pose estimation data in any processing module Returns ------- diff --git a/src/nwb_video_widgets/dandi_pose_widget.py b/src/nwb_video_widgets/dandi_pose_widget.py index 2534ecc..aeb6852 100644 --- a/src/nwb_video_widgets/dandi_pose_widget.py +++ b/src/nwb_video_widgets/dandi_pose_widget.py @@ -156,13 +156,10 @@ def __init__( colormap_name = "tab10" custom_colors = keypoint_colors - # Get pose estimation container - if "pose_estimation" not in nwbfile.processing: - raise ValueError("NWB file does not contain pose_estimation processing module") - pose_estimation = nwbfile.processing["pose_estimation"] - - # Get all PoseEstimation containers (excludes Skeletons and other metadata) + # Get all PoseEstimation containers across all processing modules pose_containers = discover_pose_estimation_cameras(nwbfile) + if not pose_containers: + raise ValueError("NWB file does not contain any PoseEstimation data in processing modules") available_cameras = list(pose_containers.keys()) # Get camera info for settings panel display @@ -185,7 +182,7 @@ def __init__( selected_camera = "" # Store references for lazy loading (not synced to JS) - self._pose_estimation = pose_estimation + self._pose_containers = pose_containers self._cmap = plt.get_cmap(colormap_name) self._custom_colors = custom_colors @@ -216,7 +213,7 @@ def _on_camera_selected(self, change): try: # Load pose data for this camera camera_data = self._load_camera_pose_data( - self._pose_estimation, camera_name, self._cmap, self._custom_colors + self._pose_containers, camera_name, self._cmap, self._custom_colors ) # Update all_camera_data (must create new dict for traitlets to detect change) @@ -296,7 +293,7 @@ def _get_video_urls_from_dandi( return video_urls @staticmethod - def _load_camera_pose_data(pose_estimation, camera_name: str, cmap, custom_colors: dict) -> dict: + def _load_camera_pose_data(pose_containers: dict, camera_name: str, cmap, custom_colors: dict) -> dict: """Load pose data for a single camera. Returns a dict with: @@ -304,7 +301,7 @@ def _load_camera_pose_data(pose_estimation, camera_name: str, cmap, custom_color - pose_coordinates: {name: [[x, y], ...]} as JSON-serializable lists - timestamps: [t0, t1, ...] as JSON-serializable list """ - camera_pose = pose_estimation[camera_name] + camera_pose = pose_containers[camera_name] keypoint_names = list(camera_pose.pose_estimation_series.keys()) n_kp = len(keypoint_names) diff --git a/src/nwb_video_widgets/local_pose_widget.py b/src/nwb_video_widgets/local_pose_widget.py index 3d9bc96..a2d4a2a 100644 --- a/src/nwb_video_widgets/local_pose_widget.py +++ b/src/nwb_video_widgets/local_pose_widget.py @@ -124,13 +124,10 @@ def __init__( colormap_name = "tab10" custom_colors = keypoint_colors - # Get pose estimation container - if "pose_estimation" not in nwbfile.processing: - raise ValueError("NWB file does not contain pose_estimation processing module") - pose_estimation = nwbfile.processing["pose_estimation"] - - # Get all PoseEstimation containers (excludes Skeletons and other metadata) + # Get all PoseEstimation containers across all processing modules pose_containers = discover_pose_estimation_cameras(nwbfile) + if not pose_containers: + raise ValueError("NWB file does not contain any PoseEstimation data in processing modules") available_cameras = list(pose_containers.keys()) # Get camera info for settings panel display @@ -153,7 +150,7 @@ def __init__( selected_camera = "" # Store references for lazy loading (not synced to JS) - self._pose_estimation = pose_estimation + self._pose_containers = pose_containers self._cmap = plt.get_cmap(colormap_name) self._custom_colors = custom_colors @@ -184,7 +181,7 @@ def _on_camera_selected(self, change): try: # Load pose data for this camera camera_data = self._load_camera_pose_data( - self._pose_estimation, camera_name, self._cmap, self._custom_colors + self._pose_containers, camera_name, self._cmap, self._custom_colors ) # Update all_camera_data (must create new dict for traitlets to detect change) @@ -277,7 +274,7 @@ def _get_video_urls_from_local(nwbfile: NWBFile) -> dict[str, str]: return video_urls @staticmethod - def _load_camera_pose_data(pose_estimation, camera_name: str, cmap, custom_colors: dict) -> dict: + def _load_camera_pose_data(pose_containers: dict, camera_name: str, cmap, custom_colors: dict) -> dict: """Load pose data for a single camera. Returns a dict with: @@ -285,7 +282,7 @@ def _load_camera_pose_data(pose_estimation, camera_name: str, cmap, custom_color - pose_coordinates: {name: [[x, y], ...]} as JSON-serializable lists - timestamps: [t0, t1, ...] as JSON-serializable list """ - camera_pose = pose_estimation[camera_name] + camera_pose = pose_containers[camera_name] keypoint_names = list(camera_pose.pose_estimation_series.keys()) n_kp = len(keypoint_names) diff --git a/tests/conftest.py b/tests/conftest.py index 1195481..36609d9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,7 +95,7 @@ def nwbfile_with_explicit_timestamps(tmp_path, synthetic_video_path): def nwbfile_with_single_camera_pose(tmp_path): """Create an NWB file with pose estimation for a single camera.""" nwbfile = create_nwbfile_with_pose_estimation( - camera_names=["LeftCamera"], + camera_names=["MockPoseEstimation"], num_nodes=3, num_frames=30, ) @@ -111,7 +111,7 @@ def nwbfile_with_single_camera_pose(tmp_path): def nwbfile_with_multiple_cameras_pose(tmp_path): """Create an NWB file with pose estimation for multiple cameras.""" nwbfile = create_nwbfile_with_pose_estimation( - camera_names=["LeftCamera", "RightCamera", "BodyCamera"], + camera_names=["MockPoseEstimation", "RightCamera", "BodyCamera"], num_nodes=5, num_frames=30, ) diff --git a/tests/fixtures/synthetic_nwb.py b/tests/fixtures/synthetic_nwb.py index 01ff657..08b0a01 100644 --- a/tests/fixtures/synthetic_nwb.py +++ b/tests/fixtures/synthetic_nwb.py @@ -4,7 +4,7 @@ import numpy as np from neuroconv.tools.testing.mock_interfaces import MockPoseEstimationInterface -from pynwb import NWBFile, ProcessingModule +from pynwb import NWBFile from pynwb.image import ImageSeries from pynwb.testing.mock.file import mock_NWBFile @@ -53,49 +53,67 @@ def create_nwbfile_with_external_videos( return nwbfile -def _add_pose_estimation_to_module( - pose_module: ProcessingModule, +def _add_cameras_via_mock_interface( + nwbfile: NWBFile, camera_names: list[str], - mock_interface: MockPoseEstimationInterface, - timestamps: np.ndarray, + num_nodes: int, + num_frames: int, + seed: int, ) -> None: - """Add PoseEstimation containers to a processing module using MockPoseEstimationInterface data. + """Add PoseEstimation containers to an NWBFile using MockPoseEstimationInterface.add_to_nwbfile. + + Uses add_to_nwbfile for the first camera (which creates the behavior module, device, + and skeleton), then adds subsequent cameras directly to the existing behavior module. Parameters ---------- - pose_module : ProcessingModule - The NWB processing module to add pose estimation containers to + nwbfile : NWBFile + The NWB file to add pose estimation data to camera_names : list[str] Names for each PoseEstimation container - mock_interface : MockPoseEstimationInterface - NeuroConv mock interface providing pose data and node names - timestamps : np.ndarray - Timestamps for all pose estimation series + num_nodes : int + Number of keypoint nodes per camera + num_frames : int + Number of frames of pose data + seed : int + Random seed for reproducible data generation """ from ndx_pose import PoseEstimation, PoseEstimationSeries - num_frames = len(timestamps) - for camera_name in camera_names: - pose_series_list = [] - for idx, node_name in enumerate(mock_interface.nodes): - pascal_case_node = "".join(word.capitalize() for word in node_name.replace("_", " ").split()) - series = PoseEstimationSeries( - name=f"PoseEstimationSeries{pascal_case_node}", - data=mock_interface.pose_data[:num_frames, idx, :], - unit="pixels", - reference_frame="top-left corner", - timestamps=timestamps, - confidence=np.ones(num_frames), - confidence_definition="Mock confidence", - ) - pose_series_list.append(series) - - pose_estimation = PoseEstimation( - name=camera_name, - pose_estimation_series=pose_series_list, - description=f"Pose estimation for {camera_name}", + for i, camera_name in enumerate(camera_names): + mock = MockPoseEstimationInterface( + pose_estimation_metadata_key=camera_name, + num_nodes=num_nodes, + num_samples=num_frames, + seed=seed, ) - pose_module.add(pose_estimation) + if i == 0: + # First camera: use add_to_nwbfile to create the behavior module, device, and skeleton + mock.add_to_nwbfile(nwbfile) + else: + # Additional cameras: add PoseEstimation directly to the existing behavior module + # (avoids Device/Skeleton naming conflicts from repeated add_to_nwbfile calls) + behavior_module = nwbfile.processing["behavior"] + timestamps = mock.get_timestamps() + pose_series_list = [] + for idx, node_name in enumerate(mock.nodes): + pascal_case_node = "".join(word.capitalize() for word in node_name.replace("_", " ").split()) + series = PoseEstimationSeries( + name=f"PoseEstimationSeries{pascal_case_node}", + data=mock.pose_data[:num_frames, idx, :], + unit="pixels", + reference_frame="top-left corner", + timestamps=timestamps, + confidence=np.ones(num_frames), + confidence_definition="Mock confidence", + ) + pose_series_list.append(series) + pose_estimation = PoseEstimation( + name=camera_name, + pose_estimation_series=pose_series_list, + description=f"Pose estimation for {camera_name}", + ) + behavior_module.add(pose_estimation) def create_nwbfile_with_pose_estimation( @@ -120,21 +138,10 @@ def create_nwbfile_with_pose_estimation( Returns ------- NWBFile - NWB file with pose estimation processing module + NWB file with pose estimation in the behavior processing module """ nwbfile = mock_NWBFile() - - pose_module = ProcessingModule( - name="pose_estimation", - description="Pose estimation data from DeepLabCut or similar", - ) - nwbfile.add_processing_module(pose_module) - - mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) - timestamps = mock_interface.get_timestamps() - - _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, timestamps) - + _add_cameras_via_mock_interface(nwbfile, camera_names, num_nodes, num_frames, seed) return nwbfile @@ -194,22 +201,6 @@ def create_nwbfile_with_videos_and_pose( nwbfile.add_acquisition(image_series) - # Add pose estimation - pose_module = ProcessingModule( - name="pose_estimation", - description="Pose estimation data from DeepLabCut or similar", - ) - nwbfile.add_processing_module(pose_module) - - # Use first video's timestamps if provided, otherwise use mock's timestamps - if timestamps: - pose_timestamps = next(iter(timestamps.values())) - else: - mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) - pose_timestamps = mock_interface.get_timestamps() - - mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=len(pose_timestamps), seed=seed) - - _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, pose_timestamps) + _add_cameras_via_mock_interface(nwbfile, camera_names, num_nodes, num_frames, seed) return nwbfile diff --git a/tests/test_local_pose_widget.py b/tests/test_local_pose_widget.py index 28f0f2f..de17175 100644 --- a/tests/test_local_pose_widget.py +++ b/tests/test_local_pose_widget.py @@ -17,18 +17,18 @@ def test_discover_single_camera(self, nwbfile_with_single_camera_pose): cameras = discover_pose_estimation_cameras(nwbfile_with_single_camera_pose) assert len(cameras) == 1 - assert "LeftCamera" in cameras + assert "MockPoseEstimation" in cameras # Check that the camera has pose estimation series - camera_pose = cameras["LeftCamera"] - assert len(camera_pose.pose_estimation_series) == 3 # Nose, LeftEar, RightEar + camera_pose = cameras["MockPoseEstimation"] + assert len(camera_pose.pose_estimation_series) == 3 def test_discover_multiple_cameras(self, nwbfile_with_multiple_cameras_pose): """Test discovering pose estimation from multiple cameras.""" cameras = discover_pose_estimation_cameras(nwbfile_with_multiple_cameras_pose) assert len(cameras) == 3 - assert "LeftCamera" in cameras + assert "MockPoseEstimation" in cameras assert "RightCamera" in cameras assert "BodyCamera" in cameras @@ -41,9 +41,6 @@ def test_discover_cameras_with_videos(self, nwbfile_with_videos_and_pose): cameras = discover_pose_estimation_cameras(nwbfile_with_videos_and_pose) assert len(cameras) == 3 - assert "LeftCamera" in cameras - assert "RightCamera" in cameras - assert "BodyCamera" in cameras class TestCameraInfoExtraction: @@ -54,9 +51,9 @@ def test_camera_info_single(self, nwbfile_with_single_camera_pose): info = get_pose_estimation_info(nwbfile_with_single_camera_pose) assert len(info) == 1 - assert "LeftCamera" in info + assert "MockPoseEstimation" in info - camera_info = info["LeftCamera"] + camera_info = info["MockPoseEstimation"] assert "start" in camera_info assert "end" in camera_info assert "frames" in camera_info @@ -74,7 +71,7 @@ def test_camera_info_multiple(self, nwbfile_with_multiple_cameras_pose): assert len(info) == 3 - for camera_name in ["LeftCamera", "RightCamera", "BodyCamera"]: + for camera_name in ["MockPoseEstimation", "RightCamera", "BodyCamera"]: assert camera_name in info camera_info = info[camera_name] assert camera_info["frames"] == 30 @@ -89,7 +86,7 @@ def test_create_widget_single_camera(self, nwbfile_with_single_camera_pose): widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose) assert len(widget.available_cameras) == 1 - assert "LeftCamera" in widget.available_cameras + assert "MockPoseEstimation" in widget.available_cameras assert widget.settings_open is True assert widget.selected_camera == "" # No camera selected by default @@ -98,7 +95,7 @@ def test_create_widget_multiple_cameras(self, nwbfile_with_multiple_cameras_pose widget = NWBLocalPoseEstimationWidget(nwbfile_with_multiple_cameras_pose) assert len(widget.available_cameras) == 3 - assert "LeftCamera" in widget.available_cameras + assert "MockPoseEstimation" in widget.available_cameras assert "RightCamera" in widget.available_cameras assert "BodyCamera" in widget.available_cameras assert widget.settings_open is True @@ -116,10 +113,10 @@ def test_create_widget_with_videos(self, nwbfile_with_videos_and_pose): def test_default_camera_selection(self, nwbfile_with_single_camera_pose): """Test selecting a default camera.""" widget = NWBLocalPoseEstimationWidget( - nwbfile_with_single_camera_pose, default_camera="LeftCamera" + nwbfile_with_single_camera_pose, default_camera="MockPoseEstimation" ) - assert widget.selected_camera == "LeftCamera" + assert widget.selected_camera == "MockPoseEstimation" def test_invalid_default_camera(self, nwbfile_with_single_camera_pose): """Test that invalid default camera falls back to no selection.""" @@ -148,11 +145,11 @@ def test_data_loads_on_selection(self, nwbfile_with_single_camera_pose): assert len(widget.all_camera_data) == 0 # Select camera - widget.selected_camera = "LeftCamera" + widget.selected_camera = "MockPoseEstimation" # Data should be loaded now - assert "LeftCamera" in widget.all_camera_data - camera_data = widget.all_camera_data["LeftCamera"] + assert "MockPoseEstimation" in widget.all_camera_data + camera_data = widget.all_camera_data["MockPoseEstimation"] assert "keypoint_metadata" in camera_data assert "pose_coordinates" in camera_data @@ -175,8 +172,8 @@ def test_default_colormap(self, nwbfile_with_single_camera_pose): """Test that default colormap is applied.""" widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose) - widget.selected_camera = "LeftCamera" - camera_data = widget.all_camera_data["LeftCamera"] + widget.selected_camera = "MockPoseEstimation" + camera_data = widget.all_camera_data["MockPoseEstimation"] # Each keypoint should have a color for keypoint_name, metadata in camera_data["keypoint_metadata"].items(): @@ -195,8 +192,8 @@ def test_custom_colors(self, nwbfile_with_single_camera_pose): nwbfile_with_single_camera_pose, keypoint_colors=custom_colors ) - widget.selected_camera = "LeftCamera" - camera_data = widget.all_camera_data["LeftCamera"] + widget.selected_camera = "MockPoseEstimation" + camera_data = widget.all_camera_data["MockPoseEstimation"] # Check custom colors are applied assert camera_data["keypoint_metadata"]["Head"]["color"] == "#FF0000" @@ -209,8 +206,8 @@ def test_different_colormap(self, nwbfile_with_single_camera_pose): nwbfile_with_single_camera_pose, keypoint_colors="Set1" ) - widget.selected_camera = "LeftCamera" - camera_data = widget.all_camera_data["LeftCamera"] + widget.selected_camera = "MockPoseEstimation" + camera_data = widget.all_camera_data["MockPoseEstimation"] # Verify colors are assigned (just check they exist) for keypoint_name in ["Head", "Neck", "LeftShoulder"]: @@ -221,8 +218,8 @@ class TestErrorHandling: """Tests for error handling.""" def test_raises_for_missing_pose_module(self, nwbfile_with_single_video): - """Test that error is raised when pose_estimation module is missing.""" - with pytest.raises(ValueError, match="pose_estimation processing module"): + """Test that error is raised when no PoseEstimation data is present.""" + with pytest.raises(ValueError, match="PoseEstimation data"): NWBLocalPoseEstimationWidget(nwbfile_with_single_video) def test_raises_for_in_memory_nwbfile(self): From 39f3b866f9bc0dbe3cf43bd98dc047658fb71f78 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 24 Feb 2026 07:50:20 -0800 Subject: [PATCH 10/12] Revert "Add ndx-pose as main dep; use add_to_nwbfile; search all modules for PoseEstimation" This reverts commit a148fa0cef957b8206327beb2b8b634e64b42b2a. --- pyproject.toml | 1 - src/nwb_video_widgets/_utils.py | 22 ++-- src/nwb_video_widgets/dandi_pose_widget.py | 17 +-- src/nwb_video_widgets/local_pose_widget.py | 17 +-- tests/conftest.py | 4 +- tests/fixtures/synthetic_nwb.py | 117 +++++++++++---------- tests/test_local_pose_widget.py | 47 +++++---- 7 files changed, 123 insertions(+), 102 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 32cfcbc..31dbb1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ dependencies = [ "numpy", "matplotlib", "ipykernel>=7.1.0", - "ndx-pose", ] [project.optional-dependencies] diff --git a/src/nwb_video_widgets/_utils.py b/src/nwb_video_widgets/_utils.py index 693ff9c..653eb76 100644 --- a/src/nwb_video_widgets/_utils.py +++ b/src/nwb_video_widgets/_utils.py @@ -223,8 +223,6 @@ def start_video_server(directory: Path) -> int: def discover_pose_estimation_cameras(nwbfile: NWBFile) -> dict: """Discover all PoseEstimation containers in an NWB file. - Searches all processing modules for PoseEstimation objects. - Parameters ---------- nwbfile : NWBFile @@ -233,14 +231,20 @@ def discover_pose_estimation_cameras(nwbfile: NWBFile) -> dict: Returns ------- dict - Mapping of camera names to PoseEstimation objects found across - all processing modules. + Mapping of camera names to PoseEstimation objects from + processing['pose_estimation']. """ + if "pose_estimation" not in nwbfile.processing: + return {} + + pose_module = nwbfile.processing["pose_estimation"] + + # Get only PoseEstimation objects (not Skeletons or other types) cameras = {} - for module in nwbfile.processing.values(): - for name, obj in module.data_interfaces.items(): - if type(obj).__name__ == "PoseEstimation": - cameras[name] = obj + for name, obj in pose_module.data_interfaces.items(): + if type(obj).__name__ == "PoseEstimation": + cameras[name] = obj + return cameras @@ -281,7 +285,7 @@ def get_pose_estimation_info(nwbfile: NWBFile) -> dict[str, dict]: Parameters ---------- nwbfile : NWBFile - NWB file containing pose estimation data in any processing module + NWB file containing pose estimation in processing['pose_estimation'] Returns ------- diff --git a/src/nwb_video_widgets/dandi_pose_widget.py b/src/nwb_video_widgets/dandi_pose_widget.py index aeb6852..2534ecc 100644 --- a/src/nwb_video_widgets/dandi_pose_widget.py +++ b/src/nwb_video_widgets/dandi_pose_widget.py @@ -156,10 +156,13 @@ def __init__( colormap_name = "tab10" custom_colors = keypoint_colors - # Get all PoseEstimation containers across all processing modules + # Get pose estimation container + if "pose_estimation" not in nwbfile.processing: + raise ValueError("NWB file does not contain pose_estimation processing module") + pose_estimation = nwbfile.processing["pose_estimation"] + + # Get all PoseEstimation containers (excludes Skeletons and other metadata) pose_containers = discover_pose_estimation_cameras(nwbfile) - if not pose_containers: - raise ValueError("NWB file does not contain any PoseEstimation data in processing modules") available_cameras = list(pose_containers.keys()) # Get camera info for settings panel display @@ -182,7 +185,7 @@ def __init__( selected_camera = "" # Store references for lazy loading (not synced to JS) - self._pose_containers = pose_containers + self._pose_estimation = pose_estimation self._cmap = plt.get_cmap(colormap_name) self._custom_colors = custom_colors @@ -213,7 +216,7 @@ def _on_camera_selected(self, change): try: # Load pose data for this camera camera_data = self._load_camera_pose_data( - self._pose_containers, camera_name, self._cmap, self._custom_colors + self._pose_estimation, camera_name, self._cmap, self._custom_colors ) # Update all_camera_data (must create new dict for traitlets to detect change) @@ -293,7 +296,7 @@ def _get_video_urls_from_dandi( return video_urls @staticmethod - def _load_camera_pose_data(pose_containers: dict, camera_name: str, cmap, custom_colors: dict) -> dict: + def _load_camera_pose_data(pose_estimation, camera_name: str, cmap, custom_colors: dict) -> dict: """Load pose data for a single camera. Returns a dict with: @@ -301,7 +304,7 @@ def _load_camera_pose_data(pose_containers: dict, camera_name: str, cmap, custom - pose_coordinates: {name: [[x, y], ...]} as JSON-serializable lists - timestamps: [t0, t1, ...] as JSON-serializable list """ - camera_pose = pose_containers[camera_name] + camera_pose = pose_estimation[camera_name] keypoint_names = list(camera_pose.pose_estimation_series.keys()) n_kp = len(keypoint_names) diff --git a/src/nwb_video_widgets/local_pose_widget.py b/src/nwb_video_widgets/local_pose_widget.py index a2d4a2a..3d9bc96 100644 --- a/src/nwb_video_widgets/local_pose_widget.py +++ b/src/nwb_video_widgets/local_pose_widget.py @@ -124,10 +124,13 @@ def __init__( colormap_name = "tab10" custom_colors = keypoint_colors - # Get all PoseEstimation containers across all processing modules + # Get pose estimation container + if "pose_estimation" not in nwbfile.processing: + raise ValueError("NWB file does not contain pose_estimation processing module") + pose_estimation = nwbfile.processing["pose_estimation"] + + # Get all PoseEstimation containers (excludes Skeletons and other metadata) pose_containers = discover_pose_estimation_cameras(nwbfile) - if not pose_containers: - raise ValueError("NWB file does not contain any PoseEstimation data in processing modules") available_cameras = list(pose_containers.keys()) # Get camera info for settings panel display @@ -150,7 +153,7 @@ def __init__( selected_camera = "" # Store references for lazy loading (not synced to JS) - self._pose_containers = pose_containers + self._pose_estimation = pose_estimation self._cmap = plt.get_cmap(colormap_name) self._custom_colors = custom_colors @@ -181,7 +184,7 @@ def _on_camera_selected(self, change): try: # Load pose data for this camera camera_data = self._load_camera_pose_data( - self._pose_containers, camera_name, self._cmap, self._custom_colors + self._pose_estimation, camera_name, self._cmap, self._custom_colors ) # Update all_camera_data (must create new dict for traitlets to detect change) @@ -274,7 +277,7 @@ def _get_video_urls_from_local(nwbfile: NWBFile) -> dict[str, str]: return video_urls @staticmethod - def _load_camera_pose_data(pose_containers: dict, camera_name: str, cmap, custom_colors: dict) -> dict: + def _load_camera_pose_data(pose_estimation, camera_name: str, cmap, custom_colors: dict) -> dict: """Load pose data for a single camera. Returns a dict with: @@ -282,7 +285,7 @@ def _load_camera_pose_data(pose_containers: dict, camera_name: str, cmap, custom - pose_coordinates: {name: [[x, y], ...]} as JSON-serializable lists - timestamps: [t0, t1, ...] as JSON-serializable list """ - camera_pose = pose_containers[camera_name] + camera_pose = pose_estimation[camera_name] keypoint_names = list(camera_pose.pose_estimation_series.keys()) n_kp = len(keypoint_names) diff --git a/tests/conftest.py b/tests/conftest.py index 36609d9..1195481 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,7 +95,7 @@ def nwbfile_with_explicit_timestamps(tmp_path, synthetic_video_path): def nwbfile_with_single_camera_pose(tmp_path): """Create an NWB file with pose estimation for a single camera.""" nwbfile = create_nwbfile_with_pose_estimation( - camera_names=["MockPoseEstimation"], + camera_names=["LeftCamera"], num_nodes=3, num_frames=30, ) @@ -111,7 +111,7 @@ def nwbfile_with_single_camera_pose(tmp_path): def nwbfile_with_multiple_cameras_pose(tmp_path): """Create an NWB file with pose estimation for multiple cameras.""" nwbfile = create_nwbfile_with_pose_estimation( - camera_names=["MockPoseEstimation", "RightCamera", "BodyCamera"], + camera_names=["LeftCamera", "RightCamera", "BodyCamera"], num_nodes=5, num_frames=30, ) diff --git a/tests/fixtures/synthetic_nwb.py b/tests/fixtures/synthetic_nwb.py index 08b0a01..01ff657 100644 --- a/tests/fixtures/synthetic_nwb.py +++ b/tests/fixtures/synthetic_nwb.py @@ -4,7 +4,7 @@ import numpy as np from neuroconv.tools.testing.mock_interfaces import MockPoseEstimationInterface -from pynwb import NWBFile +from pynwb import NWBFile, ProcessingModule from pynwb.image import ImageSeries from pynwb.testing.mock.file import mock_NWBFile @@ -53,67 +53,49 @@ def create_nwbfile_with_external_videos( return nwbfile -def _add_cameras_via_mock_interface( - nwbfile: NWBFile, +def _add_pose_estimation_to_module( + pose_module: ProcessingModule, camera_names: list[str], - num_nodes: int, - num_frames: int, - seed: int, + mock_interface: MockPoseEstimationInterface, + timestamps: np.ndarray, ) -> None: - """Add PoseEstimation containers to an NWBFile using MockPoseEstimationInterface.add_to_nwbfile. - - Uses add_to_nwbfile for the first camera (which creates the behavior module, device, - and skeleton), then adds subsequent cameras directly to the existing behavior module. + """Add PoseEstimation containers to a processing module using MockPoseEstimationInterface data. Parameters ---------- - nwbfile : NWBFile - The NWB file to add pose estimation data to + pose_module : ProcessingModule + The NWB processing module to add pose estimation containers to camera_names : list[str] Names for each PoseEstimation container - num_nodes : int - Number of keypoint nodes per camera - num_frames : int - Number of frames of pose data - seed : int - Random seed for reproducible data generation + mock_interface : MockPoseEstimationInterface + NeuroConv mock interface providing pose data and node names + timestamps : np.ndarray + Timestamps for all pose estimation series """ from ndx_pose import PoseEstimation, PoseEstimationSeries - for i, camera_name in enumerate(camera_names): - mock = MockPoseEstimationInterface( - pose_estimation_metadata_key=camera_name, - num_nodes=num_nodes, - num_samples=num_frames, - seed=seed, - ) - if i == 0: - # First camera: use add_to_nwbfile to create the behavior module, device, and skeleton - mock.add_to_nwbfile(nwbfile) - else: - # Additional cameras: add PoseEstimation directly to the existing behavior module - # (avoids Device/Skeleton naming conflicts from repeated add_to_nwbfile calls) - behavior_module = nwbfile.processing["behavior"] - timestamps = mock.get_timestamps() - pose_series_list = [] - for idx, node_name in enumerate(mock.nodes): - pascal_case_node = "".join(word.capitalize() for word in node_name.replace("_", " ").split()) - series = PoseEstimationSeries( - name=f"PoseEstimationSeries{pascal_case_node}", - data=mock.pose_data[:num_frames, idx, :], - unit="pixels", - reference_frame="top-left corner", - timestamps=timestamps, - confidence=np.ones(num_frames), - confidence_definition="Mock confidence", - ) - pose_series_list.append(series) - pose_estimation = PoseEstimation( - name=camera_name, - pose_estimation_series=pose_series_list, - description=f"Pose estimation for {camera_name}", + num_frames = len(timestamps) + for camera_name in camera_names: + pose_series_list = [] + for idx, node_name in enumerate(mock_interface.nodes): + pascal_case_node = "".join(word.capitalize() for word in node_name.replace("_", " ").split()) + series = PoseEstimationSeries( + name=f"PoseEstimationSeries{pascal_case_node}", + data=mock_interface.pose_data[:num_frames, idx, :], + unit="pixels", + reference_frame="top-left corner", + timestamps=timestamps, + confidence=np.ones(num_frames), + confidence_definition="Mock confidence", ) - behavior_module.add(pose_estimation) + pose_series_list.append(series) + + pose_estimation = PoseEstimation( + name=camera_name, + pose_estimation_series=pose_series_list, + description=f"Pose estimation for {camera_name}", + ) + pose_module.add(pose_estimation) def create_nwbfile_with_pose_estimation( @@ -138,10 +120,21 @@ def create_nwbfile_with_pose_estimation( Returns ------- NWBFile - NWB file with pose estimation in the behavior processing module + NWB file with pose estimation processing module """ nwbfile = mock_NWBFile() - _add_cameras_via_mock_interface(nwbfile, camera_names, num_nodes, num_frames, seed) + + pose_module = ProcessingModule( + name="pose_estimation", + description="Pose estimation data from DeepLabCut or similar", + ) + nwbfile.add_processing_module(pose_module) + + mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) + timestamps = mock_interface.get_timestamps() + + _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, timestamps) + return nwbfile @@ -201,6 +194,22 @@ def create_nwbfile_with_videos_and_pose( nwbfile.add_acquisition(image_series) - _add_cameras_via_mock_interface(nwbfile, camera_names, num_nodes, num_frames, seed) + # Add pose estimation + pose_module = ProcessingModule( + name="pose_estimation", + description="Pose estimation data from DeepLabCut or similar", + ) + nwbfile.add_processing_module(pose_module) + + # Use first video's timestamps if provided, otherwise use mock's timestamps + if timestamps: + pose_timestamps = next(iter(timestamps.values())) + else: + mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) + pose_timestamps = mock_interface.get_timestamps() + + mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=len(pose_timestamps), seed=seed) + + _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, pose_timestamps) return nwbfile diff --git a/tests/test_local_pose_widget.py b/tests/test_local_pose_widget.py index de17175..28f0f2f 100644 --- a/tests/test_local_pose_widget.py +++ b/tests/test_local_pose_widget.py @@ -17,18 +17,18 @@ def test_discover_single_camera(self, nwbfile_with_single_camera_pose): cameras = discover_pose_estimation_cameras(nwbfile_with_single_camera_pose) assert len(cameras) == 1 - assert "MockPoseEstimation" in cameras + assert "LeftCamera" in cameras # Check that the camera has pose estimation series - camera_pose = cameras["MockPoseEstimation"] - assert len(camera_pose.pose_estimation_series) == 3 + camera_pose = cameras["LeftCamera"] + assert len(camera_pose.pose_estimation_series) == 3 # Nose, LeftEar, RightEar def test_discover_multiple_cameras(self, nwbfile_with_multiple_cameras_pose): """Test discovering pose estimation from multiple cameras.""" cameras = discover_pose_estimation_cameras(nwbfile_with_multiple_cameras_pose) assert len(cameras) == 3 - assert "MockPoseEstimation" in cameras + assert "LeftCamera" in cameras assert "RightCamera" in cameras assert "BodyCamera" in cameras @@ -41,6 +41,9 @@ def test_discover_cameras_with_videos(self, nwbfile_with_videos_and_pose): cameras = discover_pose_estimation_cameras(nwbfile_with_videos_and_pose) assert len(cameras) == 3 + assert "LeftCamera" in cameras + assert "RightCamera" in cameras + assert "BodyCamera" in cameras class TestCameraInfoExtraction: @@ -51,9 +54,9 @@ def test_camera_info_single(self, nwbfile_with_single_camera_pose): info = get_pose_estimation_info(nwbfile_with_single_camera_pose) assert len(info) == 1 - assert "MockPoseEstimation" in info + assert "LeftCamera" in info - camera_info = info["MockPoseEstimation"] + camera_info = info["LeftCamera"] assert "start" in camera_info assert "end" in camera_info assert "frames" in camera_info @@ -71,7 +74,7 @@ def test_camera_info_multiple(self, nwbfile_with_multiple_cameras_pose): assert len(info) == 3 - for camera_name in ["MockPoseEstimation", "RightCamera", "BodyCamera"]: + for camera_name in ["LeftCamera", "RightCamera", "BodyCamera"]: assert camera_name in info camera_info = info[camera_name] assert camera_info["frames"] == 30 @@ -86,7 +89,7 @@ def test_create_widget_single_camera(self, nwbfile_with_single_camera_pose): widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose) assert len(widget.available_cameras) == 1 - assert "MockPoseEstimation" in widget.available_cameras + assert "LeftCamera" in widget.available_cameras assert widget.settings_open is True assert widget.selected_camera == "" # No camera selected by default @@ -95,7 +98,7 @@ def test_create_widget_multiple_cameras(self, nwbfile_with_multiple_cameras_pose widget = NWBLocalPoseEstimationWidget(nwbfile_with_multiple_cameras_pose) assert len(widget.available_cameras) == 3 - assert "MockPoseEstimation" in widget.available_cameras + assert "LeftCamera" in widget.available_cameras assert "RightCamera" in widget.available_cameras assert "BodyCamera" in widget.available_cameras assert widget.settings_open is True @@ -113,10 +116,10 @@ def test_create_widget_with_videos(self, nwbfile_with_videos_and_pose): def test_default_camera_selection(self, nwbfile_with_single_camera_pose): """Test selecting a default camera.""" widget = NWBLocalPoseEstimationWidget( - nwbfile_with_single_camera_pose, default_camera="MockPoseEstimation" + nwbfile_with_single_camera_pose, default_camera="LeftCamera" ) - assert widget.selected_camera == "MockPoseEstimation" + assert widget.selected_camera == "LeftCamera" def test_invalid_default_camera(self, nwbfile_with_single_camera_pose): """Test that invalid default camera falls back to no selection.""" @@ -145,11 +148,11 @@ def test_data_loads_on_selection(self, nwbfile_with_single_camera_pose): assert len(widget.all_camera_data) == 0 # Select camera - widget.selected_camera = "MockPoseEstimation" + widget.selected_camera = "LeftCamera" # Data should be loaded now - assert "MockPoseEstimation" in widget.all_camera_data - camera_data = widget.all_camera_data["MockPoseEstimation"] + assert "LeftCamera" in widget.all_camera_data + camera_data = widget.all_camera_data["LeftCamera"] assert "keypoint_metadata" in camera_data assert "pose_coordinates" in camera_data @@ -172,8 +175,8 @@ def test_default_colormap(self, nwbfile_with_single_camera_pose): """Test that default colormap is applied.""" widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose) - widget.selected_camera = "MockPoseEstimation" - camera_data = widget.all_camera_data["MockPoseEstimation"] + widget.selected_camera = "LeftCamera" + camera_data = widget.all_camera_data["LeftCamera"] # Each keypoint should have a color for keypoint_name, metadata in camera_data["keypoint_metadata"].items(): @@ -192,8 +195,8 @@ def test_custom_colors(self, nwbfile_with_single_camera_pose): nwbfile_with_single_camera_pose, keypoint_colors=custom_colors ) - widget.selected_camera = "MockPoseEstimation" - camera_data = widget.all_camera_data["MockPoseEstimation"] + widget.selected_camera = "LeftCamera" + camera_data = widget.all_camera_data["LeftCamera"] # Check custom colors are applied assert camera_data["keypoint_metadata"]["Head"]["color"] == "#FF0000" @@ -206,8 +209,8 @@ def test_different_colormap(self, nwbfile_with_single_camera_pose): nwbfile_with_single_camera_pose, keypoint_colors="Set1" ) - widget.selected_camera = "MockPoseEstimation" - camera_data = widget.all_camera_data["MockPoseEstimation"] + widget.selected_camera = "LeftCamera" + camera_data = widget.all_camera_data["LeftCamera"] # Verify colors are assigned (just check they exist) for keypoint_name in ["Head", "Neck", "LeftShoulder"]: @@ -218,8 +221,8 @@ class TestErrorHandling: """Tests for error handling.""" def test_raises_for_missing_pose_module(self, nwbfile_with_single_video): - """Test that error is raised when no PoseEstimation data is present.""" - with pytest.raises(ValueError, match="PoseEstimation data"): + """Test that error is raised when pose_estimation module is missing.""" + with pytest.raises(ValueError, match="pose_estimation processing module"): NWBLocalPoseEstimationWidget(nwbfile_with_single_video) def test_raises_for_in_memory_nwbfile(self): From c60c0e6b33bd59dfb6ec7f88177a79f41eae1791 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 24 Feb 2026 07:50:31 -0800 Subject: [PATCH 11/12] Revert "Replace custom mock pose data with NeuroConv MockPoseEstimationInterface" This reverts commit 4664342f94178df731270b552bfad7712bd0a887. --- pyproject.toml | 2 +- tests/conftest.py | 6 +- tests/fixtures/synthetic_nwb.py | 168 +++++++++++++++++++------------- tests/test_local_pose_widget.py | 55 +++++++---- 4 files changed, 140 insertions(+), 91 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 31dbb1d..56b45b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dandi = ["dandi>=0.60.0", "remfile>=0.1.13", "h5py"] test = [ "pytest>=7.0", "pytest-cov", - "neuroconv", + "ndx-pose", ] dev = [ diff --git a/tests/conftest.py b/tests/conftest.py index 1195481..3f03416 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,7 +96,7 @@ def nwbfile_with_single_camera_pose(tmp_path): """Create an NWB file with pose estimation for a single camera.""" nwbfile = create_nwbfile_with_pose_estimation( camera_names=["LeftCamera"], - num_nodes=3, + keypoint_names=["Nose", "LeftEar", "RightEar"], num_frames=30, ) nwb_path = tmp_path / "test_pose.nwb" @@ -112,7 +112,7 @@ def nwbfile_with_multiple_cameras_pose(tmp_path): """Create an NWB file with pose estimation for multiple cameras.""" nwbfile = create_nwbfile_with_pose_estimation( camera_names=["LeftCamera", "RightCamera", "BodyCamera"], - num_nodes=5, + keypoint_names=["Nose", "LeftEar", "RightEar", "LeftPaw", "RightPaw"], num_frames=30, ) nwb_path = tmp_path / "test_multi_pose.nwb" @@ -139,7 +139,7 @@ def nwbfile_with_videos_and_pose(tmp_path, synthetic_video_paths): nwbfile = create_nwbfile_with_videos_and_pose( video_paths=copied_paths, camera_names=camera_names, - num_nodes=3, + keypoint_names=["Nose", "LeftEar", "RightEar"], num_frames=30, ) nwb_path = tmp_path / "test_combined.nwb" diff --git a/tests/fixtures/synthetic_nwb.py b/tests/fixtures/synthetic_nwb.py index 01ff657..212fcde 100644 --- a/tests/fixtures/synthetic_nwb.py +++ b/tests/fixtures/synthetic_nwb.py @@ -3,7 +3,7 @@ from pathlib import Path import numpy as np -from neuroconv.tools.testing.mock_interfaces import MockPoseEstimationInterface +from ndx_pose import PoseEstimation, PoseEstimationSeries from pynwb import NWBFile, ProcessingModule from pynwb.image import ImageSeries from pynwb.testing.mock.file import mock_NWBFile @@ -53,69 +53,30 @@ def create_nwbfile_with_external_videos( return nwbfile -def _add_pose_estimation_to_module( - pose_module: ProcessingModule, - camera_names: list[str], - mock_interface: MockPoseEstimationInterface, - timestamps: np.ndarray, -) -> None: - """Add PoseEstimation containers to a processing module using MockPoseEstimationInterface data. - - Parameters - ---------- - pose_module : ProcessingModule - The NWB processing module to add pose estimation containers to - camera_names : list[str] - Names for each PoseEstimation container - mock_interface : MockPoseEstimationInterface - NeuroConv mock interface providing pose data and node names - timestamps : np.ndarray - Timestamps for all pose estimation series - """ - from ndx_pose import PoseEstimation, PoseEstimationSeries - - num_frames = len(timestamps) - for camera_name in camera_names: - pose_series_list = [] - for idx, node_name in enumerate(mock_interface.nodes): - pascal_case_node = "".join(word.capitalize() for word in node_name.replace("_", " ").split()) - series = PoseEstimationSeries( - name=f"PoseEstimationSeries{pascal_case_node}", - data=mock_interface.pose_data[:num_frames, idx, :], - unit="pixels", - reference_frame="top-left corner", - timestamps=timestamps, - confidence=np.ones(num_frames), - confidence_definition="Mock confidence", - ) - pose_series_list.append(series) - - pose_estimation = PoseEstimation( - name=camera_name, - pose_estimation_series=pose_series_list, - description=f"Pose estimation for {camera_name}", - ) - pose_module.add(pose_estimation) - - def create_nwbfile_with_pose_estimation( camera_names: list[str], - num_nodes: int = 3, + keypoint_names: list[str], num_frames: int = 30, - seed: int = 0, + timestamps: np.ndarray | None = None, + video_width: int = 160, + video_height: int = 120, ) -> NWBFile: - """Create an NWBFile with PoseEstimation data using NeuroConv's MockPoseEstimationInterface. + """Create an NWBFile with PoseEstimation data. Parameters ---------- camera_names : list[str] Names of cameras to create pose estimation data for - num_nodes : int, optional - Number of keypoint nodes per camera, by default 3 + keypoint_names : list[str] + Names of keypoints to track num_frames : int, optional Number of frames of pose data, by default 30 - seed : int, optional - Random seed for reproducible data generation, by default 0 + timestamps : np.ndarray, optional + Timestamps for the pose data. If None, uses evenly spaced from 0 to 1. + video_width : int, optional + Width of the source video in pixels, by default 160 + video_height : int, optional + Height of the source video in pixels, by default 120 Returns ------- @@ -124,16 +85,51 @@ def create_nwbfile_with_pose_estimation( """ nwbfile = mock_NWBFile() + # Create pose_estimation processing module pose_module = ProcessingModule( name="pose_estimation", description="Pose estimation data from DeepLabCut or similar", ) nwbfile.add_processing_module(pose_module) - mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) - timestamps = mock_interface.get_timestamps() + # Default timestamps + if timestamps is None: + timestamps = np.linspace(0.0, 1.0, num_frames) + + # Create a PoseEstimation container for each camera + frame_indices = np.arange(num_frames) + circle_x = video_width * (0.2 + 0.6 * frame_indices / num_frames) + circle_y = np.full(num_frames, video_height / 2) + noise_scale = max(1, int(video_width * 0.01)) + for camera_name in camera_names: + # Create PoseEstimationSeries for each keypoint + pose_series_list = [] + for idx, keypoint_name in enumerate(keypoint_names): + # Generate synthetic pose data tracking the moving circle in synthetic_video.py + x_offset = idx * int(video_width * 0.05) + y_offset = idx * int(video_height * 0.05) + x_coords = circle_x + x_offset + np.random.randn(num_frames) * noise_scale + y_coords = circle_y + y_offset + np.random.randn(num_frames) * noise_scale + data = np.column_stack([x_coords, y_coords]) + + series = PoseEstimationSeries( + name=f"{keypoint_name}PoseEstimationSeries", + data=data, + unit="pixels", + reference_frame="top-left corner", + timestamps=timestamps, + confidence=np.random.rand(num_frames), + ) + pose_series_list.append(series) - _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, timestamps) + # Create PoseEstimation container for this camera + pose_estimation = PoseEstimation( + name=camera_name, + pose_estimation_series=pose_series_list, + description=f"Pose estimation for {camera_name}", + dimensions=np.array([[video_width, video_height]], dtype="uint16"), + ) + pose_module.add(pose_estimation) return nwbfile @@ -141,29 +137,30 @@ def create_nwbfile_with_pose_estimation( def create_nwbfile_with_videos_and_pose( video_paths: dict[str, Path], camera_names: list[str], - num_nodes: int = 3, + keypoint_names: list[str], num_frames: int = 30, timestamps: dict[str, np.ndarray] | None = None, - seed: int = 0, + video_width: int = 160, + video_height: int = 120, ) -> NWBFile: """Create an NWBFile with both external videos and pose estimation. - Uses NeuroConv's MockPoseEstimationInterface for synthetic pose data generation. - Parameters ---------- video_paths : dict[str, Path] Mapping of video names to file paths camera_names : list[str] Names of cameras for pose estimation (should match video names pattern) - num_nodes : int, optional - Number of keypoint nodes per camera, by default 3 + keypoint_names : list[str] + Names of keypoints to track num_frames : int, optional Number of frames of pose data, by default 30 timestamps : dict[str, np.ndarray], optional Mapping of video names to timestamp arrays. If None, uses rate-based. - seed : int, optional - Random seed for reproducible data generation, by default 0 + video_width : int, optional + Width of the source video in pixels, by default 160 + video_height : int, optional + Height of the source video in pixels, by default 120 Returns ------- @@ -201,15 +198,46 @@ def create_nwbfile_with_videos_and_pose( ) nwbfile.add_processing_module(pose_module) - # Use first video's timestamps if provided, otherwise use mock's timestamps + # Use first video's timestamps if available + pose_timestamps = None if timestamps: - pose_timestamps = next(iter(timestamps.values())) + first_video = next(iter(timestamps.values())) + pose_timestamps = first_video else: - mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=num_frames, seed=seed) - pose_timestamps = mock_interface.get_timestamps() + pose_timestamps = np.linspace(0.0, 1.0, num_frames) + + # Create pose estimation for each camera + n = len(pose_timestamps) + frame_indices = np.arange(n) + circle_x = video_width * (0.2 + 0.6 * frame_indices / n) + circle_y = np.full(n, video_height / 2) + noise_scale = max(1, int(video_width * 0.01)) + for camera_name in camera_names: + pose_series_list = [] + for idx, keypoint_name in enumerate(keypoint_names): + # Generate synthetic pose data tracking the moving circle in synthetic_video.py + x_offset = idx * int(video_width * 0.05) + y_offset = idx * int(video_height * 0.05) + x_coords = circle_x + x_offset + np.random.randn(n) * noise_scale + y_coords = circle_y + y_offset + np.random.randn(n) * noise_scale + data = np.column_stack([x_coords, y_coords]) - mock_interface = MockPoseEstimationInterface(num_nodes=num_nodes, num_samples=len(pose_timestamps), seed=seed) + series = PoseEstimationSeries( + name=f"{keypoint_name}PoseEstimationSeries", + data=data, + unit="pixels", + reference_frame="top-left corner", + timestamps=pose_timestamps, + confidence=np.random.rand(len(pose_timestamps)), + ) + pose_series_list.append(series) - _add_pose_estimation_to_module(pose_module, camera_names, mock_interface, pose_timestamps) + pose_estimation = PoseEstimation( + name=camera_name, + pose_estimation_series=pose_series_list, + description=f"Pose estimation for {camera_name}", + dimensions=np.array([[video_width, video_height]], dtype="uint16"), + ) + pose_module.add(pose_estimation) return nwbfile diff --git a/tests/test_local_pose_widget.py b/tests/test_local_pose_widget.py index 28f0f2f..b3e2043 100644 --- a/tests/test_local_pose_widget.py +++ b/tests/test_local_pose_widget.py @@ -64,9 +64,9 @@ def test_camera_info_single(self, nwbfile_with_single_camera_pose): assert camera_info["frames"] == 30 assert len(camera_info["keypoints"]) == 3 - assert "Head" in camera_info["keypoints"] - assert "Neck" in camera_info["keypoints"] - assert "LeftShoulder" in camera_info["keypoints"] + assert "Nose" in camera_info["keypoints"] + assert "LeftEar" in camera_info["keypoints"] + assert "RightEar" in camera_info["keypoints"] def test_camera_info_multiple(self, nwbfile_with_multiple_cameras_pose): """Test extracting info for multiple cameras.""" @@ -159,12 +159,12 @@ def test_data_loads_on_selection(self, nwbfile_with_single_camera_pose): assert "timestamps" in camera_data # Check keypoints - assert "Head" in camera_data["keypoint_metadata"] - assert "Neck" in camera_data["keypoint_metadata"] - assert "LeftShoulder" in camera_data["keypoint_metadata"] + assert "Nose" in camera_data["keypoint_metadata"] + assert "LeftEar" in camera_data["keypoint_metadata"] + assert "RightEar" in camera_data["keypoint_metadata"] # Check coordinates structure - assert len(camera_data["pose_coordinates"]["Head"]) == 30 + assert len(camera_data["pose_coordinates"]["Nose"]) == 30 assert len(camera_data["timestamps"]) == 30 @@ -186,9 +186,9 @@ def test_default_colormap(self, nwbfile_with_single_camera_pose): def test_custom_colors(self, nwbfile_with_single_camera_pose): """Test custom color assignment.""" custom_colors = { - "Head": "#FF0000", - "Neck": "#00FF00", - "LeftShoulder": "#0000FF", + "Nose": "#FF0000", + "LeftEar": "#00FF00", + "RightEar": "#0000FF", } widget = NWBLocalPoseEstimationWidget( @@ -199,9 +199,9 @@ def test_custom_colors(self, nwbfile_with_single_camera_pose): camera_data = widget.all_camera_data["LeftCamera"] # Check custom colors are applied - assert camera_data["keypoint_metadata"]["Head"]["color"] == "#FF0000" - assert camera_data["keypoint_metadata"]["Neck"]["color"] == "#00FF00" - assert camera_data["keypoint_metadata"]["LeftShoulder"]["color"] == "#0000FF" + assert camera_data["keypoint_metadata"]["Nose"]["color"] == "#FF0000" + assert camera_data["keypoint_metadata"]["LeftEar"]["color"] == "#00FF00" + assert camera_data["keypoint_metadata"]["RightEar"]["color"] == "#0000FF" def test_different_colormap(self, nwbfile_with_single_camera_pose): """Test using a different colormap.""" @@ -213,7 +213,7 @@ def test_different_colormap(self, nwbfile_with_single_camera_pose): camera_data = widget.all_camera_data["LeftCamera"] # Verify colors are assigned (just check they exist) - for keypoint_name in ["Head", "Neck", "LeftShoulder"]: + for keypoint_name in ["Nose", "LeftEar", "RightEar"]: assert "color" in camera_data["keypoint_metadata"][keypoint_name] @@ -227,10 +227,31 @@ def test_raises_for_missing_pose_module(self, nwbfile_with_single_video): def test_raises_for_in_memory_nwbfile(self): """Test that error is raised for NWB files not loaded from disk.""" - from tests.fixtures.synthetic_nwb import create_nwbfile_with_pose_estimation + from ndx_pose import PoseEstimation, PoseEstimationSeries + from pynwb import ProcessingModule + from pynwb.testing.mock.file import mock_NWBFile - # Create an in-memory NWBFile (not written to disk and read back) - nwbfile = create_nwbfile_with_pose_estimation(camera_names=["TestCamera"]) + nwbfile = mock_NWBFile() + + # Add pose estimation to in-memory file + pose_module = ProcessingModule( + name="pose_estimation", + description="Test pose estimation", + ) + nwbfile.add_processing_module(pose_module) + + # Create a simple pose estimation + series = PoseEstimationSeries( + name="NosePoseEstimationSeries", + data=[[100.0, 200.0], [101.0, 201.0]], + reference_frame="top-left", + timestamps=[0.0, 0.1], + ) + pose_estimation = PoseEstimation( + name="TestCamera", + pose_estimation_series=[series], + ) + pose_module.add(pose_estimation) with pytest.raises(ValueError, match="loaded from disk"): NWBLocalPoseEstimationWidget(nwbfile) From 0abcebb6c9ce6f6bd4d2d29f570c2c0fd39a2310 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 24 Feb 2026 07:53:57 -0800 Subject: [PATCH 12/12] Removed internal utils unit tests --- tests/test_local_pose_widget.py | 92 ++------------------------------- 1 file changed, 4 insertions(+), 88 deletions(-) diff --git a/tests/test_local_pose_widget.py b/tests/test_local_pose_widget.py index b3e2043..f239ac8 100644 --- a/tests/test_local_pose_widget.py +++ b/tests/test_local_pose_widget.py @@ -3,82 +3,6 @@ import pytest from nwb_video_widgets import NWBLocalPoseEstimationWidget -from nwb_video_widgets._utils import ( - discover_pose_estimation_cameras, - get_pose_estimation_info, -) - - -class TestPoseEstimationDiscovery: - """Tests for discovering pose estimation data from NWB files.""" - - def test_discover_single_camera(self, nwbfile_with_single_camera_pose): - """Test discovering pose estimation from a single camera.""" - cameras = discover_pose_estimation_cameras(nwbfile_with_single_camera_pose) - - assert len(cameras) == 1 - assert "LeftCamera" in cameras - - # Check that the camera has pose estimation series - camera_pose = cameras["LeftCamera"] - assert len(camera_pose.pose_estimation_series) == 3 # Nose, LeftEar, RightEar - - def test_discover_multiple_cameras(self, nwbfile_with_multiple_cameras_pose): - """Test discovering pose estimation from multiple cameras.""" - cameras = discover_pose_estimation_cameras(nwbfile_with_multiple_cameras_pose) - - assert len(cameras) == 3 - assert "LeftCamera" in cameras - assert "RightCamera" in cameras - assert "BodyCamera" in cameras - - # Check that each camera has the correct number of keypoints - for camera_name, camera_pose in cameras.items(): - assert len(camera_pose.pose_estimation_series) == 5 # 5 keypoints - - def test_discover_cameras_with_videos(self, nwbfile_with_videos_and_pose): - """Test discovering pose estimation when videos are also present.""" - cameras = discover_pose_estimation_cameras(nwbfile_with_videos_and_pose) - - assert len(cameras) == 3 - assert "LeftCamera" in cameras - assert "RightCamera" in cameras - assert "BodyCamera" in cameras - - -class TestCameraInfoExtraction: - """Tests for extracting camera metadata.""" - - def test_camera_info_single(self, nwbfile_with_single_camera_pose): - """Test extracting info for a single camera.""" - info = get_pose_estimation_info(nwbfile_with_single_camera_pose) - - assert len(info) == 1 - assert "LeftCamera" in info - - camera_info = info["LeftCamera"] - assert "start" in camera_info - assert "end" in camera_info - assert "frames" in camera_info - assert "keypoints" in camera_info - - assert camera_info["frames"] == 30 - assert len(camera_info["keypoints"]) == 3 - assert "Nose" in camera_info["keypoints"] - assert "LeftEar" in camera_info["keypoints"] - assert "RightEar" in camera_info["keypoints"] - - def test_camera_info_multiple(self, nwbfile_with_multiple_cameras_pose): - """Test extracting info for multiple cameras.""" - info = get_pose_estimation_info(nwbfile_with_multiple_cameras_pose) - - assert len(info) == 3 - - for camera_name in ["LeftCamera", "RightCamera", "BodyCamera"]: - assert camera_name in info - camera_info = info[camera_name] - assert camera_info["frames"] == 30 - assert len(camera_info["keypoints"]) == 5 class TestWidgetCreation: @@ -115,17 +39,13 @@ def test_create_widget_with_videos(self, nwbfile_with_videos_and_pose): def test_default_camera_selection(self, nwbfile_with_single_camera_pose): """Test selecting a default camera.""" - widget = NWBLocalPoseEstimationWidget( - nwbfile_with_single_camera_pose, default_camera="LeftCamera" - ) + widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose, default_camera="LeftCamera") assert widget.selected_camera == "LeftCamera" def test_invalid_default_camera(self, nwbfile_with_single_camera_pose): """Test that invalid default camera falls back to no selection.""" - widget = NWBLocalPoseEstimationWidget( - nwbfile_with_single_camera_pose, default_camera="NonexistentCamera" - ) + widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose, default_camera="NonexistentCamera") assert widget.selected_camera == "" @@ -191,9 +111,7 @@ def test_custom_colors(self, nwbfile_with_single_camera_pose): "RightEar": "#0000FF", } - widget = NWBLocalPoseEstimationWidget( - nwbfile_with_single_camera_pose, keypoint_colors=custom_colors - ) + widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose, keypoint_colors=custom_colors) widget.selected_camera = "LeftCamera" camera_data = widget.all_camera_data["LeftCamera"] @@ -205,9 +123,7 @@ def test_custom_colors(self, nwbfile_with_single_camera_pose): def test_different_colormap(self, nwbfile_with_single_camera_pose): """Test using a different colormap.""" - widget = NWBLocalPoseEstimationWidget( - nwbfile_with_single_camera_pose, keypoint_colors="Set1" - ) + widget = NWBLocalPoseEstimationWidget(nwbfile_with_single_camera_pose, keypoint_colors="Set1") widget.selected_camera = "LeftCamera" camera_data = widget.all_camera_data["LeftCamera"]