diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2b3f8..c6cfdac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ## Improvements +* 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) ## Removals, Deprecations and Changes 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 = [ 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..212fcde 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,193 @@ 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, + video_width: int = 160, + video_height: int = 120, +) -> 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. + 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 + ------- + 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 + 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) + + # 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 + + +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, + video_width: int = 160, + video_height: int = 120, +) -> 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. + 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 + ------- + 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 + 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]) + + 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) + + 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 new file mode 100644 index 0000000..f239ac8 --- /dev/null +++ b/tests/test_local_pose_widget.py @@ -0,0 +1,195 @@ +"""Unit tests for NWBLocalPoseEstimationWidget.""" + +import pytest + +from nwb_video_widgets import NWBLocalPoseEstimationWidget + + +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 ndx_pose import PoseEstimation, PoseEstimationSeries + from pynwb import ProcessingModule + from pynwb.testing.mock.file import mock_NWBFile + + 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