From 6277b81b936c08f6914ee92b1ee91a979daac5e0 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:15:39 +0200 Subject: [PATCH 01/28] Add guess_software and draft predictions to cliplabels.json --- poseinterface/io.py | 203 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 204 insertions(+) diff --git a/poseinterface/io.py b/poseinterface/io.py index d1e26ef..478ae33 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -2,8 +2,20 @@ import json import re from pathlib import Path +from typing import Literal, TypeAlias import sleap_io as sio +from movement.io import _build_suffix_map, _validate_file, load_dataset +from movement.validators.files import ( + ValidAniposeCSV, + ValidDeepLabCutCSV, + ValidDeepLabCutH5, + ValidFile, + ValidNWBFile, + ValidSleapAnalysis, + ValidSleapLabels, + ValidVIATracksCSV, +) from sleap_io.io import coco from sleap_io.io.dlc import is_dlc_file @@ -24,6 +36,28 @@ POSEINTERFACE_FRAME_REGEXP = r"frame-(\d+)" +# Guessing source software for movement +SourceSoftware: TypeAlias = Literal[ + "DeepLabCut", + "SLEAP", + "LightningPose", + "Anipose", + "NWB", + "VIA-tracks", +] + +_SOURCE_SOFTWARE_VALIDATORS: dict[SourceSoftware, list[type[ValidFile]]] = { + "SLEAP": [ValidSleapLabels, ValidSleapAnalysis], + "DeepLabCut": [ValidDeepLabCutH5, ValidDeepLabCutCSV], + "Anipose": [ValidAniposeCSV], + "VIA-tracks": [ValidVIATracksCSV], + "NWB": [ValidNWBFile], +} +# Note: LightningPose is excluded because it uses the same file +# format (and validator) as DeepLabCut. A LightningPose file +# loaded as "DeepLabCut" will work correctly. + + def annotations_to_coco( input_path: Path, output_json_path: Path, @@ -158,3 +192,172 @@ def _extract_frame_number( rf"'{frame_regexp}'." ) return int(match.group(1)) + + +def predictions_to_poseinterface( + predictions_path: Path | str, + video_path: Path | str, + output_json_path: Path | str, + *, + sub_id: str, + ses_id: str, + cam_id: str, +) -> Path: + """Convert a prediction file to ``poseinterface`` COCO JSON format. + + Reads a predictions file and writes a + COCO-format JSON with ``poseinterface``-style filenames suitable + for clip-level labels (``_cliplabels.json``). + + Parameters + ---------- + predictions_path + Path to the DLC predictions CSV file. + video_path + Path to the corresponding video file. Used to attach video + metadata (resolution) to the COCO output. + output_json_path + Path to save the output COCO JSON file. + sub_id + Subject ID to include in the generated filenames. + ses_id + Session ID to include in the generated filenames. + cam_id + Camera ID to include in the generated filenames. + + Returns + ------- + Path + Path to the saved COCO JSON file. + """ + # Guess source software using movement validators + # (take first guess) + source_software = _guess_source_software(predictions_path)[0] + + # Read input file as movement dataset + # NOTE: fps=None is ignore with NWB files + ds = load_dataset( + file=predictions_path, + source_software=source_software, + fps=None, + ) + + # Get video image width and height + video = sio.load_video(video_path) + img_h, img_w = video.shape + + # Export movement dataset as cliplabels.json + # named `sub-_ses-_cam-_cliplabels.json` with + # the following format: + # ```json + # { + # images = [ + # {"id": 0, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0000", "width": 1300, "height": 1028}, + # {"id": 1, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0001", "width": 1300, "height": 1028}, + # {"id": 2, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0002", "width": 1300, "height": 1028}, + # {"id": 3, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0003", "width": 1300, "height": 1028}, + # {"id": 4, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0004", "width": 1300, "height": 1028} + # ], + # annotations = [ + # { + # "id": 1, + # "image_id": 0, + # "category_id": 1, + # "keypoints": [ + # 922.9367676, + # 537.1152344, + # 2, + # 901.0643920898438, + # 555.0507813, + # 2, + # 947.4334106445312, + # 526.1047363, + # 2, + # 959.3913574, + # 589.8626708984375, + # 2, + # 939.0410766601562, + # 595.8991088867188, + # 2, + # 952.0695801, + # 613.3984985351562, + # 2, + # 836.614502, + # 472.44915771484375, + # 2, + # ], + # "num_keypoints": 7, + # "bbox": [ + # 836.614502, + # 472.44915771484375, + # 122.77685539999993, + # 140.9493408203125 + # ], + # "area": 17305.316836620816, + # "iscrowd": 0 + # }, + # ], + # categories = [ + # { + # "id": 1, + # "name": "individual_1", + # "keypoints": [ + # "Nose", + # "EarLeft", + # "EarRight", + # "Neck", + # "BodyUpper", + # "BodyLower", + # "TailBase", + # ], + # "skeleton": [] + # } + # ], + # ``` + + +def _guess_source_software(file: Path | str) -> SourceSoftware: + """Guess the source software based on file validation. + + Tries each known file validator against the given file and returns + the source software names whose validators accept the file. + + Parameters + ---------- + file + Path to the file to identify. + + Returns + ------- + list[SourceSoftware] + List of source software names whose validators matched. + + Examples + -------- + >>> from movement.io.load import guess_source_software + >>> guess_source_software("path/to/predictions.h5") + ['DeepLabCut'] + + """ + file = Path(file) + suffix = file.suffix + matches: list[SourceSoftware] = [] + + for ( + source_software, + validator_classes, + ) in _SOURCE_SOFTWARE_VALIDATORS.items(): + map_suffix_to_validators = _build_suffix_map(validator_classes) + # If input suffix not associated to this set of validators, continue + if suffix not in map_suffix_to_validators: + continue + + # If suffix is covered by these validators, use them to + # validate the input file + try: + _validate_file(file, map_suffix_to_validators, source_software) + matches.append(source_software) + except Exception: + continue + + return matches diff --git a/pyproject.toml b/pyproject.toml index f2df49c..1adadad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dynamic = ["version"] dependencies = [ "sleap-io>=0.6.4", + "movement" ] license = {text = "BSD-3-Clause"} From d1d8c06d887ecb1a0b13deb2004c871b082d1173 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:39:16 +0200 Subject: [PATCH 02/28] Transform movement dataset to cliplabels.json --- poseinterface/io.py | 180 ++++++++++++++++++++++++++------------------ 1 file changed, 106 insertions(+), 74 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 478ae33..3621df2 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -4,8 +4,10 @@ from pathlib import Path from typing import Literal, TypeAlias +import numpy as np import sleap_io as sio -from movement.io import _build_suffix_map, _validate_file, load_dataset +from movement.io import load_dataset +from movement.io.load import _build_suffix_map, _validate_file from movement.validators.files import ( ValidAniposeCSV, ValidDeepLabCutCSV, @@ -244,79 +246,109 @@ def predictions_to_poseinterface( # Get video image width and height video = sio.load_video(video_path) - img_h, img_w = video.shape - - # Export movement dataset as cliplabels.json - # named `sub-_ses-_cam-_cliplabels.json` with - # the following format: - # ```json - # { - # images = [ - # {"id": 0, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0000", "width": 1300, "height": 1028}, - # {"id": 1, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0001", "width": 1300, "height": 1028}, - # {"id": 2, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0002", "width": 1300, "height": 1028}, - # {"id": 3, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0003", "width": 1300, "height": 1028}, - # {"id": 4, "file_name": f"sub-{sub}_ses-{ses}_cam-{cam}_frame-0004", "width": 1300, "height": 1028} - # ], - # annotations = [ - # { - # "id": 1, - # "image_id": 0, - # "category_id": 1, - # "keypoints": [ - # 922.9367676, - # 537.1152344, - # 2, - # 901.0643920898438, - # 555.0507813, - # 2, - # 947.4334106445312, - # 526.1047363, - # 2, - # 959.3913574, - # 589.8626708984375, - # 2, - # 939.0410766601562, - # 595.8991088867188, - # 2, - # 952.0695801, - # 613.3984985351562, - # 2, - # 836.614502, - # 472.44915771484375, - # 2, - # ], - # "num_keypoints": 7, - # "bbox": [ - # 836.614502, - # 472.44915771484375, - # 122.77685539999993, - # 140.9493408203125 - # ], - # "area": 17305.316836620816, - # "iscrowd": 0 - # }, - # ], - # categories = [ - # { - # "id": 1, - # "name": "individual_1", - # "keypoints": [ - # "Nose", - # "EarLeft", - # "EarRight", - # "Neck", - # "BodyUpper", - # "BodyLower", - # "TailBase", - # ], - # "skeleton": [] - # } - # ], - # ``` - - -def _guess_source_software(file: Path | str) -> SourceSoftware: + _, img_h, img_w, _ = video.shape + + # Extract position array and coordinates from dataset + positions = ds["position"].values # (time, space, keypoints, individuals) + n_frames = positions.shape[0] + + keypoint_names = ds.coords["keypoints"].values.tolist() + individual_names = ds.coords["individuals"].values.tolist() + + # Build categories list (one entry per individual) + # NOTE: categories are 1-indexed to avoid conflicts + # with models that treat category 0 as background. + categories = [ + { + "id": i + 1, + "name": name, + "keypoints": keypoint_names, + "skeleton": [], + } + for i, name in enumerate(individual_names) + ] + + # Build images list (one entry per frame) + # NOTE: image id values are always 0-indexed + images = [ + { + "id": t, + "file_name": ( + f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_frame-{t:04d}" + ), + "width": img_w, + "height": img_h, + } + for t in range(n_frames) + ] + + # Build annotations list (one entry per frame per individual) + annotations = [] + annot_id = 1 + for t in range(n_frames): + for i in range(len(individual_names)): + # Get position data for this frame and individual + xy = positions[t, :, :, i] # (2, n_keypoints) + + # Determine kpt visibility: + # 0: not labeled + # 1: labeled but not visible (occluded) + # 2: labeled and visible + # NOTE: The current code only assigns 0 or 2 because the movement + # dataset doesn't carry occlusion information + visible_array = ~np.isnan(xy[0]) & ~np.isnan(xy[1]) + n_visible = int(visible_array.sum()) + + # Get list of flattened keypoints + # [x1, y1, v1, x2, y2, v2, ...] + x = np.where(visible_array, xy[0], 0.0) + y = np.where(visible_array, xy[1], 0.0) + v = np.where(visible_array, 2, 0) + list_xyv_kpts = np.stack([x, y, v], axis=1).ravel().tolist() + + # Compute bbox from visible keypoints + # (zeros if no keypoints are visible) + if n_visible > 0: + x_visible = xy[0, visible_array] + y_visible = xy[1, visible_array] + x_min = float(x_visible.min()) + y_min = float(y_visible.min()) + bbox_w = float(x_visible.max()) - x_min + bbox_h = float(y_visible.max()) - y_min + else: + x_min, y_min, bbox_w, bbox_h = 0.0, 0.0, 0.0, 0.0 + + # Append results to list of annotations + annotations.append( + { + "id": annot_id, + "image_id": t, + "category_id": i + 1, + "keypoints": list_xyv_kpts, + "num_keypoints": n_visible, + "bbox": [x_min, y_min, bbox_w, bbox_h], + "area": bbox_w * bbox_h, + "iscrowd": 0, + } + ) + annot_id += 1 + + # Assemble and write COCO JSON + output_json_path = Path(output_json_path) + with open(output_json_path, "w") as f: + json.dump( + { + "images": images, + "annotations": annotations, + "categories": categories, + }, + f, + ) + + return output_json_path + + +def _guess_source_software(file: Path | str) -> list[SourceSoftware]: """Guess the source software based on file validation. Tries each known file validator against the given file and returns From 86bc663589626db3fed682d86bf4bcc18c1c844e Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:42:55 +0200 Subject: [PATCH 03/28] Add output parent dir --- poseinterface/io.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 3621df2..8bde5f3 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -199,7 +199,7 @@ def _extract_frame_number( def predictions_to_poseinterface( predictions_path: Path | str, video_path: Path | str, - output_json_path: Path | str, + output_json_parent_dir: Path | str, *, sub_id: str, ses_id: str, @@ -207,9 +207,8 @@ def predictions_to_poseinterface( ) -> Path: """Convert a prediction file to ``poseinterface`` COCO JSON format. - Reads a predictions file and writes a - COCO-format JSON with ``poseinterface``-style filenames suitable - for clip-level labels (``_cliplabels.json``). + Reads a predictions file and writes a JSON with ``poseinterface``-style + filenames suitable for clip-level labels (``_cliplabels.json``). Parameters ---------- @@ -218,8 +217,8 @@ def predictions_to_poseinterface( video_path Path to the corresponding video file. Used to attach video metadata (resolution) to the COCO output. - output_json_path - Path to save the output COCO JSON file. + output_json_parent_dir + Path to the directory where to save the output JSON file. sub_id Subject ID to include in the generated filenames. ses_id @@ -334,8 +333,11 @@ def predictions_to_poseinterface( annot_id += 1 # Assemble and write COCO JSON - output_json_path = Path(output_json_path) - with open(output_json_path, "w") as f: + output_json_parent_dir = ( + Path(output_json_parent_dir) + / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}.json" + ) + with open(output_json_parent_dir, "w") as f: json.dump( { "images": images, @@ -345,7 +347,7 @@ def predictions_to_poseinterface( f, ) - return output_json_path + return output_json_parent_dir def _guess_source_software(file: Path | str) -> list[SourceSoftware]: From d8f709058bf365cde6bcc3bcd4f5b8aaa29b7431 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:26:04 +0200 Subject: [PATCH 04/28] Add a test --- tests/test_unit/test_io.py | 166 ++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index 55ed56d..83e8abe 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -1,6 +1,9 @@ -from unittest.mock import patch +import json +from unittest.mock import MagicMock, patch +import numpy as np import pytest +import xarray as xr from pytest_lazy_fixtures import lf from poseinterface.io import ( @@ -9,9 +12,80 @@ _extract_frame_number, _update_image_ids, annotations_to_coco, + predictions_to_poseinterface, ) +@pytest.fixture +def sample_movement_ds(): + """ + Build a minimal movement dataset. + (2 frames, 2 keypoints, 1 individual) + """ + # Initialise position array with NaN + # shape: (time, space, keypoints, individuals) + position_array = np.full((2, 2, 2, 1), np.nan) + + # Fill in frame 0: kpt0=(10, 30), kpt1=(20, 40) + position_array[0, :, :, 0] = [ + [10.0, 20.0], # x coordinates + [30.0, 40.0], # y coordinates + ] + + # Fill in frame 1: kpt0=NaN, kpt1=(50, 60) + position_array[1, :, 1, 0] = [50.0, 60.0] # x,y + + # Build confidence array + # shape: (time, keypoints, individuals) + confidence_array = np.array( + [ + [ + [0.9], # kpt0 + [0.8], # kpt1 + ], # frame 0 + [ + [np.nan], # kpt0 + [0.7], # kpt1 + ], # frame 1 + ], + dtype=np.float32, + ) + + # Return dataset + return xr.Dataset( + { + "position": ( + ["time", "space", "keypoints", "individuals"], + position_array, + ), + "confidence": ( + ["time", "keypoints", "individuals"], + confidence_array, + ), + }, + coords={ + "time": [0, 1], + "space": ["x", "y"], + "keypoints": ["Nose", "Tail"], + "individuals": ["id_0"], + }, + ) + + +@pytest.fixture +def mock_video(): + """Mock Video object with 10 frames, matching video_labels fixture.""" + + def _mock_video(n_frames): + video = MagicMock() + video.fps = 30 + video.shape = (n_frames, 480, 640, 3) + video.stem = "sub-01_ses-01_cam-01" + return video + + return _mock_video + + @patch("poseinterface.io.coco.convert_labels") @patch("poseinterface.io.sio.load_file") def test_annotations_to_coco( @@ -203,3 +277,93 @@ def test_extract_frame_number_invalid(filename, frame_regexp): ), ): _extract_frame_number(filename, frame_regexp) + + +@patch("poseinterface.io.sio.load_video") +@patch("poseinterface.io.load_dataset") +@patch("poseinterface.io._guess_source_software") +def test_predictions_to_poseinterface( + mock_guess_source_software, + mock_load_dataset, + mock_load_video, + sample_movement_ds, + mock_video, + tmp_path, +): + """Test that predictions are converted to COCO JSON.""" + # Get movement dataset and video fixtures + ds = sample_movement_ds + video = mock_video(n_frames=3) + _, img_h, img_w, _ = video.shape + + # Mock return values for supporting functions + mock_guess_source_software.return_value = ["DeepLabCut"] + mock_load_dataset.return_value = ds + mock_load_video.return_value.shape = video.shape + + # Convert predictions + result = predictions_to_poseinterface( + predictions_path="fake.csv", + video_path="fake.mp4", + output_json_parent_dir=tmp_path, + sub_id="M01", + ses_id="20240101", + cam_id="top", + ) + + # Check output file exists + assert result.exists() + assert result.name == "sub-M01_ses-20240101_cam-top.json" + + # Check content + with open(result) as f: + data = json.load(f) + + # Check top-level keys + assert set(data.keys()) == {"images", "annotations", "categories"} + + # Check images + assert len(data["images"]) == len(ds.time) + for k in range(len(data["images"])): + assert data["images"][k]["file_name"] == ( + f"sub-M01_ses-20240101_cam-top_frame-000{k}" + ) + assert data["images"][k]["width"] == img_w + assert data["images"][k]["height"] == img_h + + # Check categories + assert len(data["categories"]) == len(ds.individuals) + assert data["categories"][0]["name"] == ds.individuals.values.tolist()[0] + assert data["categories"][0]["keypoints"] == ds.keypoints.values.tolist() + + # Check annotations + # 2 frames x 1 individual = 2 annotations + assert len(data["annotations"]) == len(ds.time) * len(ds.individuals) + + # Frame 0: both keypoints visible + # kpt0=(10, 30), kpt1=(20, 40) + annot0 = data["annotations"][0] + assert annot0["num_keypoints"] == 2 + assert annot0["keypoints"] == [ + *ds.position.isel(time=0, keypoints=0).values.squeeze().tolist(), + 2.0, + *ds.position.isel(time=0, keypoints=1).values.squeeze().tolist(), + 2.0, + ] + # bbox: [xmin, ymin, width, height] + assert annot0["bbox"] == [10.0, 30.0, 10.0, 10.0] + assert annot0["area"] == 100.0 + + # Frame 1: kpt0 is NaN, kpt1=(50, 60) + annot1 = data["annotations"][1] + assert annot1["num_keypoints"] == 1 + assert annot1["keypoints"] == [ + 0.0, + 0.0, + 0.0, + *ds.position.isel(time=1, keypoints=1).values.squeeze().tolist(), + 2.0, + ] + # bbox covers only the single visible keypoint + assert annot1["bbox"] == [50.0, 60.0, 0.0, 0.0] + assert annot1["area"] == 0.0 From 2f33813feab1cba6d98faf9a860520ed3bd54981 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:31:53 +0200 Subject: [PATCH 05/28] Factor out conversion --- poseinterface/io.py | 147 +++++++++++++++++++++++++------------------- 1 file changed, 84 insertions(+), 63 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 8bde5f3..e0127de 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -6,6 +6,7 @@ import numpy as np import sleap_io as sio +import xarray as xr from movement.io import load_dataset from movement.io.load import _build_suffix_map, _validate_file from movement.validators.files import ( @@ -247,6 +248,84 @@ def predictions_to_poseinterface( video = sio.load_video(video_path) _, img_h, img_w, _ = video.shape + # Convert movement dataset to cliplabels dict + coco_data = _convert_movement_ds_to_cliplabels( + ds, + sub_id=sub_id, + ses_id=ses_id, + cam_id=cam_id, + img_h=img_h, + img_w=img_w, + ) + + # Export dict as JSON + output_json_parent_dir = ( + Path(output_json_parent_dir) + / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}.json" + ) + with open(output_json_parent_dir, "w") as f: + json.dump(coco_data, f) + + return output_json_parent_dir + + +def _guess_source_software(file: Path | str) -> list[SourceSoftware]: + """Guess the source software based on file validation. + + Tries each known file validator against the given file and returns + the source software names whose validators accept the file. + + Parameters + ---------- + file + Path to the file to identify. + + Returns + ------- + list[SourceSoftware] + List of source software names whose validators matched. + + Examples + -------- + >>> from movement.io.load import guess_source_software + >>> guess_source_software("path/to/predictions.h5") + ['DeepLabCut'] + + """ + file = Path(file) + suffix = file.suffix + matches: list[SourceSoftware] = [] + + for ( + source_software, + validator_classes, + ) in _SOURCE_SOFTWARE_VALIDATORS.items(): + map_suffix_to_validators = _build_suffix_map(validator_classes) + # If input suffix not associated to this set of validators, continue + if suffix not in map_suffix_to_validators: + continue + + # If suffix is covered by these validators, use them to + # validate the input file + try: + _validate_file(file, map_suffix_to_validators, source_software) + matches.append(source_software) + except Exception: + continue + + return matches + + +def _convert_movement_ds_to_cliplabels( + ds: xr.Dataset, + *, + sub_id: str, + ses_id: str, + cam_id: str, + img_w: int, + img_h: int, +) -> dict[str, list[dict]]: + """Convert predictions in movement dataset to cliplabels.json""" # Extract position array and coordinates from dataset positions = ds["position"].values # (time, space, keypoints, individuals) n_frames = positions.shape[0] @@ -332,66 +411,8 @@ def predictions_to_poseinterface( ) annot_id += 1 - # Assemble and write COCO JSON - output_json_parent_dir = ( - Path(output_json_parent_dir) - / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}.json" - ) - with open(output_json_parent_dir, "w") as f: - json.dump( - { - "images": images, - "annotations": annotations, - "categories": categories, - }, - f, - ) - - return output_json_parent_dir - - -def _guess_source_software(file: Path | str) -> list[SourceSoftware]: - """Guess the source software based on file validation. - - Tries each known file validator against the given file and returns - the source software names whose validators accept the file. - - Parameters - ---------- - file - Path to the file to identify. - - Returns - ------- - list[SourceSoftware] - List of source software names whose validators matched. - - Examples - -------- - >>> from movement.io.load import guess_source_software - >>> guess_source_software("path/to/predictions.h5") - ['DeepLabCut'] - - """ - file = Path(file) - suffix = file.suffix - matches: list[SourceSoftware] = [] - - for ( - source_software, - validator_classes, - ) in _SOURCE_SOFTWARE_VALIDATORS.items(): - map_suffix_to_validators = _build_suffix_map(validator_classes) - # If input suffix not associated to this set of validators, continue - if suffix not in map_suffix_to_validators: - continue - - # If suffix is covered by these validators, use them to - # validate the input file - try: - _validate_file(file, map_suffix_to_validators, source_software) - matches.append(source_software) - except Exception: - continue - - return matches + return { + "images": images, + "annotations": annotations, + "categories": categories, + } From 2d6349c5806b7c4128d4635f3e1b36047e5df7ef Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:38:49 +0200 Subject: [PATCH 06/28] Split test --- tests/test_unit/test_io.py | 71 ++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index 83e8abe..b02d18f 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -1,4 +1,3 @@ -import json from unittest.mock import MagicMock, patch import numpy as np @@ -9,6 +8,7 @@ from poseinterface.io import ( _EMPTY_LABELS_ERROR_MSG, POSEINTERFACE_FRAME_REGEXP, + _convert_movement_ds_to_cliplabels, _extract_frame_number, _update_image_ids, annotations_to_coco, @@ -279,6 +279,7 @@ def test_extract_frame_number_invalid(filename, frame_regexp): _extract_frame_number(filename, frame_regexp) +@patch("poseinterface.io._convert_movement_ds_to_cliplabels") @patch("poseinterface.io.sio.load_video") @patch("poseinterface.io.load_dataset") @patch("poseinterface.io._guess_source_software") @@ -286,20 +287,25 @@ def test_predictions_to_poseinterface( mock_guess_source_software, mock_load_dataset, mock_load_video, + mock_convert, sample_movement_ds, mock_video, tmp_path, ): - """Test that predictions are converted to COCO JSON.""" + """Test that the relevant subfunctions are called.""" # Get movement dataset and video fixtures ds = sample_movement_ds video = mock_video(n_frames=3) - _, img_h, img_w, _ = video.shape # Mock return values for supporting functions mock_guess_source_software.return_value = ["DeepLabCut"] mock_load_dataset.return_value = ds mock_load_video.return_value.shape = video.shape + mock_convert.return_value = { + "images": [], + "annotations": [], + "categories": [], + } # Convert predictions result = predictions_to_poseinterface( @@ -311,38 +317,65 @@ def test_predictions_to_poseinterface( cam_id="top", ) - # Check output file exists + # Check subfunctions are called + mock_guess_source_software.assert_called_once() + mock_load_dataset.assert_called_once() + mock_load_video.assert_called_once() + mock_convert.assert_called_once() + + # Check output file exists with expected name assert result.exists() assert result.name == "sub-M01_ses-20240101_cam-top.json" - # Check content - with open(result) as f: - data = json.load(f) + +def test_convert_movement_ds_to_cliplabels( + sample_movement_ds, + mock_video, +): + """Test that movement dataset is converted to cliplabels dict.""" + # Get movement dataset and video fixtures + ds = sample_movement_ds + video = mock_video(n_frames=3) + _, img_h, img_w, _ = video.shape + + # Convert dataset to cliplabels dict + coco_data = _convert_movement_ds_to_cliplabels( + ds, + sub_id="M01", + ses_id="20240101", + cam_id="top", + img_h=img_h, + img_w=img_w, + ) # Check top-level keys - assert set(data.keys()) == {"images", "annotations", "categories"} + assert set(coco_data.keys()) == {"images", "annotations", "categories"} # Check images - assert len(data["images"]) == len(ds.time) - for k in range(len(data["images"])): - assert data["images"][k]["file_name"] == ( + assert len(coco_data["images"]) == len(ds.time) + for k in range(len(coco_data["images"])): + assert coco_data["images"][k]["file_name"] == ( f"sub-M01_ses-20240101_cam-top_frame-000{k}" ) - assert data["images"][k]["width"] == img_w - assert data["images"][k]["height"] == img_h + assert coco_data["images"][k]["width"] == img_w + assert coco_data["images"][k]["height"] == img_h # Check categories - assert len(data["categories"]) == len(ds.individuals) - assert data["categories"][0]["name"] == ds.individuals.values.tolist()[0] - assert data["categories"][0]["keypoints"] == ds.keypoints.values.tolist() + assert len(coco_data["categories"]) == len(ds.individuals) + assert ( + coco_data["categories"][0]["name"] == ds.individuals.values.tolist()[0] + ) + assert ( + coco_data["categories"][0]["keypoints"] == ds.keypoints.values.tolist() + ) # Check annotations # 2 frames x 1 individual = 2 annotations - assert len(data["annotations"]) == len(ds.time) * len(ds.individuals) + assert len(coco_data["annotations"]) == len(ds.time) * len(ds.individuals) # Frame 0: both keypoints visible # kpt0=(10, 30), kpt1=(20, 40) - annot0 = data["annotations"][0] + annot0 = coco_data["annotations"][0] assert annot0["num_keypoints"] == 2 assert annot0["keypoints"] == [ *ds.position.isel(time=0, keypoints=0).values.squeeze().tolist(), @@ -355,7 +388,7 @@ def test_predictions_to_poseinterface( assert annot0["area"] == 100.0 # Frame 1: kpt0 is NaN, kpt1=(50, 60) - annot1 = data["annotations"][1] + annot1 = coco_data["annotations"][1] assert annot1["num_keypoints"] == 1 assert annot1["keypoints"] == [ 0.0, From ecfc52104fd86d6c4000a7de68732898bc467a35 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:38:59 +0200 Subject: [PATCH 07/28] Fix docstring --- poseinterface/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index e0127de..0c40be6 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -325,7 +325,7 @@ def _convert_movement_ds_to_cliplabels( img_w: int, img_h: int, ) -> dict[str, list[dict]]: - """Convert predictions in movement dataset to cliplabels.json""" + """Convert predictions in movement dataset to cliplabels dict.""" # Extract position array and coordinates from dataset positions = ds["position"].values # (time, space, keypoints, individuals) n_frames = positions.shape[0] From 2a0be337acca78e1121f187d78a64ca21925ed9c Mon Sep 17 00:00:00 2001 From: niksirbi Date: Wed, 13 May 2026 09:51:56 +0100 Subject: [PATCH 08/28] Pin movement>=0.16.0 and update supported Python versions to 3.12-3.14 --- .github/workflows/docs_build_and_deploy.yml | 2 +- .github/workflows/test_and_deploy.yml | 6 +++--- pyproject.toml | 10 ++++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs_build_and_deploy.yml b/.github/workflows/docs_build_and_deploy.yml index 7996b08..e7364ce 100644 --- a/.github/workflows/docs_build_and_deploy.yml +++ b/.github/workflows/docs_build_and_deploy.yml @@ -29,7 +29,7 @@ jobs: steps: - uses: neuroinformatics-unit/actions/build_sphinx_docs@main with: - python-version: "3.13" + python-version: "3.14" use-requirements-txt: false use-make: true github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 7c107c1..1115a18 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -27,14 +27,14 @@ jobs: strategy: matrix: # Run all supported Python versions on linux - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.12", "3.13", "3.14"] os: [ubuntu-latest] # Include one windows and macos run include: - os: macos-latest - python-version: "3.13" + python-version: "3.14" - os: windows-latest - python-version: "3.13" + python-version: "3.14" steps: # Run tests diff --git a/pyproject.toml b/pyproject.toml index 1adadad..a9d0d6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,12 @@ authors = [ ] description = "A framework for benchmarking pose estimation and point tracking methods on animal beheviour videos." readme = "README.md" -requires-python = ">=3.11.0" +requires-python = ">=3.12.0" dynamic = ["version"] dependencies = [ + "jupyter>=1.1.1", + "movement>=0.16.0", "sleap-io>=0.6.4", "movement" ] @@ -21,9 +23,9 @@ classifiers = [ "Development Status :: 2 - Pre-Alpha", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "License :: OSI Approved :: BSD License", ] @@ -118,14 +120,14 @@ docstring-code-format = true # Also format code in docstrings (e.g. examples) [tool.tox] legacy_tox_ini = """ [tox] -envlist = py{311,312,313} +envlist = py{312,313,314} isolated_build = True [gh-actions] python = - 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 [testenv] extras = From d68c98e5ad30f730e7ce70ecd388a96d33acd034 Mon Sep 17 00:00:00 2001 From: niksirbi Date: Wed, 13 May 2026 10:11:53 +0100 Subject: [PATCH 09/28] Rename cliplabels to videolabels in predictions_to_poseinterface predictions_to_poseinterface produces labels for a full video, not a clip. Per the spec, this intermediate file should use the _videolabels suffix. Rename _convert_movement_ds_to_cliplabels accordingly and update the output filename and all references in tests. --- poseinterface/io.py | 66 ++---------- tests/test_unit/test_io.py | 213 ++----------------------------------- 2 files changed, 14 insertions(+), 265 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 0c40be6..34d87cd 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -8,7 +8,6 @@ import sleap_io as sio import xarray as xr from movement.io import load_dataset -from movement.io.load import _build_suffix_map, _validate_file from movement.validators.files import ( ValidAniposeCSV, ValidDeepLabCutCSV, @@ -209,7 +208,7 @@ def predictions_to_poseinterface( """Convert a prediction file to ``poseinterface`` COCO JSON format. Reads a predictions file and writes a JSON with ``poseinterface``-style - filenames suitable for clip-level labels (``_cliplabels.json``). + filenames suitable for video-level labels (``_videolabels.json``). Parameters ---------- @@ -232,15 +231,11 @@ def predictions_to_poseinterface( Path Path to the saved COCO JSON file. """ - # Guess source software using movement validators - # (take first guess) - source_software = _guess_source_software(predictions_path)[0] - # Read input file as movement dataset # NOTE: fps=None is ignore with NWB files ds = load_dataset( file=predictions_path, - source_software=source_software, + source_software="auto", # infer from validators fps=None, ) @@ -248,8 +243,8 @@ def predictions_to_poseinterface( video = sio.load_video(video_path) _, img_h, img_w, _ = video.shape - # Convert movement dataset to cliplabels dict - coco_data = _convert_movement_ds_to_cliplabels( + # Convert movement dataset to videolabels dict + coco_data = _convert_movement_ds_to_videolabels( ds, sub_id=sub_id, ses_id=ses_id, @@ -261,7 +256,7 @@ def predictions_to_poseinterface( # Export dict as JSON output_json_parent_dir = ( Path(output_json_parent_dir) - / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}.json" + / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_videolabels.json" ) with open(output_json_parent_dir, "w") as f: json.dump(coco_data, f) @@ -269,54 +264,7 @@ def predictions_to_poseinterface( return output_json_parent_dir -def _guess_source_software(file: Path | str) -> list[SourceSoftware]: - """Guess the source software based on file validation. - - Tries each known file validator against the given file and returns - the source software names whose validators accept the file. - - Parameters - ---------- - file - Path to the file to identify. - - Returns - ------- - list[SourceSoftware] - List of source software names whose validators matched. - - Examples - -------- - >>> from movement.io.load import guess_source_software - >>> guess_source_software("path/to/predictions.h5") - ['DeepLabCut'] - - """ - file = Path(file) - suffix = file.suffix - matches: list[SourceSoftware] = [] - - for ( - source_software, - validator_classes, - ) in _SOURCE_SOFTWARE_VALIDATORS.items(): - map_suffix_to_validators = _build_suffix_map(validator_classes) - # If input suffix not associated to this set of validators, continue - if suffix not in map_suffix_to_validators: - continue - - # If suffix is covered by these validators, use them to - # validate the input file - try: - _validate_file(file, map_suffix_to_validators, source_software) - matches.append(source_software) - except Exception: - continue - - return matches - - -def _convert_movement_ds_to_cliplabels( +def _convert_movement_ds_to_videolabels( ds: xr.Dataset, *, sub_id: str, @@ -325,7 +273,7 @@ def _convert_movement_ds_to_cliplabels( img_w: int, img_h: int, ) -> dict[str, list[dict]]: - """Convert predictions in movement dataset to cliplabels dict.""" + """Convert predictions in movement dataset to videolabels dict.""" # Extract position array and coordinates from dataset positions = ds["position"].values # (time, space, keypoints, individuals) n_frames = positions.shape[0] diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index b02d18f..1060252 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -3,15 +3,9 @@ import numpy as np import pytest import xarray as xr -from pytest_lazy_fixtures import lf from poseinterface.io import ( - _EMPTY_LABELS_ERROR_MSG, - POSEINTERFACE_FRAME_REGEXP, - _convert_movement_ds_to_cliplabels, - _extract_frame_number, - _update_image_ids, - annotations_to_coco, + _convert_movement_ds_to_videolabels, predictions_to_poseinterface, ) @@ -86,200 +80,7 @@ def _mock_video(n_frames): return _mock_video -@patch("poseinterface.io.coco.convert_labels") -@patch("poseinterface.io.sio.load_file") -def test_annotations_to_coco( - mock_load_file, - mock_convert_labels, - tmp_path, -): - """Test that the relevant subfunctions are called.""" - # Mock return value of load_file - mock_labels = mock_load_file.return_value - mock_labels.labeled_frames = [1] # non-empty - - # Mock return value of convert_labels - mock_convert_labels.return_value = {"images": [], "annotations": []} - - # Run function to test - input_csv = tmp_path / "input.csv" - output_path = tmp_path / "output.json" - result = annotations_to_coco(input_csv, output_path) - - # Check subfunctions are all called - mock_load_file.assert_called_once_with(input_csv) - mock_convert_labels.assert_called_once_with( - mock_labels, - image_filenames=None, - visibility_encoding="ternary", - ) - - # Check output file path is as expected - assert result == output_path - assert output_path.exists() - - -@patch("poseinterface.io.sio.load_file") -@patch("poseinterface.io.is_dlc_file") -@pytest.mark.parametrize( - "input_file, error_message", - [ - ("foo.csv", "default"), - (lf("dlc_single_index_in_project_root"), "dlc"), - (lf("dlc_multi_index_in_project_root"), "dlc"), - ], -) -def test_annotations_to_coco_invalid( - mock_load_file, - mock_is_dlc_file, - input_file, - error_message, - tmp_path, -): - # Mock return value of load_file to have empty - # labeled frames - mock_labels = mock_load_file.return_value - mock_labels.labeled_frames = [] # empty - - # Check error is raised - with pytest.raises( - ValueError, match=_EMPTY_LABELS_ERROR_MSG[error_message] - ): - annotations_to_coco( - input_file, - tmp_path / "output.json", - ) - - # Check is_dlc_file was called - mock_is_dlc_file.assert_called_once_with(input_file) - - -@patch("poseinterface.io.sio.load_file") -def test_annotations_to_coco_not_single_video( - mock_load_file, - tmp_path, -): - """Test that error is raised when labels object contains >1 videos.""" - # Mock return value of load_file - mock_labels = mock_load_file.return_value - mock_labels.labeled_frames = [1] # there are labelled frames - mock_labels.videos = [1, 2] # from multiple videos - - # Check error is raised - with pytest.raises( - ValueError, - match=(r"The annotations refer to multiple videos.*Please check .*"), - ): - annotations_to_coco( - tmp_path / "input.csv", - tmp_path / "output.json", - ) - - -def test_update_image_ids(): - """Test that image ids are updated based on frame number.""" - # Define a COCO data dict with minimal info - input_data = { - "images": [ - {"id": 234, "file_name": "frame-00011.png"}, - {"id": 100, "file_name": "frame-00012.png"}, - ], - "annotations": [ - {"id": 1, "image_id": 100}, - {"id": 2, "image_id": 234}, - ], - } - - # New image IDs are derived from filename - expected_old_to_new_image_ids = { - img["id"]: _extract_frame_number( - img["file_name"], - POSEINTERFACE_FRAME_REGEXP, - ) - for img in input_data["images"] - } - - # Update image IDs - data = _update_image_ids(input_data) - - # Check image IDs in list of images - list_ids = [img["id"] for img in data["images"]] - expected_list_ids = [ - expected_old_to_new_image_ids[img["id"]] - for img in input_data["images"] - ] - assert expected_list_ids == list_ids - - # Check image IDs in list of annotations - list_image_ids = [annot["image_id"] for annot in data["annotations"]] - expected_list_image_ids = [ - expected_old_to_new_image_ids[annot["image_id"]] - for annot in input_data["annotations"] - ] - assert expected_list_image_ids == list_image_ids - - -def test_update_image_ids_duplicate_ids(): - """Test that duplicate frame numbers raise ValueError.""" - data = { - "images": [ - {"id": 1, "file_name": "frame-0005.png"}, - {"id": 2, "file_name": "frame-0005.png"}, # duplicate! - ], - "annotations": [], - } - - with pytest.raises(ValueError, match="Extracted image IDs are not unique"): - _update_image_ids(data) - - -@pytest.mark.parametrize( - "filename, frame_regexp, expected_image_id", - [ - ("img0000.png", r"img(\d*)", 0), - ("img0234.png", r"img(0\d*)", 234), - ( - "sub-M708149_ses-20200317_view-topdown_frame-00000.png", - POSEINTERFACE_FRAME_REGEXP, - 0, - ), - ("frame-234", POSEINTERFACE_FRAME_REGEXP, 234), - ("frame-0234", POSEINTERFACE_FRAME_REGEXP, 234), - ("frame-0234abcd", POSEINTERFACE_FRAME_REGEXP, 234), - ], -) -def test_extract_frame_number(filename, frame_regexp, expected_image_id): - """Test that image id is correctly extracted from filename.""" - image_id = _extract_frame_number(filename, frame_regexp) - assert isinstance(image_id, int) - assert image_id == expected_image_id - - -@pytest.mark.parametrize( - "filename, frame_regexp", - [ - ("sub-M708149_ses-20200317_view-topdown_frame.png", r"frame-(0\d*)"), - # no frame number after "frame-" - ("frame-234", r"frame-(0\d*)"), - # no leading zero - ("sub-M708149_ses-20200317_view-topdown_.png", r"frame-(0\d*)"), - # no "frame-" prefix - ("frame-0234", r"img(0\d*)"), - # regexp does not produce a match - ], -) -def test_extract_frame_number_invalid(filename, frame_regexp): - """Test that ValueError is raised when frame number cannot be extracted.""" - with pytest.raises( - ValueError, - match=( - r"No frame number could be extracted from filename.*regexp pattern" - ), - ): - _extract_frame_number(filename, frame_regexp) - - -@patch("poseinterface.io._convert_movement_ds_to_cliplabels") +@patch("poseinterface.io._convert_movement_ds_to_videolabels") @patch("poseinterface.io.sio.load_video") @patch("poseinterface.io.load_dataset") @patch("poseinterface.io._guess_source_software") @@ -325,21 +126,21 @@ def test_predictions_to_poseinterface( # Check output file exists with expected name assert result.exists() - assert result.name == "sub-M01_ses-20240101_cam-top.json" + assert result.name == "sub-M01_ses-20240101_cam-top_videolabels.json" -def test_convert_movement_ds_to_cliplabels( +def test_convert_movement_ds_to_videolabels( sample_movement_ds, mock_video, ): - """Test that movement dataset is converted to cliplabels dict.""" + """Test that movement dataset is converted to videolabels dict.""" # Get movement dataset and video fixtures ds = sample_movement_ds video = mock_video(n_frames=3) _, img_h, img_w, _ = video.shape - # Convert dataset to cliplabels dict - coco_data = _convert_movement_ds_to_cliplabels( + # Convert dataset to videolabels dict + coco_data = _convert_movement_ds_to_videolabels( ds, sub_id="M01", ses_id="20240101", From 4083cd2f983df37187068ed9956eb71f87afaba9 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 13:21:10 +0100 Subject: [PATCH 10/28] Revert Python version support increase --- .github/workflows/docs_build_and_deploy.yml | 2 +- .github/workflows/test_and_deploy.yml | 6 +++--- pyproject.toml | 11 ++++------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs_build_and_deploy.yml b/.github/workflows/docs_build_and_deploy.yml index e7364ce..7996b08 100644 --- a/.github/workflows/docs_build_and_deploy.yml +++ b/.github/workflows/docs_build_and_deploy.yml @@ -29,7 +29,7 @@ jobs: steps: - uses: neuroinformatics-unit/actions/build_sphinx_docs@main with: - python-version: "3.14" + python-version: "3.13" use-requirements-txt: false use-make: true github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 1115a18..7c107c1 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -27,14 +27,14 @@ jobs: strategy: matrix: # Run all supported Python versions on linux - python-version: ["3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13"] os: [ubuntu-latest] # Include one windows and macos run include: - os: macos-latest - python-version: "3.14" + python-version: "3.13" - os: windows-latest - python-version: "3.14" + python-version: "3.13" steps: # Run tests diff --git a/pyproject.toml b/pyproject.toml index e4a0b58..12647a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,11 @@ authors = [ ] description = "A framework for benchmarking pose estimation and point tracking methods on animal beheviour videos." readme = "README.md" -requires-python = ">=3.12.0" +requires-python = ">=3.11.0" dynamic = ["version"] dependencies = [ - "jupyter>=1.1.1", - "movement>=0.16.0", "sleap-io>=0.6.4", - "movement" ] license = {text = "BSD-3-Clause"} @@ -23,9 +20,9 @@ classifiers = [ "Development Status :: 2 - Pre-Alpha", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "License :: OSI Approved :: BSD License", ] @@ -123,14 +120,14 @@ docstring-code-format = true # Also format code in docstrings (e.g. examples) [tool.tox] legacy_tox_ini = """ [tox] -envlist = py{312,313,314} +envlist = py{311,312,313} isolated_build = True [gh-actions] python = + 3.11: py311 3.12: py312 3.13: py313 - 3.14: py314 [testenv] dependency_groups = From a327cd1c635cfb2b18d4f3c7387487b969fab742 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 13:22:19 +0100 Subject: [PATCH 11/28] `output_json_path` --- poseinterface/io.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 6891b32..a8de265 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -524,14 +524,14 @@ def predictions_to_poseinterface( ) # Export dict as JSON - output_json_parent_dir = ( + output_json_path = ( Path(output_json_parent_dir) / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_videolabels.json" ) - with open(output_json_parent_dir, "w") as f: + with open(output_json_path, "w") as f: json.dump(coco_data, f) - return output_json_parent_dir + return output_json_path def _convert_movement_ds_to_videolabels( From 6f1c09632ea5c0257eacfea04b860c0c79867ed5 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 13:23:33 +0100 Subject: [PATCH 12/28] Apply suggestions from code review Co-authored-by: Chang Huan Lo --- poseinterface/io.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index a8de265..22bd726 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -556,21 +556,22 @@ def _convert_movement_ds_to_videolabels( # with models that treat category 0 as background. categories = [ { - "id": i + 1, + "id": i, "name": name, "keypoints": keypoint_names, "skeleton": [], } - for i, name in enumerate(individual_names) + for i, name in enumerate(individual_names, start=1) ] # Build images list (one entry per frame) # NOTE: image id values are always 0-indexed + frame_idx_width = len(str(n_frames - 1)) images = [ { "id": t, "file_name": ( - f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_frame-{t:04d}" + f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_frame-{t:0{frame_idx_width}d}" ), "width": img_w, "height": img_h, From 04b4134d3fdaf4c236efdb261dc58c82e3d62f52 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 13:33:22 +0100 Subject: [PATCH 13/28] Use sleap-io encode keypoints --- poseinterface/io.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 22bd726..e932da6 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -585,7 +585,7 @@ def _convert_movement_ds_to_videolabels( for t in range(n_frames): for i in range(len(individual_names)): # Get position data for this frame and individual - xy = positions[t, :, :, i] # (2, n_keypoints) + xy = positions[t, :, :, i].T # (n_keypoints, 2) # Determine kpt visibility: # 0: not labeled @@ -593,16 +593,9 @@ def _convert_movement_ds_to_videolabels( # 2: labeled and visible # NOTE: The current code only assigns 0 or 2 because the movement # dataset doesn't carry occlusion information - visible_array = ~np.isnan(xy[0]) & ~np.isnan(xy[1]) + visible_array = ~np.isnan(xy[:, 0]) & ~np.isnan(xy[:, 1]) n_visible = int(visible_array.sum()) - # Get list of flattened keypoints - # [x1, y1, v1, x2, y2, v2, ...] - x = np.where(visible_array, xy[0], 0.0) - y = np.where(visible_array, xy[1], 0.0) - v = np.where(visible_array, 2, 0) - list_xyv_kpts = np.stack([x, y, v], axis=1).ravel().tolist() - # Compute bbox from visible keypoints # (zeros if no keypoints are visible) if n_visible > 0: @@ -621,7 +614,9 @@ def _convert_movement_ds_to_videolabels( "id": annot_id, "image_id": t, "category_id": i + 1, - "keypoints": list_xyv_kpts, + "keypoints": coco.encode_keypoints( + np.c_[xy, visible_array] + ), # returns flattened kpts [x1, y1, v1, x2, y2, v2, ...] "num_keypoints": n_visible, "bbox": [x_min, y_min, bbox_w, bbox_h], "area": bbox_w * bbox_h, From 809eb465d650bd0b3771b7c51d16ff7d38d35bdb Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 13:38:52 +0100 Subject: [PATCH 14/28] Rename input arguments in `predictions_to_poseinterface` to match `annotations_to_poeinterface` --- poseinterface/io.py | 14 +++++++------- tests/test_unit/test_io.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index e932da6..4367c72 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -467,9 +467,9 @@ def _reencode_video( def predictions_to_poseinterface( - predictions_path: Path | str, + input_path: Path | str, video_path: Path | str, - output_json_parent_dir: Path | str, + output_dir: Path | str, *, sub_id: str, ses_id: str, @@ -482,12 +482,12 @@ def predictions_to_poseinterface( Parameters ---------- - predictions_path - Path to the DLC predictions CSV file. + input_path + Path to the predictions CSV file. video_path Path to the corresponding video file. Used to attach video metadata (resolution) to the COCO output. - output_json_parent_dir + output_dir Path to the directory where to save the output JSON file. sub_id Subject ID to include in the generated filenames. @@ -504,7 +504,7 @@ def predictions_to_poseinterface( # Read input file as movement dataset # NOTE: fps=None is ignore with NWB files ds = load_dataset( - file=predictions_path, + file=input_path, source_software="auto", # infer from validators fps=None, ) @@ -525,7 +525,7 @@ def predictions_to_poseinterface( # Export dict as JSON output_json_path = ( - Path(output_json_parent_dir) + Path(output_dir) / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_videolabels.json" ) with open(output_json_path, "w") as f: diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index 8db90d7..b8b141c 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -639,9 +639,9 @@ def test_predictions_to_poseinterface( # Convert predictions result = predictions_to_poseinterface( - predictions_path="fake.csv", + input_path="fake.csv", video_path="fake.mp4", - output_json_parent_dir=tmp_path, + output_dir=tmp_path, sub_id="M01", ses_id="20240101", cam_id="top", From 2dfaa63071a50e6f6a07177e2385fe10b01caae3 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 13:52:33 +0100 Subject: [PATCH 15/28] Link to supported formats --- docs/source/api_index.rst | 1 + poseinterface/io.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst index 796b813..7e8de89 100644 --- a/docs/source/api_index.rst +++ b/docs/source/api_index.rst @@ -12,6 +12,7 @@ io annotations_to_poseinterface video_to_poseinterface + predictions_to_poseinterface clips ----- diff --git a/poseinterface/io.py b/poseinterface/io.py index 4367c72..8dc558c 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -483,7 +483,8 @@ def predictions_to_poseinterface( Parameters ---------- input_path - Path to the predictions CSV file. + Path to the predictions file. It should be one of the formats + supported by ``movement`` (see `movement supported formats`_). video_path Path to the corresponding video file. Used to attach video metadata (resolution) to the COCO output. @@ -500,6 +501,16 @@ def predictions_to_poseinterface( ------- Path Path to the saved COCO JSON file. + + Notes + ------- + For the full list of supported formats for the input file, see + `movement supported formats`_. + + .. _movement supported formats: + https://movement.neuroinformatics.dev/dev/user_guide/input_output.html#supported-third-party-formats + + """ # Read input file as movement dataset # NOTE: fps=None is ignore with NWB files From 28b382ea6430552d9aa4c00d11aad0024fba4433 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 14:43:36 +0100 Subject: [PATCH 16/28] Error handling around reading video --- poseinterface/io.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 8dc558c..f620750 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -484,7 +484,7 @@ def predictions_to_poseinterface( ---------- input_path Path to the predictions file. It should be one of the formats - supported by ``movement`` (see `movement supported formats`_). + supported by ``movement`` (see `movement supported formats`_) video_path Path to the corresponding video file. Used to attach video metadata (resolution) to the COCO output. @@ -520,8 +520,17 @@ def predictions_to_poseinterface( fps=None, ) - # Get video image width and height + # Read video object + video_path = Path(video_path) + if not video_path.is_file(): + raise FileNotFoundError( + f"Input video file does not exist: {video_path}" + ) video = sio.load_video(video_path) + + # Get video image width and height + if video.shape is None: + raise ValueError(f"Could not extract video shape from {video_path}. ") _, img_h, img_w, _ = video.shape # Convert movement dataset to videolabels dict From 66bb6b9aa9cc9c07732d2710052d012b436c2c17 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 16:21:07 +0100 Subject: [PATCH 17/28] Add tests to cover error handling when loading video --- tests/test_unit/test_io.py | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index b8b141c..985d2fa 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -658,6 +658,55 @@ def test_predictions_to_poseinterface( assert result.name == "sub-M01_ses-20240101_cam-top_videolabels.json" +@patch("poseinterface.io.load_dataset") +def test_predictions_to_poseinterface_missing_video( + mock_load_dataset, + sample_movement_ds, + tmp_path, +): + """Check FileNotFoundError is raised when the video path does not exist.""" + mock_load_dataset.return_value = sample_movement_ds + + with pytest.raises( + FileNotFoundError, match="Input video file does not exist" + ): + predictions_to_poseinterface( + input_path="fake.csv", + video_path=tmp_path / "does_not_exist.mp4", + output_dir=tmp_path, + sub_id="M01", + ses_id="20240101", + cam_id="top", + ) + + +@patch("poseinterface.io.sio.load_video") +@patch("poseinterface.io.load_dataset") +def test_predictions_to_poseinterface_unreadable_video( + mock_load_dataset, + mock_load_video, + sample_movement_ds, + tmp_path, +): + """Check ValueError is raised when the loaded video has shape=None.""" + mock_load_dataset.return_value = sample_movement_ds + + # File exists on disk, but load_video can't read its shape + fake_video = tmp_path / "unreadable.mp4" + fake_video.touch() + mock_load_video.return_value = MagicMock(shape=None) + + with pytest.raises(ValueError, match="Could not extract video shape"): + predictions_to_poseinterface( + input_path="fake.csv", + video_path=fake_video, + output_dir=tmp_path, + sub_id="M01", + ses_id="20240101", + cam_id="top", + ) + + def test_convert_movement_ds_to_videolabels( sample_movement_ds, mock_video, From 281a5adab2edfc006ee88f15d7872b6450e927cb Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 16:34:39 +0100 Subject: [PATCH 18/28] Rename and factor out get_mock_video fixture --- tests/test_unit/conftest.py | 17 +++++++++++++++++ tests/test_unit/test_clips.py | 18 +++++------------- tests/test_unit/test_io.py | 26 ++++---------------------- 3 files changed, 26 insertions(+), 35 deletions(-) create mode 100644 tests/test_unit/conftest.py diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py new file mode 100644 index 0000000..8d0e9cf --- /dev/null +++ b/tests/test_unit/conftest.py @@ -0,0 +1,17 @@ +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def get_mock_video(): + """Mock a Video object with n frames, matching video_labels fixture.""" + + def _get_mock_video(n_frames): + video = MagicMock() + video.fps = 30 + video.shape = (n_frames, 480, 640, 3) + video.stem = "sub-01_ses-01_cam-01" + return video + + return _get_mock_video diff --git a/tests/test_unit/test_clips.py b/tests/test_unit/test_clips.py index 076d8b1..d93901b 100644 --- a/tests/test_unit/test_clips.py +++ b/tests/test_unit/test_clips.py @@ -1,7 +1,7 @@ import argparse import json import logging -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -37,16 +37,6 @@ def video_path(tmp_path, video_labels): return path -@pytest.fixture -def mock_video(): - """Mock Video object with 10 frames, matching video_labels fixture.""" - video = MagicMock() - video.fps = 30 - video.shape = (10, 480, 640, 3) - video.stem = "sub-01_ses-01_cam-01" - return video - - def test_extract_cliplabels(tmp_path, video_labels): """Test clip json file is extracted from the *_videolabels.json file.""" # Set up fake video path and corresponding videolabels.json @@ -89,10 +79,11 @@ def test_extract_cliplabels(tmp_path, video_labels): @patch("poseinterface.clips.sio.save_video") @patch("poseinterface.clips.sio.load_video") def test_extract_clip( - mock_load_video, mock_save_video, mock_video, video_path + mock_load_video, mock_save_video, get_mock_video, video_path ): """Test clip video and json are extracted from the input video.""" # Set mock_video as return value from load_video + mock_video = get_mock_video(n_frames=10) mock_load_video.return_value = mock_video # Extract clip @@ -120,10 +111,11 @@ def test_extract_clip( @patch("poseinterface.clips.sio.save_video") @patch("poseinterface.clips.sio.load_video") def test_extract_clip_clamped( - mock_load_video, mock_save_video, mock_video, video_path, caplog + mock_load_video, mock_save_video, get_mock_video, video_path, caplog ): """Test clip video and json when duration is clamped.""" # Set mock_video as return value from load_video + mock_video = get_mock_video(n_frames=10) mock_load_video.return_value = mock_video # Define clipping range diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index 985d2fa..ec91a00 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -84,20 +84,6 @@ def sample_movement_ds(): ) -@pytest.fixture -def mock_video(): - """Mock Video object with 10 frames, matching video_labels fixture.""" - - def _mock_video(n_frames): - video = MagicMock() - video.fps = 30 - video.shape = (n_frames, 480, 640, 3) - video.stem = "sub-01_ses-01_cam-01" - return video - - return _mock_video - - @patch("poseinterface.io.coco.convert_labels") @patch("poseinterface.io.sio.load_file") @pytest.mark.parametrize( @@ -612,23 +598,20 @@ def test_reencode_video(mock_load_video, mock_save_video, tmp_path): @patch("poseinterface.io._convert_movement_ds_to_videolabels") @patch("poseinterface.io.sio.load_video") @patch("poseinterface.io.load_dataset") -@patch("poseinterface.io._guess_source_software") def test_predictions_to_poseinterface( - mock_guess_source_software, mock_load_dataset, mock_load_video, mock_convert, sample_movement_ds, - mock_video, + get_mock_video, tmp_path, ): """Test that the relevant subfunctions are called.""" # Get movement dataset and video fixtures ds = sample_movement_ds - video = mock_video(n_frames=3) + video = get_mock_video(n_frames=3) # Mock return values for supporting functions - mock_guess_source_software.return_value = ["DeepLabCut"] mock_load_dataset.return_value = ds mock_load_video.return_value.shape = video.shape mock_convert.return_value = { @@ -648,7 +631,6 @@ def test_predictions_to_poseinterface( ) # Check subfunctions are called - mock_guess_source_software.assert_called_once() mock_load_dataset.assert_called_once() mock_load_video.assert_called_once() mock_convert.assert_called_once() @@ -709,12 +691,12 @@ def test_predictions_to_poseinterface_unreadable_video( def test_convert_movement_ds_to_videolabels( sample_movement_ds, - mock_video, + get_mock_video, ): """Test that movement dataset is converted to videolabels dict.""" # Get movement dataset and video fixtures ds = sample_movement_ds - video = mock_video(n_frames=3) + video = get_mock_video(n_frames=3) _, img_h, img_w, _ = video.shape # Convert dataset to videolabels dict From b42e0b55bfb7215a7e5cf3b52c45432b2a539527 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 16:43:58 +0100 Subject: [PATCH 19/28] Remove duplicate fixture --- tests/test_unit/test_clips.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_unit/test_clips.py b/tests/test_unit/test_clips.py index d93901b..5adfb28 100644 --- a/tests/test_unit/test_clips.py +++ b/tests/test_unit/test_clips.py @@ -37,13 +37,8 @@ def video_path(tmp_path, video_labels): return path -def test_extract_cliplabels(tmp_path, video_labels): +def test_extract_cliplabels(tmp_path, video_path, video_labels): """Test clip json file is extracted from the *_videolabels.json file.""" - # Set up fake video path and corresponding videolabels.json - video_path = tmp_path / "sub-01_ses-01_cam-01.mp4" - json_path = tmp_path / "sub-01_ses-01_cam-01_videolabels.json" - json_path.write_text(json.dumps(video_labels)) - # Set up a "Clips" destination directory clips_dir = tmp_path / "Clips" clips_dir.mkdir() From a2a6c355ab45dec018101e05b24ae1a87471ffab Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 18:34:59 +0100 Subject: [PATCH 20/28] Fix mismatch after .T --- poseinterface/io.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index f620750..e73d0dd 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -613,14 +613,16 @@ def _convert_movement_ds_to_videolabels( # 2: labeled and visible # NOTE: The current code only assigns 0 or 2 because the movement # dataset doesn't carry occlusion information - visible_array = ~np.isnan(xy[:, 0]) & ~np.isnan(xy[:, 1]) + visible_array = ~np.isnan(xy[:, 0]) & ~np.isnan( + xy[:, 1] + ) # (n_keypoints,) n_visible = int(visible_array.sum()) # Compute bbox from visible keypoints # (zeros if no keypoints are visible) if n_visible > 0: - x_visible = xy[0, visible_array] - y_visible = xy[1, visible_array] + x_visible = xy[visible_array, 0] + y_visible = xy[visible_array, 1] x_min = float(x_visible.min()) y_min = float(y_visible.min()) bbox_w = float(x_visible.max()) - x_min From 8537216790721875b45a795c7fa6679d65d89097 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 18:35:40 +0100 Subject: [PATCH 21/28] Fix tests --- tests/test_unit/test_io.py | 51 +++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index ec91a00..6fe2bf7 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -603,17 +603,22 @@ def test_predictions_to_poseinterface( mock_load_video, mock_convert, sample_movement_ds, + sub_ses_cam_ids, get_mock_video, tmp_path, ): """Test that the relevant subfunctions are called.""" - # Get movement dataset and video fixtures + # Get movement dataset ds = sample_movement_ds - video = get_mock_video(n_frames=3) + + # Mock video input files + fake_video = tmp_path / "unreadable.mp4" + fake_video.touch() + mock_video = get_mock_video(n_frames=3) # Mock return values for supporting functions mock_load_dataset.return_value = ds - mock_load_video.return_value.shape = video.shape + mock_load_video.return_value = mock_video mock_convert.return_value = { "images": [], "annotations": [], @@ -623,11 +628,9 @@ def test_predictions_to_poseinterface( # Convert predictions result = predictions_to_poseinterface( input_path="fake.csv", - video_path="fake.mp4", + video_path=fake_video, output_dir=tmp_path, - sub_id="M01", - ses_id="20240101", - cam_id="top", + **sub_ses_cam_ids, ) # Check subfunctions are called @@ -637,13 +640,20 @@ def test_predictions_to_poseinterface( # Check output file exists with expected name assert result.exists() - assert result.name == "sub-M01_ses-20240101_cam-top_videolabels.json" + assert ( + result.name + == "_".join( + [f"{ky.strip('_id')}-{val}" for ky, val in sub_ses_cam_ids.items()] + ) + + "_videolabels.json" + ) @patch("poseinterface.io.load_dataset") -def test_predictions_to_poseinterface_missing_video( +def test_predictions_to_poseinterface_video_file_missing( mock_load_dataset, sample_movement_ds, + sub_ses_cam_ids, tmp_path, ): """Check FileNotFoundError is raised when the video path does not exist.""" @@ -656,18 +666,17 @@ def test_predictions_to_poseinterface_missing_video( input_path="fake.csv", video_path=tmp_path / "does_not_exist.mp4", output_dir=tmp_path, - sub_id="M01", - ses_id="20240101", - cam_id="top", + **sub_ses_cam_ids, ) @patch("poseinterface.io.sio.load_video") @patch("poseinterface.io.load_dataset") -def test_predictions_to_poseinterface_unreadable_video( +def test_predictions_to_poseinterface_video_shape_none( mock_load_dataset, mock_load_video, sample_movement_ds, + sub_ses_cam_ids, tmp_path, ): """Check ValueError is raised when the loaded video has shape=None.""" @@ -683,14 +692,13 @@ def test_predictions_to_poseinterface_unreadable_video( input_path="fake.csv", video_path=fake_video, output_dir=tmp_path, - sub_id="M01", - ses_id="20240101", - cam_id="top", + **sub_ses_cam_ids, ) def test_convert_movement_ds_to_videolabels( sample_movement_ds, + sub_ses_cam_ids, get_mock_video, ): """Test that movement dataset is converted to videolabels dict.""" @@ -702,13 +710,16 @@ def test_convert_movement_ds_to_videolabels( # Convert dataset to videolabels dict coco_data = _convert_movement_ds_to_videolabels( ds, - sub_id="M01", - ses_id="20240101", - cam_id="top", + **sub_ses_cam_ids, img_h=img_h, img_w=img_w, ) + # Unwrap subject, session and camera IDs + sub_id = sub_ses_cam_ids["sub_id"] + ses_id = sub_ses_cam_ids["ses_id"] + cam_id = sub_ses_cam_ids["cam_id"] + # Check top-level keys assert set(coco_data.keys()) == {"images", "annotations", "categories"} @@ -716,7 +727,7 @@ def test_convert_movement_ds_to_videolabels( assert len(coco_data["images"]) == len(ds.time) for k in range(len(coco_data["images"])): assert coco_data["images"][k]["file_name"] == ( - f"sub-M01_ses-20240101_cam-top_frame-000{k}" + f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_frame-{k:01d}" ) assert coco_data["images"][k]["width"] == img_w assert coco_data["images"][k]["height"] == img_h From 20f8a56e5d6cbc3351649efdd223377046dbdf42 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 19:04:35 +0100 Subject: [PATCH 22/28] Apply review comments re tests --- poseinterface/io.py | 5 +++-- tests/test_unit/test_io.py | 23 ++++++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index e73d0dd..2161939 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -544,9 +544,10 @@ def predictions_to_poseinterface( ) # Export dict as JSON + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) output_json_path = ( - Path(output_dir) - / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_videolabels.json" + output_dir / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}_videolabels.json" ) with open(output_json_path, "w") as f: json.dump(coco_data, f) diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index 6fe2bf7..bdb2dbe 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -612,24 +612,28 @@ def test_predictions_to_poseinterface( ds = sample_movement_ds # Mock video input files - fake_video = tmp_path / "unreadable.mp4" + fake_video = tmp_path / "foo.mp4" fake_video.touch() mock_video = get_mock_video(n_frames=3) + # Pre-define a return value for `_convert_movement_ds_to_videolabels` + convert_output = { + "images": [{"id": 0, "file_name": "foo", "width": 10, "height": 20}], + "annotations": [{"id": 1, "image_id": 0}], + "categories": [{"id": 1, "name": "mouse"}], + } + # Mock return values for supporting functions mock_load_dataset.return_value = ds mock_load_video.return_value = mock_video - mock_convert.return_value = { - "images": [], - "annotations": [], - "categories": [], - } + mock_convert.return_value = convert_output # Convert predictions result = predictions_to_poseinterface( input_path="fake.csv", video_path=fake_video, - output_dir=tmp_path, + output_dir=tmp_path / "nested" / "out", + # (use a nested dir from tmp_path to force creation) **sub_ses_cam_ids, ) @@ -648,6 +652,11 @@ def test_predictions_to_poseinterface( + "_videolabels.json" ) + # Check output json file contains the mock output from + # the convert function + with open(result) as f: + assert json.load(f) == convert_output + @patch("poseinterface.io.load_dataset") def test_predictions_to_poseinterface_video_file_missing( From e434671f3bd8425548ff4723334cb4942dc729cf Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 20:27:26 +0100 Subject: [PATCH 23/28] Revert "Revert Python version support increase" This reverts commit 4083cd2f983df37187068ed9956eb71f87afaba9. --- .github/workflows/docs_build_and_deploy.yml | 2 +- .github/workflows/test_and_deploy.yml | 6 +++--- pyproject.toml | 11 +++++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs_build_and_deploy.yml b/.github/workflows/docs_build_and_deploy.yml index 7996b08..e7364ce 100644 --- a/.github/workflows/docs_build_and_deploy.yml +++ b/.github/workflows/docs_build_and_deploy.yml @@ -29,7 +29,7 @@ jobs: steps: - uses: neuroinformatics-unit/actions/build_sphinx_docs@main with: - python-version: "3.13" + python-version: "3.14" use-requirements-txt: false use-make: true github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 7c107c1..1115a18 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -27,14 +27,14 @@ jobs: strategy: matrix: # Run all supported Python versions on linux - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.12", "3.13", "3.14"] os: [ubuntu-latest] # Include one windows and macos run include: - os: macos-latest - python-version: "3.13" + python-version: "3.14" - os: windows-latest - python-version: "3.13" + python-version: "3.14" steps: # Run tests diff --git a/pyproject.toml b/pyproject.toml index 12647a1..e4a0b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,11 +7,14 @@ authors = [ ] description = "A framework for benchmarking pose estimation and point tracking methods on animal beheviour videos." readme = "README.md" -requires-python = ">=3.11.0" +requires-python = ">=3.12.0" dynamic = ["version"] dependencies = [ + "jupyter>=1.1.1", + "movement>=0.16.0", "sleap-io>=0.6.4", + "movement" ] license = {text = "BSD-3-Clause"} @@ -20,9 +23,9 @@ classifiers = [ "Development Status :: 2 - Pre-Alpha", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "License :: OSI Approved :: BSD License", ] @@ -120,14 +123,14 @@ docstring-code-format = true # Also format code in docstrings (e.g. examples) [tool.tox] legacy_tox_ini = """ [tox] -envlist = py{311,312,313} +envlist = py{312,313,314} isolated_build = True [gh-actions] python = - 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 [testenv] dependency_groups = From fe638941888512bee20210abc05d0434abbe0963 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 13 May 2026 20:28:17 +0100 Subject: [PATCH 24/28] Edit docstring --- poseinterface/io.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/poseinterface/io.py b/poseinterface/io.py index 2161939..4a6b798 100644 --- a/poseinterface/io.py +++ b/poseinterface/io.py @@ -477,8 +477,14 @@ def predictions_to_poseinterface( ) -> Path: """Convert a prediction file to ``poseinterface`` COCO JSON format. - Reads a predictions file and writes a JSON with ``poseinterface``-style - filenames suitable for video-level labels (``_videolabels.json``). + This function reads predictions for a given video and writes the + corresponding "video-level" COCO JSON labels in the ``poseinterface`` + format, (i.e. a + ``sub-_ses-_cam-_videolabels.json`` file). + + The output JSON file is meant to facilitate the extraction of "clip-level" + labels, (i.e. files of the format + ``sub-_ses-_cam-_start-_dur-_cliplabels.json``). Parameters ---------- From 04682771ebdf538c929cb206420e1d693abbcc5d Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 14 May 2026 10:56:34 +0100 Subject: [PATCH 25/28] Update test docstring --- tests/test_unit/test_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index bdb2dbe..09ff667 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -607,7 +607,7 @@ def test_predictions_to_poseinterface( get_mock_video, tmp_path, ): - """Test that the relevant subfunctions are called.""" + """Test output path, filename, and saved JSON content.""" # Get movement dataset ds = sample_movement_ds From 63185216e025d65e3ad85cfb222840a6b632e293 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 14 May 2026 11:12:25 +0100 Subject: [PATCH 26/28] Simplify `test_convert_movement_ds_to_videolabels` --- tests/test_unit/test_io.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index 09ff667..b57ad8f 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -708,15 +708,14 @@ def test_predictions_to_poseinterface_video_shape_none( def test_convert_movement_ds_to_videolabels( sample_movement_ds, sub_ses_cam_ids, - get_mock_video, ): """Test that movement dataset is converted to videolabels dict.""" - # Get movement dataset and video fixtures ds = sample_movement_ds - video = get_mock_video(n_frames=3) - _, img_h, img_w, _ = video.shape + sub_id = sub_ses_cam_ids["sub_id"] + ses_id = sub_ses_cam_ids["ses_id"] + cam_id = sub_ses_cam_ids["cam_id"] + img_h, img_w = 480, 640 - # Convert dataset to videolabels dict coco_data = _convert_movement_ds_to_videolabels( ds, **sub_ses_cam_ids, @@ -724,15 +723,8 @@ def test_convert_movement_ds_to_videolabels( img_w=img_w, ) - # Unwrap subject, session and camera IDs - sub_id = sub_ses_cam_ids["sub_id"] - ses_id = sub_ses_cam_ids["ses_id"] - cam_id = sub_ses_cam_ids["cam_id"] - - # Check top-level keys assert set(coco_data.keys()) == {"images", "annotations", "categories"} - # Check images assert len(coco_data["images"]) == len(ds.time) for k in range(len(coco_data["images"])): assert coco_data["images"][k]["file_name"] == ( @@ -741,27 +733,22 @@ def test_convert_movement_ds_to_videolabels( assert coco_data["images"][k]["width"] == img_w assert coco_data["images"][k]["height"] == img_h - # Check categories assert len(coco_data["categories"]) == len(ds.individuals) - assert ( - coco_data["categories"][0]["name"] == ds.individuals.values.tolist()[0] - ) + assert coco_data["categories"][0]["name"] == ds.individuals.values[0] assert ( coco_data["categories"][0]["keypoints"] == ds.keypoints.values.tolist() ) - # Check annotations # 2 frames x 1 individual = 2 annotations assert len(coco_data["annotations"]) == len(ds.time) * len(ds.individuals) - # Frame 0: both keypoints visible - # kpt0=(10, 30), kpt1=(20, 40) + # Frame 0: both keypoints visible, kpt0=(10, 30), kpt1=(20, 40) annot0 = coco_data["annotations"][0] assert annot0["num_keypoints"] == 2 assert annot0["keypoints"] == [ - *ds.position.isel(time=0, keypoints=0).values.squeeze().tolist(), + *ds.position.isel(time=0, keypoints=0, individuals=0).values.tolist(), 2.0, - *ds.position.isel(time=0, keypoints=1).values.squeeze().tolist(), + *ds.position.isel(time=0, keypoints=1, individuals=0).values.tolist(), 2.0, ] # bbox: [xmin, ymin, width, height] @@ -775,7 +762,7 @@ def test_convert_movement_ds_to_videolabels( 0.0, 0.0, 0.0, - *ds.position.isel(time=1, keypoints=1).values.squeeze().tolist(), + *ds.position.isel(time=1, keypoints=1, individuals=0).values.tolist(), 2.0, ] # bbox covers only the single visible keypoint From ce219f5d5d878b66ee60cc50324f37378ab06444 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Thu, 14 May 2026 11:24:37 +0100 Subject: [PATCH 27/28] Remove wiring checks, keep only one checking for image width and height swaps --- tests/test_unit/conftest.py | 16 +++++++++++++--- tests/test_unit/test_io.py | 14 ++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py index 8d0e9cf..c99c4d3 100644 --- a/tests/test_unit/conftest.py +++ b/tests/test_unit/conftest.py @@ -5,13 +5,23 @@ @pytest.fixture def get_mock_video(): - """Mock a Video object with n frames, matching video_labels fixture.""" + """Mock a Video object with n frames, matching video_labels fixture. + + The returned image size should not be square (that is, image width should + not be equal to image height) to correctly check for inadvertent swaps + between these two values. + """ def _get_mock_video(n_frames): + # For the fixture to correctly check if image width and height + # are mistakenly swapped in the code, the following values should + # not be the same (i.e., the frame should not be square) + img_height = 480 + img_width = 640 + video = MagicMock() video.fps = 30 - video.shape = (n_frames, 480, 640, 3) - video.stem = "sub-01_ses-01_cam-01" + video.shape = (n_frames, img_height, img_width, 3) return video return _get_mock_video diff --git a/tests/test_unit/test_io.py b/tests/test_unit/test_io.py index b57ad8f..4a342a7 100644 --- a/tests/test_unit/test_io.py +++ b/tests/test_unit/test_io.py @@ -628,6 +628,10 @@ def test_predictions_to_poseinterface( mock_load_video.return_value = mock_video mock_convert.return_value = convert_output + # Get expected image width and height + # shape = (n_frames, img_height, img_width, 3) + _, expected_h, expected_w, _ = mock_video.shape + # Convert predictions result = predictions_to_poseinterface( input_path="fake.csv", @@ -637,10 +641,12 @@ def test_predictions_to_poseinterface( **sub_ses_cam_ids, ) - # Check subfunctions are called - mock_load_dataset.assert_called_once() - mock_load_video.assert_called_once() - mock_convert.assert_called_once() + # Check surrounding code correctly routes image height to + # img_h and image width to img_w when calling the + # _convert_movement_ds_to_videolabels function + mock_convert.assert_called_once_with( + ds, **sub_ses_cam_ids, img_h=expected_h, img_w=expected_w + ) # Check output file exists with expected name assert result.exists() From d7bf39c66598da37cfb3452fcf6d489bf9db1e03 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Thu, 14 May 2026 11:27:00 +0100 Subject: [PATCH 28/28] Remove pinned movement --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e4a0b58..919595f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ dynamic = ["version"] dependencies = [ "jupyter>=1.1.1", - "movement>=0.16.0", "sleap-io>=0.6.4", "movement" ]