From 26851f545d3f0bf358864b0974ef63c1659e2b0b Mon Sep 17 00:00:00 2001 From: anshurajbisoyi98-ctrl Date: Sun, 4 Oct 2026 19:35:10 +0530 Subject: [PATCH 1/2] feat: add support for loading OCTRON bounding box CSV files Fixes #1108 --- docs/source/user_guide/input_output.md | 28 ++++ movement/io/load.py | 1 + movement/io/load_bboxes.py | 86 +++++++++- movement/validators/files.py | 98 +++++++++++ tests/test_unit/test_io/test_load_octron.py | 176 ++++++++++++++++++++ 5 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 tests/test_unit/test_io/test_load_octron.py diff --git a/docs/source/user_guide/input_output.md b/docs/source/user_guide/input_output.md index 22f4aa7198..c19e207276 100644 --- a/docs/source/user_guide/input_output.md +++ b/docs/source/user_guide/input_output.md @@ -33,6 +33,7 @@ Use {func}`~movement.io.load.get_supported_source_software` and {func}`~movement | [SLEAP](sleap:) | SLEAP | [analysis](sleap-docs:tutorial/exporting-the-results/#analysis-hdf5) .h5 or .slp file | Pose | Load & Save | | [LightningPose](lp:) | LP | DLC-style .csv file, or corresponding pandas DataFrame | Pose | Load & Save | | [Anipose](anipose:) | | triangulation .csv file, or corresponding pandas DataFrame | Pose | Load | +| [OCTRON](https://octron-tracking.github.io/OCTRON-docs/) | OCTRON | Per-track prediction .csv files | Bounding box | Load | | [VGG Image Annotator](via:) | VIA | .csv file for [tracks annotation](via:docs/face_track_annotation.html) | Bounding box | Load & Save | | [Neurodata Without Borders](https://nwb-overview.readthedocs.io/en/latest/) | NWB | .nwb file or NWBFile object with the [ndx-pose extension](https://github.com/rly/ndx-pose) | Pose | Load & Save | | Any | | Numpy arrays | Pose or Bounding box | Load & Save\* | @@ -210,6 +211,33 @@ with pynwb.NWBHDF5IO("path/to/file.nwb", mode="r") as io: ``` :::: +::::{tab-item} OCTRON .csv files +OCTRON exports one prediction CSV per track, with a metadata header describing the source video. +Load a single track using the unified interface: + +```python +ds = load_dataset("bird_track_7.csv", source_software="OCTRON", fps=30) +``` + +To combine tracks from the same video, explicitly pass the other CSV paths: + +```python +ds = load_dataset( + "bird_track_7.csv", + source_software="OCTRON", + fps=30, + additional_files=["bird_track_2.csv"], +) +``` + +The loader checks that video names, dimensions and frame counts agree across files. +Time uses original zero-based video frame indices, including when OCTRON skipped frames during prediction. +Missing observations, including frames before a track appears or after it disappears, remain NaN. +Individual names use OCTRON track IDs (`id_7`, for example), rather than class labels that may be shared by multiple animals. +The `position` variable contains bounding box centres calculated from the corner coordinates, and `shape` contains widths and heights. +Segmentation masks, interpolation and identity reassignment are outside this loader's scope. +:::: + ::::{tab-item} From NumPy In the example below, we create random position data for two individuals, `Alice` and `Bob`, with three keypoints each: `snout`, `centre`, and `tail_base`. These keypoints are tracked in 2D space for 100 frames, at 30 fps. The confidence scores are set to 1 for all points. ```python diff --git a/movement/io/load.py b/movement/io/load.py index 3aa29a979b..ebca37e38a 100644 --- a/movement/io/load.py +++ b/movement/io/load.py @@ -30,6 +30,7 @@ "Anipose", "NWB", "VIA-tracks", + "OCTRON", ] AMBIGUOUS_DLC_LP_SOURCE_SOFTWARE: Final = "DeepLabCut/LightningPose" diff --git a/movement/io/load_bboxes.py b/movement/io/load_bboxes.py index bc55392a9b..2f687e98a9 100644 --- a/movement/io/load_bboxes.py +++ b/movement/io/load_bboxes.py @@ -4,12 +4,17 @@ from typing import cast import numpy as np +import pandas as pd import xarray as xr from movement.io.load import register_loader from movement.utils.logging import logger from movement.validators.datasets import ValidBboxesInputs -from movement.validators.files import DEFAULT_FRAME_REGEXP, ValidVIATracksCSV +from movement.validators.files import ( + DEFAULT_FRAME_REGEXP, + ValidOCTRONCSV, + ValidVIATracksCSV, +) def from_numpy( @@ -343,3 +348,82 @@ def _numpy_arrays_from_valid_via_object( "ID_array": unique_ids.reshape(-1, 1), "frame_array": unique_frames.reshape(-1, 1), } + + +@register_loader("OCTRON", file_validators=[ValidOCTRONCSV]) +def from_octron_file( + file: str | Path, + fps: float | None = None, + additional_files: list[str | Path] | None = None, +) -> xr.Dataset: + """Load OCTRON tracking CSVs from one video as bounding boxes. + + Parameters + ---------- + file + Path to an OCTRON per-track CSV, including its metadata header. + fps + Sampling rate of the original video. If omitted, time is in frames. + additional_files + Other per-track CSVs from the same video to combine with ``file``. + The video name, dimensions and frame counts must agree. + + Returns + ------- + xarray.Dataset + Bounding box centres, widths, heights and confidence scores. + Original zero-based ``frame_idx`` values are preserved over the full + video duration. Missing observations remain NaN. Individuals are named + ``id_``; OCTRON class labels do not identify individuals. + + Notes + ----- + Position is calculated from bounding box corners, rather than OCTRON's + ``pos_x`` and ``pos_y``, which can describe segmentation centroids. + No interpolation, smoothing or identity reassignment is performed. + + """ + first = cast("ValidOCTRONCSV", file) + files = [first] + [ValidOCTRONCSV(path) for path in additional_files or []] + for other in files[1:]: + for key in ( + "video_name", + "frame_count", + "frame_count_analyzed", + "video_height", + "video_width", + ): + if other.metadata[key] != first.metadata[key]: + raise ValueError("OCTRON CSVs must describe the same video.") + data = pd.concat([item.data for item in files], ignore_index=True) + if data.duplicated(["frame_idx", "track_id"]).any(): + raise ValueError( + "Duplicate OCTRON frame_idx and track_id observations." + ) + ids = np.sort(data.track_id.unique()) + frames = np.arange(int(first.metadata["frame_count"])) + position = np.full((len(frames), 2, len(ids)), np.nan) + shape = np.full_like(position, np.nan) + confidence = np.full((len(frames), len(ids)), np.nan) + for individual, track_id in enumerate(ids): + track = data[data.track_id == track_id] + indices = track.frame_idx.to_numpy() + for axis, name in enumerate(("x", "y")): + lower = track[f"bbox_{name}_min"].to_numpy() + upper = track[f"bbox_{name}_max"].to_numpy() + position[indices, axis, individual] = (lower + upper) / 2 + shape[indices, axis, individual] = upper - lower + confidence[indices, individual] = track.confidence.to_numpy() + ds = from_numpy( + position_array=position, + shape_array=shape, + confidence_array=confidence, + individual_names=[f"id_{track_id}" for track_id in ids], + frame_array=frames.reshape(-1, 1), + fps=fps, + source_software="OCTRON", + ) + ds.attrs["source_file"] = first.file.as_posix() + if len(files) > 1: + ds.attrs["source_files"] = [item.file.as_posix() for item in files] + return ds diff --git a/movement/validators/files.py b/movement/validators/files.py index e75baf8ac5..0aa6099b69 100644 --- a/movement/validators/files.py +++ b/movement/validators/files.py @@ -1000,3 +1000,101 @@ class ValidROICollectionGeoJSON: data: dict = field(init=False, factory=dict) """Parsed JSON data from the file, available after validation.""" + + +@define +class ValidOCTRONCSV: + """Validate an OCTRON per-track CSV and cache its tracking table.""" + + suffixes: ClassVar[set[str]] = {".csv"} + file: Path = field( + converter=Path, + validator=_file_validator(permission="r", suffixes=suffixes), + ) + metadata: dict[str, str] = field(init=False, factory=dict) + data: pd.DataFrame = field(init=False) + + @file.validator + def _validate_octron_csv(self, attribute, value): + """Check the metadata, frame indices, IDs and bounding box extents.""" + self._read_table(value) + + required = { + "frame_counter", + "frame_idx", + "track_id", + "label", + "confidence", + "bbox_x_min", + "bbox_x_max", + "bbox_y_min", + "bbox_y_max", + } + if not required.issubset(self.data.columns): + raise ValueError("Missing required OCTRON tracking columns.") + if self.data.empty: + raise ValueError("OCTRON tracking CSV contains no observations.") + try: + frame_count = int(self.metadata["frame_count"]) + except ValueError as error: + raise ValueError("Invalid OCTRON frame_count.") from error + if frame_count <= 0: + raise ValueError("OCTRON frame_count must be positive.") + self._validate_indices(frame_count) + for key in required - { + "frame_counter", + "frame_idx", + "track_id", + "label", + }: + self.data[key] = pd.to_numeric(self.data[key], errors="raise") + if np.isinf(self.data[key]).any(): + raise ValueError(f"OCTRON {key} contains infinite values.") + if (self.data.bbox_x_max < self.data.bbox_x_min).any() or ( + self.data.bbox_y_max < self.data.bbox_y_min + ).any(): + raise ValueError( + "OCTRON bounding box extents must be non-negative." + ) + + def _read_table(self, path): + """Read the metadata header and tracking table.""" + expected_metadata = ( + "video_name", + "frame_count", + "frame_count_analyzed", + "video_height", + "video_width", + "created_at", + ) + with open(path) as stream: + for key in expected_metadata: + name, separator, content = stream.readline().partition(":") + if not separator or name != key: + raise ValueError("Invalid OCTRON metadata header.") + self.metadata[key] = content.strip() + if stream.readline().strip(): + raise ValueError( + "Expected a blank line after OCTRON metadata." + ) + self.data = pd.read_csv(stream) + + def _validate_indices(self, frame_count): + """Validate original frame numbers and track identifiers.""" + for key in ("frame_idx", "track_id"): + values = pd.to_numeric(self.data[key], errors="raise") + if ( + not np.isfinite(values).all() + or (values < 0).any() + or (values != np.floor(values)).any() + ): + raise ValueError( + f"OCTRON {key} must contain non-negative integers." + ) + self.data[key] = values.astype(np.int64) + if (self.data.frame_idx >= frame_count).any(): + raise ValueError("OCTRON frame_idx exceeds frame_count.") + if self.data.duplicated(["frame_idx", "track_id"]).any(): + raise ValueError( + "Duplicate OCTRON frame_idx and track_id observations." + ) diff --git a/tests/test_unit/test_io/test_load_octron.py b/tests/test_unit/test_io/test_load_octron.py new file mode 100644 index 0000000000..fb725e49ad --- /dev/null +++ b/tests/test_unit/test_io/test_load_octron.py @@ -0,0 +1,176 @@ +"""Test OCTRON tracking CSV loading without installing OCTRON.""" + +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from movement.io import load_bboxes, load_dataset +from movement.io.load import infer_source_software +from movement.validators.files import ValidOCTRONCSV + + +@pytest.fixture +def octron_csv(tmp_path): + """Write the metadata and indexed table emitted by OCTRON's CSV writer. + + The layout follows AnalysisOctron.predict_batch: six metadata lines, + a blank line, then DataFrame.to_csv with its three-level index. + Writer reference: OCTRON-GUI revision + 54db1e19ae01b86c6f9fd521ea5c4e5fb7519b79, analysis_octron.py. + """ + + def write(name="bird_track_7.csv", track_id=7, video_name="birds.mp4"): + path = tmp_path / name + data = pd.DataFrame( + { + "frame_counter": [1, 3], + "frame_idx": [2, 6], + "track_id": [track_id, track_id], + "label": ["bird", "bird"], + "confidence": [0.9, 0.7], + "pos_x": [999, 999], + "pos_y": [999, 999], + "bbox_x_min": [10, 20], + "bbox_x_max": [30, 50], + "bbox_y_min": [40, 50], + "bbox_y_max": [60, 90], + } + ).set_index(["frame_counter", "frame_idx", "track_id"]) + with path.open("w") as stream: + stream.write( + f"video_name: {video_name}\nframe_count: 8\n" + "frame_count_analyzed: 4\nvideo_height: 100\n" + "video_width: 200\ncreated_at: 2026-09-16 12:00:00\n\n" + ) + data.to_csv(stream, na_rep="NaN") + return path + + return write + + +@pytest.mark.parametrize("fps", [None, 20]) +def test_load_octron_coordinates(octron_csv, fps): + """Preserve original frames, confidences, gaps and bounding box centres.""" + path = octron_csv() + ds = load_dataset(path, source_software="OCTRON", fps=fps) + np.testing.assert_allclose(ds.time, np.arange(8) / (fps or 1)) + assert ds.individual.values.tolist() == ["id_7"] + np.testing.assert_allclose( + ds.position.isel(time=[2, 6], individual=0), [[20, 50], [35, 70]] + ) + np.testing.assert_allclose( + ds.shape.isel(time=[2, 6], individual=0), [[20, 20], [30, 40]] + ) + np.testing.assert_allclose( + ds.confidence.isel(time=[2, 6], individual=0), [0.9, 0.7] + ) + assert ds.position.isel(time=[0, 1, 3, 4, 5, 7]).isnull().all() + assert ds.shape.isel(time=[0, 1, 3, 4, 5, 7]).isnull().all() + assert ds.confidence.isel(time=[0, 1, 3, 4, 5, 7]).isnull().all() + assert ds.attrs["source_software"] == "OCTRON" + assert ds.attrs["source_file"] == path.as_posix() + + +def test_octron_auto_detection(octron_csv): + """Detect OCTRON and dispatch through the unified loading interface.""" + path = octron_csv() + assert infer_source_software(path) == "OCTRON" + xr.testing.assert_identical( + load_dataset(path), load_bboxes.from_octron_file(path) + ) + + +def test_combine_octron_tracks(octron_csv): + """Combine distinct IDs even when class labels are identical.""" + first = octron_csv() + second = octron_csv("bird_track_2.csv", track_id=2) + ds = load_dataset(first, "OCTRON", additional_files=[second]) + assert ds.individual.values.tolist() == ["id_2", "id_7"] + assert ds.attrs["source_files"] == [str(first), str(second)] + np.testing.assert_allclose(ds.confidence.isel(time=2), [0.9, 0.9]) + + +def test_reject_mixed_videos(octron_csv): + """Reject files from distinct videos instead of silently mixing tracks.""" + first = octron_csv() + second = octron_csv("other.csv", track_id=2, video_name="other.mp4") + with pytest.raises(ValueError, match="same video"): + load_bboxes.from_octron_file(first, additional_files=[second]) + + +def test_reject_duplicate_tracks(octron_csv): + """Reject overlapping observations across input files.""" + path = octron_csv() + with pytest.raises(ValueError, match="Duplicate"): + load_bboxes.from_octron_file(path, additional_files=[path]) + + +@pytest.mark.parametrize( + "old,new,match", + [ + ("video_name:", "wrong_name:", "metadata"), + ("frame_count: 8", "frame_count: 0", "positive"), + ("bbox_x_min", "wrong_column", "columns"), + ("1,2,7,", "1,-2,7,", "non-negative"), + ("1,2,7,", "1,2.5,7,", "non-negative"), + ("1,2,7,", "1,8,7,", "exceeds"), + ("10,30,40,60", "40,30,40,60", "extents"), + ], +) +def test_invalid_octron_data(octron_csv, old, new, match): + """Give actionable errors for malformed tracking files.""" + path = octron_csv() + path.write_text(path.read_text().replace(old, new)) + with pytest.raises(ValueError, match=match): + ValidOCTRONCSV(path) + + +def test_empty_octron_table(octron_csv): + """Reject metadata-only files with no track observations.""" + path = octron_csv() + path.write_text("\n".join(path.read_text().splitlines()[:8]) + "\n") + with pytest.raises(ValueError, match="no observations"): + ValidOCTRONCSV(path) + + +def test_missing_octron_file(tmp_path): + """Use the standard file validator for nonexistent inputs.""" + with pytest.raises(FileNotFoundError): + load_bboxes.from_octron_file(tmp_path / "missing.csv") + + +@pytest.mark.parametrize("old,new,match", [ + ("frame_count: 8", "frame_count: unknown", "frame_count"), + ("1,2,7,", "1,2,NaN,", "non-negative"), + ("0.9", "inf", "infinite"), + ("40,60", "90,60", "extents"), + ("\n\nframe_counter", "\nnot blank\nframe_counter", "blank line"), +]) +def test_invalid_octron_metadata_and_values(octron_csv, old, new, match): + """Reject malformed metadata, track IDs and numeric values.""" + path = octron_csv() + path.write_text(path.read_text().replace(old, new)) + with pytest.raises(ValueError, match=match): + ValidOCTRONCSV(path) + + +def test_duplicate_octron_rows(octron_csv): + """Reject repeated observations within a tracking CSV.""" + path = octron_csv() + lines = path.read_text().splitlines() + path.write_text("\n".join(lines + [lines[-1]]) + "\n") + with pytest.raises(ValueError, match="Duplicate"): + ValidOCTRONCSV(path) + + +def test_unsorted_octron_rows_and_nan(octron_csv): + """Unsorted input and absent confidence retain original frame alignment.""" + path = octron_csv() + lines = path.read_text().splitlines() + lines[-2:] = reversed(lines[-2:]) + path.write_text("\n".join(lines).replace("0.9", "NaN") + "\n") + ds = load_dataset(path, "OCTRON") + assert np.isnan(ds.confidence.sel(time=2, individual="id_7")) + np.testing.assert_allclose(ds.position.sel(time=2, individual="id_7"), + [20, 50]) From 47824f7da521bbf7e24c9b2f5b56576c62bedbf5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:06:18 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_unit/test_io/test_load_octron.py | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/test_unit/test_io/test_load_octron.py b/tests/test_unit/test_io/test_load_octron.py index fb725e49ad..cb07daaffc 100644 --- a/tests/test_unit/test_io/test_load_octron.py +++ b/tests/test_unit/test_io/test_load_octron.py @@ -140,13 +140,16 @@ def test_missing_octron_file(tmp_path): load_bboxes.from_octron_file(tmp_path / "missing.csv") -@pytest.mark.parametrize("old,new,match", [ - ("frame_count: 8", "frame_count: unknown", "frame_count"), - ("1,2,7,", "1,2,NaN,", "non-negative"), - ("0.9", "inf", "infinite"), - ("40,60", "90,60", "extents"), - ("\n\nframe_counter", "\nnot blank\nframe_counter", "blank line"), -]) +@pytest.mark.parametrize( + "old,new,match", + [ + ("frame_count: 8", "frame_count: unknown", "frame_count"), + ("1,2,7,", "1,2,NaN,", "non-negative"), + ("0.9", "inf", "infinite"), + ("40,60", "90,60", "extents"), + ("\n\nframe_counter", "\nnot blank\nframe_counter", "blank line"), + ], +) def test_invalid_octron_metadata_and_values(octron_csv, old, new, match): """Reject malformed metadata, track IDs and numeric values.""" path = octron_csv() @@ -172,5 +175,6 @@ def test_unsorted_octron_rows_and_nan(octron_csv): path.write_text("\n".join(lines).replace("0.9", "NaN") + "\n") ds = load_dataset(path, "OCTRON") assert np.isnan(ds.confidence.sel(time=2, individual="id_7")) - np.testing.assert_allclose(ds.position.sel(time=2, individual="id_7"), - [20, 50]) + np.testing.assert_allclose( + ds.position.sel(time=2, individual="id_7"), [20, 50] + )