"""Conversion functions from ``movement`` datasets to napari layers."""
import numpy as np
import pandas as pd
import xarray as xr
from movement.utils.logging import logger
def _construct_properties_dataframe(ds: xr.Dataset) -> pd.DataFrame:
"""Construct a properties DataFrame from a ``movement`` dataset."""
data = {
"individual": ds.coords["individual"].values,
"time": ds.coords["time"].values,
"confidence": ds["confidence"].values.flatten(),
}
desired_order = list(data.keys())
if "keypoint" in ds.coords:
data["keypoint"] = ds.coords["keypoint"].values
desired_order.insert(1, "keypoint")
if "edited" in ds:
data["edited"] = ds["edited"].values.flatten()
desired_order.append("edited")
# sort
return pd.DataFrame(data).reindex(columns=desired_order)
def _construct_track_and_time_cols(
ds: xr.Dataset,
) -> tuple[np.ndarray, np.ndarray]:
"""Compute napari track_id and time columns from a ``movement`` dataset."""
n_frames = ds.sizes["time"]
n_individuals = ds.sizes["individual"]
n_keypoints = ds.sizes.get("keypoint", 1)
n_tracks = n_individuals * n_keypoints
# Each keypoint of each individual is a separate track
track_id_col = np.repeat(np.arange(n_tracks), n_frames).reshape(-1, 1)
time_col = np.tile(np.arange(n_frames), (n_tracks)).reshape(-1, 1)
return track_id_col, time_col
[docs]
def ds_to_napari_layers(
ds: xr.Dataset,
) -> tuple[np.ndarray, np.ndarray | None, pd.DataFrame]:
"""Convert ``movement`` dataset to napari Tracks array and properties.
Parameters
----------
ds
``movement`` dataset containing pose or bounding box tracks,
confidence scores, and associated metadata.
Returns
-------
points_as_napari : numpy.ndarray
position data as a napari Tracks array with shape (N, 4),
where N is n_keypoints * n_individuals * n_frames
and the 4 columns are (track_id, frame_idx, y, x).
bboxes_as_napari : numpy.ndarray | None
bounding box data as a napari Shapes array with shape (N, 4, 4),
where N is n_individuals * n_frames and each (4, 4) entry is
a matrix of 4 rows (1 per corner vertex, starting from upper left
and progressing in counterclockwise order) with the columns
(track_id, frame, y, x). Returns None when the input dataset doesn't
have a "shape" variable.
properties : pandas.DataFrame
DataFrame with properties (individual, keypoint, time, confidence)
for use with napari layers. An ``edited`` column is also included
if the input dataset has an ``edited`` variable.
See Also
--------
napari_layers_to_ds :
The function carrying out the inverse conversion.
Notes
-----
A corresponding napari Points array can be derived from the Tracks array
by taking its last 3 columns: (frame_idx, y, x). See the documentation
on the napari Tracks [1]_ and Points [2]_ layers.
References
----------
.. [1] https://napari.org/stable/howtos/layers/tracks.html
.. [2] https://napari.org/stable/howtos/layers/points.html
"""
# Construct the track_ID and time columns for the napari Tracks array
track_id_col, time_col = _construct_track_and_time_cols(ds)
# Reorder axes to (individual, keypoint, frames, xy)
axes_reordering: tuple[int, ...] = (2, 0, 1)
if "keypoint" in ds.coords:
axes_reordering = (3,) + axes_reordering
yx_cols = np.transpose(
ds.position.values, # from: frames, xy, keypoint, individual
axes_reordering, # to: individual, keypoint, frames, xy
).reshape(-1, 2)[:, [1, 0]] # swap x and y columns
points_as_napari = np.hstack((track_id_col, time_col, yx_cols))
bboxes_as_napari = None
# Construct the napari Shapes array if the input dataset is a
# bounding boxes one
if ds.ds_type == "bboxes":
# Compute bbox corners
xmin_ymin = ds.position - (ds.shape / 2)
xmax_ymax = ds.position + (ds.shape / 2)
# initialise xmax, ymin corner as xmin, ymin
xmax_ymin = xmin_ymin.copy()
# overwrite its x coordinate to xmax
xmax_ymin.loc[{"space": "x"}] = xmax_ymax.loc[{"space": "x"}]
# initialise xmin, ymin corner as xmin, ymin
xmin_ymax = xmin_ymin.copy()
# overwrite its y coordinate to ymax
xmin_ymax.loc[{"space": "y"}] = xmax_ymax.loc[{"space": "y"}]
# Add track_id and time columns to each corner array
corner_arrays_with_track_id_and_time = [
np.c_[
track_id_col,
time_col,
np.transpose(corner.values, axes_reordering).reshape(-1, 2),
]
for corner in [xmin_ymin, xmin_ymax, xmax_ymax, xmax_ymin]
]
# Concatenate corner arrays along columns
corners_array = np.concatenate(
corner_arrays_with_track_id_and_time, axis=1
)
# Reshape to napari expected format
# goes through corners counterclockwise from xmin_ymin
# in image coordinates
corners_array = corners_array.reshape(
-1, 4, 4
) # last dimension: track_id, time, x, y
bboxes_as_napari = corners_array[
:, :, [0, 1, 3, 2]
] # swap x and y columns
# Construct the properties DataFrame
# Stack individual, time and keypoint (if present) dimensions
# into a new single dimension named "tracks"
dimensions_to_stack: tuple[str, ...] = ("individual", "time")
if "keypoint" in ds.coords:
dimensions_to_stack += ("keypoint",) # add last
ds_ = ds.stack(tracks=sorted(dimensions_to_stack))
properties = _construct_properties_dataframe(ds_)
return points_as_napari, bboxes_as_napari, properties
[docs]
def napari_layers_to_ds(
points_as_napari: np.ndarray,
properties: dict,
properties_with_nans: pd.DataFrame,
attrs: dict | None = None,
) -> xr.Dataset:
"""Convert napari Points layer data to a ``movement`` dataset.
Parameters
----------
points_as_napari
Live napari Points layer data, shape (N, 3):
(``frame_idx``, ``y``, ``x``).
NaN rows are excluded (napari cannot handle NaN coordinates),
so this may be shorter than the full timeline.
properties
Live napari Point properties data. It is in-sync with the
Points layer data. It is a dictionary with keys
``individual``, ``keypoint``, ``time`` and ``confidence``
(plus ``edited`` once any point has been dragged), each mapping
to a list of values, and each value corresponding to a point.
properties_with_nans:
Properties DataFrame derived from the original loaded dataset
including any NaN position data. Must include a
``position_is_nan`` boolean column flagging any such points.
attrs
Attributes of the original loaded dataset (e.g.
``source_software``, ``fps``, ``time_unit`` and
``source_file``).
Returns
-------
xarray.Dataset
``movement`` dataset derived from the napari Points layer,
containing pose tracks, confidence scores, and associated metadata.
Raises
------
ValueError
If no keypoint or individual has any data left, i.e. all
points have been removed from the dataset.
NotImplementedError
If the napari Points layer data does not represent a pose dataset.
See Also
--------
ds_to_napari_layers :
The function carrying out the inverse conversion.
Notes
-----
The dataset type is inferred from the presence of ``keypoint`` in
``properties``. If present, a poses dataset is returned. Currently,
bounding box datasets are not supported.
:func:`ds_to_napari_layers` returns a Tracks array of shape (N, 4) with
columns (``track_id``, ``frame``, ``y``, ``x``). When loading into
napari, the ``DataLoader`` widget derives a Points layer from this
Tracks array by dropping the ``track_id`` column, giving a (N, 3)
array of (``frame``, ``y``, ``x``). The Points layer is considered
the "source of truth", as it immediately reflects any manipulation
of the data done in the napari UI. The function
:func:`napari_layers_to_ds` therefore relies on the Points layer data
as one of its inputs, and uses it to reconstruct the corresponding
dataset.
:func:`ds_to_napari_layers` preserves NaN values in the output arrays,
but napari cannot handle NaN coordinates, so the ``DataLoader`` widget
filters them out upon creation of the napari layers. As a result, when
reconstructing a dataset via :func:`napari_layers_to_ds`, the input arrays
will have no NaN (i.e. missing) coordinates.
This function reconstructs the full dataset by restoring missing points
using the full coordinate structure from ``properties_with_nans``.
If a keypoint or individual has no remaining points in any frame,
it is dropped from the returned dataset rather than kept as an
all-NaN entry. The ``time`` dimension is never reduced in this
way: a frame from which every point has been removed is kept,
with NaN values for position and confidence.
"""
properties_df = pd.DataFrame.from_dict(
properties
) # live data without nans
fps = attrs.get("fps") if attrs is not None else None
if "keypoint" in properties_df.columns:
# Get full coordinates from the original properties with nan
time_coords = np.sort(properties_with_nans["time"].unique())
space_coords = ["x", "y"]
keypoint_coords = properties_with_nans["keypoint"].unique().tolist()
individual_coords = (
properties_with_nans["individual"].unique().tolist()
)
# Build position dataframe from napari's live point layer data
position_df = pd.DataFrame(
points_as_napari, columns=["frame", "y", "x"]
)
# Use the frame coordinate from the live napari layer as the
# source of truth for time. This avoids relying on
# properties_df["time"], which may become stale when users add
# points in napari because new points inherit the properties of
# the last selected point.
position_df["time"] = (
position_df["frame"] / fps if fps else position_df["frame"]
)
position_df["keypoint"] = properties_df["keypoint"].to_numpy()
position_df["individual"] = properties_df["individual"].to_numpy()
# Points dragged in napari gain an `edited` flag; the column is
# absent until the first drag.
if "edited" not in properties_df.columns:
properties_df["edited"] = False
# Reconstruct the confidence and edited arrays from the live
# properties DataFrame
live_das = (
properties_df.set_index(["time", "keypoint", "individual"])[
["confidence", "edited"]
]
.to_xarray()
.reindex(
time=time_coords,
keypoint=keypoint_coords,
individual=individual_coords,
)
)
confidence_da = live_das["confidence"]
# Reconstruct the position array from the live napari Points layer
position_df = position_df.melt(
id_vars=["time", "frame", "keypoint", "individual"],
value_vars=["x", "y"],
var_name="space",
value_name="position",
)
position_da = (
position_df.set_index(["time", "space", "keypoint", "individual"])[
"position"
]
.to_xarray()
.reindex(
time=time_coords,
space=space_coords,
keypoint=keypoint_coords,
individual=individual_coords,
)
)
# A point missing from the live layer shows up as NaN position
# after the reindex above
point_missing_now = position_da.isnull().all("space")
position_was_present_originally = ~(
properties_with_nans.set_index(["time", "keypoint", "individual"])[
"position_is_nan"
]
.to_xarray()
.reindex(
time=time_coords,
keypoint=keypoint_coords,
individual=individual_coords,
fill_value=True,
)
.astype(bool)
)
# A point counts as edited if it was dragged in napari, or removed
# by the user (i.e. is now missing but was present originally).
edited_da = live_das["edited"].fillna(False).astype(bool) | (
point_missing_now & position_was_present_originally
)
ds = xr.Dataset(
data_vars={
"position": position_da,
"confidence": confidence_da,
"edited": edited_da,
},
coords={
"time": time_coords,
"space": space_coords,
"keypoint": keypoint_coords,
"individual": individual_coords,
},
attrs=attrs if attrs is not None else {},
)
# Drop keypoints/individuals with no data left; never `time`.
# `edited` is excluded from the check: it's boolean (fill value
# False), so it's never "null" and would otherwise prevent any
# keypoint/individual from ever being dropped.
dropna_subset = ["position", "confidence"]
ds = ds.dropna(dim="keypoint", how="all", subset=dropna_subset).dropna(
dim="individual", how="all", subset=dropna_subset
)
if ds.sizes["keypoint"] == 0 or ds.sizes["individual"] == 0:
raise logger.error(
ValueError(
"No points found in the napari layer. "
"This happens when all points have been removed."
)
)
# Drop `edited` entirely if nothing survived the trim above:
# e.g. the only edits were on a keypoint/individual just dropped.
if not ds["edited"].any():
ds = ds.drop_vars("edited")
return ds
raise NotImplementedError(
"Reconstruction of bounding box datasets from napari layers "
"is not yet implemented."
)