diff --git a/movement/kinematics/path.py b/movement/kinematics/path.py index c040ecfba..508be8adf 100644 --- a/movement/kinematics/path.py +++ b/movement/kinematics/path.py @@ -144,7 +144,7 @@ def compute_path_straightness( valid observed positions in between. Note that the total path length (L), and therefore the straightness index, - is sensitive to the temporal sampling rate (i.e. frames per second), + is sensitive to the temporal sampling rate (i.e. frames per second), as described in the Notes of :func:`compute_path_length`. See Also diff --git a/movement/napari/convert.py b/movement/napari/convert.py index b7eb60289..5c8b7a195 100644 --- a/movement/napari/convert.py +++ b/movement/napari/convert.py @@ -4,6 +4,8 @@ import pandas as pd import xarray as xr +from movement.io import load_bboxes, load_poses + def _construct_properties_dataframe(ds: xr.Dataset) -> pd.DataFrame: """Construct a properties DataFrame from a ``movement`` dataset.""" @@ -145,3 +147,112 @@ def ds_to_napari_layers( properties = _construct_properties_dataframe(ds_) return points_as_napari, bboxes_as_napari, properties + + +def napari_layers_to_ds( + napari_layers: np.ndarray, + properties: pd.DataFrame, + fps: float | None = None, +) -> xr.Dataset: + """Convert napari layer data back to a ``movement`` dataset. + + Parameters + ---------- + napari_layers + Napari layer data generated by + ``movement.napari.convert.ds_to_napari_layers``. + This can be either a Tracks array with shape (N, 4), + where the 4 columns are (track_id, frame_idx, y, x), + or a Shapes array representing bounding box data + (rectangle vertices). + properties + DataFrame with properties (individual, keypoint, time, confidence) + for use with napari layers. + fps + Frames per second of the video. Defaults to None, in which case + the ``time`` coordinates will be in frame numbers. + + Returns + ------- + ds : xarray.Dataset + ``movement`` dataset containing pose or bounding box tracks, + confidence scores, and associated metadata. + + Notes + ----- + The dataset type is inferred from the presence of ``keypoint`` in + ``properties``. If present, a poses dataset is returned. Otherwise, + a bounding boxes dataset is returned. + + """ + if "keypoint" in properties.columns: + individual_names = properties["individual"].unique().tolist() + keypoint_names = properties["keypoint"].unique().tolist() + + position = ( + napari_layers[:, [3, 2]] # 3:x, 2:y + .reshape(len(individual_names), len(keypoint_names), -1, 2) + .transpose(2, 3, 1, 0) + ) + confidence = ( + properties["confidence"] + .to_numpy() + .reshape(len(individual_names), len(keypoint_names), -1) + .transpose(2, 1, 0) + ) + + return load_poses.from_numpy( + position_array=position, + confidence_array=confidence, + individual_names=individual_names, + keypoint_names=keypoint_names, + fps=fps, + ) + + else: ##bboxes + individual_names = properties["individual"].unique().tolist() + n_individuals = len(individual_names) + n_frames = len(properties["time"].unique()) + + xmin_ymin = napari_layers[:, 0, :] + xmax_ymax = napari_layers[:, 2, :] + + xmin = xmin_ymin[:, 3] + ymin = xmin_ymin[:, 2] + xmax = xmax_ymax[:, 3] + ymax = xmax_ymax[:, 2] + + position = np.column_stack( # center of box + [ + (xmin + xmax) / 2, + (ymin + ymax) / 2, + ] + ) + + shape = np.column_stack( # width,length of box + [ + xmax - xmin, + ymax - ymin, + ] + ) + + position = position.reshape(n_individuals, n_frames, 2).transpose( + 1, 2, 0 + ) + + shape = shape.reshape(n_individuals, n_frames, 2).transpose(1, 2, 0) + + confidence = ( + properties["confidence"] + .to_numpy() + .reshape(n_individuals, n_frames) + .transpose(1, 0) + ) + + return load_bboxes.from_numpy( + position_array=position, + shape_array=shape, + confidence_array=confidence, + individual_names=individual_names, + fps=fps, + ) diff --git a/movement/roi/line.py b/movement/roi/line.py index 90a07d772..c0e07c7e5 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -53,9 +53,8 @@ def __init__( additional line segment to the first, creating a closed loop. (See Notes). name - Name of the LoI that is to be created. A default name will be - inherited from the base class if not provided, and - defaults are inherited from. + Name of the LoI that is to be created. If not provided, a default + name will be inherited from the base class. Notes ----- diff --git a/movement/roi/polygon.py b/movement/roi/polygon.py index 1806c9d03..bd1d105af 100644 --- a/movement/roi/polygon.py +++ b/movement/roi/polygon.py @@ -122,7 +122,7 @@ def interior_boundaries(self) -> tuple[LineOfInterest, ...]: """The interior boundaries of this RoI. Interior boundaries are the boundaries of holes contained within the - polygon. A region with no holes returns the empty tuple. + polygon. A region with no holes returns the empty tuple. """ return tuple( LineOfInterest( @@ -136,7 +136,7 @@ def interior_boundaries(self) -> tuple[LineOfInterest, ...]: def _plot( self, fig: Figure | SubFigure, ax: Axes, **matplotlib_kwargs ) -> tuple[Figure | SubFigure, Axes]: - """Polygonal regions need to use patch to be plotted. + """Polygonal regions need to use a patch to be plotted. In addition, ``matplotlib`` requires hole coordinates to be listed in the reverse orientation to the exterior boundary. Running diff --git a/tests/test_unit/test_napari_plugin/test_convert.py b/tests/test_unit/test_napari_plugin/test_convert.py index b322dc9ed..ba669eee7 100644 --- a/tests/test_unit/test_napari_plugin/test_convert.py +++ b/tests/test_unit/test_napari_plugin/test_convert.py @@ -5,7 +5,7 @@ import pytest from pandas.testing import assert_frame_equal -from movement.napari.convert import ds_to_napari_layers +from movement.napari.convert import ds_to_napari_layers, napari_layers_to_ds def set_some_confidence_values_to_nan(ds, individuals, time): @@ -262,3 +262,131 @@ def test_invalid_poses_to_napari_layers(ds_name, expected_exception, request): ds = request.getfixturevalue(ds_name) with pytest.raises(expected_exception): ds_to_napari_layers(ds) + + +@pytest.mark.parametrize( + "ds_dataset", + [ + "dataset", + "dataset_with_nan", + "confidence_with_some_nan", + "confidence_with_all_nan", + ], +) +def test_valid_poses_napari_layer_to_dataset(ds_dataset, request): + """Test conversion from napari tracks array to movement pose dataset + If I convert a dataset to napari and then back to a xarray dataset, + do I recover the original values? + """ + ds_name = f"valid_poses_{ds_dataset}" + ds = request.getfixturevalue(ds_name) + napari_tracks, _, properties = ds_to_napari_layers(ds) + reconstructed_ds = napari_layers_to_ds(napari_tracks, properties) + np.testing.assert_allclose( # are position values the same? + reconstructed_ds.position.values, + ds.position.values, + equal_nan=True, + ) + np.testing.assert_allclose( # are the confidence values the same? + reconstructed_ds.confidence.values, + ds.confidence.values, + equal_nan=True, # reconstructed ds should have nans in the same position + ) + np.testing.assert_array_equal( # are the individual labels the same? + reconstructed_ds.individual.values, + ds.individual.values, + ) + np.testing.assert_array_equal( # are the keypoint labels the same? + reconstructed_ds.keypoint.values, + ds.keypoint.values, + ) + + assert reconstructed_ds.position.shape == ds.position.shape + assert reconstructed_ds.confidence.shape == ds.confidence.shape + + +@pytest.mark.parametrize( + "fps", + [ + None, + 10.0, + 30.0, + 100.0, + ], +) +def test_valid_poses_napari_layer_to_dataset_with_fps( + valid_poses_dataset, fps +): + """Test reconstruction of time coordinates from napari layers to movement + xarrays, with different fps values + """ + napari_tracks, _, properties = ds_to_napari_layers(valid_poses_dataset) + reconstructed_ds = napari_layers_to_ds( + napari_tracks, + properties, + fps=fps, + ) + + expected_time = ( + np.arange(valid_poses_dataset.sizes["time"]) + if fps is None + else np.arange(valid_poses_dataset.sizes["time"]) / fps + ) + + np.testing.assert_allclose( + reconstructed_ds.time.values, + expected_time, + ) + + +@pytest.mark.parametrize( + "ds_dataset", + [ + "dataset", + "dataset_with_nan", + "confidence_with_some_nan", + "confidence_with_all_nan", + ], +) +def test_valid_bboxes_napari_layer_to_datset(ds_dataset, request): + """Test reconstruction from napari shapes array to movement bboxes dataset.""" + ds_name = f"valid_bboxes_{ds_dataset}" + ds = request.getfixturevalue(ds_name) + _, napari_bboxes, properties = ds_to_napari_layers(ds) + reconstructed_ds = napari_layers_to_ds(napari_bboxes, properties) + + ds_name = f"valid_bboxes_{ds_dataset}" + ds = request.getfixturevalue(ds_name) + + print(f"\nTesting fixture: {ds_name}") + print("Position NaNs:", np.isnan(ds.position.values).sum()) + print("Shape NaNs:", np.isnan(ds.shape.values).sum()) + print("Position NaN mask:") + print(np.isnan(ds.position.values)) + print("Shape NaN mask:") + print(np.isnan(ds.shape.values)) + + np.testing.assert_allclose( # are the position (centroid) values the same? + reconstructed_ds.position.values, + ds.position.values, + equal_nan=True, # reconstructed ds should have nans in the same position + ) + + np.testing.assert_allclose( # are the shape (width,length) values the same? + reconstructed_ds.shape.values, + ds.shape.values, + equal_nan=True, + ) + np.testing.assert_allclose( + reconstructed_ds.confidence.values, # are the confidence values the same? + ds.confidence.values, + equal_nan=True, + ) + np.testing.assert_array_equal( # are the individual values the same? + reconstructed_ds.individual.values, + ds.individual.values, + ) + np.testing.assert_array_equal( + reconstructed_ds.time.values, # are the time values the same? + ds.time.values, + )