Skip to content
Draft
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
2 changes: 1 addition & 1 deletion movement/kinematics/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions movement/napari/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
)
5 changes: 2 additions & 3 deletions movement/roi/line.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch on these typos and thanks for keeping these unrelated fixes in their own commit.

When little fixes like this aren't related to what the PR is about, we prefer to open them as separate quick PRs (or even a single 'docs: fix typos' PR collecting several). It keeps each PR's history clean, telling one story, which makes reviews and future git blame easier. For context, we 'squash-merge' PRs, meaning that individual commits here won't be visible on the main branch's history; instead the PR title becomes the commit message on main after merging (and hence good PR titles are important).

For tiny stuff like this it's a soft preference, and I've violated it myself in the past.

But it's a habit worth building as changes get bigger, so I'd recommed doing it here.

The unrelated changes being cleanly isolated in their own commit will make things easier. I would handle this by using git cherry-pick to move that commit onto a fresh branch off main and open it as its own PR, then drop it from this branch (you can use git revert for that.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out! I think I fixed those typos while I was reading through the code, probably before I created this branch, and I didn't realize they had ended up mixed into this PR.

I agree that they should live in a separate PR. Sorry for the mess! 😅

The unrelated changes being cleanly isolated in their own commit will make things easier. I would handle this by using git cherry-pick to move that commit onto a fresh branch off main and open it as its own PR, then drop it from this branch (you can use git revert for that.)

Thanks for this info, I'll do that ☺️

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
-----
Expand Down
4 changes: 2 additions & 2 deletions movement/roi/polygon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
130 changes: 129 additions & 1 deletion tests/test_unit/test_napari_plugin/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)
Loading