Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/source/user_guide/input_output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| [COCO](coco:) | COCO | COCO keypoint detection [results](coco:format-results) `.json` file, optionally with a COCO [annotations](coco:format-data) `.json` file | Pose | 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 |
Expand Down Expand Up @@ -233,6 +234,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
Expand Down
1 change: 1 addition & 0 deletions movement/io/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Anipose",
"NWB",
"VIA-tracks",
"OCTRON",
"COCO",
]
AMBIGUOUS_DLC_LP_SOURCE_SOFTWARE: Final = "DeepLabCut/LightningPose"
Expand Down
86 changes: 85 additions & 1 deletion movement/io/load_bboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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_<track_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
98 changes: 98 additions & 0 deletions movement/validators/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,3 +1218,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."
)
Loading