Source code for movement.napari.convert

"""Conversion functions from ``movement`` datasets to napari layers."""

from dataclasses import dataclass

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:
        edited = ds["edited"].fillna(False).astype(bool)
        data["edited"] = 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] @dataclass(frozen=True) class LoadedPointKeys: """Integer keys for the loaded points, to match against live napari points. Each loaded point's (frame, keypoint, individual) is encoded as an int, so matching can be done via array indexing. Attributes ---------- keys Key of each row of the loaded properties table. frames Frame index of each row of the loaded properties table. id_columns ``(column, unique values)`` pairs for the identity columns (``keypoint``, ``individual``) present in the loaded table. n_keys Number of possible keys. """ keys: np.ndarray frames: np.ndarray id_columns: tuple[tuple[str, pd.Index], ...] n_keys: int
[docs] @classmethod def from_properties( cls, properties_with_nans: pd.DataFrame ) -> "LoadedPointKeys": """Build keys for every row of the loaded properties table.""" # Frame index is the time's position along the sorted time axis, # matching the frame column built by ds_to_napari_layers times = np.sort(properties_with_nans["time"].unique()) frames = np.searchsorted(times, properties_with_nans["time"]) keys = frames.astype(np.int64) id_columns = [] n_keys = len(times) for col in ("keypoint", "individual"): if col in properties_with_nans: codes, uniques = pd.factorize(properties_with_nans[col]) keys = keys * len(uniques) + codes id_columns.append((col, pd.Index(uniques))) n_keys *= len(uniques) return cls(keys, frames, tuple(id_columns), n_keys)
[docs] def present(self, live_frames: np.ndarray, live_properties) -> np.ndarray: """Flag the loaded rows that have a matching live point.""" live_keys = np.asarray(live_frames).astype(np.int64) valid = live_keys >= 0 for col, uniques in self.id_columns: codes = uniques.get_indexer(live_properties[col]) valid &= codes >= 0 live_keys = live_keys * len(uniques) + codes valid &= live_keys < self.n_keys present = np.zeros(self.n_keys, dtype=bool) present[live_keys[valid]] = True return present[self.keys]
[docs] def removed_points_mask( properties_with_nans: pd.DataFrame, live_frames: np.ndarray, live_properties, keys: LoadedPointKeys | None = None, ) -> np.ndarray: """Flag the loaded points that count as removed. A point counts as removed if it has no corresponding live napari point and was either present at load (removed in this session) or flagged as ``edited`` at load (removed in an earlier session). Parameters ---------- properties_with_nans Properties DataFrame of the loaded dataset, including the NaN position rows flagged in its ``position_is_nan`` column. live_frames Frame index of each live point. live_properties Live point properties (DataFrame or dict of arrays), with the identity columns of ``properties_with_nans``. keys Precomputed keys for ``properties_with_nans``, to avoid rebuilding them on every call. Built here if not given. Returns ------- numpy.ndarray Boolean mask, row-aligned with ``properties_with_nans``. """ if keys is None: keys = LoadedPointKeys.from_properties(properties_with_nans) removable = ~properties_with_nans["position_is_nan"].to_numpy() if "edited" in properties_with_nans: removable |= properties_with_nans["edited"].to_numpy(dtype=bool) return removable & ~keys.present(live_frames, live_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, ) ) removed = removed_points_mask( properties_with_nans, live_frames=points_as_napari[:, 0], live_properties=properties_df, ) removed_da = ( properties_with_nans.assign(removed=removed) .set_index(["time", "keypoint", "individual"])["removed"] .to_xarray() .reindex( time=time_coords, keypoint=keypoint_coords, individual=individual_coords, fill_value=False, ) ) # A point counts as edited if it was dragged in napari or removed edited_da = live_das["edited"].fillna(False).astype(bool) | removed_da 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." )