From 9a5728799b4856e63f066cda362918bcc91e1497 Mon Sep 17 00:00:00 2001 From: Luigi Petrucco Date: Thu, 30 May 2024 11:19:44 +0200 Subject: [PATCH 01/65] First draft for function and tests --- docs/source/conf.py | 2 +- movement/io/load_poses.py | 35 ++++++++++++++++++++++++++++++ tests/test_unit/test_load_poses.py | 18 +++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index d035f9b0..ed9854e1 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -104,7 +104,7 @@ "binderhub_url": "https://mybinder.org", "dependencies": ["environment.yml"], }, - 'remove_config_comments': True, + "remove_config_comments": True, # do not render config params set as # sphinx_gallery_config [= value] } diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index fd1d280a..cf852b9a 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -273,6 +273,41 @@ def from_dlc_file( ) +def from_multi_view( + file_path_dict: dict[str, Union[Path, str]], + source_software: Literal["DeepLabCut", "SLEAP", "LightningPose"], + fps: Optional[float] = None, +) -> xr.Dataset: + """Load and merge pose tracking data from multiple views (cameras). + + Parameters + ---------- + file_path_dict : dict[str, Union[Path, str]] + A dict whose keys are the view names and values are the paths to load. + source_software : {'LightningPose', 'SLEAP', 'DeepLabCut'} + The source software of the file. + fps : float, optional + The number of frames per second in the video. If None (default), + the `time` coordinates will be in frame numbers. + + Returns + ------- + xarray.Dataset + Dataset containing the pose tracks, confidence scores, and metadata, + with an additional views dimension. + + """ + views_list = list(file_path_dict.keys()) + new_coord_views = xr.DataArray(views_list, dims="view") + + dataset_list = [ + from_file(f, source_software=source_software, fps=fps) + for f in file_path_dict.values() + ] + + return xr.concat(dataset_list, dim=new_coord_views) + + def _from_lp_or_dlc_file( file_path: Union[Path, str], source_software: Literal["LightningPose", "DeepLabCut"], diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index f5e728bb..e702f41a 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -274,3 +274,21 @@ def test_from_file_delegates_correctly(self, source_software, fps): with patch(software_to_loader[source_software]) as mock_loader: load_poses.from_file("some_file", source_software, fps) mock_loader.assert_called_with("some_file", fps) + + def test_from_multi_view(self): + """Test that the from_file() function delegates to the correct + loader function according to the source_software. + """ + view_names = ["view_0", "view_1"] + file_path_dict = { + view: POSE_DATA_PATHS.get("DLC_single-wasp.predictions.h5") + for view in view_names + } + + multi_view_ds = load_poses.from_multi_view( + file_path_dict, source_software="DeepLabCut" + ) + + assert isinstance(multi_view_ds, xr.Dataset) + assert "view" in multi_view_ds.dims + assert multi_view_ds.view.values.tolist() == view_names From 0f2cd887ec1205837ac279155cac7f99b7bbc3be Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Fri, 31 May 2024 17:18:35 +0100 Subject: [PATCH 02/65] Added example using `median_filter()` and `savgol_filter()` (#193) * example usage for the median filter * formating and wording tweaks * make example titles and subtitles consistently imperative * finished first full draft of the example * added section about combining smoothing filters * ignore doi link during linkcheck * Apply suggestions from code review Co-authored-by: Chang Huan Lo * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix line lenghts * use px2 instead of dB --------- Co-authored-by: Chang Huan Lo Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/conf.py | 4 + examples/compute_kinematics.py | 2 +- examples/filter_and_interpolate.py | 4 +- examples/smooth.py | 314 +++++++++++++++++++++++++++++ pyproject.toml | 2 + 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 examples/smooth.py diff --git a/docs/source/conf.py b/docs/source/conf.py index d035f9b0..e33171c0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -166,6 +166,10 @@ "https://neuroinformatics.zulipchat.com/", "https://github.com/talmolab/sleap/blob/v1.3.3/sleap/info/write_tracking_h5.py", ] +# A list of regular expressions that match URIs that should not be checked +linkcheck_ignore = [ + "https://pubs.acs.org/doi/*", # Checking dois is forbidden here +] myst_url_schemes = { "http": None, diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py index a2da2645..b2e05c7b 100644 --- a/examples/compute_kinematics.py +++ b/examples/compute_kinematics.py @@ -2,7 +2,7 @@ """Compute and visualise kinematics. ==================================== -Compute displacement, velocity and acceleration data on an example dataset and +Compute displacement, velocity and acceleration, and visualise the results. """ diff --git a/examples/filter_and_interpolate.py b/examples/filter_and_interpolate.py index b3461dc2..d3e52ca5 100644 --- a/examples/filter_and_interpolate.py +++ b/examples/filter_and_interpolate.py @@ -1,5 +1,5 @@ -"""Filtering and interpolation -============================ +"""Drop outliers and interpolate +================================ Filter out points with low confidence scores and interpolate over missing values. diff --git a/examples/smooth.py b/examples/smooth.py new file mode 100644 index 00000000..3bb44162 --- /dev/null +++ b/examples/smooth.py @@ -0,0 +1,314 @@ +"""Smooth pose tracks +===================== + +Smooth pose tracks using the median and Savitzky-Golay filters. +""" + +# %% +# Imports +# ------- + +from matplotlib import pyplot as plt +from scipy.signal import welch + +from movement import sample_data +from movement.filtering import ( + interpolate_over_time, + median_filter, + savgol_filter, +) + +# %% +# Load a sample dataset +# --------------------- +# Let's load a sample dataset and print it to inspect its contents. +# Note that if you are running this notebook interactively, you can simply +# type the variable name (here ``ds_wasp``) in a cell to get an interactive +# display of the dataset's contents. + +ds_wasp = sample_data.fetch_dataset("DLC_single-wasp.predictions.h5") +print(ds_wasp) + +# %% +# We see that the dataset contains a single individual (a wasp) with two +# keypoints tracked in 2D space. The video was recorded at 40 fps and lasts for +# ~27 seconds. + +# %% +# Define a plotting function +# -------------------------- +# Let's define a plotting function to help us visualise the effects smoothing +# both in the time and frequency domains. +# The function takes as inputs two datasets containing raw and smooth data +# respectively, and plots the position time series and power spectral density +# (PSD) for a given individual and keypoint. The function also allows you to +# specify the spatial coordinate (``x`` or ``y``) and a time range to focus on. + + +def plot_raw_and_smooth_timeseries_and_psd( + ds_raw, + ds_smooth, + individual="individual_0", + keypoint="stinger", + space="x", + time_range=None, +): + # If no time range is specified, plot the entire time series + if time_range is None: + time_range = slice(0, ds_raw.time[-1]) + + selection = { + "time": time_range, + "individuals": individual, + "keypoints": keypoint, + "space": space, + } + + fig, ax = plt.subplots(2, 1, figsize=(10, 6)) + + for ds, color, label in zip( + [ds_raw, ds_smooth], ["k", "r"], ["raw", "smooth"] + ): + # plot position time series + pos = ds.position.sel(**selection) + ax[0].plot( + pos.time, + pos, + color=color, + lw=2, + alpha=0.7, + label=f"{label} {space}", + ) + + # generate interpolated dataset to avoid NaNs in the PSD calculation + ds_interp = interpolate_over_time(ds, max_gap=None, print_report=False) + pos_interp = ds_interp.position.sel(**selection) + # compute and plot the PSD + freq, psd = welch(pos_interp, fs=ds.fps, nperseg=256) + ax[1].semilogy( + freq, + psd, + color=color, + lw=2, + alpha=0.7, + label=f"{label} {space}", + ) + + ax[0].set_ylabel(f"{space} position (px)") + ax[0].set_xlabel("Time (s)") + ax[0].set_title("Time Domain") + ax[0].legend() + + ax[1].set_ylabel("PSD (px$^2$/Hz)") + ax[1].set_xlabel("Frequency (Hz)") + ax[1].set_title("Frequency Domain") + ax[1].legend() + + plt.tight_layout() + fig.show() + + +# %% +# Smoothing with a median filter +# ------------------------------ +# Here we use the :py:func:`movement.filtering.median_filter` function to +# apply a rolling window median filter to the wasp dataset. +# The ``window_length`` parameter is defined in seconds (according to the +# ``time_unit`` dataset attribute). + +ds_wasp_medfilt = median_filter(ds_wasp, window_length=0.1) + +# %% +# We see from the printed report that the dataset has no missing values +# neither before nor after smoothing. Let's visualise the effects of the +# median filter in the time and frequency domains. + +plot_raw_and_smooth_timeseries_and_psd( + ds_wasp, ds_wasp_medfilt, keypoint="stinger" +) + +# %% +# We see that the median filter has removed the "spikes" present around the +# 14 second mark in the raw data. However, it has not dealt the big shift +# occurring during the final second. In the frequency domain, we can see that +# the filter has reduced the power in the high-frequency components, without +# affecting the low frequency components. +# +# This illustrates what the median filter is good at: removing brief "spikes" +# (e.g. a keypoint abruptly jumping to a different location for a frame or two) +# and high-frequency "jitter" (often present due to pose estimation +# working on a per-frame basis). + +# %% +# Choosing parameters for the median filter +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# You can control the behaviour of :py:func:`movement.filtering.median_filter` +# via two parameters: ``window_length`` and ``min_periods``. +# To better understand the effect of these parameters, let's use a +# dataset that contains missing values. + +ds_mouse = sample_data.fetch_dataset("SLEAP_single-mouse_EPM.analysis.h5") +print(ds_mouse) + +# %% +# The dataset contains a single mouse with six keypoints tracked in +# 2D space. The video was recorded at 30 fps and lasts for ~616 seconds. We can +# see that there are some missing values, indicated as "nan" in the +# printed dataset. Let's apply the median filter to this dataset, with +# the ``window_length`` set to 0.1 seconds. + +ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1) + +# %% +# The report informs us that the raw data contains NaN values, most of which +# occur at the ``snout`` and ``tail_end`` keypoints. After filtering, the +# number of NaNs has increased. This is because the default behaviour of the +# median filter is to propagate NaN values, i.e. if any value in the rolling +# window is NaN, the output will also be NaN. +# +# To modify this behaviour, you can set the value of the ``min_periods`` +# parameter to an integer value. This parameter determines the minimum number +# of non-NaN values required in the window for the output to be non-NaN. +# For example, setting ``min_periods=2`` means that two non-NaN values in the +# window are sufficient for the median to be calculated. Let's try this. + +ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1, min_periods=2) + +# %% +# We see that this time the number of NaN values has decreased +# across all keypoints. +# Let's visualise the effects of the median filter in the time and frequency +# domains. Here we focus on the first 80 seconds for the ``snout`` keypoint. +# You can adjust the ``keypoint`` and ``time_range`` arguments to explore other +# parts of the data. + +plot_raw_and_smooth_timeseries_and_psd( + ds_mouse, ds_mouse_medfilt, keypoint="snout", time_range=slice(0, 80) +) + +# %% +# The smoothing once again reduces the power of high-frequency components, but +# the resulting time series stays quite close to the raw data. +# +# What happens if we increase the ``window_length`` to 2 seconds? + +ds_mouse_medfilt = median_filter(ds_mouse, window_length=2, min_periods=2) + +# %% +# The number of NaN values has decreased even further. That's because the +# chance of finding at least 2 valid values within a 2 second window is +# quite high. Let's plot the results for the same keypoint and time range +# as before. + +plot_raw_and_smooth_timeseries_and_psd( + ds_mouse, ds_mouse_medfilt, keypoint="snout", time_range=slice(0, 80) +) +# %% +# We see that the filtered time series is much smoother and it has even +# "bridged" over some small gaps. That said, it often deviates from the raw +# data, in ways that may not be desirable, depending on the application. +# That means that our choice of ``window_length`` may be too large. +# In general, you should choose a ``window_length`` that is small enough to +# preserve the original data structure, but large enough to remove +# "spikes" and high-frequency noise. Always inspect the results to ensure +# that the filter is not removing important features. + +# %% +# Smoothing with a Savitzky-Golay filter +# -------------------------------------- +# Here we use the :py:func:`movement.filtering.savgol_filter` function, +# which is a wrapper around :py:func:`scipy.signal.savgol_filter`. +# The Savitzky-Golay filter is a polynomial smoothing filter that can be +# applied to time series data on a rolling window basis. A polynomial of +# degree ``polyorder`` is fitted to the data in each window of length +# ``window_length``, and the value of the polynomial at the center of the +# window is used as the output value. +# +# Let's try it on the mouse dataset. + +ds_mouse_savgol = savgol_filter(ds_mouse, window_length=0.2, polyorder=2) + + +# %% +# We see that the number of NaN values has increased after filtering. This is +# for the same reason as with the median filter (in its default mode), i.e. +# if there is at least one NaN value in the window, the output will be NaN. +# Unlike the median filter, the Savitzky-Golay filter does not provide a +# ``min_periods`` parameter to control this behaviour. Let's visualise the +# effects in the time and frequency domains. + +plot_raw_and_smooth_timeseries_and_psd( + ds_mouse, ds_mouse_savgol, keypoint="snout", time_range=slice(0, 80) +) +# %% +# Once again, the power of high-frequency components has been reduced, but more +# missing values have been introduced. + +# %% +# Now let's take a look at the wasp dataset. + +ds_wasp_savgol = savgol_filter(ds_wasp, window_length=0.2, polyorder=2) + +# %% +plot_raw_and_smooth_timeseries_and_psd( + ds_wasp, + ds_wasp_savgol, + keypoint="stinger", +) +# %% +# This example shows two important limitations of the Savitzky-Golay filter. +# First, the filter can introduce artefacts around sharp boundaries. For +# example, focus on what happens around the sudden drop in position +# during the final second. Second, the PSD appears to have large periodic +# drops at certain frequencies. Both of these effects vary with the +# choice of ``window_length`` and ``polyorder``. You can read more about these +# and other limitations of the Savitzky-Golay filter in +# `this paper `_. + + +# %% +# Combining multiple smoothing filters +# ------------------------------------ +# You can also combine multiple smoothing filters by applying them +# sequentially. For example, we can first apply the median filter with a small +# ``window_length`` to remove "spikes" and then apply the Savitzky-Golay filter +# with a larger ``window_length`` to further smooth the data. +# Between the two filters, we can interpolate over small gaps to avoid the +# excessive proliferation of NaN values. Let's try this on the mouse dataset. +# First, let's apply the median filter. + +ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1, min_periods=2) + +# %% +# Next, let's linearly interpolate over gaps smaller than 1 second. + +ds_mouse_medfilt_interp = interpolate_over_time(ds_mouse_medfilt, max_gap=1) + +# %% +# Finally, let's apply the Savitzky-Golay filter. + +ds_mouse_medfilt_interp_savgol = savgol_filter( + ds_mouse_medfilt_interp, window_length=0.4, polyorder=2 +) + +# %% +# A record of all applied operations is stored in the dataset's ``log`` +# attribute. Let's inspect it to summarise what we've done. + +for entry in ds_mouse_medfilt_interp_savgol.log: + print(entry) + +# %% +# Now let's visualise the difference between the raw data and the final +# smoothed result. + +plot_raw_and_smooth_timeseries_and_psd( + ds_mouse, + ds_mouse_medfilt_interp_savgol, + keypoint="snout", + time_range=slice(0, 80), +) + +# %% +# Feel free to play around with the parameters of the applied filters and to +# also look at other keypoints and time ranges. diff --git a/pyproject.toml b/pyproject.toml index d97bc3c4..49b16caa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,8 @@ per-file-ignores = { "tests/*" = [ "D205", # missing blank line between summary and description "D103", # missing docstring in public function ], "examples/*" = [ + "B018", # Found useless expression + "D103", # Missing docstring in public function "D400", # first line should end with a period. "D415", # first line should end with a period, question mark... "D205", # missing blank line between summary and description From 8e20498616442d81584ff97fd610a4c5da8fa134 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Mon, 3 Jun 2024 15:26:21 +0100 Subject: [PATCH 03/65] Refactored modules related to input-output (#194) * added from_numpy() function to the load_poses module * unit test new function * use from_numpy() function in other loaders as well * add Examples section to docstring and render in API index * add Examples section to docstring and render in API index * confidence array is optional * None is the default for confidence * rename private functions * renamef from_dlc_df to from_dlc_style_df * harmonise docstrings in load_poses * harmonised function names and docstrings in save_poses * harmonised docstrings in validators * split Input/Output section of API index into modules * renamed `_from_lp_or_dlc_file` to `_ds_from_lp_or_dlc_file` --- docs/source/api_index.rst | 18 ++- movement/io/load_poses.py | 228 +++++++++++++++++++---------- movement/io/save_poses.py | 113 +++++++------- movement/io/validators.py | 26 ++-- tests/test_integration/test_io.py | 6 +- tests/test_unit/test_load_poses.py | 34 ++++- tests/test_unit/test_save_poses.py | 10 +- 7 files changed, 276 insertions(+), 159 deletions(-) diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst index a6715af6..a76a19f1 100644 --- a/docs/source/api_index.rst +++ b/docs/source/api_index.rst @@ -4,27 +4,33 @@ API Reference ============= -Input/Output ------------- +Load poses +---------- .. currentmodule:: movement.io.load_poses .. autosummary:: :toctree: api + from_numpy from_file from_sleap_file from_dlc_file - from_dlc_df from_lp_file + from_dlc_style_df + +Save poses +---------- .. currentmodule:: movement.io.save_poses .. autosummary:: :toctree: api to_dlc_file - to_dlc_df - to_sleap_analysis_file to_lp_file + to_sleap_analysis_file + to_dlc_style_df +Validators +---------- .. currentmodule:: movement.io.validators .. autosummary:: :toctree: api @@ -58,7 +64,7 @@ Filtering Analysis ------------ +-------- .. currentmodule:: movement.analysis.kinematics .. autosummary:: :toctree: api diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index fd1d280a..e2b30910 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -23,23 +23,91 @@ logger = logging.getLogger(__name__) +def from_numpy( + position_array: np.ndarray, + confidence_array: Optional[np.ndarray] = None, + individual_names: Optional[list[str]] = None, + keypoint_names: Optional[list[str]] = None, + fps: Optional[float] = None, + source_software: Optional[str] = None, +) -> xr.Dataset: + """Create a ``movement`` dataset from NumPy arrays. + + Parameters + ---------- + position_array : np.ndarray + Array of shape (n_frames, n_individuals, n_keypoints, n_space) + containing the poses. It will be converted to a + :py:class:`xarray.DataArray` object named "position". + confidence_array : np.ndarray, optional + Array of shape (n_frames, n_individuals, n_keypoints) containing + the point-wise confidence scores. It will be converted to a + :py:class:`xarray.DataArray` object named "confidence". + If None (default), the scores will be set to an array of NaNs. + individual_names : list of str, optional + List of unique names for the individuals in the video. If None + (default), the individuals will be named "individual_0", + "individual_1", etc. + keypoint_names : list of str, optional + List of unique names for the keypoints in the skeleton. If None + (default), the keypoints will be named "keypoint_0", "keypoint_1", + etc. + fps : float, optional + Frames per second of the video. Defaults to None, in which case + the time coordinates will be in frame numbers. + source_software : str, optional + Name of the pose estimation software from which the data originate. + Defaults to None. + + Returns + ------- + xarray.Dataset + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. + + Examples + -------- + Create random position data for two individuals, ``Alice`` and ``Bob``, + with three keypoints each: ``snout``, ``centre``, and ``tail_base``. + These are tracked in 2D space over 100 frames, at 30 fps. + The confidence scores are set to 1 for all points. + + >>> import numpy as np + >>> from movement.io import load_poses + >>> ds = load_poses.from_numpy( + ... position_array=np.random.rand((100, 2, 3, 2)), + ... confidence_array=np.ones((100, 2, 3)), + ... individual_names=["Alice", "Bob"], + ... keypoint_names=["snout", "centre", "tail_base"], + ... fps=30, + ... ) + + """ + valid_data = ValidPosesDataset( + position_array=position_array, + confidence_array=confidence_array, + individual_names=individual_names, + keypoint_names=keypoint_names, + fps=fps, + source_software=source_software, + ) + return _ds_from_valid_data(valid_data) + + def from_file( file_path: Union[Path, str], source_software: Literal["DeepLabCut", "SLEAP", "LightningPose"], fps: Optional[float] = None, ) -> xr.Dataset: - """Load pose tracking data from any supported file format. - - Data can be loaded from a DeepLabCut (DLC), LightningPose (LP) or - SLEAP output file into an xarray Dataset. + """Create a ``movement`` dataset from any supported file. Parameters ---------- file_path : pathlib.Path or str Path to the file containing predicted poses. The file format must be among those supported by the ``from_dlc_file()``, - ``from_slp_file()`` or ``from_lp_file()`` functions, - since one of these functions will be called internally, based on + ``from_slp_file()`` or ``from_lp_file()`` functions. One of these + these functions will be called internally, based on the value of ``source_software``. source_software : "DeepLabCut", "SLEAP" or "LightningPose" The source software of the file. @@ -50,7 +118,8 @@ def from_file( Returns ------- xarray.Dataset - Dataset containing the pose tracks, confidence scores, and metadata. + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. See Also -------- @@ -58,6 +127,13 @@ def from_file( movement.io.load_poses.from_sleap_file movement.io.load_poses.from_lp_file + Examples + -------- + >>> from movement.io import load_poses + >>> ds = load_poses.from_file( + ... "path/to/file.h5", source_software="DeepLabCut", fps=30 + ... ) + """ if source_software == "DeepLabCut": return from_dlc_file(file_path, fps) @@ -71,8 +147,12 @@ def from_file( ) -def from_dlc_df(df: pd.DataFrame, fps: Optional[float] = None) -> xr.Dataset: - """Create an xarray.Dataset from a DeepLabCut-style pandas DataFrame. +def from_dlc_style_df( + df: pd.DataFrame, + fps: Optional[float] = None, + source_software: Literal["DeepLabCut", "LightningPose"] = "DeepLabCut", +) -> xr.Dataset: + """Create a ``movement`` dataset from a DeepLabCut-style DataFrame. Parameters ---------- @@ -82,11 +162,16 @@ def from_dlc_df(df: pd.DataFrame, fps: Optional[float] = None) -> xr.Dataset: fps : float, optional The number of frames per second in the video. If None (default), the `time` coordinates will be in frame numbers. + source_software : str, optional + Name of the pose estimation software from which the data originate. + Defaults to "DeepLabCut", but it can also be "LightningPose" + (because they the same DataFrame format). Returns ------- xarray.Dataset - Dataset containing the pose tracks, confidence scores, and metadata. + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. Notes ----- @@ -99,7 +184,7 @@ def from_dlc_df(df: pd.DataFrame, fps: Optional[float] = None) -> xr.Dataset: See Also -------- - movement.io.load_poses.from_dlc_file : Load pose tracks directly from file. + movement.io.load_poses.from_dlc_file """ # read names of individuals and keypoints from the DataFrame @@ -120,20 +205,20 @@ def from_dlc_df(df: pd.DataFrame, fps: Optional[float] = None) -> xr.Dataset: (-1, len(individual_names), len(keypoint_names), 3) ) - valid_data = ValidPosesDataset( + return from_numpy( position_array=tracks_with_scores[:, :, :, :-1], confidence_array=tracks_with_scores[:, :, :, -1], individual_names=individual_names, keypoint_names=keypoint_names, fps=fps, + source_software=source_software, ) - return _from_valid_data(valid_data) def from_sleap_file( file_path: Union[Path, str], fps: Optional[float] = None ) -> xr.Dataset: - """Load pose tracking data from a SLEAP file into an xarray Dataset. + """Create a ``movement`` dataset from a SLEAP file. Parameters ---------- @@ -148,7 +233,8 @@ def from_sleap_file( Returns ------- xarray.Dataset - Dataset containing the pose tracks, confidence scores, and metadata. + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. Notes ----- @@ -193,16 +279,11 @@ def from_sleap_file( # Load and validate data if file.path.suffix == ".h5": - valid_data = _load_from_sleap_analysis_file(file.path, fps=fps) + ds = _ds_from_sleap_analysis_file(file.path, fps=fps) else: # file.path.suffix == ".slp" - valid_data = _load_from_sleap_labels_file(file.path, fps=fps) - logger.debug(f"Validated pose tracks from {file.path}.") - - # Initialize an xarray dataset from the dictionary - ds = _from_valid_data(valid_data) + ds = _ds_from_sleap_labels_file(file.path, fps=fps) # Add metadata as attrs - ds.attrs["source_software"] = "SLEAP" ds.attrs["source_file"] = file.path.as_posix() logger.info(f"Loaded pose tracks from {file.path}:") @@ -213,12 +294,12 @@ def from_sleap_file( def from_lp_file( file_path: Union[Path, str], fps: Optional[float] = None ) -> xr.Dataset: - """Load pose tracking data from a LightningPose (LP) output file. + """Create a ``movement`` dataset from a LightningPose file. Parameters ---------- file_path : pathlib.Path or str - Path to the file containing the LP predicted poses, in .csv format. + Path to the file containing the predicted poses, in .csv format. fps : float, optional The number of frames per second in the video. If None (default), the `time` coordinates will be in frame numbers. @@ -226,7 +307,8 @@ def from_lp_file( Returns ------- xarray.Dataset - Dataset containing the pose tracks, confidence scores, and metadata. + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. Examples -------- @@ -234,7 +316,7 @@ def from_lp_file( >>> ds = load_poses.from_lp_file("path/to/file.csv", fps=30) """ - return _from_lp_or_dlc_file( + return _ds_from_lp_or_dlc_file( file_path=file_path, source_software="LightningPose", fps=fps ) @@ -242,12 +324,12 @@ def from_lp_file( def from_dlc_file( file_path: Union[Path, str], fps: Optional[float] = None ) -> xr.Dataset: - """Load pose tracking data from a DeepLabCut (DLC) output file. + """Create a ``movement`` dataset from a DeepLabCut file. Parameters ---------- file_path : pathlib.Path or str - Path to the file containing the DLC predicted poses, either in .h5 + Path to the file containing the predicted poses, either in .h5 or .csv format. fps : float, optional The number of frames per second in the video. If None (default), @@ -256,11 +338,12 @@ def from_dlc_file( Returns ------- xarray.Dataset - Dataset containing the pose tracks, confidence scores, and metadata. + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. See Also -------- - movement.io.load_poses.from_dlc_df : Load pose tracks from a DataFrame. + movement.io.load_poses.from_dlc_style_df Examples -------- @@ -268,22 +351,22 @@ def from_dlc_file( >>> ds = load_poses.from_dlc_file("path/to/file.h5", fps=30) """ - return _from_lp_or_dlc_file( + return _ds_from_lp_or_dlc_file( file_path=file_path, source_software="DeepLabCut", fps=fps ) -def _from_lp_or_dlc_file( +def _ds_from_lp_or_dlc_file( file_path: Union[Path, str], source_software: Literal["LightningPose", "DeepLabCut"], fps: Optional[float] = None, ) -> xr.Dataset: - """Load data from DeepLabCut (DLC) or LightningPose (LP) output files. + """Create a ``movement`` dataset from a LightningPose or DeepLabCut file. Parameters ---------- file_path : pathlib.Path or str - Path to the file containing the DLC predicted poses, either in .h5 + Path to the file containing the predicted poses, either in .h5 or .csv format. source_software : {'LightningPose', 'DeepLabCut'} The source software of the file. @@ -294,7 +377,8 @@ def _from_lp_or_dlc_file( Returns ------- xarray.Dataset - Dataset containing the pose tracks, confidence scores, and metadata. + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. """ expected_suffix = [".csv"] @@ -305,35 +389,28 @@ def _from_lp_or_dlc_file( file_path, expected_permission="r", expected_suffix=expected_suffix ) - # Load the DLC poses into a DataFrame + # Load the DeepLabCut poses into a DataFrame if file.path.suffix == ".csv": - df = _load_df_from_dlc_csv(file.path) + df = _df_from_dlc_csv(file.path) else: # file.path.suffix == ".h5" - df = _load_df_from_dlc_h5(file.path) + df = _df_from_dlc_h5(file.path) logger.debug(f"Loaded poses from {file.path} into a DataFrame.") # Convert the DataFrame to an xarray dataset - ds = from_dlc_df(df=df, fps=fps) + ds = from_dlc_style_df(df=df, fps=fps, source_software=source_software) # Add metadata as attrs - ds.attrs["source_software"] = source_software ds.attrs["source_file"] = file.path.as_posix() - # If source_software="LightningPose", we need to re-validate (because the - # validation call in from_dlc_df was run with source_software="DeepLabCut") - # This rerun enforces a single individual for LightningPose datasets. - if source_software == "LightningPose": - ds.move.validate() - logger.info(f"Loaded pose tracks from {file.path}:") logger.info(ds) return ds -def _load_from_sleap_analysis_file( +def _ds_from_sleap_analysis_file( file_path: Path, fps: Optional[float] -) -> ValidPosesDataset: - """Load and validate data from a SLEAP analysis file. +) -> xr.Dataset: + """Create a ``movement`` dataset from a SLEAP analysis (.h5) file. Parameters ---------- @@ -345,8 +422,9 @@ def _load_from_sleap_analysis_file( Returns ------- - movement.io.tracks_validators.ValidPosesDataset - The validated pose tracks and confidence scores. + xarray.Dataset + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. """ file = ValidHDF5(file_path, expected_datasets=["tracks"]) @@ -367,7 +445,7 @@ def _load_from_sleap_analysis_file( # and transpose to shape: (n_frames, n_tracks, n_keypoints) if "point_scores" in f: scores = f["point_scores"][:].transpose((2, 0, 1)) - return ValidPosesDataset( + return from_numpy( position_array=tracks.astype(np.float32), confidence_array=scores.astype(np.float32), individual_names=individual_names, @@ -377,10 +455,10 @@ def _load_from_sleap_analysis_file( ) -def _load_from_sleap_labels_file( +def _ds_from_sleap_labels_file( file_path: Path, fps: Optional[float] -) -> ValidPosesDataset: - """Load and validate data from a SLEAP labels file. +) -> xr.Dataset: + """Create a ``movement`` dataset from a SLEAP labels (.slp) file. Parameters ---------- @@ -392,8 +470,9 @@ def _load_from_sleap_labels_file( Returns ------- - movement.io.tracks_validators.ValidPosesDataset - The validated pose tracks and confidence scores. + xarray.Dataset + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. """ file = ValidHDF5(file_path, expected_datasets=["pred_points", "metadata"]) @@ -406,7 +485,7 @@ def _load_from_sleap_labels_file( "Assuming single-individual dataset and assigning " "default individual name." ) - return ValidPosesDataset( + return from_numpy( position_array=tracks_with_scores[:, :, :, :-1], confidence_array=tracks_with_scores[:, :, :, -1], individual_names=individual_names, @@ -417,9 +496,9 @@ def _load_from_sleap_labels_file( def _sleap_labels_to_numpy(labels: Labels) -> np.ndarray: - """Convert a SLEAP `Labels` object to a NumPy array. + """Convert a SLEAP ``Labels`` object to a NumPy array. - The output array contains pose tracks with point-wise confidence scores. + The output array contains pose tracks and point-wise confidence scores. Parameters ---------- @@ -484,18 +563,18 @@ def _sleap_labels_to_numpy(labels: Labels) -> np.ndarray: return tracks -def _load_df_from_dlc_csv(file_path: Path) -> pd.DataFrame: - """Parse a DeepLabCut-style .csv file into a pandas DataFrame. +def _df_from_dlc_csv(file_path: Path) -> pd.DataFrame: + """Create a DeepLabCut-style DataFrame from a .csv file. - If poses are loaded from a DeepLabCut .csv file, the DataFrame + If poses are loaded from a DeepLabCut-style .csv file, the DataFrame lacks the multi-index columns that are present in the .h5 file. This - function parses the .csv file to a pandas DataFrame with multi-index - columns, i.e. the same format as in the .h5 file. + function parses the .csv file to DataFrame with multi-index columns, + i.e. the same format as in the .h5 file. Parameters ---------- file_path : pathlib.Path - Path to the DeepLabCut-style .csv file. + Path to the DeepLabCut-style .csv file containing pose tracks. Returns ------- @@ -520,7 +599,7 @@ def _load_df_from_dlc_csv(file_path: Path) -> pd.DataFrame: column_tuples = list(zip(*[line[1:] for line in header_lines])) columns = pd.MultiIndex.from_tuples(column_tuples, names=level_names) - # Import the DLC poses as a DataFrame + # Import the DeepLabCut poses as a DataFrame df = pd.read_csv( file.path, skiprows=len(header_lines), @@ -531,8 +610,8 @@ def _load_df_from_dlc_csv(file_path: Path) -> pd.DataFrame: return df -def _load_df_from_dlc_h5(file_path: Path) -> pd.DataFrame: - """Load data from a DeepLabCut .h5 file into a pandas DataFrame. +def _df_from_dlc_h5(file_path: Path) -> pd.DataFrame: + """Create a DeepLabCut-style DataFrame from a .h5 file. Parameters ---------- @@ -542,7 +621,7 @@ def _load_df_from_dlc_h5(file_path: Path) -> pd.DataFrame: Returns ------- pandas.DataFrame - DeepLabCut-style Dataframe. + DeepLabCut-style DataFrame with multi-index columns. """ file = ValidHDF5(file_path, expected_datasets=["df_with_missing"]) @@ -552,8 +631,8 @@ def _load_df_from_dlc_h5(file_path: Path) -> pd.DataFrame: return df -def _from_valid_data(data: ValidPosesDataset) -> xr.Dataset: - """Convert already validated pose tracking data to an xarray Dataset. +def _ds_from_valid_data(data: ValidPosesDataset) -> xr.Dataset: + """Create a ``movement`` dataset from validated pose tracking data. Parameters ---------- @@ -563,7 +642,8 @@ def _from_valid_data(data: ValidPosesDataset) -> xr.Dataset: Returns ------- xarray.Dataset - Dataset containing the pose tracks, confidence scores, and metadata. + ``movement`` dataset containing the pose tracks, confidence scores, + and associated metadata. """ n_frames = data.position_array.shape[0] @@ -594,7 +674,7 @@ def _from_valid_data(data: ValidPosesDataset) -> xr.Dataset: attrs={ "fps": data.fps, "time_unit": time_unit, - "source_software": None, + "source_software": data.source_software, "source_file": None, }, ) diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py index 63ce689b..e0ee5778 100644 --- a/movement/io/save_poses.py +++ b/movement/io/save_poses.py @@ -15,15 +15,18 @@ logger = logging.getLogger(__name__) -def _xarray_to_dlc_df(ds: xr.Dataset, columns: pd.MultiIndex) -> pd.DataFrame: - """Convert an xarray dataset to DLC-style multi-index pandas DataFrame. +def _ds_to_dlc_style_df( + ds: xr.Dataset, columns: pd.MultiIndex +) -> pd.DataFrame: + """Convert a ``movement`` dataset to a DeepLabCut-style DataFrame. Parameters ---------- ds : xarray.Dataset - Dataset containing pose tracks, confidence scores, and metadata. + ``movement`` dataset containing pose tracks, confidence scores, + and associated metadata. columns : pandas.MultiIndex - DLC-style multi-index columns + DeepLabCut-style multi-index columns Returns ------- @@ -57,7 +60,7 @@ def _auto_split_individuals(ds: xr.Dataset) -> bool: def _save_dlc_df(filepath: Path, df: pd.DataFrame) -> None: - """Given a filepath, will save the dataframe as either a .h5 or .csv. + """Save the dataframe as either a .h5 or .csv depending on the file path. Parameters ---------- @@ -74,20 +77,20 @@ def _save_dlc_df(filepath: Path, df: pd.DataFrame) -> None: df.to_hdf(filepath, key="df_with_missing") -def to_dlc_df( +def to_dlc_style_df( ds: xr.Dataset, split_individuals: bool = False ) -> Union[pd.DataFrame, dict[str, pd.DataFrame]]: - """Convert an xarray dataset to DeepLabCut-style pandas DataFrame(s). + """Convert a ``movement`` dataset to DeepLabCut-style DataFrame(s). Parameters ---------- ds : xarray.Dataset - Dataset containing pose tracks, confidence scores, and metadata. + ``movement`` dataset containing pose tracks, confidence scores, + and associated metadata. split_individuals : bool, optional - If True, return a dictionary of pandas DataFrames per individual, - with individual names as keys and DataFrames as values. - If False, return a single pandas DataFrame for all individuals. - Default is False. + If True, return a dictionary of DataFrames per individual, with + individual names as keys. If False (default), return a single + DataFrame for all individuals (see Notes). Returns ------- @@ -107,8 +110,7 @@ def to_dlc_df( See Also -------- - to_dlc_file : Save the xarray dataset containing pose tracks directly - to a DeepLabCut-style .h5 or .csv file. + to_dlc_file : Save dataset directly to a DeepLabCut-style .h5 or .csv file. """ _validate_dataset(ds) @@ -128,7 +130,7 @@ def to_dlc_df( [scorer, bodyparts, coords], names=index_levels ) - df = _xarray_to_dlc_df(individual_data, columns) + df = _ds_to_dlc_style_df(individual_data, columns) df_dict[individual] = df logger.info( @@ -142,9 +144,9 @@ def to_dlc_df( [scorer, individuals, bodyparts, coords], names=index_levels ) - df_all = _xarray_to_dlc_df(ds, columns) + df_all = _ds_to_dlc_style_df(ds, columns) - logger.info("Converted poses dataset to DLC-style DataFrame.") + logger.info("Converted poses dataset to DeepLabCut-style DataFrame.") return df_all @@ -153,35 +155,35 @@ def to_dlc_file( file_path: Union[str, Path], split_individuals: Union[bool, Literal["auto"]] = "auto", ) -> None: - """Save the xarray dataset to a DeepLabCut-style .h5 or .csv file. + """Save a ``movement`` dataset to DeepLabCut file(s). Parameters ---------- ds : xarray.Dataset - Dataset containing pose tracks, confidence scores, and metadata. + ``movement`` dataset containing pose tracks, confidence scores, + and associated metadata. file_path : pathlib.Path or str - Path to the file to save the DLC poses to. The file extension + Path to the file to save the poses to. The file extension must be either .h5 (recommended) or .csv. split_individuals : bool or "auto", optional - Whether to save individuals to separate files or to the same file.\n - If True, each individual will be saved to a separate file, - formatted as in a single-animal DeepLabCut project - i.e. without - the "individuals" column level. The individual's name will be appended - to the file path, just before the file extension, i.e. - "/path/to/filename_individual1.h5".\n - If False, all individuals will be saved to the same file, - formatted as in a multi-animal DeepLabCut project - i.e. the columns - will include the "individuals" level. The file path will not be - modified.\n - If "auto" the argument's value is determined based on the number of - individuals in the dataset: True if there is only one, and - False if there are more than one. This is the default. + Whether to save individuals to separate files or to the same file + (see Notes). Defaults to "auto". + + Notes + ----- + If ``split_individuals`` is True, each individual will be saved to a + separate file, formatted as in a single-animal DeepLabCut project + (without the "individuals" column level). The individual's name will be + appended to the file path, just before the file extension, e.g. + "/path/to/filename_individual1.h5". If False, all individuals will be + saved to the same file, formatted as in a multi-animal DeepLabCut project + (with the "individuals" column level). The file path will not be modified. + If "auto", the argument's value is determined based on the number of + individuals in the dataset: True if there is only one, False otherwise. See Also -------- - to_dlc_df : Convert an xarray dataset containing pose tracks into a single - DeepLabCut-style pandas DataFrame or a dictionary of DataFrames - per individual. + to_dlc_style_df : Convert dataset to DeepLabCut-style DataFrame(s). Examples -------- @@ -205,7 +207,7 @@ def to_dlc_file( if split_individuals: # split the dataset into a dictionary of dataframes per individual - df_dict = to_dlc_df(ds, split_individuals=True) + df_dict = to_dlc_style_df(ds, split_individuals=True) for key, df in df_dict.items(): # the key is the individual's name @@ -215,7 +217,7 @@ def to_dlc_file( logger.info(f"Saved poses for individual {key} to {file.path}.") else: # convert the dataset to a single dataframe for all individuals - df_all = to_dlc_df(ds, split_individuals=False) + df_all = to_dlc_style_df(ds, split_individuals=False) if isinstance(df_all, pd.DataFrame): _save_dlc_df(file.path, df_all) logger.info(f"Saved poses dataset to {file.path}.") @@ -225,28 +227,29 @@ def to_lp_file( ds: xr.Dataset, file_path: Union[str, Path], ) -> None: - """Save the xarray dataset to a LightningPose-style .csv file (see Notes). + """Save a ``movement`` dataset to a LightningPose file. Parameters ---------- ds : xarray.Dataset - Dataset containing pose tracks, confidence scores, and metadata. + ``movement`` dataset containing pose tracks, confidence scores, + and associated metadata. file_path : pathlib.Path or str - Path to the .csv file to save the poses to. + Path to the file to save the poses to. File extension must be .csv. Notes ----- LightningPose saves pose estimation outputs as .csv files, using the same format as single-animal DeepLabCut projects. Therefore, under the hood, - this function calls ``to_dlc_file`` with ``split_individuals=True``. This - setting means that each individual is saved to a separate file, with - the individual's name appended to the file path, just before the file - extension, i.e. "/path/to/filename_individual1.csv". + this function calls :py:func:`movement.io.save_poses.to_dlc_file` + with ``split_individuals=True``. This setting means that each individual + is saved to a separate file, with the individual's name appended to the + file path, just before the file extension, + i.e. "/path/to/filename_individual1.csv". See Also -------- - to_dlc_file : Save the xarray dataset containing pose tracks to a - DeepLabCut-style .h5 or .csv file. + to_dlc_file : Save dataset to a DeepLabCut-style .h5 or .csv file. """ file = _validate_file_path(file_path=file_path, expected_suffix=[".csv"]) @@ -257,14 +260,15 @@ def to_lp_file( def to_sleap_analysis_file( ds: xr.Dataset, file_path: Union[str, Path] ) -> None: - """Save the xarray dataset to a SLEAP-style .h5 analysis file. + """Save a ``movement`` dataset to a SLEAP analysis file. Parameters ---------- ds : xarray.Dataset - Dataset containing pose tracks, confidence scores, and metadata. + ``movement`` dataset containing pose tracks, confidence scores, + and associated metadata. file_path : pathlib.Path or str - Path to the file to save the poses to. The file extension must be .h5. + Path to the file to save the poses to. File extension must be .h5. Notes ----- @@ -355,12 +359,13 @@ def to_sleap_analysis_file( def _remove_unoccupied_tracks(ds: xr.Dataset): - """Remove tracks that are completely unoccupied in the xarray dataset. + """Remove tracks that are completely unoccupied from the dataset. Parameters ---------- ds : xarray.Dataset - Dataset containing pose tracks, confidence scores, and metadata. + ``movement`` dataset containing pose tracks, confidence scores, + and associated metadata. Returns ------- @@ -412,7 +417,7 @@ def _validate_file_path( def _validate_dataset(ds: xr.Dataset) -> None: - """Validate the input dataset is an xarray Dataset with valid poses. + """Validate the input as a proper ``movement`` dataset. Parameters ---------- @@ -422,7 +427,7 @@ def _validate_dataset(ds: xr.Dataset) -> None: Raises ------ ValueError - If `ds` is not an xarray Dataset with valid poses. + If `ds` is not an a valid ``movement`` dataset. """ if not isinstance(ds, xr.Dataset): diff --git a/movement/io/validators.py b/movement/io/validators.py index 38c11cdd..6174f945 100644 --- a/movement/io/validators.py +++ b/movement/io/validators.py @@ -20,11 +20,11 @@ class ValidFile: ---------- path : str or pathlib.Path Path to the file. - expected_permission : {'r', 'w', 'rw'} - Expected access permission(s) for the file. If 'r', the file is - expected to be readable. If 'w', the file is expected to be writable. - If 'rw', the file is expected to be both readable and writable. - Default: 'r'. + expected_permission : {"r", "w", "rw"} + Expected access permission(s) for the file. If "r", the file is + expected to be readable. If "w", the file is expected to be writable. + If "rw", the file is expected to be both readable and writable. + Default: "r". expected_suffix : list of str Expected suffix(es) for the file. If an empty list (default), this check is skipped. @@ -36,9 +36,9 @@ class ValidFile: PermissionError If the file does not have the expected access permission(s). FileNotFoundError - If the file does not exist when `expected_permission` is 'r' or 'rw'. + If the file does not exist when `expected_permission` is "r" or "rw". FileExistsError - If the file exists when `expected_permission` is 'w'. + If the file exists when `expected_permission` is "w". ValueError If the file does not have one of the expected suffix(es). @@ -70,7 +70,7 @@ def file_exists_when_expected(self, attribute, value): raise log_error( FileNotFoundError, f"File {value} does not exist." ) - else: # expected_permission is 'w' + else: # expected_permission is "w" if value.exists(): raise log_error( FileExistsError, f"File {value} already exists." @@ -159,7 +159,7 @@ def file_contains_expected_datasets(self, attribute, value): @define class ValidDeepLabCutCSV: - """Class for validating DLC-style .csv files. + """Class for validating DeepLabCut-style .csv files. Parameters ---------- @@ -251,18 +251,16 @@ def _validate_list_length( @define(kw_only=True) class ValidPosesDataset: - """Class for validating pose tracking data imported from a file. + """Class for validating data intended for a ``movement`` dataset. Attributes ---------- position_array : np.ndarray Array of shape (n_frames, n_individuals, n_keypoints, n_space) - containing the poses. It will be converted to a - `xarray.DataArray` object named "position". + containing the poses. confidence_array : np.ndarray, optional Array of shape (n_frames, n_individuals, n_keypoints) containing - the point-wise confidence scores. It will be converted to a - `xarray.DataArray` object named "confidence". + the point-wise confidence scores. If None (default), the scores will be set to an array of NaNs. individual_names : list of str, optional List of unique names for the individuals in the video. If None diff --git a/tests/test_integration/test_io.py b/tests/test_integration/test_io.py index fc291eff..e8820ad2 100644 --- a/tests/test_integration/test_io.py +++ b/tests/test_integration/test_io.py @@ -15,12 +15,12 @@ def dlc_output_file(self, request, tmp_path): """Return the output file path for a DLC .h5 or .csv file.""" return tmp_path / request.param - def test_load_and_save_to_dlc_df(self, dlc_style_df): + def test_load_and_save_to_dlc_style_df(self, dlc_style_df): """Test that loading pose tracks from a DLC-style DataFrame and converting back to a DataFrame returns the same data values. """ - ds = load_poses.from_dlc_df(dlc_style_df) - df = save_poses.to_dlc_df(ds, split_individuals=False) + ds = load_poses.from_dlc_style_df(dlc_style_df) + df = save_poses.to_dlc_style_df(ds, split_individuals=False) np.testing.assert_allclose(df.values, dlc_style_df.values) def test_save_and_load_dlc_file( diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index f5e728bb..58bfa237 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -175,12 +175,17 @@ def test_load_from_dlc_file(self, file_name): ds = load_poses.from_dlc_file(file_path) self.assert_dataset(ds, file_path, "DeepLabCut") - def test_load_from_dlc_df(self, dlc_style_df): + @pytest.mark.parametrize( + "source_software", ["DeepLabCut", "LightningPose", None] + ) + def test_load_from_dlc_style_df(self, dlc_style_df, source_software): """Test that loading pose tracks from a valid DLC-style DataFrame returns a proper Dataset. """ - ds = load_poses.from_dlc_df(dlc_style_df) - self.assert_dataset(ds) + ds = load_poses.from_dlc_style_df( + dlc_style_df, source_software=source_software + ) + self.assert_dataset(ds, expected_source_software=source_software) def test_load_from_dlc_file_csv_or_h5_file_returns_same(self): """Test that loading pose tracks from DLC .csv and .h5 files @@ -274,3 +279,26 @@ def test_from_file_delegates_correctly(self, source_software, fps): with patch(software_to_loader[source_software]) as mock_loader: load_poses.from_file("some_file", source_software, fps) mock_loader.assert_called_with("some_file", fps) + + @pytest.mark.parametrize("source_software", [None, "SLEAP"]) + def test_from_numpy_valid( + self, + valid_position_array, + source_software, + ): + """Test that loading pose tracks from a multi-animal numpy array + with valid parameters returns a proper Dataset. + """ + valid_position = valid_position_array("multi_individual_array") + rng = np.random.default_rng(seed=42) + valid_confidence = rng.random(valid_position.shape[:-1]) + + ds = load_poses.from_numpy( + valid_position, + valid_confidence, + individual_names=["mouse1", "mouse2"], + keypoint_names=["snout", "tail"], + fps=None, + source_software=source_software, + ) + self.assert_dataset(ds, expected_source_software=source_software) diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index c4b830f1..ed3baf9c 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -98,12 +98,12 @@ def output_file_params(self, request): ), # valid dataset ], ) - def test_to_dlc_df(self, ds, expected_exception): + def test_to_dlc_style_df(self, ds, expected_exception): """Test that converting a valid/invalid xarray dataset to a DeepLabCut-style pandas DataFrame returns the expected result. """ with expected_exception as e: - df = save_poses.to_dlc_df(ds, split_individuals=False) + df = save_poses.to_dlc_style_df(ds, split_individuals=False) if e is None: # valid input assert isinstance(df, pd.DataFrame) assert isinstance(df.columns, pd.MultiIndex) @@ -163,15 +163,15 @@ def test_auto_split_individuals(self, valid_poses_dataset, split_value): ], indirect=["valid_poses_dataset"], ) - def test_to_dlc_df_split_individuals( + def test_to_dlc_style_df_split_individuals( self, valid_poses_dataset, split_individuals, ): """Test that the `split_individuals` argument affects the behaviour - of the `to_dlc_df` function as expected. + of the `to_dlc_style_df` function as expected. """ - df = save_poses.to_dlc_df(valid_poses_dataset, split_individuals) + df = save_poses.to_dlc_style_df(valid_poses_dataset, split_individuals) # Get the names of the individuals in the dataset ind_names = valid_poses_dataset.individuals.values if split_individuals is False: From 53a9eff95f8cf03f2c6bd74536000e7606300c37 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 18:23:14 +0100 Subject: [PATCH 04/65] [pre-commit.ci] pre-commit autoupdate (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.3 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.3...v0.4.7) - [github.com/codespell-project/codespell: v2.2.6 → v2.3.0](https://github.com/codespell-project/codespell/compare/v2.2.6...v2.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3266baa3..da27b9cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.3 + rev: v0.4.7 hooks: - id: ruff - id: ruff-format @@ -52,7 +52,7 @@ repos: additional_dependencies: [setuptools-scm] - repo: https://github.com/codespell-project/codespell # Configuration for codespell is in pyproject.toml - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell additional_dependencies: From 52ac099f7948b4b8cea1ac26680a525107d31b80 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Thu, 6 Jun 2024 10:02:10 +0100 Subject: [PATCH 05/65] Update supported Python versions (#208) * bump python versions for PyPI and CI * recommend py3.11 for conda env during installation * display supported python versions as a shield in README * pyupgrade auto-update Union to | syntax --- .github/workflows/test_and_deploy.yml | 8 ++--- README.md | 1 + docs/source/getting_started/installation.md | 2 +- examples/compute_kinematics.py | 12 ++++--- examples/smooth.py | 2 +- movement/filtering.py | 9 +++--- movement/io/load_poses.py | 36 +++++++++++---------- movement/io/save_poses.py | 16 ++++----- movement/io/validators.py | 18 +++++------ pyproject.toml | 8 ++--- 10 files changed, 57 insertions(+), 55 deletions(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 5ce4de5a..009baa63 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -28,16 +28,16 @@ jobs: strategy: matrix: # Run all supported Python versions on linux - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] os: [ubuntu-latest] # Include 1 Intel macos (13) and 1 M1 macos (latest) and 1 Windows run include: - os: macos-13 - python-version: "3.10" + python-version: "3.11" - os: macos-latest - python-version: "3.10" + python-version: "3.11" - os: windows-latest - python-version: "3.10" + python-version: "3.11" steps: - name: Cache Test Data diff --git a/README.md b/README.md index 847ba6ed..e4ebadf5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Python Version](https://img.shields.io/pypi/pyversions/movement.svg)](https://pypi.org/project/movement) [![License](https://img.shields.io/badge/License-BSD_3--Clause-orange.svg)](https://opensource.org/licenses/BSD-3-Clause) ![CI](https://img.shields.io/github/actions/workflow/status/neuroinformatics-unit/movement/test_and_deploy.yml?label=CI) [![codecov](https://codecov.io/gh/neuroinformatics-unit/movement/branch/main/graph/badge.svg?token=P8CCH3TI8K)](https://codecov.io/gh/neuroinformatics-unit/movement) diff --git a/docs/source/getting_started/installation.md b/docs/source/getting_started/installation.md index d4f51123..d1937944 100644 --- a/docs/source/getting_started/installation.md +++ b/docs/source/getting_started/installation.md @@ -15,7 +15,7 @@ First, create and activate an environment with some prerequisites. You can call your environment whatever you like, we've used `movement-env`. ```sh -conda create -n movement-env -c conda-forge python=3.10 pytables +conda create -n movement-env -c conda-forge python=3.11 pytables conda activate movement-env ``` diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py index b2e05c7b..3ada5789 100644 --- a/examples/compute_kinematics.py +++ b/examples/compute_kinematics.py @@ -52,7 +52,9 @@ # colouring them by individual. fig, ax = plt.subplots(1, 1) -for mouse_name, col in zip(position.individuals.values, ["r", "g", "b"]): +for mouse_name, col in zip( + position.individuals.values, ["r", "g", "b"], strict=False +): ax.plot( position.sel(individuals=mouse_name, space="x"), position.sel(individuals=mouse_name, space="y"), @@ -78,7 +80,7 @@ # %% # We can also color the data points based on their timestamps: fig, axes = plt.subplots(3, 1, sharey=True) -for mouse_name, ax in zip(position.individuals.values, axes): +for mouse_name, ax in zip(position.individuals.values, axes, strict=False): sc = ax.scatter( position.sel(individuals=mouse_name, space="x"), position.sel(individuals=mouse_name, space="y"), @@ -297,7 +299,7 @@ # %% # We can also visualise the speed, as the norm of the velocity vector: fig, axes = plt.subplots(3, 1, sharex=True, sharey=True) -for mouse_name, ax in zip(velocity.individuals.values, axes): +for mouse_name, ax in zip(velocity.individuals.values, axes, strict=False): # compute the norm of the velocity vector for one mouse speed_one_mouse = np.linalg.norm( velocity.sel(individuals=mouse_name, space=["x", "y"]).squeeze(), @@ -357,7 +359,7 @@ # and plot of the components of the acceleration vector ``ax``, ``ay`` per # individual: fig, axes = plt.subplots(3, 1, sharex=True, sharey=True) -for mouse_name, ax in zip(accel.individuals.values, axes): +for mouse_name, ax in zip(accel.individuals.values, axes, strict=False): # plot x-component of acceleration vector ax.plot( accel.sel(individuals=mouse_name, space=["x"]).squeeze(), @@ -379,7 +381,7 @@ # acceleration. # We can also represent this for each individual. fig, axes = plt.subplots(3, 1, sharex=True, sharey=True) -for mouse_name, ax in zip(accel.individuals.values, axes): +for mouse_name, ax in zip(accel.individuals.values, axes, strict=False): # compute norm of the acceleration vector for one mouse accel_one_mouse = np.linalg.norm( accel.sel(individuals=mouse_name, space=["x", "y"]).squeeze(), diff --git a/examples/smooth.py b/examples/smooth.py index 3bb44162..54a2135f 100644 --- a/examples/smooth.py +++ b/examples/smooth.py @@ -67,7 +67,7 @@ def plot_raw_and_smooth_timeseries_and_psd( fig, ax = plt.subplots(2, 1, figsize=(10, 6)) for ds, color, label in zip( - [ds_raw, ds_smooth], ["k", "r"], ["raw", "smooth"] + [ds_raw, ds_smooth], ["k", "r"], ["raw", "smooth"], strict=False ): # plot position time series pos = ds.position.sel(**selection) diff --git a/movement/filtering.py b/movement/filtering.py index 451830e8..c2d20ea1 100644 --- a/movement/filtering.py +++ b/movement/filtering.py @@ -3,7 +3,6 @@ import logging from datetime import datetime from functools import wraps -from typing import Optional, Union import xarray as xr from scipy import signal @@ -79,9 +78,9 @@ def report_nan_values(ds: xr.Dataset, ds_label: str = "dataset"): def interpolate_over_time( ds: xr.Dataset, method: str = "linear", - max_gap: Union[int, None] = None, + max_gap: int | None = None, print_report: bool = True, -) -> Union[xr.Dataset, None]: +) -> xr.Dataset | None: """Fill in NaN values by interpolating over the time dimension. Parameters @@ -122,7 +121,7 @@ def filter_by_confidence( ds: xr.Dataset, threshold: float = 0.6, print_report: bool = True, -) -> Union[xr.Dataset, None]: +) -> xr.Dataset | None: """Drop all points below a certain confidence threshold. Position points with an associated confidence value below the threshold are @@ -173,7 +172,7 @@ def filter_by_confidence( def median_filter( ds: xr.Dataset, window_length: int, - min_periods: Optional[int] = None, + min_periods: int | None = None, print_report: bool = True, ) -> xr.Dataset: """Smooth pose tracks by applying a median filter over time. diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index e2b30910..4f7fdfaa 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -2,7 +2,7 @@ import logging from pathlib import Path -from typing import Literal, Optional, Union +from typing import Literal import h5py import numpy as np @@ -25,11 +25,11 @@ def from_numpy( position_array: np.ndarray, - confidence_array: Optional[np.ndarray] = None, - individual_names: Optional[list[str]] = None, - keypoint_names: Optional[list[str]] = None, - fps: Optional[float] = None, - source_software: Optional[str] = None, + confidence_array: np.ndarray | None = None, + individual_names: list[str] | None = None, + keypoint_names: list[str] | None = None, + fps: float | None = None, + source_software: str | None = None, ) -> xr.Dataset: """Create a ``movement`` dataset from NumPy arrays. @@ -95,9 +95,9 @@ def from_numpy( def from_file( - file_path: Union[Path, str], + file_path: Path | str, source_software: Literal["DeepLabCut", "SLEAP", "LightningPose"], - fps: Optional[float] = None, + fps: float | None = None, ) -> xr.Dataset: """Create a ``movement`` dataset from any supported file. @@ -149,7 +149,7 @@ def from_file( def from_dlc_style_df( df: pd.DataFrame, - fps: Optional[float] = None, + fps: float | None = None, source_software: Literal["DeepLabCut", "LightningPose"] = "DeepLabCut", ) -> xr.Dataset: """Create a ``movement`` dataset from a DeepLabCut-style DataFrame. @@ -216,7 +216,7 @@ def from_dlc_style_df( def from_sleap_file( - file_path: Union[Path, str], fps: Optional[float] = None + file_path: Path | str, fps: float | None = None ) -> xr.Dataset: """Create a ``movement`` dataset from a SLEAP file. @@ -292,7 +292,7 @@ def from_sleap_file( def from_lp_file( - file_path: Union[Path, str], fps: Optional[float] = None + file_path: Path | str, fps: float | None = None ) -> xr.Dataset: """Create a ``movement`` dataset from a LightningPose file. @@ -322,7 +322,7 @@ def from_lp_file( def from_dlc_file( - file_path: Union[Path, str], fps: Optional[float] = None + file_path: Path | str, fps: float | None = None ) -> xr.Dataset: """Create a ``movement`` dataset from a DeepLabCut file. @@ -357,9 +357,9 @@ def from_dlc_file( def _ds_from_lp_or_dlc_file( - file_path: Union[Path, str], + file_path: Path | str, source_software: Literal["LightningPose", "DeepLabCut"], - fps: Optional[float] = None, + fps: float | None = None, ) -> xr.Dataset: """Create a ``movement`` dataset from a LightningPose or DeepLabCut file. @@ -408,7 +408,7 @@ def _ds_from_lp_or_dlc_file( def _ds_from_sleap_analysis_file( - file_path: Path, fps: Optional[float] + file_path: Path, fps: float | None ) -> xr.Dataset: """Create a ``movement`` dataset from a SLEAP analysis (.h5) file. @@ -456,7 +456,7 @@ def _ds_from_sleap_analysis_file( def _ds_from_sleap_labels_file( - file_path: Path, fps: Optional[float] + file_path: Path, fps: float | None ) -> xr.Dataset: """Create a ``movement`` dataset from a SLEAP labels (.slp) file. @@ -596,7 +596,9 @@ def _df_from_dlc_csv(file_path: Path) -> pd.DataFrame: # Form multi-index column names from the header lines level_names = [line[0] for line in header_lines] - column_tuples = list(zip(*[line[1:] for line in header_lines])) + column_tuples = list( + zip(*[line[1:] for line in header_lines], strict=False) + ) columns = pd.MultiIndex.from_tuples(column_tuples, names=level_names) # Import the DeepLabCut poses as a DataFrame diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py index e0ee5778..151ac670 100644 --- a/movement/io/save_poses.py +++ b/movement/io/save_poses.py @@ -2,7 +2,7 @@ import logging from pathlib import Path -from typing import Literal, Union +from typing import Literal import h5py import numpy as np @@ -79,7 +79,7 @@ def _save_dlc_df(filepath: Path, df: pd.DataFrame) -> None: def to_dlc_style_df( ds: xr.Dataset, split_individuals: bool = False -) -> Union[pd.DataFrame, dict[str, pd.DataFrame]]: +) -> pd.DataFrame | dict[str, pd.DataFrame]: """Convert a ``movement`` dataset to DeepLabCut-style DataFrame(s). Parameters @@ -152,8 +152,8 @@ def to_dlc_style_df( def to_dlc_file( ds: xr.Dataset, - file_path: Union[str, Path], - split_individuals: Union[bool, Literal["auto"]] = "auto", + file_path: str | Path, + split_individuals: bool | Literal["auto"] = "auto", ) -> None: """Save a ``movement`` dataset to DeepLabCut file(s). @@ -225,7 +225,7 @@ def to_dlc_file( def to_lp_file( ds: xr.Dataset, - file_path: Union[str, Path], + file_path: str | Path, ) -> None: """Save a ``movement`` dataset to a LightningPose file. @@ -257,9 +257,7 @@ def to_lp_file( to_dlc_file(ds, file.path, split_individuals=True) -def to_sleap_analysis_file( - ds: xr.Dataset, file_path: Union[str, Path] -) -> None: +def to_sleap_analysis_file(ds: xr.Dataset, file_path: str | Path) -> None: """Save a ``movement`` dataset to a SLEAP analysis file. Parameters @@ -378,7 +376,7 @@ def _remove_unoccupied_tracks(ds: xr.Dataset): def _validate_file_path( - file_path: Union[str, Path], expected_suffix: list[str] + file_path: str | Path, expected_suffix: list[str] ) -> ValidFile: """Validate the input file path. diff --git a/movement/io/validators.py b/movement/io/validators.py index 6174f945..26d5dbd3 100644 --- a/movement/io/validators.py +++ b/movement/io/validators.py @@ -3,7 +3,7 @@ import os from collections.abc import Iterable from pathlib import Path -from typing import Any, Literal, Optional, Union +from typing import Any, Literal import h5py import numpy as np @@ -202,7 +202,7 @@ def csv_file_contains_expected_levels(self, attribute, value): ) -def _list_of_str(value: Union[str, Iterable[Any]]) -> list[str]: +def _list_of_str(value: str | Iterable[Any]) -> list[str]: """Try to coerce the value into a list of strings.""" if isinstance(value, str): log_warning( @@ -226,7 +226,7 @@ def _ensure_type_ndarray(value: Any) -> None: ) -def _set_fps_to_none_if_invalid(fps: Optional[float]) -> Optional[float]: +def _set_fps_to_none_if_invalid(fps: float | None) -> float | None: """Set fps to None if a non-positive float is passed.""" if fps is not None and fps <= 0: log_warning( @@ -238,7 +238,7 @@ def _set_fps_to_none_if_invalid(fps: Optional[float]) -> Optional[float]: def _validate_list_length( - attribute: str, value: Optional[list], expected_length: int + attribute: str, value: list | None, expected_length: int ): """Raise a ValueError if the list does not have the expected length.""" if (value is not None) and (len(value) != expected_length): @@ -280,22 +280,22 @@ class ValidPosesDataset: # Define class attributes position_array: np.ndarray = field() - confidence_array: Optional[np.ndarray] = field(default=None) - individual_names: Optional[list[str]] = field( + confidence_array: np.ndarray | None = field(default=None) + individual_names: list[str] | None = field( default=None, converter=converters.optional(_list_of_str), ) - keypoint_names: Optional[list[str]] = field( + keypoint_names: list[str] | None = field( default=None, converter=converters.optional(_list_of_str), ) - fps: Optional[float] = field( + fps: float | None = field( default=None, converter=converters.pipe( # type: ignore converters.optional(float), _set_fps_to_none_if_invalid ), ) - source_software: Optional[str] = field( + source_software: str | None = field( default=None, validator=validators.optional(validators.instance_of(str)), ) diff --git a/pyproject.toml b/pyproject.toml index 49b16caa..ebbe35bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] description = "Analysis of body movement" readme = "README.md" -requires-python = ">=3.9.0" +requires-python = ">=3.10.0" dynamic = ["version"] license = { text = "BSD-3-Clause" } @@ -27,9 +27,9 @@ classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Operating System :: OS Independent", "License :: OSI Approved :: BSD License", ] @@ -138,14 +138,14 @@ check-hidden = true legacy_tox_ini = """ [tox] requires = tox-conda -envlist = py{39,310,311} +envlist = py{310,311,312} isolated_build = True [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 [testenv] conda_deps = From a1c91e69b8d00adcb4ca19ce0fb207a5ecf8cdc2 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Fri, 7 Jun 2024 10:18:29 +0100 Subject: [PATCH 06/65] Accessor `compute_` to only validate required `position` data variable (#206) * Validate required dimensions only * Fix fstring Co-authored-by: Niko Sirmpilatze * Update docstring --------- Co-authored-by: Niko Sirmpilatze --- movement/analysis/kinematics.py | 4 +-- movement/move_accessor.py | 28 +++++++++++++------ .../test_kinematics_vector_transform.py | 2 +- tests/test_unit/test_kinematics.py | 2 +- tests/test_unit/test_move_accessor.py | 7 +---- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index a1241b29..55057d12 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -124,11 +124,11 @@ def _validate_time_dimension(data: xr.DataArray) -> None: Raises ------ - ValueError + AttributeError If the input data does not contain a ``time`` dimension. """ if "time" not in data.dims: raise log_error( - ValueError, "Input data must contain 'time' as a dimension." + AttributeError, "Input data must contain 'time' as a dimension." ) diff --git a/movement/move_accessor.py b/movement/move_accessor.py index 17c268dc..9bf14b8c 100644 --- a/movement/move_accessor.py +++ b/movement/move_accessor.py @@ -7,6 +7,7 @@ from movement.analysis import kinematics from movement.io.validators import ValidPosesDataset +from movement.logging import log_error logger = logging.getLogger(__name__) @@ -76,14 +77,26 @@ def __getattr__(self, name: str) -> xr.DataArray: """ def method(*args, **kwargs): - if name.startswith("compute_") and hasattr(kinematics, name): - self.validate() + if not name.startswith("compute_") or not hasattr( + kinematics, name + ): + error_msg = ( + f"'{self.__class__.__name__}' object has " + f"no attribute '{name}'" + ) + raise log_error(AttributeError, error_msg) + if not hasattr(self._obj, "position"): + raise log_error( + AttributeError, + "Missing required data variables: 'position'", + ) + try: return getattr(kinematics, name)( self._obj.position, *args, **kwargs ) - raise AttributeError( - f"'{self.__class__.__name__}' object has no attribute '{name}'" - ) + except Exception as e: + error_msg = f"Failed to evoke '{name}'. " + raise log_error(AttributeError, error_msg) from e return method @@ -116,6 +129,5 @@ def validate(self) -> None: source_software=source_software, ) except Exception as e: - error_msg = "The dataset does not contain valid poses." - logger.error(error_msg) - raise ValueError(error_msg) from e + error_msg = "The dataset does not contain valid poses. " + str(e) + raise log_error(ValueError, error_msg) from e diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index c81183ea..dbd39943 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -16,7 +16,7 @@ class TestKinematicsVectorTransform: [ ("valid_poses_dataset", does_not_raise()), ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_dataset", pytest.raises(ValueError)), + ("missing_dim_dataset", pytest.raises(AttributeError)), ], ) def test_cart_and_pol_transform( diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 2d9096bc..e43999a4 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -53,7 +53,7 @@ def _expected_dataarray(property): kinematic_test_params = [ ("valid_poses_dataset", does_not_raise()), ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_dataset", pytest.raises(ValueError)), + ("missing_dim_dataset", pytest.raises(AttributeError)), ] @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) diff --git a/tests/test_unit/test_move_accessor.py b/tests/test_unit/test_move_accessor.py index ae6b4962..76422c97 100644 --- a/tests/test_unit/test_move_accessor.py +++ b/tests/test_unit/test_move_accessor.py @@ -23,12 +23,7 @@ def test_compute_kinematics_with_invalid_dataset( """Test that computing a kinematic property of an invalid pose dataset via accessor methods raises the appropriate error. """ - expected_exception = ( - ValueError - if isinstance(invalid_poses_dataset, xr.Dataset) - else AttributeError - ) - with pytest.raises(expected_exception): + with pytest.raises(AttributeError): getattr( invalid_poses_dataset.move, f"compute_{kinematic_property}" )() From cf0a6b1bb272817665e4cb705eb77f95f96dbd5a Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Mon, 10 Jun 2024 18:59:17 +0800 Subject: [PATCH 07/65] Use raw string for ASCII art in CLI entrypoint (#212) --- movement/cli_entrypoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/movement/cli_entrypoint.py b/movement/cli_entrypoint.py index f760ba87..8be03137 100644 --- a/movement/cli_entrypoint.py +++ b/movement/cli_entrypoint.py @@ -9,7 +9,7 @@ import movement -ASCII_ART = """ +ASCII_ART = r""" _ __ ___ _____ _____ _ __ ___ ___ _ __ | |_ | '_ ` _ \ / _ \ \ / / _ \ '_ ` _ \ / _ \ '_ \| __| | | | | | | (_) \ V / __/ | | | | | __/ | | | |_ From 0114b912249ec7b2c2f0bd9beb25c6792acabc03 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:49:22 +0100 Subject: [PATCH 08/65] Refactor validators (#204) * Split validators into modules * Fix API index * Fix API reference * Fix docstring * Move validator module one level up --- docs/source/api_index.rst | 13 +- movement/io/load_poses.py | 8 +- movement/io/save_poses.py | 2 +- movement/move_accessor.py | 2 +- movement/validators/__init__.py | 0 movement/validators/datasets.py | 178 +++++++++++++++++ .../{io/validators.py => validators/files.py} | 179 +----------------- tests/test_unit/test_validators.py | 8 +- 8 files changed, 198 insertions(+), 192 deletions(-) create mode 100644 movement/validators/__init__.py create mode 100644 movement/validators/datasets.py rename movement/{io/validators.py => validators/files.py} (50%) diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst index a76a19f1..09454002 100644 --- a/docs/source/api_index.rst +++ b/docs/source/api_index.rst @@ -29,15 +29,22 @@ Save poses to_sleap_analysis_file to_dlc_style_df -Validators ----------- -.. currentmodule:: movement.io.validators +Validators - Files +------------------ +.. currentmodule:: movement.validators.files .. autosummary:: :toctree: api ValidFile ValidHDF5 ValidDeepLabCutCSV + +Validators - Datasets +---------------------- +.. currentmodule:: movement.validators.datasets +.. autosummary:: + :toctree: api + ValidPosesDataset Sample Data diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index 4f7fdfaa..4d2a3a23 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -12,13 +12,9 @@ from sleap_io.model.labels import Labels from movement import MovementDataset -from movement.io.validators import ( - ValidDeepLabCutCSV, - ValidFile, - ValidHDF5, - ValidPosesDataset, -) from movement.logging import log_error, log_warning +from movement.validators.datasets import ValidPosesDataset +from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 logger = logging.getLogger(__name__) diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py index 151ac670..2fd31f19 100644 --- a/movement/io/save_poses.py +++ b/movement/io/save_poses.py @@ -9,8 +9,8 @@ import pandas as pd import xarray as xr -from movement.io.validators import ValidFile from movement.logging import log_error +from movement.validators.files import ValidFile logger = logging.getLogger(__name__) diff --git a/movement/move_accessor.py b/movement/move_accessor.py index 9bf14b8c..8ffdccaf 100644 --- a/movement/move_accessor.py +++ b/movement/move_accessor.py @@ -6,8 +6,8 @@ import xarray as xr from movement.analysis import kinematics -from movement.io.validators import ValidPosesDataset from movement.logging import log_error +from movement.validators.datasets import ValidPosesDataset logger = logging.getLogger(__name__) diff --git a/movement/validators/__init__.py b/movement/validators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py new file mode 100644 index 00000000..2ab84845 --- /dev/null +++ b/movement/validators/datasets.py @@ -0,0 +1,178 @@ +"""`attrs` classes for validating data structures.""" + +from collections.abc import Iterable +from typing import Any + +import numpy as np +from attrs import converters, define, field, validators + +from movement.logging import log_error, log_warning + + +def _list_of_str(value: str | Iterable[Any]) -> list[str]: + """Try to coerce the value into a list of strings.""" + if isinstance(value, str): + log_warning( + f"Invalid value ({value}). Expected a list of strings. " + "Converting to a list of length 1." + ) + return [value] + elif isinstance(value, Iterable): + return [str(item) for item in value] + else: + raise log_error( + ValueError, f"Invalid value ({value}). Expected a list of strings." + ) + + +def _ensure_type_ndarray(value: Any) -> None: + """Raise ValueError the value is a not numpy array.""" + if not isinstance(value, np.ndarray): + raise log_error( + ValueError, f"Expected a numpy array, but got {type(value)}." + ) + + +def _set_fps_to_none_if_invalid(fps: float | None) -> float | None: + """Set fps to None if a non-positive float is passed.""" + if fps is not None and fps <= 0: + log_warning( + f"Invalid fps value ({fps}). Expected a positive number. " + "Setting fps to None." + ) + return None + return fps + + +def _validate_list_length( + attribute: str, value: list | None, expected_length: int +): + """Raise a ValueError if the list does not have the expected length.""" + if (value is not None) and (len(value) != expected_length): + raise log_error( + ValueError, + f"Expected `{attribute}` to have length {expected_length}, " + f"but got {len(value)}.", + ) + + +@define(kw_only=True) +class ValidPosesDataset: + """Class for validating data intended for a ``movement`` dataset. + + Attributes + ---------- + position_array : np.ndarray + Array of shape (n_frames, n_individuals, n_keypoints, n_space) + containing the poses. + confidence_array : np.ndarray, optional + Array of shape (n_frames, n_individuals, n_keypoints) containing + the point-wise confidence scores. + If None (default), the scores will be set to an array of NaNs. + individual_names : list of str, optional + List of unique names for the individuals in the video. If None + (default), the individuals will be named "individual_0", + "individual_1", etc. + keypoint_names : list of str, optional + List of unique names for the keypoints in the skeleton. If None + (default), the keypoints will be named "keypoint_0", "keypoint_1", + etc. + fps : float, optional + Frames per second of the video. Defaults to None. + source_software : str, optional + Name of the software from which the poses were loaded. + Defaults to None. + + """ + + # Define class attributes + position_array: np.ndarray = field() + confidence_array: np.ndarray | None = field(default=None) + individual_names: list[str] | None = field( + default=None, + converter=converters.optional(_list_of_str), + ) + keypoint_names: list[str] | None = field( + default=None, + converter=converters.optional(_list_of_str), + ) + fps: float | None = field( + default=None, + converter=converters.pipe( # type: ignore + converters.optional(float), _set_fps_to_none_if_invalid + ), + ) + source_software: str | None = field( + default=None, + validator=validators.optional(validators.instance_of(str)), + ) + + # Add validators + @position_array.validator + def _validate_position_array(self, attribute, value): + _ensure_type_ndarray(value) + if value.ndim != 4: + raise log_error( + ValueError, + f"Expected `{attribute}` to have 4 dimensions, " + f"but got {value.ndim}.", + ) + if value.shape[-1] not in [2, 3]: + raise log_error( + ValueError, + f"Expected `{attribute}` to have 2 or 3 spatial dimensions, " + f"but got {value.shape[-1]}.", + ) + + @confidence_array.validator + def _validate_confidence_array(self, attribute, value): + if value is not None: + _ensure_type_ndarray(value) + expected_shape = self.position_array.shape[:-1] + if value.shape != expected_shape: + raise log_error( + ValueError, + f"Expected `{attribute}` to have shape " + f"{expected_shape}, but got {value.shape}.", + ) + + @individual_names.validator + def _validate_individual_names(self, attribute, value): + if self.source_software == "LightningPose": + # LightningPose only supports a single individual + _validate_list_length(attribute, value, 1) + else: + _validate_list_length( + attribute, value, self.position_array.shape[1] + ) + + @keypoint_names.validator + def _validate_keypoint_names(self, attribute, value): + _validate_list_length(attribute, value, self.position_array.shape[2]) + + def __attrs_post_init__(self): + """Assign default values to optional attributes (if None).""" + if self.confidence_array is None: + self.confidence_array = np.full( + (self.position_array.shape[:-1]), np.nan, dtype="float32" + ) + log_warning( + "Confidence array was not provided." + "Setting to an array of NaNs." + ) + if self.individual_names is None: + self.individual_names = [ + f"individual_{i}" for i in range(self.position_array.shape[1]) + ] + log_warning( + "Individual names were not provided. " + f"Setting to {self.individual_names}." + ) + if self.keypoint_names is None: + self.keypoint_names = [ + f"keypoint_{i}" for i in range(self.position_array.shape[2]) + ] + log_warning( + "Keypoint names were not provided. " + f"Setting to {self.keypoint_names}." + ) diff --git a/movement/io/validators.py b/movement/validators/files.py similarity index 50% rename from movement/io/validators.py rename to movement/validators/files.py index 26d5dbd3..b7e7ca9e 100644 --- a/movement/io/validators.py +++ b/movement/validators/files.py @@ -1,15 +1,13 @@ -"""`attrs` classes for validating file paths and data structures.""" +"""`attrs` classes for validating file paths.""" import os -from collections.abc import Iterable from pathlib import Path -from typing import Any, Literal +from typing import Literal import h5py -import numpy as np -from attrs import converters, define, field, validators +from attrs import define, field, validators -from movement.logging import log_error, log_warning +from movement.logging import log_error @define @@ -200,172 +198,3 @@ def csv_file_contains_expected_levels(self, attribute, value): ".csv header rows do not match the known format for " "DeepLabCut pose estimation output files.", ) - - -def _list_of_str(value: str | Iterable[Any]) -> list[str]: - """Try to coerce the value into a list of strings.""" - if isinstance(value, str): - log_warning( - f"Invalid value ({value}). Expected a list of strings. " - "Converting to a list of length 1." - ) - return [value] - elif isinstance(value, Iterable): - return [str(item) for item in value] - else: - raise log_error( - ValueError, f"Invalid value ({value}). Expected a list of strings." - ) - - -def _ensure_type_ndarray(value: Any) -> None: - """Raise ValueError the value is a not numpy array.""" - if not isinstance(value, np.ndarray): - raise log_error( - ValueError, f"Expected a numpy array, but got {type(value)}." - ) - - -def _set_fps_to_none_if_invalid(fps: float | None) -> float | None: - """Set fps to None if a non-positive float is passed.""" - if fps is not None and fps <= 0: - log_warning( - f"Invalid fps value ({fps}). Expected a positive number. " - "Setting fps to None." - ) - return None - return fps - - -def _validate_list_length( - attribute: str, value: list | None, expected_length: int -): - """Raise a ValueError if the list does not have the expected length.""" - if (value is not None) and (len(value) != expected_length): - raise log_error( - ValueError, - f"Expected `{attribute}` to have length {expected_length}, " - f"but got {len(value)}.", - ) - - -@define(kw_only=True) -class ValidPosesDataset: - """Class for validating data intended for a ``movement`` dataset. - - Attributes - ---------- - position_array : np.ndarray - Array of shape (n_frames, n_individuals, n_keypoints, n_space) - containing the poses. - confidence_array : np.ndarray, optional - Array of shape (n_frames, n_individuals, n_keypoints) containing - the point-wise confidence scores. - If None (default), the scores will be set to an array of NaNs. - individual_names : list of str, optional - List of unique names for the individuals in the video. If None - (default), the individuals will be named "individual_0", - "individual_1", etc. - keypoint_names : list of str, optional - List of unique names for the keypoints in the skeleton. If None - (default), the keypoints will be named "keypoint_0", "keypoint_1", - etc. - fps : float, optional - Frames per second of the video. Defaults to None. - source_software : str, optional - Name of the software from which the poses were loaded. - Defaults to None. - - """ - - # Define class attributes - position_array: np.ndarray = field() - confidence_array: np.ndarray | None = field(default=None) - individual_names: list[str] | None = field( - default=None, - converter=converters.optional(_list_of_str), - ) - keypoint_names: list[str] | None = field( - default=None, - converter=converters.optional(_list_of_str), - ) - fps: float | None = field( - default=None, - converter=converters.pipe( # type: ignore - converters.optional(float), _set_fps_to_none_if_invalid - ), - ) - source_software: str | None = field( - default=None, - validator=validators.optional(validators.instance_of(str)), - ) - - # Add validators - @position_array.validator - def _validate_position_array(self, attribute, value): - _ensure_type_ndarray(value) - if value.ndim != 4: - raise log_error( - ValueError, - f"Expected `{attribute}` to have 4 dimensions, " - f"but got {value.ndim}.", - ) - if value.shape[-1] not in [2, 3]: - raise log_error( - ValueError, - f"Expected `{attribute}` to have 2 or 3 spatial dimensions, " - f"but got {value.shape[-1]}.", - ) - - @confidence_array.validator - def _validate_confidence_array(self, attribute, value): - if value is not None: - _ensure_type_ndarray(value) - expected_shape = self.position_array.shape[:-1] - if value.shape != expected_shape: - raise log_error( - ValueError, - f"Expected `{attribute}` to have shape " - f"{expected_shape}, but got {value.shape}.", - ) - - @individual_names.validator - def _validate_individual_names(self, attribute, value): - if self.source_software == "LightningPose": - # LightningPose only supports a single individual - _validate_list_length(attribute, value, 1) - else: - _validate_list_length( - attribute, value, self.position_array.shape[1] - ) - - @keypoint_names.validator - def _validate_keypoint_names(self, attribute, value): - _validate_list_length(attribute, value, self.position_array.shape[2]) - - def __attrs_post_init__(self): - """Assign default values to optional attributes (if None).""" - if self.confidence_array is None: - self.confidence_array = np.full( - (self.position_array.shape[:-1]), np.nan, dtype="float32" - ) - log_warning( - "Confidence array was not provided." - "Setting to an array of NaNs." - ) - if self.individual_names is None: - self.individual_names = [ - f"individual_{i}" for i in range(self.position_array.shape[1]) - ] - log_warning( - "Individual names were not provided. " - f"Setting to {self.individual_names}." - ) - if self.keypoint_names is None: - self.keypoint_names = [ - f"keypoint_{i}" for i in range(self.position_array.shape[2]) - ] - log_warning( - "Keypoint names were not provided. " - f"Setting to {self.keypoint_names}." - ) diff --git a/tests/test_unit/test_validators.py b/tests/test_unit/test_validators.py index fc44f94f..60002e7c 100644 --- a/tests/test_unit/test_validators.py +++ b/tests/test_unit/test_validators.py @@ -3,12 +3,8 @@ import numpy as np import pytest -from movement.io.validators import ( - ValidDeepLabCutCSV, - ValidFile, - ValidHDF5, - ValidPosesDataset, -) +from movement.validators.datasets import ValidPosesDataset +from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 class TestValidators: From b1239945ebc57c02c8dd8a6b54d9f662f6155b77 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Wed, 12 Jun 2024 16:43:40 +0100 Subject: [PATCH 09/65] add missing __init__.py files (#215) --- movement/analysis/__init__.py | 0 movement/utils/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 movement/analysis/__init__.py create mode 100644 movement/utils/__init__.py diff --git a/movement/analysis/__init__.py b/movement/analysis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/movement/utils/__init__.py b/movement/utils/__init__.py new file mode 100644 index 00000000..e69de29b From e4eea35c18eb8c9a5e7a80e0857df59c2b68702f Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Wed, 12 Jun 2024 19:00:23 +0100 Subject: [PATCH 10/65] Move logging.py inside utils (#216) * move logging.py inside utils * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/api_index.rst | 2 +- movement/__init__.py | 2 +- movement/analysis/kinematics.py | 2 +- movement/filtering.py | 2 +- movement/io/load_poses.py | 2 +- movement/io/save_poses.py | 2 +- movement/move_accessor.py | 2 +- movement/sample_data.py | 2 +- movement/{ => utils}/logging.py | 0 movement/utils/vector.py | 2 +- movement/validators/datasets.py | 2 +- movement/validators/files.py | 2 +- tests/conftest.py | 2 +- tests/test_unit/test_logging.py | 2 +- 14 files changed, 13 insertions(+), 13 deletions(-) rename movement/{ => utils}/logging.py (100%) diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst index 09454002..6ce0caa8 100644 --- a/docs/source/api_index.rst +++ b/docs/source/api_index.rst @@ -97,7 +97,7 @@ MovementDataset Logging ------- -.. currentmodule:: movement.logging +.. currentmodule:: movement.utils.logging .. autosummary:: :toctree: api diff --git a/movement/__init__.py b/movement/__init__.py index 7f6efda3..bc9115b1 100644 --- a/movement/__init__.py +++ b/movement/__init__.py @@ -1,6 +1,6 @@ from importlib.metadata import PackageNotFoundError, version -from movement.logging import configure_logging +from movement.utils.logging import configure_logging from movement.move_accessor import MovementDataset try: diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 55057d12..39984d36 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -3,7 +3,7 @@ import numpy as np import xarray as xr -from movement.logging import log_error +from movement.utils.logging import log_error def compute_displacement(data: xr.DataArray) -> xr.DataArray: diff --git a/movement/filtering.py b/movement/filtering.py index c2d20ea1..38b3a9bb 100644 --- a/movement/filtering.py +++ b/movement/filtering.py @@ -7,7 +7,7 @@ import xarray as xr from scipy import signal -from movement.logging import log_error +from movement.utils.logging import log_error def log_to_attrs(func): diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index 4d2a3a23..fc14b0df 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -12,7 +12,7 @@ from sleap_io.model.labels import Labels from movement import MovementDataset -from movement.logging import log_error, log_warning +from movement.utils.logging import log_error, log_warning from movement.validators.datasets import ValidPosesDataset from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py index 2fd31f19..e1bce6d4 100644 --- a/movement/io/save_poses.py +++ b/movement/io/save_poses.py @@ -9,7 +9,7 @@ import pandas as pd import xarray as xr -from movement.logging import log_error +from movement.utils.logging import log_error from movement.validators.files import ValidFile logger = logging.getLogger(__name__) diff --git a/movement/move_accessor.py b/movement/move_accessor.py index 8ffdccaf..549472c5 100644 --- a/movement/move_accessor.py +++ b/movement/move_accessor.py @@ -6,7 +6,7 @@ import xarray as xr from movement.analysis import kinematics -from movement.logging import log_error +from movement.utils.logging import log_error from movement.validators.datasets import ValidPosesDataset logger = logging.getLogger(__name__) diff --git a/movement/sample_data.py b/movement/sample_data.py index 29875845..bfbf3234 100644 --- a/movement/sample_data.py +++ b/movement/sample_data.py @@ -15,7 +15,7 @@ from requests.exceptions import RequestException from movement.io import load_poses -from movement.logging import log_error, log_warning +from movement.utils.logging import log_error, log_warning logger = logging.getLogger(__name__) diff --git a/movement/logging.py b/movement/utils/logging.py similarity index 100% rename from movement/logging.py rename to movement/utils/logging.py diff --git a/movement/utils/vector.py b/movement/utils/vector.py index a30b5658..c35990eb 100644 --- a/movement/utils/vector.py +++ b/movement/utils/vector.py @@ -3,7 +3,7 @@ import numpy as np import xarray as xr -from movement.logging import log_error +from movement.utils.logging import log_error def cart2pol(data: xr.DataArray) -> xr.DataArray: diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py index 2ab84845..fa3cd76c 100644 --- a/movement/validators/datasets.py +++ b/movement/validators/datasets.py @@ -6,7 +6,7 @@ import numpy as np from attrs import converters, define, field, validators -from movement.logging import log_error, log_warning +from movement.utils.logging import log_error, log_warning def _list_of_str(value: str | Iterable[Any]) -> list[str]: diff --git a/movement/validators/files.py b/movement/validators/files.py index b7e7ca9e..cfb2c82b 100644 --- a/movement/validators/files.py +++ b/movement/validators/files.py @@ -7,7 +7,7 @@ import h5py from attrs import define, field, validators -from movement.logging import log_error +from movement.utils.logging import log_error @define diff --git a/tests/conftest.py b/tests/conftest.py index 9acc418e..d3b3f9f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,8 +12,8 @@ import xarray as xr from movement import MovementDataset -from movement.logging import configure_logging from movement.sample_data import fetch_dataset_paths, list_datasets +from movement.utils.logging import configure_logging def pytest_configure(): diff --git a/tests/test_unit/test_logging.py b/tests/test_unit/test_logging.py index 40e4415d..680c4c90 100644 --- a/tests/test_unit/test_logging.py +++ b/tests/test_unit/test_logging.py @@ -2,7 +2,7 @@ import pytest -from movement.logging import log_error, log_warning +from movement.utils.logging import log_error, log_warning log_messages = { "DEBUG": "This is a debug message", From bba94034341893474fdf5811665a465506ccf04c Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Thu, 13 Jun 2024 17:13:09 +0100 Subject: [PATCH 11/65] Add a ValidBboxesDataset class (#201) * Skeleton for ValidBboxesDataset class * Add ID string format check * Print all IDs with wrong format * Check IDs are 1-based * Make ID list not optional * Add test for invalid centroid or shape arrays * Add test for invalid ID array * Fix confidence array setting to nan * Add test for confidence array * Add fixtures for valid bboxes arrays * move method to class * Add log message check to invalid_centroid_position_array test * Print attribute.name (rather than attribute) in error messages * Add log messages for shape_array tests * Add logs check for ID attribute * Fix log message * Add log message check to confidence array * Combine pattern matching tests * Change log messages in tests * Rename data array from centroid_position to position * Rename IDs to individual_names * Remove checks for position and shape arrays to be 3 dimensional * Remove enforcing IDs are 1-based in valid bbox class * Make individual_names optional and assign default value * Remove requirement (and test) for a indvidual_names to be in a specific format * Change validator and test to take individual_names as optional inputs * Add fixture request to confidence array test * Clean up * Clean up tests * Fix docstring * Small cosmetic edits * Add assert statement for default confidence matrix * Feedback from code review * Remove some comments * length of box --> extent * Refactor parameters into dict * Refactor as a function * Revert "Refactor as a function" This reverts commit 8056e28f37f56793aba90997db445f7694090ae8. * Clarify docstring for `individual_names` * Add feedback from code review --- movement/validators/datasets.py | 147 +++++++++++++++++++++++-- tests/test_unit/test_validators.py | 167 ++++++++++++++++++++++++++++- 2 files changed, 305 insertions(+), 9 deletions(-) diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py index fa3cd76c..73e505cd 100644 --- a/movement/validators/datasets.py +++ b/movement/validators/datasets.py @@ -44,14 +44,12 @@ def _set_fps_to_none_if_invalid(fps: float | None) -> float | None: return fps -def _validate_list_length( - attribute: str, value: list | None, expected_length: int -): +def _validate_list_length(attribute, value: list | None, expected_length: int): """Raise a ValueError if the list does not have the expected length.""" if (value is not None) and (len(value) != expected_length): raise log_error( ValueError, - f"Expected `{attribute}` to have length {expected_length}, " + f"Expected '{attribute.name}' to have length {expected_length}, " f"but got {len(value)}.", ) @@ -114,14 +112,14 @@ def _validate_position_array(self, attribute, value): if value.ndim != 4: raise log_error( ValueError, - f"Expected `{attribute}` to have 4 dimensions, " + f"Expected '{attribute.name}' to have 4 dimensions, " f"but got {value.ndim}.", ) if value.shape[-1] not in [2, 3]: raise log_error( ValueError, - f"Expected `{attribute}` to have 2 or 3 spatial dimensions, " - f"but got {value.shape[-1]}.", + f"Expected '{attribute.name}' to have 2 or 3 spatial " + f"dimensions, but got {value.shape[-1]}.", ) @confidence_array.validator @@ -132,7 +130,7 @@ def _validate_confidence_array(self, attribute, value): if value.shape != expected_shape: raise log_error( ValueError, - f"Expected `{attribute}` to have shape " + f"Expected '{attribute.name}' to have shape " f"{expected_shape}, but got {value.shape}.", ) @@ -176,3 +174,136 @@ def __attrs_post_init__(self): "Keypoint names were not provided. " f"Setting to {self.keypoint_names}." ) + + +@define(kw_only=True) +class ValidBboxesDataset: + """Class for validating bounding boxes' data for a ``movement`` dataset. + + We consider 2D bounding boxes only. + + Attributes + ---------- + position_array : np.ndarray + Array of shape (n_frames, n_individual_names, n_space) + containing the bounding boxes' centroid positions. + shape_array : np.ndarray + Array of shape (n_frames, n_individual_names, n_space) + containing the bounding boxes' width (extent along the + x-axis) and height (extent along the y-axis). + confidence_array : np.ndarray, optional + Array of shape (n_frames, n_individuals, n_keypoints) containing + the bounding boxes' confidence scores. If None (default), the + confidence scores will be set to an array of NaNs. + individual_names : list of str, optional + List of individual names for the tracked bounding boxes in the video. + If None (default), bounding boxes are assigned names based on the size + of the `position_array`. The names will be in the format of `id_`, + where is an integer from 1 to `position_array.shape[1]`. + fps : float, optional + Frames per second defining the sampling rate of the data. + Defaults to None. + source_software : str, optional + Name of the software from which the bounding boxes were loaded. + Defaults to None. + + """ + + # Required attributes + position_array: np.ndarray = field() + shape_array: np.ndarray = field() + + # Optional attributes + confidence_array: np.ndarray | None = field(default=None) + individual_names: list[str] | None = field( + default=None, + converter=converters.optional( + _list_of_str + ), # force into list of strings if not + ) + fps: float | None = field( + default=None, + converter=converters.pipe( # type: ignore + converters.optional(float), _set_fps_to_none_if_invalid + ), + ) + source_software: str | None = field( + default=None, + validator=validators.optional(validators.instance_of(str)), + ) + + # Validators + @position_array.validator + @shape_array.validator + def _validate_position_and_shape_arrays(self, attribute, value): + _ensure_type_ndarray(value) + + # check last dimension (spatial) has 2 coordinates + n_expected_spatial_coordinates = 2 + if value.shape[-1] != n_expected_spatial_coordinates: + raise log_error( + ValueError, + f"Expected '{attribute.name}' to have 2 spatial coordinates, " + f"but got {value.shape[-1]}.", + ) + + @individual_names.validator + def _validate_individual_names(self, attribute, value): + if value is not None: + _validate_list_length( + attribute, value, self.position_array.shape[1] + ) + + # check n_individual_names are unique + # NOTE: combined with the requirement above, we are enforcing + # unique IDs per frame + if len(value) != len(set(value)): + raise log_error( + ValueError, + "individual_names passed to the dataset are not unique. " + f"There are {len(value)} elements in the list, but " + f"only {len(set(value))} are unique.", + ) + + @confidence_array.validator + def _validate_confidence_array(self, attribute, value): + if value is not None: + _ensure_type_ndarray(value) + + expected_shape = self.position_array.shape[:-1] + if value.shape != expected_shape: + raise log_error( + ValueError, + f"Expected '{attribute.name}' to have shape " + f"{expected_shape}, but got {value.shape}.", + ) + + # Define defaults + def __attrs_post_init__(self): + """Assign default values to optional attributes (if None). + + If no confidence_array is provided, set it to an array of NaNs. + If no individual names are provided, assign them unique IDs per frame, + starting with 1 ("id_1") + """ + if self.confidence_array is None: + self.confidence_array = np.full( + (self.position_array.shape[:-1]), + np.nan, + dtype="float32", + ) + log_warning( + "Confidence array was not provided." + "Setting to an array of NaNs." + ) + + if self.individual_names is None: + self.individual_names = [ + f"id_{i+1}" for i in range(self.position_array.shape[1]) + ] + log_warning( + "Individual names for the bounding boxes " + "were not provided. " + "Setting to 1-based IDs that are unique per frame: \n" + f"{self.individual_names}.\n" + ) diff --git a/tests/test_unit/test_validators.py b/tests/test_unit/test_validators.py index 60002e7c..6da2328a 100644 --- a/tests/test_unit/test_validators.py +++ b/tests/test_unit/test_validators.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from movement.validators.datasets import ValidPosesDataset +from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 @@ -59,6 +59,25 @@ class TestValidators: }, # invalid input ] + invalid_bboxes_arrays_and_expected_log = { + key: [ + ( + None, + f"Expected a numpy array, but got {type(None)}.", + ), # invalid, argument is non-optional + ( + [1, 2, 3], + f"Expected a numpy array, but got {type(list())}.", + ), # not an ndarray + ( + np.zeros((10, 2, 3)), + f"Expected '{key}' to have 2 spatial " + "coordinates, but got 3.", + ), # last dim not 2 + ] + for key in ["position_array", "shape_array"] + } + @pytest.fixture(params=position_arrays) def position_array_params(self, request): """Return a dictionary containing parameters for testing @@ -66,6 +85,21 @@ def position_array_params(self, request): """ return request.param + @pytest.fixture + def valid_bboxes_inputs(self): + """Return a dictionary with valid inputs for a ValidBboxesDataset.""" + n_frames, n_individuals, n_space = (10, 2, 2) + # valid array for position or shape + valid_bbox_array = np.zeros((n_frames, n_individuals, n_space)) + + return { + "position_array": valid_bbox_array, + "shape_array": valid_bbox_array, + "individual_names": [ + "id_" + str(id) for id in range(valid_bbox_array.shape[1]) + ], + } + @pytest.mark.parametrize( "invalid_input, expected_exception", [ @@ -227,3 +261,134 @@ def test_poses_dataset_validator_source_software( assert ds.source_software == source_software else: assert ds.source_software is None + + @pytest.mark.parametrize( + "invalid_position_array, log_message", + invalid_bboxes_arrays_and_expected_log["position_array"], + ) + def test_bboxes_dataset_validator_with_invalid_position_array( + self, invalid_position_array, log_message, request + ): + """Test that invalid centroid position arrays raise an error.""" + with pytest.raises(ValueError) as excinfo: + ValidBboxesDataset( + position_array=invalid_position_array, + shape_array=request.getfixturevalue("valid_bboxes_inputs")[ + "shape_array" + ], + individual_names=request.getfixturevalue( + "valid_bboxes_inputs" + )["individual_names"], + ) + assert str(excinfo.value) == log_message + + @pytest.mark.parametrize( + "invalid_shape_array, log_message", + invalid_bboxes_arrays_and_expected_log["shape_array"], + ) + def test_bboxes_dataset_validator_with_invalid_shape_array( + self, invalid_shape_array, log_message, request + ): + """Test that invalid shape arrays raise an error.""" + with pytest.raises(ValueError) as excinfo: + ValidBboxesDataset( + position_array=request.getfixturevalue("valid_bboxes_inputs")[ + "position_array" + ], + shape_array=invalid_shape_array, + individual_names=request.getfixturevalue( + "valid_bboxes_inputs" + )["individual_names"], + ) + assert str(excinfo.value) == log_message + + @pytest.mark.parametrize( + "list_individual_names, expected_exception, log_message", + [ + ( + None, + does_not_raise(), + "", + ), # valid, should default to unique IDs per frame + ( + [1, 2, 3], + pytest.raises(ValueError), + "Expected 'individual_names' to have length 2, but got 3.", + ), # length doesn't match position_array.shape[1] + ( + ["id_1", "id_1"], + pytest.raises(ValueError), + "individual_names passed to the dataset are not unique. " + "There are 2 elements in the list, but " + "only 1 are unique.", + ), # some IDs are not unique + ], + ) + def test_bboxes_dataset_validator_individual_names( + self, list_individual_names, expected_exception, log_message, request + ): + """Test individual_names inputs.""" + with expected_exception as excinfo: + ds = ValidBboxesDataset( + position_array=request.getfixturevalue("valid_bboxes_inputs")[ + "position_array" + ], + shape_array=request.getfixturevalue("valid_bboxes_inputs")[ + "shape_array" + ], + individual_names=list_individual_names, + ) + if list_individual_names is None: + # check IDs are unique per frame + assert len(ds.individual_names) == len(set(ds.individual_names)) + assert ds.position_array.shape[1] == len(ds.individual_names) + else: + assert str(excinfo.value) == log_message + + @pytest.mark.parametrize( + "confidence_array, expected_exception, log_message", + [ + ( + np.ones((10, 3, 2)), + pytest.raises(ValueError), + "Expected 'confidence_array' to have shape (10, 2), " + "but got (10, 3, 2).", + ), # will not match position_array shape + ( + [1, 2, 3], + pytest.raises(ValueError), + f"Expected a numpy array, but got {type(list())}.", + ), # not an ndarray, should raise ValueError + ( + None, + does_not_raise(), + "", + ), # valid, should default to array of NaNs + ], + ) + def test_bboxes_dataset_validator_confidence_array( + self, confidence_array, expected_exception, log_message, request + ): + """Test that invalid confidence arrays raise the appropriate errors.""" + with expected_exception as excinfo: + poses = ValidBboxesDataset( + position_array=request.getfixturevalue("valid_bboxes_inputs")[ + "position_array" + ], + shape_array=request.getfixturevalue("valid_bboxes_inputs")[ + "shape_array" + ], + individual_names=request.getfixturevalue( + "valid_bboxes_inputs" + )["individual_names"], + confidence_array=confidence_array, + ) + if confidence_array is None: + assert np.all( + np.isnan(poses.confidence_array) + ) # assert it is a NaN array + assert ( + poses.confidence_array.shape == poses.position_array.shape[:-1] + ) # assert shape matches position array + else: + assert str(excinfo.value) == log_message From bfe1640822b13a41976827ad4d764df5d32c8a8e Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Fri, 14 Jun 2024 10:55:31 +0100 Subject: [PATCH 12/65] Refactor validators tests (#207) * Split validators into modules * Split validators tests. Factor out long fixture. * Check log messages in tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add bboxes tests and put dataset fixtures in test file * Fix rebase side effect * Fix ky -> key --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/test_unit/test_validators.py | 394 ------------------ tests/test_unit/test_validators/__init__.py | 0 .../test_datasets_validators.py | 356 ++++++++++++++++ .../test_validators/test_files_validators.py | 62 +++ 4 files changed, 418 insertions(+), 394 deletions(-) delete mode 100644 tests/test_unit/test_validators.py create mode 100644 tests/test_unit/test_validators/__init__.py create mode 100644 tests/test_unit/test_validators/test_datasets_validators.py create mode 100644 tests/test_unit/test_validators/test_files_validators.py diff --git a/tests/test_unit/test_validators.py b/tests/test_unit/test_validators.py deleted file mode 100644 index 6da2328a..00000000 --- a/tests/test_unit/test_validators.py +++ /dev/null @@ -1,394 +0,0 @@ -from contextlib import nullcontext as does_not_raise - -import numpy as np -import pytest - -from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset -from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 - - -class TestValidators: - """Test suite for the validators module.""" - - position_arrays = [ - { - "names": None, - "array_type": "multi_individual_array", - "individual_names_expected_exception": does_not_raise( - ["individual_0", "individual_1"] - ), - "keypoint_names_expected_exception": does_not_raise( - ["keypoint_0", "keypoint_1"] - ), - }, # valid input, will generate default names - { - "names": ["a", "b"], - "array_type": "multi_individual_array", - "individual_names_expected_exception": does_not_raise(["a", "b"]), - "keypoint_names_expected_exception": does_not_raise(["a", "b"]), - }, # valid input - { - "names": ("a", "b"), - "array_type": "multi_individual_array", - "individual_names_expected_exception": does_not_raise(["a", "b"]), - "keypoint_names_expected_exception": does_not_raise(["a", "b"]), - }, # valid input, will be converted to ["a", "b"] - { - "names": [1, 2], - "array_type": "multi_individual_array", - "individual_names_expected_exception": does_not_raise(["1", "2"]), - "keypoint_names_expected_exception": does_not_raise(["1", "2"]), - }, # valid input, will be converted to ["1", "2"] - { - "names": "a", - "array_type": "single_individual_array", - "individual_names_expected_exception": does_not_raise(["a"]), - "keypoint_names_expected_exception": pytest.raises(ValueError), - }, # single individual array with multiple keypoints - { - "names": "a", - "array_type": "single_keypoint_array", - "individual_names_expected_exception": pytest.raises(ValueError), - "keypoint_names_expected_exception": does_not_raise(["a"]), - }, # single keypoint array with multiple individuals - { - "names": 5, - "array_type": "multi_individual_array", - "individual_names_expected_exception": pytest.raises(ValueError), - "keypoint_names_expected_exception": pytest.raises(ValueError), - }, # invalid input - ] - - invalid_bboxes_arrays_and_expected_log = { - key: [ - ( - None, - f"Expected a numpy array, but got {type(None)}.", - ), # invalid, argument is non-optional - ( - [1, 2, 3], - f"Expected a numpy array, but got {type(list())}.", - ), # not an ndarray - ( - np.zeros((10, 2, 3)), - f"Expected '{key}' to have 2 spatial " - "coordinates, but got 3.", - ), # last dim not 2 - ] - for key in ["position_array", "shape_array"] - } - - @pytest.fixture(params=position_arrays) - def position_array_params(self, request): - """Return a dictionary containing parameters for testing - position array keypoint and individual names. - """ - return request.param - - @pytest.fixture - def valid_bboxes_inputs(self): - """Return a dictionary with valid inputs for a ValidBboxesDataset.""" - n_frames, n_individuals, n_space = (10, 2, 2) - # valid array for position or shape - valid_bbox_array = np.zeros((n_frames, n_individuals, n_space)) - - return { - "position_array": valid_bbox_array, - "shape_array": valid_bbox_array, - "individual_names": [ - "id_" + str(id) for id in range(valid_bbox_array.shape[1]) - ], - } - - @pytest.mark.parametrize( - "invalid_input, expected_exception", - [ - ("unreadable_file", pytest.raises(PermissionError)), - ("unwriteable_file", pytest.raises(PermissionError)), - ("fake_h5_file", pytest.raises(FileExistsError)), - ("wrong_ext_file", pytest.raises(ValueError)), - ("nonexistent_file", pytest.raises(FileNotFoundError)), - ("directory", pytest.raises(IsADirectoryError)), - ], - ) - def test_file_validator_with_invalid_input( - self, invalid_input, expected_exception, request - ): - """Test that invalid files raise the appropriate errors.""" - invalid_dict = request.getfixturevalue(invalid_input) - with expected_exception: - ValidFile( - invalid_dict.get("file_path"), - expected_permission=invalid_dict.get("expected_permission"), - expected_suffix=invalid_dict.get("expected_suffix", []), - ) - - @pytest.mark.parametrize( - "invalid_input, expected_exception", - [ - ("h5_file_no_dataframe", pytest.raises(ValueError)), - ("fake_h5_file", pytest.raises(ValueError)), - ], - ) - def test_hdf5_validator_with_invalid_input( - self, invalid_input, expected_exception, request - ): - """Test that invalid HDF5 files raise the appropriate errors.""" - invalid_dict = request.getfixturevalue(invalid_input) - with expected_exception: - ValidHDF5( - invalid_dict.get("file_path"), - expected_datasets=invalid_dict.get("expected_datasets"), - ) - - @pytest.mark.parametrize( - "invalid_input, expected_exception", - [ - ("invalid_single_individual_csv_file", pytest.raises(ValueError)), - ("invalid_multi_individual_csv_file", pytest.raises(ValueError)), - ], - ) - def test_poses_csv_validator_with_invalid_input( - self, invalid_input, expected_exception, request - ): - """Test that invalid CSV files raise the appropriate errors.""" - file_path = request.getfixturevalue(invalid_input) - with expected_exception: - ValidDeepLabCutCSV(file_path) - - @pytest.mark.parametrize( - "invalid_position_array", - [ - None, # invalid, argument is non-optional - [1, 2, 3], # not an ndarray - np.zeros((10, 2, 3)), # not 4d - np.zeros((10, 2, 3, 4)), # last dim not 2 or 3 - ], - ) - def test_poses_dataset_validator_with_invalid_position_array( - self, invalid_position_array - ): - """Test that invalid position arrays raise the appropriate errors.""" - with pytest.raises(ValueError): - ValidPosesDataset(position_array=invalid_position_array) - - @pytest.mark.parametrize( - "confidence_array, expected_exception", - [ - ( - np.ones((10, 3, 2)), - pytest.raises(ValueError), - ), # will not match position_array shape - ( - [1, 2, 3], - pytest.raises(ValueError), - ), # not an ndarray, should raise ValueError - ( - None, - does_not_raise(), - ), # valid, should default to array of NaNs - ], - ) - def test_poses_dataset_validator_confidence_array( - self, - confidence_array, - expected_exception, - valid_position_array, - ): - """Test that invalid confidence arrays raise the appropriate errors.""" - with expected_exception: - poses = ValidPosesDataset( - position_array=valid_position_array("multi_individual_array"), - confidence_array=confidence_array, - ) - if confidence_array is None: - assert np.all(np.isnan(poses.confidence_array)) - - def test_poses_dataset_validator_keypoint_names( - self, position_array_params, valid_position_array - ): - """Test that invalid keypoint names raise the appropriate errors.""" - with position_array_params.get( - "keypoint_names_expected_exception" - ) as e: - poses = ValidPosesDataset( - position_array=valid_position_array( - position_array_params.get("array_type") - ), - keypoint_names=position_array_params.get("names"), - ) - assert poses.keypoint_names == e - - def test_poses_dataset_validator_individual_names( - self, position_array_params, valid_position_array - ): - """Test that invalid keypoint names raise the appropriate errors.""" - with position_array_params.get( - "individual_names_expected_exception" - ) as e: - poses = ValidPosesDataset( - position_array=valid_position_array( - position_array_params.get("array_type") - ), - individual_names=position_array_params.get("names"), - ) - assert poses.individual_names == e - - @pytest.mark.parametrize( - "source_software, expected_exception", - [ - (None, does_not_raise()), - ("SLEAP", does_not_raise()), - ("DeepLabCut", does_not_raise()), - ("LightningPose", pytest.raises(ValueError)), - ("fake_software", does_not_raise()), - (5, pytest.raises(TypeError)), # not a string - ], - ) - def test_poses_dataset_validator_source_software( - self, valid_position_array, source_software, expected_exception - ): - """Test that the source_software attribute is validated properly. - LightnigPose is incompatible with multi-individual arrays. - """ - with expected_exception: - ds = ValidPosesDataset( - position_array=valid_position_array("multi_individual_array"), - source_software=source_software, - ) - - if source_software is not None: - assert ds.source_software == source_software - else: - assert ds.source_software is None - - @pytest.mark.parametrize( - "invalid_position_array, log_message", - invalid_bboxes_arrays_and_expected_log["position_array"], - ) - def test_bboxes_dataset_validator_with_invalid_position_array( - self, invalid_position_array, log_message, request - ): - """Test that invalid centroid position arrays raise an error.""" - with pytest.raises(ValueError) as excinfo: - ValidBboxesDataset( - position_array=invalid_position_array, - shape_array=request.getfixturevalue("valid_bboxes_inputs")[ - "shape_array" - ], - individual_names=request.getfixturevalue( - "valid_bboxes_inputs" - )["individual_names"], - ) - assert str(excinfo.value) == log_message - - @pytest.mark.parametrize( - "invalid_shape_array, log_message", - invalid_bboxes_arrays_and_expected_log["shape_array"], - ) - def test_bboxes_dataset_validator_with_invalid_shape_array( - self, invalid_shape_array, log_message, request - ): - """Test that invalid shape arrays raise an error.""" - with pytest.raises(ValueError) as excinfo: - ValidBboxesDataset( - position_array=request.getfixturevalue("valid_bboxes_inputs")[ - "position_array" - ], - shape_array=invalid_shape_array, - individual_names=request.getfixturevalue( - "valid_bboxes_inputs" - )["individual_names"], - ) - assert str(excinfo.value) == log_message - - @pytest.mark.parametrize( - "list_individual_names, expected_exception, log_message", - [ - ( - None, - does_not_raise(), - "", - ), # valid, should default to unique IDs per frame - ( - [1, 2, 3], - pytest.raises(ValueError), - "Expected 'individual_names' to have length 2, but got 3.", - ), # length doesn't match position_array.shape[1] - ( - ["id_1", "id_1"], - pytest.raises(ValueError), - "individual_names passed to the dataset are not unique. " - "There are 2 elements in the list, but " - "only 1 are unique.", - ), # some IDs are not unique - ], - ) - def test_bboxes_dataset_validator_individual_names( - self, list_individual_names, expected_exception, log_message, request - ): - """Test individual_names inputs.""" - with expected_exception as excinfo: - ds = ValidBboxesDataset( - position_array=request.getfixturevalue("valid_bboxes_inputs")[ - "position_array" - ], - shape_array=request.getfixturevalue("valid_bboxes_inputs")[ - "shape_array" - ], - individual_names=list_individual_names, - ) - if list_individual_names is None: - # check IDs are unique per frame - assert len(ds.individual_names) == len(set(ds.individual_names)) - assert ds.position_array.shape[1] == len(ds.individual_names) - else: - assert str(excinfo.value) == log_message - - @pytest.mark.parametrize( - "confidence_array, expected_exception, log_message", - [ - ( - np.ones((10, 3, 2)), - pytest.raises(ValueError), - "Expected 'confidence_array' to have shape (10, 2), " - "but got (10, 3, 2).", - ), # will not match position_array shape - ( - [1, 2, 3], - pytest.raises(ValueError), - f"Expected a numpy array, but got {type(list())}.", - ), # not an ndarray, should raise ValueError - ( - None, - does_not_raise(), - "", - ), # valid, should default to array of NaNs - ], - ) - def test_bboxes_dataset_validator_confidence_array( - self, confidence_array, expected_exception, log_message, request - ): - """Test that invalid confidence arrays raise the appropriate errors.""" - with expected_exception as excinfo: - poses = ValidBboxesDataset( - position_array=request.getfixturevalue("valid_bboxes_inputs")[ - "position_array" - ], - shape_array=request.getfixturevalue("valid_bboxes_inputs")[ - "shape_array" - ], - individual_names=request.getfixturevalue( - "valid_bboxes_inputs" - )["individual_names"], - confidence_array=confidence_array, - ) - if confidence_array is None: - assert np.all( - np.isnan(poses.confidence_array) - ) # assert it is a NaN array - assert ( - poses.confidence_array.shape == poses.position_array.shape[:-1] - ) # assert shape matches position array - else: - assert str(excinfo.value) == log_message diff --git a/tests/test_unit/test_validators/__init__.py b/tests/test_unit/test_validators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_unit/test_validators/test_datasets_validators.py b/tests/test_unit/test_validators/test_datasets_validators.py new file mode 100644 index 00000000..f3f3856a --- /dev/null +++ b/tests/test_unit/test_validators/test_datasets_validators.py @@ -0,0 +1,356 @@ +from contextlib import nullcontext as does_not_raise + +import numpy as np +import pytest + +from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset + +position_arrays = [ + { + "names": None, + "array_type": "multi_individual_array", + "individual_names_expected_exception": does_not_raise( + ["individual_0", "individual_1"] + ), + "keypoint_names_expected_exception": does_not_raise( + ["keypoint_0", "keypoint_1"] + ), + }, # valid input, will generate default names + { + "names": ["a", "b"], + "array_type": "multi_individual_array", + "individual_names_expected_exception": does_not_raise(["a", "b"]), + "keypoint_names_expected_exception": does_not_raise(["a", "b"]), + }, # valid input + { + "names": ("a", "b"), + "array_type": "multi_individual_array", + "individual_names_expected_exception": does_not_raise(["a", "b"]), + "keypoint_names_expected_exception": does_not_raise(["a", "b"]), + }, # valid input, will be converted to ["a", "b"] + { + "names": [1, 2], + "array_type": "multi_individual_array", + "individual_names_expected_exception": does_not_raise(["1", "2"]), + "keypoint_names_expected_exception": does_not_raise(["1", "2"]), + }, # valid input, will be converted to ["1", "2"] + { + "names": "a", + "array_type": "single_individual_array", + "individual_names_expected_exception": does_not_raise(["a"]), + "keypoint_names_expected_exception": pytest.raises(ValueError), + }, # single individual array with multiple keypoints + { + "names": "a", + "array_type": "single_keypoint_array", + "individual_names_expected_exception": pytest.raises(ValueError), + "keypoint_names_expected_exception": does_not_raise(["a"]), + }, # single keypoint array with multiple individuals + { + "names": 5, + "array_type": "multi_individual_array", + "individual_names_expected_exception": pytest.raises(ValueError), + "keypoint_names_expected_exception": pytest.raises(ValueError), + }, # invalid input +] + + +@pytest.fixture(params=position_arrays) +def position_array_params(request): + """Return a dictionary containing parameters for testing + position array keypoint and individual names. + """ + return request.param + + +# Fixtures bbox dataset +invalid_bboxes_arrays_and_expected_log = { + key: [ + ( + None, + f"Expected a numpy array, but got {type(None)}.", + ), # invalid, argument is non-optional + ( + [1, 2, 3], + f"Expected a numpy array, but got {type(list())}.", + ), # not an ndarray + ( + np.zeros((10, 2, 3)), + f"Expected '{key}' to have 2 spatial " "coordinates, but got 3.", + ), # last dim not 2 + ] + for key in ["position_array", "shape_array"] +} + + +@pytest.fixture +def valid_bboxes_inputs(): + """Return a dictionary with valid inputs for a ValidBboxesDataset.""" + n_frames, n_individuals, n_space = (10, 2, 2) + # valid array for position or shape + valid_bbox_array = np.zeros((n_frames, n_individuals, n_space)) + + return { + "position_array": valid_bbox_array, + "shape_array": valid_bbox_array, + "individual_names": [ + "id_" + str(id) for id in range(valid_bbox_array.shape[1]) + ], + } + + +# Tests pose dataset +@pytest.mark.parametrize( + "invalid_position_array, log_message", + [ + ( + None, + f"Expected a numpy array, but got {type(None)}.", + ), # invalid, argument is non-optional + ( + [1, 2, 3], + f"Expected a numpy array, but got {type(list())}.", + ), # not an ndarray + ( + np.zeros((10, 2, 3)), + "Expected 'position_array' to have 4 dimensions, but got 3.", + ), # not 4d + ( + np.zeros((10, 2, 3, 4)), + "Expected 'position_array' to have 2 or 3 " + "spatial dimensions, but got 4.", + ), # last dim not 2 or 3 + ], +) +def test_poses_dataset_validator_with_invalid_position_array( + invalid_position_array, log_message +): + """Test that invalid position arrays raise the appropriate errors.""" + with pytest.raises(ValueError) as excinfo: + ValidPosesDataset(position_array=invalid_position_array) + assert str(excinfo.value) == log_message + + +@pytest.mark.parametrize( + "confidence_array, expected_exception", + [ + ( + np.ones((10, 3, 2)), + pytest.raises(ValueError), + ), # will not match position_array shape + ( + [1, 2, 3], + pytest.raises(ValueError), + ), # not an ndarray, should raise ValueError + ( + None, + does_not_raise(), + ), # valid, should default to array of NaNs + ], +) +def test_poses_dataset_validator_confidence_array( + confidence_array, + expected_exception, + valid_position_array, +): + """Test that invalid confidence arrays raise the appropriate errors.""" + with expected_exception: + poses = ValidPosesDataset( + position_array=valid_position_array("multi_individual_array"), + confidence_array=confidence_array, + ) + if confidence_array is None: + assert np.all(np.isnan(poses.confidence_array)) + + +def test_poses_dataset_validator_keypoint_names( + position_array_params, valid_position_array +): + """Test that invalid keypoint names raise the appropriate errors.""" + with position_array_params.get("keypoint_names_expected_exception") as e: + poses = ValidPosesDataset( + position_array=valid_position_array( + position_array_params.get("array_type") + ), + keypoint_names=position_array_params.get("names"), + ) + assert poses.keypoint_names == e + + +def test_poses_dataset_validator_individual_names( + position_array_params, valid_position_array +): + """Test that invalid keypoint names raise the appropriate errors.""" + with position_array_params.get("individual_names_expected_exception") as e: + poses = ValidPosesDataset( + position_array=valid_position_array( + position_array_params.get("array_type") + ), + individual_names=position_array_params.get("names"), + ) + assert poses.individual_names == e + + +@pytest.mark.parametrize( + "source_software, expected_exception", + [ + (None, does_not_raise()), + ("SLEAP", does_not_raise()), + ("DeepLabCut", does_not_raise()), + ("LightningPose", pytest.raises(ValueError)), + ("fake_software", does_not_raise()), + (5, pytest.raises(TypeError)), # not a string + ], +) +def test_poses_dataset_validator_source_software( + valid_position_array, source_software, expected_exception +): + """Test that the source_software attribute is validated properly. + LightnigPose is incompatible with multi-individual arrays. + """ + with expected_exception: + ds = ValidPosesDataset( + position_array=valid_position_array("multi_individual_array"), + source_software=source_software, + ) + + if source_software is not None: + assert ds.source_software == source_software + else: + assert ds.source_software is None + + +# Tests bboxes dataset +@pytest.mark.parametrize( + "invalid_position_array, log_message", + invalid_bboxes_arrays_and_expected_log["position_array"], +) +def test_bboxes_dataset_validator_with_invalid_position_array( + invalid_position_array, log_message, request +): + """Test that invalid centroid position arrays raise an error.""" + with pytest.raises(ValueError) as excinfo: + ValidBboxesDataset( + position_array=invalid_position_array, + shape_array=request.getfixturevalue("valid_bboxes_inputs")[ + "shape_array" + ], + individual_names=request.getfixturevalue("valid_bboxes_inputs")[ + "individual_names" + ], + ) + assert str(excinfo.value) == log_message + + +@pytest.mark.parametrize( + "invalid_shape_array, log_message", + invalid_bboxes_arrays_and_expected_log["shape_array"], +) +def test_bboxes_dataset_validator_with_invalid_shape_array( + invalid_shape_array, log_message, request +): + """Test that invalid shape arrays raise an error.""" + with pytest.raises(ValueError) as excinfo: + ValidBboxesDataset( + position_array=request.getfixturevalue("valid_bboxes_inputs")[ + "position_array" + ], + shape_array=invalid_shape_array, + individual_names=request.getfixturevalue("valid_bboxes_inputs")[ + "individual_names" + ], + ) + assert str(excinfo.value) == log_message + + +@pytest.mark.parametrize( + "list_individual_names, expected_exception, log_message", + [ + ( + None, + does_not_raise(), + "", + ), # valid, should default to unique IDs per frame + ( + [1, 2, 3], + pytest.raises(ValueError), + "Expected 'individual_names' to have length 2, but got 3.", + ), # length doesn't match position_array.shape[1] + ( + ["id_1", "id_1"], + pytest.raises(ValueError), + "individual_names passed to the dataset are not unique. " + "There are 2 elements in the list, but " + "only 1 are unique.", + ), # some IDs are not unique + ], +) +def test_bboxes_dataset_validator_individual_names( + list_individual_names, expected_exception, log_message, request +): + """Test individual_names inputs.""" + with expected_exception as excinfo: + ds = ValidBboxesDataset( + position_array=request.getfixturevalue("valid_bboxes_inputs")[ + "position_array" + ], + shape_array=request.getfixturevalue("valid_bboxes_inputs")[ + "shape_array" + ], + individual_names=list_individual_names, + ) + if list_individual_names is None: + # check IDs are unique per frame + assert len(ds.individual_names) == len(set(ds.individual_names)) + assert ds.position_array.shape[1] == len(ds.individual_names) + else: + assert str(excinfo.value) == log_message + + +@pytest.mark.parametrize( + "confidence_array, expected_exception, log_message", + [ + ( + np.ones((10, 3, 2)), + pytest.raises(ValueError), + "Expected 'confidence_array' to have shape (10, 2), " + "but got (10, 3, 2).", + ), # will not match position_array shape + ( + [1, 2, 3], + pytest.raises(ValueError), + f"Expected a numpy array, but got {type(list())}.", + ), # not an ndarray, should raise ValueError + ( + None, + does_not_raise(), + "", + ), # valid, should default to array of NaNs + ], +) +def test_bboxes_dataset_validator_confidence_array( + confidence_array, expected_exception, log_message, request +): + """Test that invalid confidence arrays raise the appropriate errors.""" + with expected_exception as excinfo: + poses = ValidBboxesDataset( + position_array=request.getfixturevalue("valid_bboxes_inputs")[ + "position_array" + ], + shape_array=request.getfixturevalue("valid_bboxes_inputs")[ + "shape_array" + ], + individual_names=request.getfixturevalue("valid_bboxes_inputs")[ + "individual_names" + ], + confidence_array=confidence_array, + ) + if confidence_array is None: + assert np.all( + np.isnan(poses.confidence_array) + ) # assert it is a NaN array + assert ( + poses.confidence_array.shape == poses.position_array.shape[:-1] + ) # assert shape matches position array + else: + assert str(excinfo.value) == log_message diff --git a/tests/test_unit/test_validators/test_files_validators.py b/tests/test_unit/test_validators/test_files_validators.py new file mode 100644 index 00000000..9261e8a2 --- /dev/null +++ b/tests/test_unit/test_validators/test_files_validators.py @@ -0,0 +1,62 @@ +import pytest + +from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 + + +@pytest.mark.parametrize( + "invalid_input, expected_exception", + [ + ("unreadable_file", pytest.raises(PermissionError)), + ("unwriteable_file", pytest.raises(PermissionError)), + ("fake_h5_file", pytest.raises(FileExistsError)), + ("wrong_ext_file", pytest.raises(ValueError)), + ("nonexistent_file", pytest.raises(FileNotFoundError)), + ("directory", pytest.raises(IsADirectoryError)), + ], +) +def test_file_validator_with_invalid_input( + invalid_input, expected_exception, request +): + """Test that invalid files raise the appropriate errors.""" + invalid_dict = request.getfixturevalue(invalid_input) + with expected_exception: + ValidFile( + invalid_dict.get("file_path"), + expected_permission=invalid_dict.get("expected_permission"), + expected_suffix=invalid_dict.get("expected_suffix", []), + ) + + +@pytest.mark.parametrize( + "invalid_input, expected_exception", + [ + ("h5_file_no_dataframe", pytest.raises(ValueError)), + ("fake_h5_file", pytest.raises(ValueError)), + ], +) +def test_hdf5_validator_with_invalid_input( + invalid_input, expected_exception, request +): + """Test that invalid HDF5 files raise the appropriate errors.""" + invalid_dict = request.getfixturevalue(invalid_input) + with expected_exception: + ValidHDF5( + invalid_dict.get("file_path"), + expected_datasets=invalid_dict.get("expected_datasets"), + ) + + +@pytest.mark.parametrize( + "invalid_input, expected_exception", + [ + ("invalid_single_individual_csv_file", pytest.raises(ValueError)), + ("invalid_multi_individual_csv_file", pytest.raises(ValueError)), + ], +) +def test_deeplabcut_csv_validator_with_invalid_input( + invalid_input, expected_exception, request +): + """Test that invalid CSV files raise the appropriate errors.""" + file_path = request.getfixturevalue(invalid_input) + with expected_exception: + ValidDeepLabCutCSV(file_path) From ba102630070f26cc288bec288e61089b992b8183 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Fri, 14 Jun 2024 10:55:46 +0100 Subject: [PATCH 13/65] Update python version in README.md (#221) Since [PR208](https://github.com/neuroinformatics-unit/movement/pull/208), python v3.11 is the main version we test across OSes and it's the one we recommend in the installation guide. But I'd forgotten to update the python version in the quick install section of the README. This PR rectifies that. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e4ebadf5..5932c5a2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A Python toolbox for analysing body movements across space and time, to aid the First, create and activate a conda environment with the required dependencies: ``` -conda create -n movement-env -c conda-forge python=3.10 pytables +conda create -n movement-env -c conda-forge python=3.11 pytables conda activate movement-env ``` From 2e406e1f4c5f2434c19511d41fc01f632e929cc6 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Tue, 18 Jun 2024 18:03:37 +0100 Subject: [PATCH 14/65] added ruff rule to check for numpy2.0 compatibility (#227) --- pyproject.toml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ebbe35bd..648959bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,14 +106,15 @@ ignore = [ "D213", # multi-line-summary second line ] select = [ - "E", # pycodestyle errors - "F", # Pyflakes - "UP", # pyupgrade - "I", # isort - "B", # flake8 bugbear - "SIM", # flake8 simplify - "C90", # McCabe complexity - "D", # pydocstyle + "E", # pycodestyle errors + "F", # Pyflakes + "UP", # pyupgrade + "I", # isort + "B", # flake8 bugbear + "SIM", # flake8 simplify + "C90", # McCabe complexity + "D", # pydocstyle + "NPY201", # checks for syntax that was deprecated in numpy2.0 ] per-file-ignores = { "tests/*" = [ "D100", # missing docstring in public module From 7f6553b73f1cb20e8d39a46acdc1bcf2bee489be Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Fri, 28 Jun 2024 16:43:07 +0100 Subject: [PATCH 15/65] Add bboxes sample data (#231) * Add "type" to sample datasets metadata, to include bbox data * rename pytest fixture POSE_DATA_PATHS to DATA_PATHS * Add 'type' to metadata required fields * Update docs * Apply suggestions from code review Co-authored-by: Chang Huan Lo * Add platform specific tabs for computing hash * Remove pass for not implemented bit * pytest.DATA_PATHS refactor * Refactor `fetch_dataset_paths` * Update `sample_data` docstrings to accommodate bboxes data * Suggestion from code review * Remove if poses case for now, clarify TODO --------- Co-authored-by: Chang Huan Lo --- CONTRIBUTING.md | 31 +++++++++++++++-- movement/sample_data.py | 53 +++++++++++++++++------------ tests/conftest.py | 15 ++++---- tests/test_integration/test_io.py | 6 ++-- tests/test_unit/test_load_poses.py | 28 +++++++-------- tests/test_unit/test_sample_data.py | 5 +-- tests/test_unit/test_save_poses.py | 12 +++---- 7 files changed, 92 insertions(+), 58 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 384638c4..10d3bab2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -322,17 +322,44 @@ To add a new file, you will need to: 2. Ask to be added as a collaborator on the [movement data repository](gin:neuroinformatics/movement-test-data) (if not already) 3. Download the [GIN CLI](gin:G-Node/Info/wiki/GIN+CLI+Setup#quickstart) and set it up with your GIN credentials, by running `gin login` in a terminal. 4. Clone the movement data repository to your local machine, by running `gin get neuroinformatics/movement-test-data` in a terminal. -5. Add your new files to the `poses`, `videos`, and/or `frames` folders as appropriate. Follow the existing file naming conventions as closely as possible. -6. Determine the sha256 checksum hash of each new file by running `sha256sum ` in a terminal. For convenience, we've included a `get_sha256_hashes.py` script in the [movement data repository](gin:neuroinformatics/movement-test-data). If you run this from the root of the data repository, within a Python environment with `movement` installed, it will calculate the sha256 hashes for all files in the `poses`, `videos`, and `frames` folders and write them to files named `poses_hashes.txt`, `videos_hashes.txt`, and `frames_hashes.txt`, respectively. +5. Add your new files to the `poses`, `videos`, `frames`, and/or `bboxes` folders as appropriate. Follow the existing file naming conventions as closely as possible. +6. Determine the sha256 checksum hash of each new file. You can do this in a terminal by running: + ::::{tab-set} + + :::{tab-item} Ubuntu + ```bash + sha256sum + ``` + ::: + + :::{tab-item} MacOS + ```bash + shasum -a 256 + ``` + ::: + + :::{tab-item} Windows + ```bash + certutil -hashfile SHA256 + ``` + ::: + :::: + For convenience, we've included a `get_sha256_hashes.py` script in the [movement data repository](gin:neuroinformatics/movement-test-data). If you run this from the root of the data repository, within a Python environment with `movement` installed, it will calculate the sha256 hashes for all files in the `poses`, `videos`, `frames`, and `bboxes` folders and write them to files named `poses_hashes.txt`, `videos_hashes.txt`, `frames_hashes.txt`, and `bboxes_hashes.txt`, respectively. + 7. Add metadata for your new files to `metadata.yaml`, including their sha256 hashes you've calculated. See the example entry below for guidance. + 8. Commit a specific file with `gin commit -m `, or `gin commit -m .` to commit all changes. + 9. Upload the committed changes to the GIN repository by running `gin upload`. Latest changes to the repository can be pulled via `gin download`. `gin sync` will synchronise the latest changes bidirectionally. + + ### `metadata.yaml` example entry ```yaml "SLEAP_three-mice_Aeon_proofread.analysis.h5": sha256sum: "82ebd281c406a61536092863bc51d1a5c7c10316275119f7daf01c1ff33eac2a" source_software: "SLEAP" + type: "poses" # "poses" or "bboxes" depending on the type of tracked data fps: 50 species: "mouse" number_of_individuals: 3 diff --git a/movement/sample_data.py b/movement/sample_data.py index bfbf3234..900a352a 100644 --- a/movement/sample_data.py +++ b/movement/sample_data.py @@ -87,7 +87,7 @@ def _fetch_metadata( ------- dict A dictionary containing metadata for each sample dataset, with the - dataset name (pose file name) as the key. + dataset file name as the key. """ local_file_path = Path(data_dir / file_name) @@ -116,7 +116,8 @@ def _fetch_metadata( def _generate_file_registry(metadata: dict[str, dict]) -> dict[str, str]: """Generate a file registry based on the contents of the metadata. - This includes files containing poses, frames, or entire videos. + This includes files containing poses, frames, videos, or bounding boxes + data. Parameters ---------- @@ -131,7 +132,7 @@ def _generate_file_registry(metadata: dict[str, dict]) -> dict[str, str]: """ file_registry = {} for ds, val in metadata.items(): - file_registry[f"poses/{ds}"] = val["sha256sum"] + file_registry[f"{val['type']}/{ds}"] = val["sha256sum"] for key in ["video", "frame"]: file_name = val[key]["file_name"] if file_name: @@ -139,7 +140,7 @@ def _generate_file_registry(metadata: dict[str, dict]) -> dict[str, str]: return file_registry -# Create a download manager for the pose data +# Create a download manager for the sample data metadata = _fetch_metadata(METADATA_FILE, DATA_DIR) file_registry = _generate_file_registry(metadata) SAMPLE_DATA = pooch.create( @@ -151,19 +152,19 @@ def _generate_file_registry(metadata: dict[str, dict]) -> dict[str, str]: def list_datasets() -> list[str]: - """Find available sample datasets. + """List available sample datasets. Returns ------- filenames : list of str - List of filenames for available pose data. + List of filenames for available sample datasets. """ return list(metadata.keys()) def fetch_dataset_paths(filename: str) -> dict: - """Get paths to sample pose data and any associated frames or videos. + """Get paths to sample dataset and any associated frames or videos. The data are downloaded from the ``movement`` data repository to the user's local machine upon first use and are stored in a local cache directory. @@ -172,20 +173,21 @@ def fetch_dataset_paths(filename: str) -> dict: Parameters ---------- filename : str - Name of the pose file to fetch. + Name of the sample data file to fetch. Returns ------- paths : dict Dictionary mapping file types to their respective paths. The possible - file types are: "poses", "frame", "video". If "frame" or "video" are - not available, the corresponding value is None. + file types are: "poses", "frame", "video" or "bboxes". If "frame" or + "video" is not available, the corresponding value is None. Examples -------- >>> from movement.sample_data import fetch_dataset_paths >>> paths = fetch_dataset_paths("DLC_single-mouse_EPM.predictions.h5") - >>> poses_path = paths["poses"] + >>> poses_path = paths["poses"] # if the data is "pose" data + >>> bboxes_path = paths["bboxes"] # if the data is "bboxes" data >>> frame_path = paths["frame"] >>> video_path = paths["video"] @@ -194,21 +196,17 @@ def fetch_dataset_paths(filename: str) -> dict: fetch_dataset """ - available_pose_files = list_datasets() - if filename not in available_pose_files: + available_data_files = list_datasets() + if filename not in available_data_files: raise log_error( ValueError, f"File '{filename}' is not in the registry. " - f"Valid filenames are: {available_pose_files}", + f"Valid filenames are: {available_data_files}", ) frame_file_name = metadata[filename]["frame"]["file_name"] video_file_name = metadata[filename]["video"]["file_name"] - - return { - "poses": Path( - SAMPLE_DATA.fetch(f"poses/{filename}", progressbar=True) - ), + paths_dict = { "frame": None if not frame_file_name else Path( @@ -220,16 +218,23 @@ def fetch_dataset_paths(filename: str) -> dict: SAMPLE_DATA.fetch(f"videos/{video_file_name}", progressbar=True) ), } + # Add trajectory data + # Assume "poses" if not of type "bboxes" + data_type = "bboxes" if metadata[filename]["type"] == "bboxes" else "poses" + paths_dict[data_type] = Path( + SAMPLE_DATA.fetch(f"{data_type}/{filename}", progressbar=True) + ) + return paths_dict def fetch_dataset( filename: str, ) -> xarray.Dataset: - """Load a sample dataset containing pose data. + """Load a sample dataset. The data are downloaded from the ``movement`` data repository to the user's local machine upon first use and are stored in a local cache directory. - This function returns the pose data as an xarray Dataset. + This function returns the data as an xarray Dataset. If there are any associated frames or videos, these files are also downloaded and the paths are stored as dataset attributes. @@ -241,7 +246,7 @@ def fetch_dataset( Returns ------- ds : xarray.Dataset - Pose data contained in the fetched sample file. + Data contained in the fetched sample file. Examples -------- @@ -262,6 +267,10 @@ def fetch_dataset( source_software=metadata[filename]["source_software"], fps=metadata[filename]["fps"], ) + + # TODO: Add support for loading bounding boxes data. + # Implemented in PR 229: https://github.com/neuroinformatics-unit/movement/pull/229 + ds.attrs["frame_path"] = file_paths["frame"] ds.attrs["video_path"] = file_paths["video"] diff --git a/tests/conftest.py b/tests/conftest.py index d3b3f9f1..bb3e77d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,10 +20,11 @@ def pytest_configure(): """Perform initial configuration for pytest. Fetches pose data file paths as a dictionary for tests. """ - pytest.POSE_DATA_PATHS = { - file_name: fetch_dataset_paths(file_name)["poses"] - for file_name in list_datasets() - } + pytest.DATA_PATHS = {} + for file_name in list_datasets(): + paths_dict = fetch_dataset_paths(file_name) + data_path = paths_dict.get("poses") or paths_dict.get("bboxes") + pytest.DATA_PATHS[file_name] = data_path @pytest.fixture(autouse=True) @@ -194,9 +195,7 @@ def new_csv_file(tmp_path): @pytest.fixture def dlc_style_df(): """Return a valid DLC-style DataFrame.""" - return pd.read_hdf( - pytest.POSE_DATA_PATHS.get("DLC_single-wasp.predictions.h5") - ) + return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) @pytest.fixture( @@ -211,7 +210,7 @@ def dlc_style_df(): ) def sleap_file(request): """Return the file path for a SLEAP .h5 or .slp file.""" - return pytest.POSE_DATA_PATHS.get(request.param) + return pytest.DATA_PATHS.get(request.param) @pytest.fixture diff --git a/tests/test_integration/test_io.py b/tests/test_integration/test_io.py index e8820ad2..50f03933 100644 --- a/tests/test_integration/test_io.py +++ b/tests/test_integration/test_io.py @@ -2,7 +2,7 @@ import numpy as np import pytest import xarray as xr -from pytest import POSE_DATA_PATHS +from pytest import DATA_PATHS from movement.io import load_poses, save_poses @@ -62,7 +62,7 @@ def test_to_sleap_analysis_file_returns_same_h5_file_content( file) to a SLEAP-style .h5 analysis file returns the same file contents. """ - sleap_h5_file_path = POSE_DATA_PATHS.get(sleap_h5_file) + sleap_h5_file_path = DATA_PATHS.get(sleap_h5_file) ds = load_poses.from_sleap_file(sleap_h5_file_path, fps=fps) save_poses.to_sleap_analysis_file(ds, new_h5_file) @@ -93,7 +93,7 @@ def test_to_sleap_analysis_file_source_file(self, file, new_h5_file): to a SLEAP-style .h5 analysis file stores the .slp labels path only when the source file is a .slp file. """ - file_path = POSE_DATA_PATHS.get(file) + file_path = DATA_PATHS.get(file) if file.startswith("DLC"): ds = load_poses.from_dlc_file(file_path) else: diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 58bfa237..2c63500c 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -4,7 +4,7 @@ import numpy as np import pytest import xarray as xr -from pytest import POSE_DATA_PATHS +from pytest import DATA_PATHS from sleap_io.io.slp import read_labels, write_labels from sleap_io.model.labels import LabeledFrame, Labels @@ -18,9 +18,7 @@ class TestLoadPoses: @pytest.fixture def sleap_slp_file_without_tracks(self, tmp_path): """Mock and return the path to a SLEAP .slp file without tracks.""" - sleap_file = POSE_DATA_PATHS.get( - "SLEAP_single-mouse_EPM.predictions.slp" - ) + sleap_file = DATA_PATHS.get("SLEAP_single-mouse_EPM.predictions.slp") labels = read_labels(sleap_file) file_path = tmp_path / "track_is_none.slp" lfs = [] @@ -48,7 +46,7 @@ def sleap_slp_file_without_tracks(self, tmp_path): @pytest.fixture def sleap_h5_file_without_tracks(self, tmp_path): """Mock and return the path to a SLEAP .h5 file without tracks.""" - sleap_file = POSE_DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5") + sleap_file = DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5") file_path = tmp_path / "track_is_none.h5" with h5py.File(sleap_file, "r") as f1, h5py.File(file_path, "w") as f2: for key in list(f1.keys()): @@ -120,7 +118,7 @@ def test_load_from_sleap_file_without_tracks( sleap_file_without_tracks ) ds_from_tracked = load_poses.from_sleap_file( - POSE_DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5") + DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5") ) # Check if the "individuals" coordinate matches # the assigned default "individuals_0" @@ -153,8 +151,8 @@ def test_load_from_sleap_slp_file_or_h5_file_returns_same( """Test that loading pose tracks from SLEAP .slp and .h5 files return the same Dataset. """ - slp_file_path = POSE_DATA_PATHS.get(slp_file) - h5_file_path = POSE_DATA_PATHS.get(h5_file) + slp_file_path = DATA_PATHS.get(slp_file) + h5_file_path = DATA_PATHS.get(h5_file) ds_from_slp = load_poses.from_sleap_file(slp_file_path) ds_from_h5 = load_poses.from_sleap_file(h5_file_path) xr.testing.assert_allclose(ds_from_h5, ds_from_slp) @@ -171,7 +169,7 @@ def test_load_from_dlc_file(self, file_name): """Test that loading pose tracks from valid DLC files returns a proper Dataset. """ - file_path = POSE_DATA_PATHS.get(file_name) + file_path = DATA_PATHS.get(file_name) ds = load_poses.from_dlc_file(file_path) self.assert_dataset(ds, file_path, "DeepLabCut") @@ -191,8 +189,8 @@ def test_load_from_dlc_file_csv_or_h5_file_returns_same(self): """Test that loading pose tracks from DLC .csv and .h5 files return the same Dataset. """ - csv_file_path = POSE_DATA_PATHS.get("DLC_single-wasp.predictions.csv") - h5_file_path = POSE_DATA_PATHS.get("DLC_single-wasp.predictions.h5") + csv_file_path = DATA_PATHS.get("DLC_single-wasp.predictions.csv") + h5_file_path = DATA_PATHS.get("DLC_single-wasp.predictions.h5") ds_from_csv = load_poses.from_dlc_file(csv_file_path) ds_from_h5 = load_poses.from_dlc_file(h5_file_path) xr.testing.assert_allclose(ds_from_h5, ds_from_csv) @@ -210,7 +208,7 @@ def test_load_from_dlc_file_csv_or_h5_file_returns_same(self): def test_fps_and_time_coords(self, fps, expected_fps, expected_time_unit): """Test that time coordinates are set according to the provided fps.""" ds = load_poses.from_sleap_file( - POSE_DATA_PATHS.get("SLEAP_three-mice_Aeon_proofread.analysis.h5"), + DATA_PATHS.get("SLEAP_three-mice_Aeon_proofread.analysis.h5"), fps=fps, ) assert ds.time_unit == expected_time_unit @@ -234,7 +232,7 @@ def test_load_from_lp_file(self, file_name): """Test that loading pose tracks from valid LightningPose (LP) files returns a proper Dataset. """ - file_path = POSE_DATA_PATHS.get(file_name) + file_path = DATA_PATHS.get(file_name) ds = load_poses.from_lp_file(file_path) self.assert_dataset(ds, file_path, "LightningPose") @@ -243,7 +241,7 @@ def test_load_from_lp_or_dlc_file_returns_same(self): using either the `from_lp_file` or `from_dlc_file` function returns the same Dataset (except for the source_software). """ - file_path = POSE_DATA_PATHS.get("LP_mouse-face_AIND.predictions.csv") + file_path = DATA_PATHS.get("LP_mouse-face_AIND.predictions.csv") ds_drom_lp = load_poses.from_lp_file(file_path) ds_from_dlc = load_poses.from_dlc_file(file_path) xr.testing.assert_allclose(ds_from_dlc, ds_drom_lp) @@ -254,7 +252,7 @@ def test_load_multi_individual_from_lp_file_raises(self): """Test that loading a multi-individual .csv file using the `from_lp_file` function raises a ValueError. """ - file_path = POSE_DATA_PATHS.get("DLC_two-mice.predictions.csv") + file_path = DATA_PATHS.get("DLC_two-mice.predictions.csv") with pytest.raises(ValueError): load_poses.from_lp_file(file_path) diff --git a/tests/test_unit/test_sample_data.py b/tests/test_unit/test_sample_data.py index 50967d62..ce94db19 100644 --- a/tests/test_unit/test_sample_data.py +++ b/tests/test_unit/test_sample_data.py @@ -38,6 +38,7 @@ def validate_metadata(metadata: dict[str, dict]) -> None: """Assert that the metadata is in the expected format.""" metadata_fields = [ "sha256sum", + "type", "source_software", "fps", "species", @@ -59,9 +60,9 @@ def validate_metadata(metadata: dict[str, dict]) -> None: ), f"Expected metadata values to be dicts. {check_yaml_msg}" assert all( set(val.keys()) == set(metadata_fields) for val in metadata.values() - ), f"Found issues with the names of medatada fields. {check_yaml_msg}" + ), f"Found issues with the names of metadata fields. {check_yaml_msg}" - # check that metadata keys (pose file names) are unique + # check that metadata keys (file names) are unique assert len(metadata.keys()) == len(set(metadata.keys())) # check that the first 2 fields are present and are strings diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index ed3baf9c..1efd3a47 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -5,7 +5,7 @@ import pandas as pd import pytest import xarray as xr -from pytest import POSE_DATA_PATHS +from pytest import DATA_PATHS from movement.io import load_poses, save_poses @@ -66,25 +66,25 @@ def output_file_params(self, request): (np.array([1, 2, 3]), pytest.raises(ValueError)), # incorrect type ( load_poses.from_dlc_file( - POSE_DATA_PATHS.get("DLC_single-wasp.predictions.h5") + DATA_PATHS.get("DLC_single-wasp.predictions.h5") ), does_not_raise(), ), # valid dataset ( load_poses.from_dlc_file( - POSE_DATA_PATHS.get("DLC_two-mice.predictions.csv") + DATA_PATHS.get("DLC_two-mice.predictions.csv") ), does_not_raise(), ), # valid dataset ( load_poses.from_sleap_file( - POSE_DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5") + DATA_PATHS.get("SLEAP_single-mouse_EPM.analysis.h5") ), does_not_raise(), ), # valid dataset ( load_poses.from_sleap_file( - POSE_DATA_PATHS.get( + DATA_PATHS.get( "SLEAP_three-mice_Aeon_proofread.predictions.slp" ) ), @@ -92,7 +92,7 @@ def output_file_params(self, request): ), # valid dataset ( load_poses.from_lp_file( - POSE_DATA_PATHS.get("LP_mouse-face_AIND.predictions.csv") + DATA_PATHS.get("LP_mouse-face_AIND.predictions.csv") ), does_not_raise(), ), # valid dataset From 1f774ed4222e23adc95b9b28b4f04eb6f0c082f8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:54:18 +0100 Subject: [PATCH 16/65] [pre-commit.ci] pre-commit autoupdate (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.5.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.5.0) - [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.10.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.10.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da27b9cd..9cb8559c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,12 +29,12 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.7 + rev: v0.5.0 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.10.1 hooks: - id: mypy additional_dependencies: From 43f17d7e86443c3e76e2a1a0ae6b61f58c4db472 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Wed, 3 Jul 2024 10:05:48 +0100 Subject: [PATCH 17/65] Re-organise API reference (#213) * API index just lists modules not single funcs * add customised jinja templated for sphinx-autosummary * Sanitise module-level docstrings * pre-commit applied * add modules rubric on api_index page * updated contributing guide section on API reference * monospace formating of attrs in module docstrings * Work around failing tests due to PR#231 * Hide attrs validator functions * Change class section "parameters" to "attributes" * Add MovementDataset class attributes docstrings * Revert "Work around failing tests due to PR#231" This reverts commit fe2c1c42e629338a0676de59a51e785ecad6d760. * Hide class method header if empty * Remove class attributes autosummary table * Remove extra space --------- Co-authored-by: lochhh --- CONTRIBUTING.md | 29 +++-- docs/source/_templates/autosummary/class.rst | 31 +++++ .../_templates/autosummary/function.rst | 5 + docs/source/_templates/autosummary/module.rst | 31 +++++ docs/source/api_index.rst | 110 +++--------------- movement/analysis/kinematics.py | 2 +- movement/filtering.py | 2 +- movement/io/load_poses.py | 2 +- movement/io/save_poses.py | 2 +- movement/move_accessor.py | 10 +- movement/sample_data.py | 2 +- movement/validators/datasets.py | 2 +- movement/validators/files.py | 22 ++-- 13 files changed, 123 insertions(+), 127 deletions(-) create mode 100644 docs/source/_templates/autosummary/class.rst create mode 100644 docs/source/_templates/autosummary/function.rst create mode 100644 docs/source/_templates/autosummary/module.rst diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d3bab2..281c5eda 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -209,24 +209,31 @@ If it is not yet defined and you have multiple external links pointing to the sa ### Updating the API reference -If your PR introduces new public-facing functions, classes, or methods, -make sure to add them to the `docs/source/api_index.rst` page, so that they are -included in the [API reference](target-api), -e.g.: +If your PR introduces new public modules, or renames existing ones, +make sure to add them to the `docs/source/api_index.rst` page, so that they are included in the [API reference](target-api), e.g.: ```rst -My new module --------------- -.. currentmodule:: movement.new_module +API Reference +============= + +Information on specific functions, classes, and methods. + +.. rubric:: Modules + .. autosummary:: :toctree: api + :recursive: + :nosignatures: - new_function - NewClass + movement.move_accessor + movement.io.load_poses + movement.io.save_poses + movement.your_new_module ``` -For this to work, your functions/classes/methods will need to have docstrings -that follow the [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) style. +The API reference is auto-generated by the `sphinx-autodoc` and `sphinx-autosummary` plugins, based on docstrings. +So make sure that all your public functions/classes/methods have valid docstrings following the [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) style. +Our `pre-commit` hooks include some checks (`ruff` rules) that ensure the docstrings are formatted consistently. ### Updating the examples We use [sphinx-gallery](sphinx-gallery:) diff --git a/docs/source/_templates/autosummary/class.rst b/docs/source/_templates/autosummary/class.rst new file mode 100644 index 00000000..ab085cb7 --- /dev/null +++ b/docs/source/_templates/autosummary/class.rst @@ -0,0 +1,31 @@ +{{ name | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :show-inheritance: + :inherited-members: + + {% block methods %} + {% set ns = namespace(has_public_methods=false) %} + + {% if methods %} + {% for item in methods %} + {% if not item.startswith('_') %} + {% set ns.has_public_methods = true %} + {% endif %} + {%- endfor %} + {% endif %} + + {% if ns.has_public_methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + {% for item in methods %} + {% if not item.startswith('_') %} + ~{{ name }}.{{ item }} + {% endif %} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/docs/source/_templates/autosummary/function.rst b/docs/source/_templates/autosummary/function.rst new file mode 100644 index 00000000..5536fa10 --- /dev/null +++ b/docs/source/_templates/autosummary/function.rst @@ -0,0 +1,5 @@ +{{ name | escape | underline}} + +.. currentmodule:: {{ module }} + +.. auto{{ objtype }}:: {{ objname }} diff --git a/docs/source/_templates/autosummary/module.rst b/docs/source/_templates/autosummary/module.rst new file mode 100644 index 00000000..306ccd36 --- /dev/null +++ b/docs/source/_templates/autosummary/module.rst @@ -0,0 +1,31 @@ +{{ fullname | escape | underline }} + +.. rubric:: Description + +.. automodule:: {{ fullname }} + +.. currentmodule:: {{ fullname }} + +{% if classes %} +.. rubric:: Classes + +.. autosummary:: + :toctree: . + :nosignatures: + {% for class in classes %} + {{ class.split('.')[-1] }} + {% endfor %} + +{% endif %} + +{% if functions %} +.. rubric:: Functions + +.. autosummary:: + :toctree: . + :nosignatures: + {% for function in functions %} + {{ function.split('.')[-1] }} + {% endfor %} + +{% endif %} diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst index 6ce0caa8..d6d04eda 100644 --- a/docs/source/api_index.rst +++ b/docs/source/api_index.rst @@ -3,104 +3,22 @@ API Reference ============= +Information on specific functions, classes, and methods. -Load poses ----------- -.. currentmodule:: movement.io.load_poses -.. autosummary:: - :toctree: api - - from_numpy - from_file - from_sleap_file - from_dlc_file - from_lp_file - from_dlc_style_df - -Save poses ----------- - -.. currentmodule:: movement.io.save_poses -.. autosummary:: - :toctree: api - - to_dlc_file - to_lp_file - to_sleap_analysis_file - to_dlc_style_df - -Validators - Files ------------------- -.. currentmodule:: movement.validators.files -.. autosummary:: - :toctree: api - - ValidFile - ValidHDF5 - ValidDeepLabCutCSV - -Validators - Datasets ----------------------- -.. currentmodule:: movement.validators.datasets -.. autosummary:: - :toctree: api - - ValidPosesDataset - -Sample Data ------------ -.. currentmodule:: movement.sample_data -.. autosummary:: - :toctree: api - - list_datasets - fetch_dataset_paths - fetch_dataset - -Filtering ---------- -.. currentmodule:: movement.filtering -.. autosummary:: - :toctree: api - - filter_by_confidence - median_filter - savgol_filter - interpolate_over_time - report_nan_values - - -Analysis --------- -.. currentmodule:: movement.analysis.kinematics -.. autosummary:: - :toctree: api - - compute_displacement - compute_velocity - compute_acceleration - -.. currentmodule:: movement.utils.vector -.. autosummary:: - :toctree: api - - cart2pol - pol2cart - -MovementDataset ---------------- -.. currentmodule:: movement.move_accessor -.. autosummary:: - :toctree: api - - MovementDataset +.. rubric:: Modules -Logging -------- -.. currentmodule:: movement.utils.logging .. autosummary:: :toctree: api + :recursive: + :nosignatures: - configure_logging - log_error - log_warning + movement.move_accessor + movement.io.load_poses + movement.io.save_poses + movement.filtering + movement.analysis.kinematics + movement.utils.vector + movement.utils.logging + movement.sample_data + movement.validators.files + movement.validators.datasets diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 39984d36..ed826cc1 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -1,4 +1,4 @@ -"""Functions for computing kinematic variables.""" +"""Compute kinematic variables like velocity and acceleration.""" import numpy as np import xarray as xr diff --git a/movement/filtering.py b/movement/filtering.py index 38b3a9bb..5779205e 100644 --- a/movement/filtering.py +++ b/movement/filtering.py @@ -1,4 +1,4 @@ -"""Functions for filtering and interpolating pose tracks in xarray datasets.""" +"""Filter and interpolate pose tracks in ``movement`` datasets.""" import logging from datetime import datetime diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index fc14b0df..da524e4d 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -1,4 +1,4 @@ -"""Functions for loading pose tracking data from various frameworks.""" +"""Load pose tracking data from various frameworks into ``movement``.""" import logging from pathlib import Path diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py index e1bce6d4..07ba0bd0 100644 --- a/movement/io/save_poses.py +++ b/movement/io/save_poses.py @@ -1,4 +1,4 @@ -"""Functions for saving pose tracking data to various file formats.""" +"""Save pose tracking data from ``movement`` to various file formats.""" import logging from pathlib import Path diff --git a/movement/move_accessor.py b/movement/move_accessor.py index 549472c5..406550dd 100644 --- a/movement/move_accessor.py +++ b/movement/move_accessor.py @@ -1,4 +1,4 @@ -"""Accessor for extending xarray.Dataset objects.""" +"""Accessor for extending :py:class:`xarray.Dataset` objects.""" import logging from typing import ClassVar @@ -28,6 +28,12 @@ class MovementDataset: ``movement``-specific methods are accessed using the ``move`` keyword, for example ``ds.move.validate()`` (see [1]_ for more details). + Attributes + ---------- + dim_names : tuple + Names of the expected dimensions in the dataset. + var_names : tuple + Names of the expected data variables in the dataset. References ---------- @@ -35,7 +41,6 @@ class MovementDataset: """ - # Names of the expected dimensions in the dataset dim_names: ClassVar[tuple] = ( "time", "individuals", @@ -43,7 +48,6 @@ class MovementDataset: "space", ) - # Names of the expected data variables in the dataset var_names: ClassVar[tuple] = ( "position", "confidence", diff --git a/movement/sample_data.py b/movement/sample_data.py index 900a352a..bd4666f8 100644 --- a/movement/sample_data.py +++ b/movement/sample_data.py @@ -1,4 +1,4 @@ -"""Module for fetching and loading sample datasets. +"""Fetch and load sample datasets. This module provides functions for fetching and loading sample data used in tests, examples, and tutorials. The data are stored in a remote repository diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py index 73e505cd..b7cfdd8f 100644 --- a/movement/validators/datasets.py +++ b/movement/validators/datasets.py @@ -1,4 +1,4 @@ -"""`attrs` classes for validating data structures.""" +"""``attrs`` classes for validating data structures.""" from collections.abc import Iterable from typing import Any diff --git a/movement/validators/files.py b/movement/validators/files.py index cfb2c82b..5fefdc54 100644 --- a/movement/validators/files.py +++ b/movement/validators/files.py @@ -1,4 +1,4 @@ -"""`attrs` classes for validating file paths.""" +"""``attrs`` classes for validating file paths.""" import os from pathlib import Path @@ -14,7 +14,7 @@ class ValidFile: """Class for validating file paths. - Parameters + Attributes ---------- path : str or pathlib.Path Path to the file. @@ -49,7 +49,7 @@ class ValidFile: expected_suffix: list[str] = field(factory=list, kw_only=True) @path.validator - def path_is_not_dir(self, attribute, value): + def _path_is_not_dir(self, attribute, value): """Ensure that the path does not point to a directory.""" if value.is_dir(): raise log_error( @@ -58,7 +58,7 @@ def path_is_not_dir(self, attribute, value): ) @path.validator - def file_exists_when_expected(self, attribute, value): + def _file_exists_when_expected(self, attribute, value): """Ensure that the file exists (or not) as needed. This depends on the expected usage (read and/or write). @@ -75,7 +75,7 @@ def file_exists_when_expected(self, attribute, value): ) @path.validator - def file_has_access_permissions(self, attribute, value): + def _file_has_access_permissions(self, attribute, value): """Ensure that the file has the expected access permission(s). Raises a PermissionError if not. @@ -96,7 +96,7 @@ def file_has_access_permissions(self, attribute, value): ) @path.validator - def file_has_expected_suffix(self, attribute, value): + def _file_has_expected_suffix(self, attribute, value): """Ensure that the file has one of the expected suffix(es).""" if self.expected_suffix and value.suffix not in self.expected_suffix: raise log_error( @@ -110,7 +110,7 @@ def file_has_expected_suffix(self, attribute, value): class ValidHDF5: """Class for validating HDF5 files. - Parameters + Attributes ---------- path : pathlib.Path Path to the HDF5 file. @@ -130,7 +130,7 @@ class ValidHDF5: expected_datasets: list[str] = field(factory=list, kw_only=True) @path.validator - def file_is_h5(self, attribute, value): + def _file_is_h5(self, attribute, value): """Ensure that the file is indeed in HDF5 format.""" try: with h5py.File(value, "r") as f: @@ -142,7 +142,7 @@ def file_is_h5(self, attribute, value): ) from e @path.validator - def file_contains_expected_datasets(self, attribute, value): + def _file_contains_expected_datasets(self, attribute, value): """Ensure that the HDF5 file contains the expected datasets.""" if self.expected_datasets: with h5py.File(value, "r") as f: @@ -159,7 +159,7 @@ def file_contains_expected_datasets(self, attribute, value): class ValidDeepLabCutCSV: """Class for validating DeepLabCut-style .csv files. - Parameters + Attributes ---------- path : pathlib.Path Path to the .csv file. @@ -175,7 +175,7 @@ class ValidDeepLabCutCSV: path: Path = field(validator=validators.instance_of(Path)) @path.validator - def csv_file_contains_expected_levels(self, attribute, value): + def _csv_file_contains_expected_levels(self, attribute, value): """Ensure that the .csv file contains the expected index column levels. These are to be found among the top 4 rows of the file. From b98cac06afb5e3a6723c262fbd440bc21d559515 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Thu, 11 Jul 2024 14:59:29 +0100 Subject: [PATCH 18/65] Make video download optional for sample datasets (#224) * added optional video argument to sample data fetchers * renamed otpional arg to with_video * use the smaller Aeon video for testing video fetching * updated docs section on sample data * apply suggestions from review * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/getting_started/sample_data.md | 41 +++++++++++++------- movement/sample_data.py | 45 +++++++++++++++++----- tests/test_unit/test_sample_data.py | 20 ++++++---- 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/docs/source/getting_started/sample_data.md b/docs/source/getting_started/sample_data.md index ca4f3d5d..9b045a5d 100644 --- a/docs/source/getting_started/sample_data.md +++ b/docs/source/getting_started/sample_data.md @@ -14,30 +14,43 @@ file_names = sample_data.list_datasets() print(file_names) ``` -This will print a list of file names containing sample pose data. +This prints a list of file names containing sample pose data. Each file is prefixed with the name of the pose estimation software package that was used to generate it - either "DLC", "SLEAP", or "LP". -To load one of the sample datasets, you can use the +To load one of the sample datasets as a +[movement dataset](target-dataset), use the {func}`movement.sample_data.fetch_dataset()` function: ```python -ds = sample_data.fetch_dataset("DLC_two-mice.predictions.csv") +filename = "SLEAP_three-mice_Aeon_proofread.analysis.h5" +ds = sample_data.fetch_dataset(filename) ``` -This function loads the sample pose data as a -[movement dataset](target-dataset). Some sample datasets may also have an -associated video file (the video based on which the poses were predicted) -or a single frame extracted from that video. These files are not directly -loaded into the `movement` dataset, but their paths can be accessed as dataset attributes: +Some sample datasets also have an associated video file +(the video from which the poses were predicted), +which you can request by setting `with_video=True`: ```python -ds.frame_path -ds.video_path +ds = sample_data.fetch_dataset(filename, with_video=True) ``` -If the value of one of these attributes is `None`, it means that the -associated file is not available for the sample dataset. +If available, the video file is downloaded and its path is stored +in the `video_path` attribute of the dataset (i.e., `ds.video_path`). +The value of this attribute is `None` if no video file is +available for this dataset or if you did not request it +(`with_video=False`, which is the default). + +Some datasets also have an associated frame file, which is a single +still frame extracted from the video. This can be useful for visualisation +(e.g., as a background image for plotting trajectories). If available, +this file is always downloaded when fetching the dataset, +and its path is stored in the `frame_path` attribute +(i.e., `ds.frame_path`). If no frame file is available for the dataset, +this attribute's value is `None`. + +:::{note} Under the hood, the first time you call the `fetch_dataset()` function, -it will download the corresponding files to your local machine and cache them +it downloads the corresponding files to your local machine and caches them in the `~/.movement/data` directory. On subsequent calls, the data are directly -loaded from the local cache. +loaded from this local cache. +::: diff --git a/movement/sample_data.py b/movement/sample_data.py index bd4666f8..96518c02 100644 --- a/movement/sample_data.py +++ b/movement/sample_data.py @@ -163,7 +163,7 @@ def list_datasets() -> list[str]: return list(metadata.keys()) -def fetch_dataset_paths(filename: str) -> dict: +def fetch_dataset_paths(filename: str, with_video: bool = False) -> dict: """Get paths to sample dataset and any associated frames or videos. The data are downloaded from the ``movement`` data repository to the user's @@ -174,23 +174,40 @@ def fetch_dataset_paths(filename: str) -> dict: ---------- filename : str Name of the sample data file to fetch. + with_video : bool, optional + Whether to download the associated video file (if available). If set + to False, the "video" entry in the returned dictionary will be None. + Defaults to False. Returns ------- paths : dict Dictionary mapping file types to their respective paths. The possible - file types are: "poses", "frame", "video" or "bboxes". If "frame" or - "video" is not available, the corresponding value is None. + file types are: "poses" or "bboxes" (depending on tracking type), + "frame", "video". A None value for "frame" or "video" indicates that + the file is either not available or not requested + (if ``with_video=False``). Examples -------- + Fetch a sample dataset and get the paths to the file containing the + predicted poses, as well as the associated frame and video files: + >>> from movement.sample_data import fetch_dataset_paths - >>> paths = fetch_dataset_paths("DLC_single-mouse_EPM.predictions.h5") - >>> poses_path = paths["poses"] # if the data is "pose" data - >>> bboxes_path = paths["bboxes"] # if the data is "bboxes" data + >>> paths = fetch_dataset_paths( + ... "DLC_single-mouse_EPM.predictions.h5", with_video=True + ... ) + >>> poses_path = paths["poses"] >>> frame_path = paths["frame"] >>> video_path = paths["video"] + If the sample dataset contains bounding boxes instead of + poses, use ``paths["bboxes"]`` instead of ``paths["poses"]``: + + >>> paths = fetch_dataset_paths("VIA_multiple-crabs_5-frames_labels.csv") + >>> bboxes_path = paths["bboxes"] + + See Also -------- fetch_dataset @@ -213,7 +230,7 @@ def fetch_dataset_paths(filename: str) -> dict: SAMPLE_DATA.fetch(f"frames/{frame_file_name}", progressbar=True) ), "video": None - if not video_file_name + if (not video_file_name) or not (with_video) else Path( SAMPLE_DATA.fetch(f"videos/{video_file_name}", progressbar=True) ), @@ -229,6 +246,7 @@ def fetch_dataset_paths(filename: str) -> dict: def fetch_dataset( filename: str, + with_video: bool = False, ) -> xarray.Dataset: """Load a sample dataset. @@ -242,6 +260,10 @@ def fetch_dataset( ---------- filename : str Name of the file to fetch. + with_video : bool, optional + Whether to download the associated video file (if available). If set + to False, the "video" entry in the returned dictionary will be None. + Defaults to False. Returns ------- @@ -250,8 +272,13 @@ def fetch_dataset( Examples -------- + Fetch a sample dataset and get the paths to the associated frame and video + files: + >>> from movement.sample_data import fetch_dataset - >>> ds = fetch_dataset("DLC_single-mouse_EPM.predictions.h5") + >>> ds = fetch_dataset( + "DLC_single-mouse_EPM.predictions.h5", with_video=True + ) >>> frame_path = ds.video_path >>> video_path = ds.frame_path @@ -260,7 +287,7 @@ def fetch_dataset( fetch_dataset_paths """ - file_paths = fetch_dataset_paths(filename) + file_paths = fetch_dataset_paths(filename, with_video=with_video) ds = load_poses.from_file( file_paths["poses"], diff --git a/tests/test_unit/test_sample_data.py b/tests/test_unit/test_sample_data.py index ce94db19..224545bb 100644 --- a/tests/test_unit/test_sample_data.py +++ b/tests/test_unit/test_sample_data.py @@ -16,10 +16,10 @@ def valid_sample_datasets(): respective fps values, and associated frame and video file names. """ return { - "SLEAP_single-mouse_EPM.analysis.h5": { - "fps": 30, - "frame_file": "single-mouse_EPM_frame-20sec.png", - "video_file": "single-mouse_EPM_video.mp4", + "SLEAP_three-mice_Aeon_proofread.analysis.h5": { + "fps": 50, + "frame_file": "three-mice_Aeon_frame-5sec.png", + "video_file": "three-mice_Aeon_video.avi", }, "DLC_single-wasp.predictions.h5": { "fps": 40, @@ -121,18 +121,24 @@ def test_list_datasets(valid_sample_datasets): assert all(file in list_datasets() for file in valid_sample_datasets) -def test_fetch_dataset(valid_sample_datasets): +@pytest.mark.parametrize("with_video", [True, False]) +def test_fetch_dataset(valid_sample_datasets, with_video): # test with valid files for sample_name, sample in valid_sample_datasets.items(): - ds = fetch_dataset(sample_name) + ds = fetch_dataset(sample_name, with_video=with_video) assert isinstance(ds, Dataset) assert ds.attrs["fps"] == sample["fps"] if sample["frame_file"]: assert ds.attrs["frame_path"].name == sample["frame_file"] - if sample["video_file"]: + else: + assert ds.attrs["frame_path"] is None + + if sample["video_file"] and with_video: assert ds.attrs["video_path"].name == sample["video_file"] + else: + assert ds.attrs["video_path"] is None # Test with an invalid file with pytest.raises(ValueError): From b27d7a254b142fe6eb6fabab6b2343030b5a8418 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Wed, 17 Jul 2024 11:05:21 +0200 Subject: [PATCH 19/65] Refactor `filtering` module to take DataArrays as input (#209) * Draft dataarray accessor * Move dataarray accessor methods to `filtering` * Add dataarray functions, test equality * Add tests * Add integration test * Remove filters taking Dataset as input * Reorganise filtering module * Update filter and smooth examples * Replace `window_length` with `window` * Format assert string * Remove old code accidentally reintroduced during rebase * Update docstrings * Add filtering methods to the `move` accessor * Add example to docstring * Remove obsolete and unused function imports * Move util functions to reports.py and logging.py * Apply suggestions from code review Co-authored-by: Niko Sirmpilatze * Update docstrings * Add missing docstring * Add `move` accessor examples in docstrings * Remove `position` check in kinematics wrapper * Change`interpolate_over_time` to operate on num of observations * Add test for different `max_gap` values * Update `filter_and_interpolate.py` example * Fix `filtering_wrapper` bug * Update filter examples * Use dictionary `update` in `smooth` example * Move `logger` assignment to top of file * Add `update` example to "getting started" * Cover both dataarray and dataset in `test_log_to_attrs` * Test that ``log`` contains the filtering method applied * Use :py:meth: syntax for xarray.DataArray.squeeze() in examples * Update `reports.py` docstrings * Handle missing `individuals` and `keypoints` dims in NaN-reports * Return str in `report_nan_values` * Clean up examples * Convert filtering multiple data variables tip to section * Use `update()` in `filter_and_interpolate` example --------- Co-authored-by: Niko Sirmpilatze --- docs/source/api_index.rst | 1 + docs/source/conf.py | 6 +- .../getting_started/movement_dataset.md | 7 + examples/compute_kinematics.py | 12 +- examples/filter_and_interpolate.py | 151 ++++++-- examples/smooth.py | 180 +++++---- movement/filtering.py | 354 +++++++----------- movement/move_accessor.py | 163 +++++++- movement/utils/logging.py | 33 ++ movement/utils/reports.py | 95 +++++ tests/conftest.py | 48 +-- tests/test_integration/test_filtering.py | 132 +++++-- .../test_kinematics_vector_transform.py | 2 +- tests/test_unit/test_filtering.py | 229 ++++++----- tests/test_unit/test_logging.py | 28 +- tests/test_unit/test_move_accessor.py | 11 +- tests/test_unit/test_reports.py | 31 ++ 17 files changed, 941 insertions(+), 542 deletions(-) create mode 100644 movement/utils/reports.py create mode 100644 tests/test_unit/test_reports.py diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst index d6d04eda..3960acf1 100644 --- a/docs/source/api_index.rst +++ b/docs/source/api_index.rst @@ -19,6 +19,7 @@ Information on specific functions, classes, and methods. movement.analysis.kinematics movement.utils.vector movement.utils.logging + movement.utils.reports movement.sample_data movement.validators.files movement.validators.datasets diff --git a/docs/source/conf.py b/docs/source/conf.py index e33171c0..dc731d09 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -47,6 +47,7 @@ "sphinx_design", "sphinx_gallery.gen_gallery", "sphinx_sitemap", + "sphinx.ext.autosectionlabel", ] # Configure the myst parser to enable cool markdown features @@ -76,6 +77,9 @@ autosummary_generate = True autodoc_default_flags = ["members", "inherited-members"] +# Prefix section labels with the document name +autosectionlabel_prefix_document = True + # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. @@ -104,7 +108,7 @@ "binderhub_url": "https://mybinder.org", "dependencies": ["environment.yml"], }, - 'remove_config_comments': True, + "remove_config_comments": True, # do not render config params set as # sphinx_gallery_config [= value] } diff --git a/docs/source/getting_started/movement_dataset.md b/docs/source/getting_started/movement_dataset.md index ffadc150..9c85adc6 100644 --- a/docs/source/getting_started/movement_dataset.md +++ b/docs/source/getting_started/movement_dataset.md @@ -150,3 +150,10 @@ Custom **attributes** can also be added to the dataset: ds.attrs["my_custom_attribute"] = "my_custom_value" # henceforth accessible as ds.my_custom_attribute ``` + +To update existing **data variables** in-place, e.g. `position` +and `velocity`: + +```python +ds.update({"position": position, "velocity": velocity_filtered}) +``` diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py index 3ada5789..8cea0912 100644 --- a/examples/compute_kinematics.py +++ b/examples/compute_kinematics.py @@ -104,8 +104,9 @@ # %% # We can also easily plot the components of the position vector against time -# using ``xarray``'s built-in plotting methods. We use ``squeeze()`` to -# remove the dimension of length 1 from the data (the keypoints dimension). +# using ``xarray``'s built-in plotting methods. We use +# :py:meth:`xarray.DataArray.squeeze` to +# remove the dimension of length 1 from the data (the ``keypoints`` dimension). position.squeeze().plot.line(x="time", row="individuals", aspect=2, size=2.5) plt.gcf().show() @@ -130,7 +131,7 @@ # %% # Notice that we could also compute the displacement (and all the other -# kinematic variables) using the kinematics module: +# kinematic variables) using the :py:mod:`movement.analysis.kinematics` module: # %% import movement.analysis.kinematics as kin @@ -282,8 +283,9 @@ # %% # We can plot the components of the velocity vector against time -# using ``xarray``'s built-in plotting methods. We use ``squeeze()`` to -# remove the dimension of length 1 from the data (the keypoints dimension). +# using ``xarray``'s built-in plotting methods. We use +# :py:meth:`xarray.DataArray.squeeze` to +# remove the dimension of length 1 from the data (the ``keypoints`` dimension). velocity.squeeze().plot.line(x="time", row="individuals", aspect=2, size=2.5) plt.gcf().show() diff --git a/examples/filter_and_interpolate.py b/examples/filter_and_interpolate.py index d3e52ca5..dbe33044 100644 --- a/examples/filter_and_interpolate.py +++ b/examples/filter_and_interpolate.py @@ -9,7 +9,6 @@ # Imports # ------- from movement import sample_data -from movement.filtering import filter_by_confidence, interpolate_over_time # %% # Load a sample dataset @@ -19,16 +18,21 @@ print(ds) # %% -# We can see that this dataset contains the 2D pose tracks and confidence -# scores for a single wasp, generated with DeepLabCut. There are 2 keypoints: -# "head" and "stinger". +# We see that the dataset contains the 2D pose tracks and confidence scores +# for a single wasp, generated with DeepLabCut. The wasp is tracked at two +# keypoints: "head" and "stinger" in a video that was recorded at 40 fps and +# lasts for approximately 27 seconds. # %% # Visualise the pose tracks # ------------------------- +# Since the data contains only a single wasp, we use +# :py:meth:`xarray.DataArray.squeeze` to remove +# the dimension of length 1 from the data (the ``individuals`` dimension). -position = ds.position.sel(individuals="individual_0") -position.plot.line(x="time", row="keypoints", hue="space", aspect=2, size=2.5) +ds.position.squeeze().plot.line( + x="time", row="keypoints", hue="space", aspect=2, size=2.5 +) # %% # We can see that the pose tracks contain some implausible "jumps", such @@ -46,70 +50,113 @@ # estimation frameworks, and their ranges can vary. Therefore, # it's always a good idea to inspect the actual confidence values in the data. # -# Let's first look at a histogram of the confidence scores. -ds.confidence.plot.hist(bins=20) +# Let's first look at a histogram of the confidence scores. As before, we use +# :py:meth:`xarray.DataArray.squeeze` to remove the ``individuals`` dimension +# from the data. + +ds.confidence.squeeze().plot.hist(bins=20) # %% # Based on the above histogram, we can confirm that the confidence scores # indeed range between 0 and 1, with most values closer to 1. Now let's see how # they evolve over time. -confidence = ds.confidence.sel(individuals="individual_0") -confidence.plot.line(x="time", row="keypoints", aspect=2, size=2.5) +ds.confidence.squeeze().plot.line( + x="time", row="keypoints", aspect=2, size=2.5 +) # %% # Encouragingly, some of the drops in confidence scores do seem to correspond # to the implausible jumps and spikes we had seen in the position. # We can use that to our advantage. - # %% # Filter out points with low confidence # ------------------------------------- -# We can filter out points with confidence scores below a certain threshold. -# Here, we use ``threshold=0.6``. Points in the ``position`` data variable -# with confidence scores below this threshold will be converted to NaN. -# The ``print_report`` argument, which is True by default, reports the number -# of NaN values in the dataset before and after the filtering operation. +# Using the +# :py:meth:`filter_by_confidence()\ +# ` +# method of the ``move`` accessor, +# we can filter out points with confidence scores below a certain threshold. +# The default ``threshold=0.6`` will be used when ``threshold`` is not +# provided. +# This method will also report the number of NaN values in the dataset before +# and after the filtering operation by default (``print_report=True``). +# We will use :py:meth:`xarray.Dataset.update` to update ``ds`` in-place +# with the filtered ``position``. + +ds.update({"position": ds.move.filter_by_confidence()}) -ds_filtered = filter_by_confidence(ds, threshold=0.6, print_report=True) +# %% +# .. note:: +# The ``move`` accessor :py:meth:`filter_by_confidence()\ +# ` +# method is a convenience method that applies +# :py:func:`movement.filtering.filter_by_confidence`, +# which takes ``position`` and ``confidence`` as arguments. +# The equivalent function call using the +# :py:mod:`movement.filtering` module would be: +# +# .. code-block:: python +# +# from movement.filtering import filter_by_confidence +# +# ds.update({"position": filter_by_confidence(position, confidence)}) # %% # We can see that the filtering operation has introduced NaN values in the # ``position`` data variable. Let's visualise the filtered data. -position_filtered = ds_filtered.position.sel(individuals="individual_0") -position_filtered.plot.line( +ds.position.squeeze().plot.line( x="time", row="keypoints", hue="space", aspect=2, size=2.5 ) # %% -# Here we can see that gaps have appeared in the pose tracks, some of which -# are over the implausible jumps and spikes we had seen earlier. Moreover, -# most gaps seem to be brief, lasting < 1 second. +# Here we can see that gaps (consecutive NaNs) have appeared in the +# pose tracks, some of which are over the implausible jumps and spikes we had +# seen earlier. Moreover, most gaps seem to be brief, +# lasting < 1 second (or 40 frames). # %% # Interpolate over missing values # ------------------------------- -# We can interpolate over the gaps we've introduced in the pose tracks. -# Here we use the default linear interpolation method and ``max_gap=1``, -# meaning that we will only interpolate over gaps of 1 second or shorter. -# Setting ``max_gap=None`` would interpolate over all gaps, regardless of -# their length, which should be used with caution as it can introduce +# Using the +# :py:meth:`interpolate_over_time()\ +# ` +# method of the ``move`` accessor, +# we can interpolate over the gaps we've introduced in the pose tracks. +# Here we use the default linear interpolation method (``method=linear``) +# and interpolate over gaps of 40 frames or less (``max_gap=40``). +# The default ``max_gap=None`` would interpolate over all gaps, regardless of +# their length, but this should be used with caution as it can introduce # spurious data. The ``print_report`` argument acts as described above. -ds_interpolated = interpolate_over_time( - ds_filtered, method="linear", max_gap=1, print_report=True -) +ds.update({"position": ds.move.interpolate_over_time(max_gap=40)}) + +# %% +# .. note:: +# The ``move`` accessor :py:meth:`interpolate_over_time()\ +# ` +# is also a convenience method that applies +# :py:func:`movement.filtering.interpolate_over_time` +# to the ``position`` data variable. +# The equivalent function call using the +# :py:mod:`movement.filtering` module would be: +# +# .. code-block:: python +# +# from movement.filtering import interpolate_over_time +# +# ds.update({"position": interpolate_over_time( +# position_filtered, max_gap=40 +# )}) # %% # We see that all NaN values have disappeared, meaning that all gaps were -# indeed shorter than 1 second. Let's visualise the interpolated pose tracks +# indeed shorter than 40 frames. +# Let's visualise the interpolated pose tracks. -position_interpolated = ds_interpolated.position.sel( - individuals="individual_0" -) -position_interpolated.plot.line( +ds.position.squeeze().plot.line( x="time", row="keypoints", hue="space", aspect=2, size=2.5 ) @@ -119,9 +166,37 @@ # So, far we've processed the pose tracks first by filtering out points with # low confidence scores, and then by interpolating over missing values. # The order of these operations and the parameters with which they were -# performed are saved in the ``log`` attribute of the dataset. +# performed are saved in the ``log`` attribute of the ``position`` data array. # This is useful for keeping track of the processing steps that have been -# applied to the data. +# applied to the data. Let's inspect the log entries. -for log_entry in ds_interpolated.log: +for log_entry in ds.position.log: print(log_entry) + +# %% +# Filtering multiple data variables +# --------------------------------- +# All :py:mod:`movement.filtering` functions are available via the +# ``move`` accessor. These ``move`` accessor methods operate on the +# ``position`` data variable in the dataset ``ds`` by default. +# There is also an additional argument ``data_vars`` that allows us to +# specify which data variables in ``ds`` to filter. +# When multiple data variable names are specified in ``data_vars``, +# the method will return a dictionary with the data variable names as keys +# and the filtered DataArrays as values, otherwise it will return a single +# DataArray that is the filtered data. +# This is useful when we want to apply the same filtering operation to +# multiple data variables in ``ds`` at the same time. +# +# For instance, to filter both ``position`` and ``velocity`` data variables +# in ``ds``, based on the confidence scores, we can specify +# ``data_vars=["position", "velocity"]`` in the method call. +# As the filtered data variables are returned as a dictionary, we can once +# again use :py:meth:`xarray.Dataset.update` to update ``ds`` in-place +# with the filtered data variables. + +ds["velocity"] = ds.move.compute_velocity() +filtered_data_dict = ds.move.filter_by_confidence( + data_vars=["position", "velocity"] +) +ds.update(filtered_data_dict) diff --git a/examples/smooth.py b/examples/smooth.py index 54a2135f..f9b969b5 100644 --- a/examples/smooth.py +++ b/examples/smooth.py @@ -12,11 +12,6 @@ from scipy.signal import welch from movement import sample_data -from movement.filtering import ( - interpolate_over_time, - median_filter, - savgol_filter, -) # %% # Load a sample dataset @@ -30,9 +25,10 @@ print(ds_wasp) # %% -# We see that the dataset contains a single individual (a wasp) with two -# keypoints tracked in 2D space. The video was recorded at 40 fps and lasts for -# ~27 seconds. +# We see that the dataset contains the 2D pose tracks and confidence scores +# for a single wasp, generated with DeepLabCut. The wasp is tracked at two +# keypoints: "head" and "stinger" in a video that was recorded at 40 fps and +# lasts for approximately 27 seconds. # %% # Define a plotting function @@ -80,9 +76,10 @@ def plot_raw_and_smooth_timeseries_and_psd( label=f"{label} {space}", ) - # generate interpolated dataset to avoid NaNs in the PSD calculation - ds_interp = interpolate_over_time(ds, max_gap=None, print_report=False) - pos_interp = ds_interp.position.sel(**selection) + # interpolate data to remove NaNs in the PSD calculation + pos_interp = ds.sel(**selection).move.interpolate_over_time( + print_report=False + ) # compute and plot the PSD freq, psd = welch(pos_interp, fs=ds.fps, nperseg=256) ax[1].semilogy( @@ -111,12 +108,36 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # Smoothing with a median filter # ------------------------------ -# Here we use the :py:func:`movement.filtering.median_filter` function to -# apply a rolling window median filter to the wasp dataset. -# The ``window_length`` parameter is defined in seconds (according to the -# ``time_unit`` dataset attribute). +# Using the +# :py:meth:`median_filter()\ +# ` +# method of the ``move`` accessor, +# we apply a rolling window median filter over a 0.1-second window +# (4 frames) to the wasp dataset. +# As the ``window`` parameter is defined in *number of observations*, +# we can simply multiply the desired time window by the frame rate +# of the video. We will also create a copy of the dataset to avoid +# modifying the original data. + +window = int(0.1 * ds_wasp.fps) +ds_wasp_smooth = ds_wasp.copy() +ds_wasp_smooth.update({"position": ds_wasp_smooth.move.median_filter(window)}) -ds_wasp_medfilt = median_filter(ds_wasp, window_length=0.1) +# %% +# .. note:: +# The ``move`` accessor :py:meth:`median_filter()\ +# ` +# method is a convenience method that applies +# :py:func:`movement.filtering.median_filter` +# to the ``position`` data variable. +# The equivalent function call using the +# :py:mod:`movement.filtering` module would be: +# +# .. code-block:: python +# +# from movement.filtering import median_filter +# +# ds_wasp_smooth.update({"position": median_filter(position, window)}) # %% # We see from the printed report that the dataset has no missing values @@ -124,7 +145,7 @@ def plot_raw_and_smooth_timeseries_and_psd( # median filter in the time and frequency domains. plot_raw_and_smooth_timeseries_and_psd( - ds_wasp, ds_wasp_medfilt, keypoint="stinger" + ds_wasp, ds_wasp_smooth, keypoint="stinger" ) # %% @@ -142,8 +163,8 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # Choosing parameters for the median filter # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# You can control the behaviour of :py:func:`movement.filtering.median_filter` -# via two parameters: ``window_length`` and ``min_periods``. +# We can control the behaviour of the median filter +# via two parameters: ``window`` and ``min_periods``. # To better understand the effect of these parameters, let's use a # dataset that contains missing values. @@ -154,10 +175,15 @@ def plot_raw_and_smooth_timeseries_and_psd( # The dataset contains a single mouse with six keypoints tracked in # 2D space. The video was recorded at 30 fps and lasts for ~616 seconds. We can # see that there are some missing values, indicated as "nan" in the -# printed dataset. Let's apply the median filter to this dataset, with -# the ``window_length`` set to 0.1 seconds. - -ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1) +# printed dataset. +# Let's apply the median filter over a 0.1-second window (3 frames) +# to the dataset. + +window = int(0.1 * ds_mouse.fps) +ds_mouse_smooth = ds_mouse.copy() +ds_mouse_smooth.update( + {"position": ds_mouse_smooth.move.median_filter(window)} +) # %% # The report informs us that the raw data contains NaN values, most of which @@ -172,7 +198,9 @@ def plot_raw_and_smooth_timeseries_and_psd( # For example, setting ``min_periods=2`` means that two non-NaN values in the # window are sufficient for the median to be calculated. Let's try this. -ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1, min_periods=2) +ds_mouse_smooth.update( + {"position": ds_mouse.move.median_filter(window, min_periods=2)} +) # %% # We see that this time the number of NaN values has decreased @@ -183,32 +211,36 @@ def plot_raw_and_smooth_timeseries_and_psd( # parts of the data. plot_raw_and_smooth_timeseries_and_psd( - ds_mouse, ds_mouse_medfilt, keypoint="snout", time_range=slice(0, 80) + ds_mouse, ds_mouse_smooth, keypoint="snout", time_range=slice(0, 80) ) # %% # The smoothing once again reduces the power of high-frequency components, but # the resulting time series stays quite close to the raw data. # -# What happens if we increase the ``window_length`` to 2 seconds? +# What happens if we increase the ``window`` to 2 seconds (60 frames)? -ds_mouse_medfilt = median_filter(ds_mouse, window_length=2, min_periods=2) +window = int(2 * ds_mouse.fps) +ds_mouse_smooth.update( + {"position": ds_mouse.move.median_filter(window, min_periods=2)} +) # %% -# The number of NaN values has decreased even further. That's because the -# chance of finding at least 2 valid values within a 2 second window is -# quite high. Let's plot the results for the same keypoint and time range +# The number of NaN values has decreased even further. +# That's because the chance of finding at least 2 valid values within +# a 2-second window (i.e. 60 frames) is quite high. +# Let's plot the results for the same keypoint and time range # as before. plot_raw_and_smooth_timeseries_and_psd( - ds_mouse, ds_mouse_medfilt, keypoint="snout", time_range=slice(0, 80) + ds_mouse, ds_mouse_smooth, keypoint="snout", time_range=slice(0, 80) ) # %% # We see that the filtered time series is much smoother and it has even # "bridged" over some small gaps. That said, it often deviates from the raw # data, in ways that may not be desirable, depending on the application. -# That means that our choice of ``window_length`` may be too large. -# In general, you should choose a ``window_length`` that is small enough to +# Here, our choice of ``window`` may be too large. +# In general, you should choose a ``window`` that is small enough to # preserve the original data structure, but large enough to remove # "spikes" and high-frequency noise. Always inspect the results to ensure # that the filter is not removing important features. @@ -216,18 +248,27 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # Smoothing with a Savitzky-Golay filter # -------------------------------------- -# Here we use the :py:func:`movement.filtering.savgol_filter` function, -# which is a wrapper around :py:func:`scipy.signal.savgol_filter`. +# Here we use the +# :py:meth:`savgol_filter()\ +# ` +# method of the ``move`` accessor, which is a convenience method that applies +# :py:func:`movement.filtering.savgol_filter` +# (a wrapper around :py:func:`scipy.signal.savgol_filter`), +# to the ``position`` data variable. # The Savitzky-Golay filter is a polynomial smoothing filter that can be -# applied to time series data on a rolling window basis. A polynomial of -# degree ``polyorder`` is fitted to the data in each window of length -# ``window_length``, and the value of the polynomial at the center of the -# window is used as the output value. +# applied to time series data on a rolling window basis. +# A polynomial with a degree specified by ``polyorder`` is applied to each +# data segment defined by the size ``window``. +# The value of the polynomial at the midpoint of each ``window`` is then +# used as the output value. # -# Let's try it on the mouse dataset. - -ds_mouse_savgol = savgol_filter(ds_mouse, window_length=0.2, polyorder=2) +# Let's try it on the mouse dataset, this time using a 0.2-second +# window (i.e. 6 frames) and the default ``polyorder=2`` for smoothing. +# As before, we first compute the corresponding number of observations +# to be used as the ``window`` size. +window = int(0.2 * ds_mouse.fps) +ds_mouse_smooth.update({"position": ds_mouse.move.savgol_filter(window)}) # %% # We see that the number of NaN values has increased after filtering. This is @@ -238,22 +279,21 @@ def plot_raw_and_smooth_timeseries_and_psd( # effects in the time and frequency domains. plot_raw_and_smooth_timeseries_and_psd( - ds_mouse, ds_mouse_savgol, keypoint="snout", time_range=slice(0, 80) + ds_mouse, ds_mouse_smooth, keypoint="snout", time_range=slice(0, 80) ) # %% # Once again, the power of high-frequency components has been reduced, but more # missing values have been introduced. # %% -# Now let's take a look at the wasp dataset. +# Now let's apply the same Savitzky-Golay filter to the wasp dataset. -ds_wasp_savgol = savgol_filter(ds_wasp, window_length=0.2, polyorder=2) +window = int(0.2 * ds_wasp.fps) +ds_wasp_smooth.update({"position": ds_wasp.move.savgol_filter(window)}) # %% plot_raw_and_smooth_timeseries_and_psd( - ds_wasp, - ds_wasp_savgol, - keypoint="stinger", + ds_wasp, ds_wasp_smooth, keypoint="stinger" ) # %% # This example shows two important limitations of the Savitzky-Golay filter. @@ -261,7 +301,7 @@ def plot_raw_and_smooth_timeseries_and_psd( # example, focus on what happens around the sudden drop in position # during the final second. Second, the PSD appears to have large periodic # drops at certain frequencies. Both of these effects vary with the -# choice of ``window_length`` and ``polyorder``. You can read more about these +# choice of ``window`` and ``polyorder``. You can read more about these # and other limitations of the Savitzky-Golay filter in # `this paper `_. @@ -269,33 +309,41 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # Combining multiple smoothing filters # ------------------------------------ -# You can also combine multiple smoothing filters by applying them +# We can also combine multiple smoothing filters by applying them # sequentially. For example, we can first apply the median filter with a small -# ``window_length`` to remove "spikes" and then apply the Savitzky-Golay filter -# with a larger ``window_length`` to further smooth the data. +# ``window`` to remove "spikes" and then apply the Savitzky-Golay filter +# with a larger ``window`` to further smooth the data. # Between the two filters, we can interpolate over small gaps to avoid the # excessive proliferation of NaN values. Let's try this on the mouse dataset. -# First, let's apply the median filter. +# First, we will apply the median filter. -ds_mouse_medfilt = median_filter(ds_mouse, window_length=0.1, min_periods=2) +window = int(0.1 * ds_mouse.fps) +ds_mouse_smooth.update( + {"position": ds_mouse.move.median_filter(window, min_periods=2)} +) # %% -# Next, let's linearly interpolate over gaps smaller than 1 second. +# Next, let's linearly interpolate over gaps smaller than 1 second (30 frames). -ds_mouse_medfilt_interp = interpolate_over_time(ds_mouse_medfilt, max_gap=1) +ds_mouse_smooth.update( + {"position": ds_mouse_smooth.move.interpolate_over_time(max_gap=30)} +) # %% -# Finally, let's apply the Savitzky-Golay filter. +# Finally, let's apply the Savitzky-Golay filter over a 0.4-second window +# (12 frames). -ds_mouse_medfilt_interp_savgol = savgol_filter( - ds_mouse_medfilt_interp, window_length=0.4, polyorder=2 +window = int(0.4 * ds_mouse.fps) +ds_mouse_smooth.update( + {"position": ds_mouse_smooth.move.savgol_filter(window)} ) # %% -# A record of all applied operations is stored in the dataset's ``log`` -# attribute. Let's inspect it to summarise what we've done. +# A record of all applied operations is stored in the ``log`` attribute of the +# ``ds_mouse_smooth.position`` data array. Let's inspect it to summarise +# what we've done. -for entry in ds_mouse_medfilt_interp_savgol.log: +for entry in ds_mouse_smooth.position.log: print(entry) # %% @@ -304,7 +352,7 @@ def plot_raw_and_smooth_timeseries_and_psd( plot_raw_and_smooth_timeseries_and_psd( ds_mouse, - ds_mouse_medfilt_interp_savgol, + ds_mouse_smooth, keypoint="snout", time_range=slice(0, 80), ) @@ -312,3 +360,9 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # Feel free to play around with the parameters of the applied filters and to # also look at other keypoints and time ranges. + +# %% +# .. seealso:: +# :ref:`examples/filter_and_interpolate:Filtering multiple data variables` +# in the +# :ref:`sphx_glr_examples_filter_and_interpolate.py` example. diff --git a/movement/filtering.py b/movement/filtering.py index 5779205e..573e8635 100644 --- a/movement/filtering.py +++ b/movement/filtering.py @@ -1,136 +1,30 @@ """Filter and interpolate pose tracks in ``movement`` datasets.""" -import logging -from datetime import datetime -from functools import wraps - import xarray as xr from scipy import signal -from movement.utils.logging import log_error - - -def log_to_attrs(func): - """Log the operation performed by the wrapped function. - - This decorator appends log entries to the xarray.Dataset's "log" attribute. - For the decorator to work, the wrapped function must accept an - xarray.Dataset as its first argument and return an xarray.Dataset. - """ - - @wraps(func) - def wrapper(*args, **kwargs): - result = func(*args, **kwargs) - - log_entry = { - "operation": func.__name__, - "datetime": str(datetime.now()), - **{f"arg_{i}": arg for i, arg in enumerate(args[1:], start=1)}, - **kwargs, - } - - # Append the log entry to the result's attributes - if result is not None and hasattr(result, "attrs"): - if "log" not in result.attrs: - result.attrs["log"] = [] - result.attrs["log"].append(log_entry) - - return result - - return wrapper - - -def report_nan_values(ds: xr.Dataset, ds_label: str = "dataset"): - """Report the number and percentage of points that are NaN. - - Numbers are reported for each individual and keypoint in the dataset. - - Parameters - ---------- - ds : xarray.Dataset - Dataset containing position, confidence scores, and metadata. - ds_label : str - Label to identify the dataset in the report. Default is "dataset". - - """ - # Compile the report - nan_report = f"\nMissing points (marked as NaN) in {ds_label}:" - for ind in ds.individuals.values: - nan_report += f"\n\tIndividual: {ind}" - for kp in ds.keypoints.values: - # Get the position track for the current individual and keypoint - position = ds.position.sel(individuals=ind, keypoints=kp) - # A point is considered NaN if any of its space coordinates are NaN - n_nans = position.isnull().any(["space"]).sum(["time"]).item() - n_points = position.time.size - percent_nans = round((n_nans / n_points) * 100, 1) - nan_report += f"\n\t\t{kp}: {n_nans}/{n_points} ({percent_nans}%)" - - # Write nan report to logger - logger = logging.getLogger(__name__) - logger.info(nan_report) - # Also print the report to the console - print(nan_report) - return None - - -@log_to_attrs -def interpolate_over_time( - ds: xr.Dataset, - method: str = "linear", - max_gap: int | None = None, - print_report: bool = True, -) -> xr.Dataset | None: - """Fill in NaN values by interpolating over the time dimension. - - Parameters - ---------- - ds : xarray.Dataset - Dataset containing position, confidence scores, and metadata. - method : str - String indicating which method to use for interpolation. - Default is ``linear``. See documentation for - ``xarray.DataArray.interpolate_na`` for complete list of options. - max_gap : - The largest time gap of consecutive NaNs (in seconds) that will be - interpolated over. The default value is ``None`` (no limit). - print_report : bool - Whether to print a report on the number of NaNs in the dataset - before and after interpolation. Default is ``True``. - - Returns - ------- - ds_interpolated : xr.Dataset - The provided dataset (ds), where NaN values have been - interpolated over using the parameters provided. - - """ - ds_interpolated = ds.copy() - position_interpolated = ds.position.interpolate_na( - dim="time", method=method, max_gap=max_gap, fill_value="extrapolate" - ) - ds_interpolated.update({"position": position_interpolated}) - if print_report: - report_nan_values(ds, "input dataset") - report_nan_values(ds_interpolated, "interpolated dataset") - return ds_interpolated +from movement.utils.logging import log_error, log_to_attrs +from movement.utils.reports import report_nan_values @log_to_attrs def filter_by_confidence( - ds: xr.Dataset, + data: xr.DataArray, + confidence: xr.DataArray, threshold: float = 0.6, print_report: bool = True, -) -> xr.Dataset | None: - """Drop all points below a certain confidence threshold. +) -> xr.DataArray: + """Drop data points below a certain confidence threshold. - Position points with an associated confidence value below the threshold are + Data points with an associated confidence value below the threshold are converted to NaN. Parameters ---------- - ds : xarray.Dataset - Dataset containing position, confidence scores, and metadata. + data : xarray.DataArray + The input data to be filtered. + confidence : xarray.DataArray + The data array containing confidence scores to filter by. threshold : float The confidence threshold below which datapoints are filtered. A default value of ``0.6`` is used. See notes for more information. @@ -140,10 +34,9 @@ def filter_by_confidence( Returns ------- - ds_thresholded : xarray.Dataset - The provided dataset (ds), where points with a confidence - value below the user-defined threshold have been converted - to NaNs. + xarray.DataArray + The data where points with a confidence value below the + user-defined threshold have been converted to NaNs. Notes ----- @@ -157,57 +50,107 @@ def filter_by_confidence( in their dataset and adjust the threshold accordingly. """ - ds_thresholded = ds.copy() - ds_thresholded.update( - {"position": ds.position.where(ds.confidence >= threshold)} - ) + data_filtered = data.where(confidence >= threshold) if print_report: - report_nan_values(ds, "input dataset") - report_nan_values(ds_thresholded, "filtered dataset") + print(report_nan_values(data, "input")) + print(report_nan_values(data_filtered, "output")) + return data_filtered - return ds_thresholded + +@log_to_attrs +def interpolate_over_time( + data: xr.DataArray, + method: str = "linear", + max_gap: int | None = None, + print_report: bool = True, +) -> xr.DataArray: + """Fill in NaN values by interpolating over the ``time`` dimension. + + This method uses :py:meth:`xarray.DataArray.interpolate_na` under the + hood and passes the ``method`` and ``max_gap`` parameters to it. + See the xarray documentation for more details on these parameters. + + Parameters + ---------- + data : xarray.DataArray + The input data to be interpolated. + method : str + String indicating which method to use for interpolation. + Default is ``linear``. + max_gap : int, optional + Maximum size of gap, a continuous sequence of missing observations + (represented as NaNs), to fill. + The default value is ``None`` (no limit). + Gap size is defined as the number of consecutive NaNs. + print_report : bool + Whether to print a report on the number of NaNs in the dataset + before and after interpolation. Default is ``True``. + + Returns + ------- + xr.DataArray + The data where NaN values have been interpolated over + using the parameters provided. + + Notes + ----- + The ``max_gap`` parameter differs slightly from that in + :py:meth:`xarray.DataArray.interpolate_na`, in which the gap size + is defined as the difference between the ``time`` coordinate values + at the first data point after a gap and the last value before a gap. + + """ + data_interpolated = data.interpolate_na( + dim="time", + method=method, + use_coordinate=False, + max_gap=max_gap + 1 if max_gap is not None else None, + fill_value="extrapolate", + ) + if print_report: + print(report_nan_values(data, "input")) + print(report_nan_values(data_interpolated, "output")) + return data_interpolated @log_to_attrs def median_filter( - ds: xr.Dataset, - window_length: int, + data: xr.DataArray, + window: int, min_periods: int | None = None, print_report: bool = True, -) -> xr.Dataset: - """Smooth pose tracks by applying a median filter over time. +) -> xr.DataArray: + """Smooth data by applying a median filter over time. Parameters ---------- - ds : xarray.Dataset - Dataset containing position, confidence scores, and metadata. - window_length : int - The size of the filter window. Window length is interpreted - as being in the input dataset's time unit, which can be inspected - with ``ds.time_unit``. + data : xarray.DataArray + The input data to be smoothed. + window : int + The size of the filter window, representing the fixed number + of observations used for each window. min_periods : int - Minimum number of observations in window required to have a value - (otherwise result is NaN). The default, None, is equivalent to - setting ``min_periods`` equal to the size of the window. + Minimum number of observations in the window required to have + a value (otherwise result is NaN). The default, None, is + equivalent to setting ``min_periods`` equal to the size of the window. This argument is directly passed to the ``min_periods`` parameter of - ``xarray.DataArray.rolling``. + :py:meth:`xarray.DataArray.rolling`. print_report : bool Whether to print a report on the number of NaNs in the dataset before and after filtering. Default is ``True``. Returns ------- - ds_smoothed : xarray.Dataset - The provided dataset (ds), where pose tracks have been smoothed - using a median filter with the provided parameters. + xarray.DataArray + The data smoothed using a median filter with the provided parameters. Notes ----- By default, whenever one or more NaNs are present in the filter window, a NaN is returned to the output array. As a result, any - stretch of NaNs present in the input dataset will be propagated - proportionally to the size of the window in frames (specifically, by - ``floor(window_length/2)``). To control this behaviour, the + stretch of NaNs present in the input data will be propagated + proportionally to the size of the window (specifically, by + ``floor(window/2)``). To control this behaviour, the ``min_periods`` option can be used to specify the minimum number of non-NaN values required in the window to compute a result. For example, setting ``min_periods=1`` will result in the filter returning NaNs @@ -215,85 +158,74 @@ def median_filter( is sufficient to compute the median. """ - ds_smoothed = ds.copy() - - # Express window length (and its half) in frames - if ds.time_unit == "seconds": - window_length = int(window_length * ds.fps) - - half_window = window_length // 2 - - ds_smoothed.update( - { - "position": ds.position.pad( # Pad the edges to avoid NaNs - time=half_window, mode="reflect" - ) - .rolling( # Take rolling windows across time - time=window_length, center=True, min_periods=min_periods - ) - .median( # Compute the median of each window - skipna=True - ) - .isel( # Remove the padded edges - time=slice(half_window, -half_window) - ) - } + half_window = window // 2 + data_smoothed = ( + data.pad( # Pad the edges to avoid NaNs + time=half_window, mode="reflect" + ) + .rolling( # Take rolling windows across time + time=window, center=True, min_periods=min_periods + ) + .median( # Compute the median of each window + skipna=True + ) + .isel( # Remove the padded edges + time=slice(half_window, -half_window) + ) ) - if print_report: - report_nan_values(ds, "input dataset") - report_nan_values(ds_smoothed, "filtered dataset") - - return ds_smoothed + print(report_nan_values(data, "input")) + print(report_nan_values(data_smoothed, "output")) + return data_smoothed @log_to_attrs def savgol_filter( - ds: xr.Dataset, - window_length: int, + data: xr.DataArray, + window: int, polyorder: int = 2, print_report: bool = True, **kwargs, -) -> xr.Dataset: - """Smooth pose tracks by applying a Savitzky-Golay filter over time. +) -> xr.DataArray: + """Smooth data by applying a Savitzky-Golay filter over time. Parameters ---------- - ds : xarray.Dataset - Dataset containing position, confidence scores, and metadata. - window_length : int - The size of the filter window. Window length is interpreted - as being in the input dataset's time unit, which can be inspected - with ``ds.time_unit``. + data : xarray.DataArray + The input data to be smoothed. + window : int + The size of the filter window, representing the fixed number + of observations used for each window. polyorder : int The order of the polynomial used to fit the samples. Must be - less than ``window_length``. By default, a ``polyorder`` of + less than ``window``. By default, a ``polyorder`` of 2 is used. print_report : bool Whether to print a report on the number of NaNs in the dataset before and after filtering. Default is ``True``. **kwargs : dict - Additional keyword arguments are passed to scipy.signal.savgol_filter. + Additional keyword arguments are passed to + :py:func:`scipy.signal.savgol_filter`. Note that the ``axis`` keyword argument may not be overridden. Returns ------- - ds_smoothed : xarray.Dataset - The provided dataset (ds), where pose tracks have been smoothed - using a Savitzky-Golay filter with the provided parameters. + xarray.DataArray + The data smoothed using a Savitzky-Golay filter with the + provided parameters. Notes ----- - Uses the ``scipy.signal.savgol_filter`` function to apply a Savitzky-Golay - filter to the input dataset's ``position`` variable. - See the scipy documentation for more information on that function. + Uses the :py:func:`scipy.signal.savgol_filter` function to apply a + Savitzky-Golay filter to the input data. + See the SciPy documentation for more information on that function. Whenever one or more NaNs are present in a filter window of the - input dataset, a NaN is returned to the output array. As a result, any - stretch of NaNs present in the input dataset will be propagated - proportionally to the size of the window in frames (specifically, by - ``floor(window_length/2)``). Note that, unlike - ``movement.filtering.median_filter()``, there is no ``min_periods`` + input data, a NaN is returned to the output array. As a result, any + stretch of NaNs present in the input data will be propagated + proportionally to the size of the window (specifically, by + ``floor(window/2)``). Note that, unlike + :py:func:`movement.filtering.median_filter()`, there is no ``min_periods`` option to control this behaviour. """ @@ -301,25 +233,15 @@ def savgol_filter( raise log_error( ValueError, "The 'axis' argument may not be overridden." ) - - ds_smoothed = ds.copy() - - if ds.time_unit == "seconds": - window_length = int(window_length * ds.fps) - - position_smoothed = signal.savgol_filter( - ds.position, - window_length, + data_smoothed = data.copy() + data_smoothed.values = signal.savgol_filter( + data, + window, polyorder, axis=0, **kwargs, ) - position_smoothed_da = ds.position.copy(data=position_smoothed) - - ds_smoothed.update({"position": position_smoothed_da}) - if print_report: - report_nan_values(ds, "input dataset") - report_nan_values(ds_smoothed, "filtered dataset") - - return ds_smoothed + print(report_nan_values(data, "input")) + print(report_nan_values(data_smoothed, "output")) + return data_smoothed diff --git a/movement/move_accessor.py b/movement/move_accessor.py index 406550dd..e6252831 100644 --- a/movement/move_accessor.py +++ b/movement/move_accessor.py @@ -5,6 +5,7 @@ import xarray as xr +from movement import filtering from movement.analysis import kinematics from movement.utils.logging import log_error from movement.validators.datasets import ValidPosesDataset @@ -61,7 +62,9 @@ def __getattr__(self, name: str) -> xr.DataArray: """Forward requested but undefined attributes to relevant modules. This method currently only forwards kinematic property computation - to the respective functions in the ``kinematics`` module. + and filtering operations to the respective functions in + :py:mod:`movement.analysis.kinematics` and + :py:mod:`movement.filtering`. Parameters ---------- @@ -81,29 +84,157 @@ def __getattr__(self, name: str) -> xr.DataArray: """ def method(*args, **kwargs): - if not name.startswith("compute_") or not hasattr( - kinematics, name - ): + if hasattr(kinematics, name): + return self.kinematics_wrapper(name, *args, **kwargs) + elif hasattr(filtering, name): + return self.filtering_wrapper(name, *args, **kwargs) + else: error_msg = ( f"'{self.__class__.__name__}' object has " f"no attribute '{name}'" ) raise log_error(AttributeError, error_msg) - if not hasattr(self._obj, "position"): - raise log_error( - AttributeError, - "Missing required data variables: 'position'", - ) - try: - return getattr(kinematics, name)( - self._obj.position, *args, **kwargs - ) - except Exception as e: - error_msg = f"Failed to evoke '{name}'. " - raise log_error(AttributeError, error_msg) from e return method + def kinematics_wrapper( + self, fn_name: str, *args, **kwargs + ) -> xr.DataArray: + """Provide convenience method for computing kinematic properties. + + This method forwards kinematic property computation + to the respective functions in :py:mod:`movement.analysis.kinematics`. + + Parameters + ---------- + fn_name : str + The name of the kinematics function to call. + args : tuple + Positional arguments to pass to the function. + kwargs : dict + Keyword arguments to pass to the function. + + Returns + ------- + xarray.DataArray + The computed kinematics attribute value. + + Raises + ------ + RuntimeError + If the requested function fails to execute. + + Examples + -------- + Compute ``displacement`` based on the ``position`` data variable + in the Dataset ``ds`` and store the result in ``ds``. + + >>> ds["displacement"] = ds.move.compute_displacement() + + Compute ``velocity`` based on the ``position`` data variable in + the Dataset ``ds`` and store the result in ``ds``. + + >>> ds["velocity"] = ds.move.compute_velocity() + + Compute ``acceleration`` based on the ``position`` data variable + in the Dataset ``ds`` and store the result in ``ds``. + + >>> ds["acceleration"] = ds.move.compute_acceleration() + + """ + try: + return getattr(kinematics, fn_name)( + self._obj.position, *args, **kwargs + ) + except Exception as e: + error_msg = ( + f"Failed to evoke '{fn_name}' via 'move' accessor. {str(e)}" + ) + raise log_error(RuntimeError, error_msg) from e + + def filtering_wrapper( + self, fn_name: str, *args, data_vars: list[str] | None = None, **kwargs + ) -> xr.DataArray | dict[str, xr.DataArray]: + """Provide convenience method for filtering data variables. + + This method forwards filtering and/or smoothing to the respective + functions in :py:mod:`movement.filtering`. The data variables to + filter can be specified in ``data_vars``. If ``data_vars`` is not + specified, the ``position`` data variable is selected by default. + + Parameters + ---------- + fn_name : str + The name of the filtering function to call. + args : tuple + Positional arguments to pass to the function. + data_vars : list[str] | None + The data variables to apply filtering. If ``None``, the + ``position`` data variable will be passed by default. + kwargs : dict + Keyword arguments to pass to the function. + + Returns + ------- + xarray.DataArray | dict[str, xarray.DataArray] + The filtered data variable or a dictionary of filtered data + variables, if multiple data variables are specified. + + Raises + ------ + RuntimeError + If the requested function fails to execute. + + Examples + -------- + Filter the ``position`` data variable to drop points with + ``confidence`` below 0.7 and store the result back into the + Dataset ``ds``. + Since ``data_vars`` is not supplied, the filter will be applied to + the ``position`` data variable by default. + + >>> ds["position"] = ds.move.filter_by_confidence(threshold=0.7) + + Apply a median filter to the ``position`` data variable and + store this back into the Dataset ``ds``. + + >>> ds["position"] = ds.move.median_filter(window=3) + + Apply a Savitzky-Golay filter to both the ``position`` and + ``velocity`` data variables and store these back into the + Dataset ``ds``. ``filtered_data`` is a dictionary, where the keys + are the data variable names and the values are the filtered + DataArrays. + + >>> filtered_data = ds.move.savgol_filter( + ... window=3, data_vars=["position", "velocity"] + ... ) + >>> ds.update(filtered_data) + + """ + ds = self._obj + if data_vars is None: # Default to filter on position + data_vars = ["position"] + if fn_name == "filter_by_confidence": + # Add confidence to kwargs + kwargs["confidence"] = ds.confidence + try: + result = { + data_var: getattr(filtering, fn_name)( + ds[data_var], *args, **kwargs + ) + for data_var in data_vars + } + # Return DataArray if result only has one key + if len(result) == 1: + return result[list(result.keys())[0]] + return result + except Exception as e: + error_msg = ( + f"Failed to evoke '{fn_name}' via 'move' accessor. {str(e)}" + ) + raise log_error(RuntimeError, error_msg) from e + def validate(self) -> None: """Validate the dataset. diff --git a/movement/utils/logging.py b/movement/utils/logging.py index 9311711d..14add0a4 100644 --- a/movement/utils/logging.py +++ b/movement/utils/logging.py @@ -1,6 +1,8 @@ """Logging utilities for the movement package.""" import logging +from datetime import datetime +from functools import wraps from logging.handlers import RotatingFileHandler from pathlib import Path @@ -105,3 +107,34 @@ def log_warning(message: str, logger_name: str = "movement"): """ logger = logging.getLogger(logger_name) logger.warning(message) + + +def log_to_attrs(func): + """Log the operation performed by the wrapped function. + + This decorator appends log entries to the data's ``log`` + attribute. The wrapped function must accept an :py:class:`xarray.Dataset` + or :py:class:`xarray.DataArray` as its first argument and return an + object of the same type. + """ + + @wraps(func) + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + + log_entry = { + "operation": func.__name__, + "datetime": str(datetime.now()), + **{f"arg_{i}": arg for i, arg in enumerate(args[1:], start=1)}, + **kwargs, + } + + # Append the log entry to the result's attributes + if result is not None and hasattr(result, "attrs"): + if "log" not in result.attrs: + result.attrs["log"] = [] + result.attrs["log"].append(log_entry) + + return result + + return wrapper diff --git a/movement/utils/reports.py b/movement/utils/reports.py new file mode 100644 index 00000000..416baec1 --- /dev/null +++ b/movement/utils/reports.py @@ -0,0 +1,95 @@ +"""Utility functions for reporting missing data.""" + +import logging + +import xarray as xr + +logger = logging.getLogger(__name__) + + +def calculate_nan_stats( + data: xr.DataArray, + keypoint: str | None = None, + individual: str | None = None, +) -> str: + """Calculate NaN stats for a given keypoint and individual. + + This function calculates the number and percentage of NaN points + for a given keypoint and individual in the input data. A keypoint + is considered NaN if any of its ``space`` coordinates are NaN. + + Parameters + ---------- + data : xarray.DataArray + The input data containing ``keypoints`` and ``individuals`` + dimensions. + keypoint : str, optional + The name of the keypoint for which to generate the report. + If ``None``, it is assumed that the input data contains only + one keypoint and this keypoint is used. + Default is ``None``. + individual : str, optional + The name of the individual for which to generate the report. + If ``None``, it is assumed that the input data contains only + one individual and this individual is used. + Default is ``None``. + + Returns + ------- + str + A string containing the report. + + """ + selection_criteria = {} + if individual is not None: + selection_criteria["individuals"] = individual + if keypoint is not None: + selection_criteria["keypoints"] = keypoint + selected_data = ( + data.sel(**selection_criteria) if selection_criteria else data + ) + n_nans = selected_data.isnull().any(["space"]).sum(["time"]).item() + n_points = selected_data.time.size + percent_nans = round((n_nans / n_points) * 100, 1) + return f"\n\t\t{keypoint}: {n_nans}/{n_points} ({percent_nans}%)" + + +def report_nan_values(da: xr.DataArray, label: str | None = None) -> str: + """Report the number and percentage of keypoints that are NaN. + + Numbers are reported for each individual and keypoint in the data. + + Parameters + ---------- + da : xarray.DataArray + The input data containing ``keypoints`` and ``individuals`` + dimensions. + label : str, optional + Label to identify the data in the report. If not provided, + the name of the DataArray is used as the label. + Default is ``None``. + + Returns + ------- + str + A string containing the report. + + """ + # Compile the report + label = label or da.name + nan_report = f"\nMissing points (marked as NaN) in {label}" + # Check if the data has individuals and keypoints dimensions + has_individuals_dim = "individuals" in da.dims + has_keypoints_dim = "keypoints" in da.dims + # Default values for individuals and keypoints + individuals = da.individuals.values if has_individuals_dim else [None] + keypoints = da.keypoints.values if has_keypoints_dim else [None] + + for ind in individuals: + ind_name = ind if ind is not None else da.individuals.item() + nan_report += f"\n\tIndividual: {ind_name}" + for kp in keypoints: + nan_report += calculate_nan_stats(da, keypoint=kp, individual=ind) + # Write nan report to logger + logger.info(nan_report) + return nan_report diff --git a/tests/conftest.py b/tests/conftest.py index bb3e77d6..dcb81962 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -251,17 +251,20 @@ def valid_poses_dataset(valid_position_array, request): except AttributeError: array_format = "multi_individual_array" position_array = valid_position_array(array_format) - n_individuals, n_keypoints = position_array.shape[1:3] + n_frames, n_individuals, n_keypoints = position_array.shape[:3] return xr.Dataset( data_vars={ "position": xr.DataArray(position_array, dims=dim_names), "confidence": xr.DataArray( - np.ones(position_array.shape[:-1]), + np.repeat( + np.linspace(0.1, 1.0, n_frames), + n_individuals * n_keypoints, + ).reshape(position_array.shape[:-1]), dims=dim_names[:-1], ), }, coords={ - "time": np.arange(position_array.shape[0]), + "time": np.arange(n_frames), "individuals": [f"ind{i}" for i in range(1, n_individuals + 1)], "keypoints": [f"key{i}" for i in range(1, n_keypoints + 1)], "space": ["x", "y"], @@ -328,42 +331,17 @@ def kinematic_property(request): class Helpers: - """Generic helper methods for ``movement`` testing modules.""" + """Generic helper methods for ``movement`` test modules.""" @staticmethod - def count_nans(ds): - """Count NaNs in the x coordinate timeseries of the first keypoint - of the first individual in the dataset. - """ - n_nans = np.count_nonzero( - np.isnan( - ds.position.isel(individuals=0, keypoints=0, space=0).values - ) - ) - return n_nans + def count_nans(da): + """Count number of NaNs in a DataArray.""" + return da.isnull().sum().item() @staticmethod - def count_nan_repeats(ds): - """Count the number of continuous stretches of NaNs in the - x coordinate timeseries of the first keypoint of the first individual - in the dataset. - """ - x = ds.position.isel(individuals=0, keypoints=0, space=0).values - repeats = [] - running_count = 1 - for i in range(len(x)): - if i != len(x) - 1: - if np.isnan(x[i]) and np.isnan(x[i + 1]): - running_count += 1 - elif np.isnan(x[i]): - repeats.append(running_count) - running_count = 1 - else: - running_count = 1 - elif np.isnan(x[i]): - repeats.append(running_count) - running_count = 1 - return len(repeats) + def count_consecutive_nans(da): + """Count occurrences of consecutive NaNs in a DataArray.""" + return (da.isnull().astype(int).diff("time") == 1).sum().item() @pytest.fixture diff --git a/tests/test_integration/test_filtering.py b/tests/test_integration/test_filtering.py index 1f55f9ca..90efce6c 100644 --- a/tests/test_integration/test_filtering.py +++ b/tests/test_integration/test_filtering.py @@ -1,16 +1,13 @@ +from contextlib import nullcontext as does_not_raise + import pytest +import xarray as xr -from movement.filtering import ( - filter_by_confidence, - interpolate_over_time, - median_filter, - savgol_filter, -) from movement.io import load_poses from movement.sample_data import fetch_dataset_paths -@pytest.fixture(scope="module") +@pytest.fixture def sample_dataset(): """Return a single-animal sample dataset, with time unit in frames. This allows us to better control the expected number of NaNs in the tests. @@ -18,55 +15,112 @@ def sample_dataset(): ds_path = fetch_dataset_paths("DLC_single-mouse_EPM.predictions.h5")[ "poses" ] - return load_poses.from_dlc_file(ds_path, fps=None) + ds = load_poses.from_dlc_file(ds_path) + ds["velocity"] = ds.move.compute_velocity() + return ds -@pytest.mark.parametrize("window_length", [3, 5, 6, 13]) -def test_nan_propagation_through_filters( - sample_dataset, window_length, helpers -): - """Tests how NaNs are propagated when passing a dataset through multiple - filters sequentially. For the ``median_filter`` and ``savgol_filter``, - we expect the number of NaNs to increase at most by the filter's window - length minus one (``window_length - 1``) multiplied by the number of - continuous stretches of NaNs present in the input dataset. +@pytest.mark.parametrize("window", [3, 5, 6, 13]) +def test_nan_propagation_through_filters(sample_dataset, window, helpers): + """Test NaN propagation when passing a DataArray through + multiple filters sequentially. For the ``median_filter`` + and ``savgol_filter``, the number of NaNs is expected to increase + at most by the filter's window length minus one (``window - 1``) + multiplied by the number of consecutive NaNs in the input data. """ # Introduce nans via filter_by_confidence - ds_with_nans = filter_by_confidence(sample_dataset, threshold=0.6) - nans_after_confilt = helpers.count_nans(ds_with_nans) - nan_repeats_after_confilt = helpers.count_nan_repeats(ds_with_nans) - assert nans_after_confilt == 2555, ( - f"Unexpected number of NaNs in filtered dataset: " - f"expected: 2555, got: {nans_after_confilt}" + sample_dataset.update( + {"position": sample_dataset.move.filter_by_confidence()} + ) + expected_n_nans = 13136 + n_nans_confilt = helpers.count_nans(sample_dataset.position) + assert n_nans_confilt == expected_n_nans, ( + f"Expected {expected_n_nans} NaNs in filtered data, " + f"got: {n_nans_confilt}" + ) + n_consecutive_nans = helpers.count_consecutive_nans( + sample_dataset.position ) - # Apply median filter and check that # it doesn't introduce too many or too few NaNs - ds_medfilt = median_filter(ds_with_nans, window_length) - nans_after_medfilt = helpers.count_nans(ds_medfilt) - nan_repeats_after_medfilt = helpers.count_nan_repeats(ds_medfilt) - max_nans_increase = (window_length - 1) * nan_repeats_after_confilt + sample_dataset.update( + {"position": sample_dataset.move.median_filter(window)} + ) + n_nans_medfilt = helpers.count_nans(sample_dataset.position) + max_nans_increase = (window - 1) * n_consecutive_nans assert ( - nans_after_medfilt <= nans_after_confilt + max_nans_increase + n_nans_medfilt <= n_nans_confilt + max_nans_increase ), "Median filter introduced more NaNs than expected." assert ( - nans_after_medfilt >= nans_after_confilt + n_nans_medfilt >= n_nans_confilt ), "Median filter mysteriously removed NaNs." + n_consecutive_nans = helpers.count_consecutive_nans( + sample_dataset.position + ) # Apply savgol filter and check that # it doesn't introduce too many or too few NaNs - ds_savgol = savgol_filter( - ds_medfilt, window_length, polyorder=2, print_report=True + sample_dataset.update( + {"position": sample_dataset.move.savgol_filter(window, polyorder=2)} ) - nans_after_savgol = helpers.count_nans(ds_savgol) - max_nans_increase = (window_length - 1) * nan_repeats_after_medfilt + n_nans_savgol = helpers.count_nans(sample_dataset.position) + max_nans_increase = (window - 1) * n_consecutive_nans assert ( - nans_after_savgol <= nans_after_medfilt + max_nans_increase + n_nans_savgol <= n_nans_medfilt + max_nans_increase ), "Savgol filter introduced more NaNs than expected." assert ( - nans_after_savgol >= nans_after_medfilt + n_nans_savgol >= n_nans_medfilt ), "Savgol filter mysteriously removed NaNs." - # Apply interpolate_over_time (without max_gap) to eliminate all NaNs - ds_interpolated = interpolate_over_time(ds_savgol, print_report=True) - assert helpers.count_nans(ds_interpolated) == 0 + # Interpolate data (without max_gap) to eliminate all NaNs + sample_dataset.update( + {"position": sample_dataset.move.interpolate_over_time()} + ) + assert helpers.count_nans(sample_dataset.position) == 0 + + +@pytest.mark.parametrize( + "method", + [ + "filter_by_confidence", + "interpolate_over_time", + "median_filter", + "savgol_filter", + ], +) +@pytest.mark.parametrize( + "data_vars, expected_exception", + [ + (None, does_not_raise(xr.DataArray)), + (["position", "velocity"], does_not_raise(dict)), + (["vlocity"], pytest.raises(RuntimeError)), # Does not exist + ], +) +def test_accessor_filter_method( + sample_dataset, method, data_vars, expected_exception +): + """Test that filtering methods in the ``move`` accessor + return the expected data type and structure, and the + expected ``log`` attribute containing the filtering method + applied, if valid data variables are passed, otherwise + raise an exception. + """ + with expected_exception as expected_type: + if method in ["median_filter", "savgol_filter"]: + # supply required "window" argument + result = getattr(sample_dataset.move, method)( + data_vars=data_vars, window=3 + ) + else: + result = getattr(sample_dataset.move, method)(data_vars=data_vars) + assert isinstance(result, expected_type) + if isinstance(result, xr.DataArray): + assert hasattr(result, "log") + assert result.log[0]["operation"] == method + elif isinstance(result, dict): + assert set(result.keys()) == set(data_vars) + assert all(hasattr(value, "log") for value in result.values()) + assert all( + value.log[0]["operation"] == method + for value in result.values() + ) diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index dbd39943..78b4cf93 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -16,7 +16,7 @@ class TestKinematicsVectorTransform: [ ("valid_poses_dataset", does_not_raise()), ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_dataset", pytest.raises(AttributeError)), + ("missing_dim_dataset", pytest.raises(RuntimeError)), ], ) def test_cart_and_pol_transform( diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index a3500b27..0336f0c1 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -1,125 +1,122 @@ +from contextlib import nullcontext as does_not_raise + import pytest import xarray as xr from movement.filtering import ( filter_by_confidence, interpolate_over_time, - log_to_attrs, median_filter, savgol_filter, ) -from movement.sample_data import fetch_dataset - - -@pytest.fixture(scope="module") -def sample_dataset(): - """Return a single-animal sample dataset, with time unit in seconds.""" - return fetch_dataset("DLC_single-mouse_EPM.predictions.h5") -def test_log_to_attrs(sample_dataset): - """Test for the ``log_to_attrs()`` decorator. Decorates a mock function and - checks that ``attrs`` contains all expected values. +@pytest.mark.parametrize( + "max_gap, expected_n_nans", [(None, 0), (1, 8), (2, 0)] +) +def test_interpolate_over_time( + valid_poses_dataset_with_nan, helpers, max_gap, expected_n_nans +): + """Test that the number of NaNs decreases after interpolating + over time and that the resulting number of NaNs is as expected + for different values of ``max_gap``. """ - - @log_to_attrs - def fake_func(ds, arg, kwarg=None): - return ds - - ds = fake_func(sample_dataset, "test1", kwarg="test2") - - assert "log" in ds.attrs - assert ds.attrs["log"][0]["operation"] == "fake_func" - assert ( - ds.attrs["log"][0]["arg_1"] == "test1" - and ds.attrs["log"][0]["kwarg"] == "test2" + # First dataset with time unit in frames + data_in_frames = valid_poses_dataset_with_nan.position + # Create second dataset with time unit in seconds + data_in_seconds = data_in_frames.copy() + data_in_seconds["time"] = data_in_seconds["time"] * 0.1 + data_interp_frames = interpolate_over_time(data_in_frames, max_gap=max_gap) + data_interp_seconds = interpolate_over_time( + data_in_seconds, max_gap=max_gap ) - - -def test_interpolate_over_time(sample_dataset, helpers): - """Test the ``interpolate_over_time`` function. - - Check that the number of nans is decreased after running this function - on a filtered dataset + n_nans_before = helpers.count_nans(data_in_frames) + n_nans_after_frames = helpers.count_nans(data_interp_frames) + n_nans_after_seconds = helpers.count_nans(data_interp_seconds) + # The number of NaNs should be the same for both datasets + # as max_gap is based on number of missing observations (NaNs) + assert n_nans_after_frames == n_nans_after_seconds + assert n_nans_after_frames < n_nans_before + assert n_nans_after_frames == expected_n_nans + + +def test_filter_by_confidence(valid_poses_dataset, helpers): + """Test that points below the default 0.6 confidence threshold + are converted to NaN. """ - ds_filtered = filter_by_confidence(sample_dataset) - ds_interpolated = interpolate_over_time(ds_filtered) - - assert helpers.count_nans(ds_interpolated) < helpers.count_nans( - ds_filtered + data = valid_poses_dataset.position + confidence = valid_poses_dataset.confidence + data_filtered = filter_by_confidence(data, confidence) + n_nans = helpers.count_nans(data_filtered) + assert isinstance(data_filtered, xr.DataArray) + # 5 timepoints * 2 individuals * 2 keypoints * 2 space dimensions + # have confidence below 0.6 + assert n_nans == 40 + + +@pytest.mark.parametrize("window_size", [2, 4]) +def test_median_filter(valid_poses_dataset_with_nan, window_size): + """Test that applying the median filter returns + a different xr.DataArray than the input data. + """ + data = valid_poses_dataset_with_nan.position + data_smoothed = median_filter(data, window_size) + del data_smoothed.attrs["log"] + assert isinstance(data_smoothed, xr.DataArray) and not ( + data_smoothed.equals(data) ) -def test_filter_by_confidence(sample_dataset, caplog, helpers): - """Tests for the ``filter_by_confidence()`` function. - Checks that the function filters the expected amount of values - from a known dataset, and tests that this value is logged - correctly. +def test_median_filter_with_nans(valid_poses_dataset_with_nan, helpers): + """Test NaN behaviour of the median filter. The input data + contains NaNs in all keypoints of the first individual at timepoints + 3, 7, and 8 (0-indexed, 10 total timepoints). The median filter + should propagate NaNs within the windows of the filter, + but it should not introduce any NaNs for the second individual. """ - ds_filtered = filter_by_confidence(sample_dataset, threshold=0.6) - - assert isinstance(ds_filtered, xr.Dataset) - - n_nans = helpers.count_nans(ds_filtered) - assert n_nans == 2555 - - # Check that diagnostics are being logged correctly - assert f"snout: {n_nans}/{ds_filtered.time.values.shape[0]}" in caplog.text + data = valid_poses_dataset_with_nan.position + data_smoothed = median_filter(data, window=3) + # All points of the first individual are converted to NaNs except + # at timepoints 0, 1, and 5. + assert not ( + data_smoothed.isel(individuals=0, time=[0, 1, 5]).isnull().any() + ) + # 7 timepoints * 1 individual * 2 keypoints * 2 space dimensions + assert helpers.count_nans(data_smoothed) == 28 + # No NaNs should be introduced for the second individual + assert not data_smoothed.isel(individuals=1).isnull().any() -@pytest.mark.parametrize("window_size", [0.2, 1, 4, 12]) -def test_median_filter(sample_dataset, window_size): - """Tests for the ``median_filter()`` function. Checks that - the function successfully receives the input data and - returns a different xr.Dataset with the correct dimensions. +@pytest.mark.parametrize("window, polyorder", [(2, 1), (4, 2)]) +def test_savgol_filter(valid_poses_dataset_with_nan, window, polyorder): + """Test that applying the Savitzky-Golay filter returns + a different xr.DataArray than the input data. """ - ds_smoothed = median_filter(sample_dataset, window_size) - - # Test whether filter received and returned correct data - assert isinstance(ds_smoothed, xr.Dataset) and ~( - ds_smoothed == sample_dataset + data = valid_poses_dataset_with_nan.position + data_smoothed = savgol_filter(data, window, polyorder=polyorder) + del data_smoothed.attrs["log"] + assert isinstance(data_smoothed, xr.DataArray) and not ( + data_smoothed.equals(data) ) - assert ds_smoothed.position.shape == sample_dataset.position.shape -def test_median_filter_with_nans(valid_poses_dataset_with_nan, helpers): - """Test nan behavior of the ``median_filter()`` function. The - ``valid_poses_dataset_with_nan`` dataset (fixture defined in conftest.py) +def test_savgol_filter_with_nans(valid_poses_dataset_with_nan, helpers): + """Test NaN behaviour of the Savitzky-Golay filter. The input data contains NaN values in all keypoints of the first individual at times 3, 7, and 8 (0-indexed, 10 total timepoints). - The median filter should propagate NaNs within the windows of the filter, - but it should not introduce any NaNs for the second individual. + The Savitzky-Golay filter should propagate NaNs within the windows of + the filter, but it should not introduce any NaNs for the second individual. """ - ds_smoothed = median_filter(valid_poses_dataset_with_nan, 3) - # There should be NaNs at 7 timepoints for the first individual + data = valid_poses_dataset_with_nan.position + data_smoothed = savgol_filter(data, window=3, polyorder=2) + # There should be 28 NaNs in total for the first individual, i.e. + # at 7 timepoints, 2 keypoints, 2 space dimensions # all except for timepoints 0, 1 and 5 - assert helpers.count_nans(ds_smoothed) == 7 - assert ( - ~ds_smoothed.position.isel(individuals=0, time=[0, 1, 5]) - .isnull() - .any() - ) - # The second individual should not contain any NaNs - assert ~ds_smoothed.position.sel(individuals="ind2").isnull().any() - - -@pytest.mark.parametrize("window_length", [0.2, 1, 4, 12]) -@pytest.mark.parametrize("polyorder", [1, 2, 3]) -def test_savgol_filter(sample_dataset, window_length, polyorder): - """Tests for the ``savgol_filter()`` function. - Checks that the function successfully receives the input - data and returns a different xr.Dataset with the correct - dimensions. - """ - ds_smoothed = savgol_filter( - sample_dataset, window_length, polyorder=polyorder - ) - - # Test whether filter received and returned correct data - assert isinstance(ds_smoothed, xr.Dataset) and ~( - ds_smoothed == sample_dataset + assert helpers.count_nans(data_smoothed) == 28 + assert not ( + data_smoothed.isel(individuals=0, time=[0, 1, 5]).isnull().any() ) - assert ds_smoothed.position.shape == sample_dataset.position.shape + assert not data_smoothed.isel(individuals=1).isnull().any() @pytest.mark.parametrize( @@ -130,36 +127,20 @@ def test_savgol_filter(sample_dataset, window_length, polyorder): {"mode": "nearest", "axis": 1}, ], ) -def test_savgol_filter_kwargs_override(sample_dataset, override_kwargs): - """Further tests for the ``savgol_filter()`` function. - Checks that the function raises a ValueError when the ``axis`` keyword - argument is overridden, as this is not allowed. Overriding other keyword - arguments (e.g. ``mode``) should not raise an error. - """ - if "axis" in override_kwargs: - with pytest.raises(ValueError): - savgol_filter(sample_dataset, 5, **override_kwargs) - else: - ds_smoothed = savgol_filter(sample_dataset, 5, **override_kwargs) - assert isinstance(ds_smoothed, xr.Dataset) - - -def test_savgol_filter_with_nans(valid_poses_dataset_with_nan, helpers): - """Test nan behavior of the ``savgol_filter()`` function. The - ``valid_poses_dataset_with_nan`` dataset (fixture defined in conftest.py) - contains NaN values in all keypoints of the first individual at times - 3, 7, and 8 (0-indexed, 10 total timepoints). - The Savitzky-Golay filter should propagate NaNs within the windows of - the filter, but it should not introduce any NaNs for the second individual. +def test_savgol_filter_kwargs_override( + valid_poses_dataset_with_nan, override_kwargs +): + """Test that overriding keyword arguments in the Savitzky-Golay filter + works, except for the ``axis`` argument, which should raise a ValueError. """ - ds_smoothed = savgol_filter(valid_poses_dataset_with_nan, 3, polyorder=2) - # There should be NaNs at 7 timepoints for the first individual - # all except for timepoints 0, 1 and 5 - assert helpers.count_nans(ds_smoothed) == 7 - assert ( - ~ds_smoothed.position.isel(individuals=0, time=[0, 1, 5]) - .isnull() - .any() + expected_exception = ( + pytest.raises(ValueError) + if "axis" in override_kwargs + else does_not_raise() ) - # The second individual should not contain any NaNs - assert ~ds_smoothed.position.sel(individuals="ind2").isnull().any() + with expected_exception: + savgol_filter( + valid_poses_dataset_with_nan.position, + window=3, + **override_kwargs, + ) diff --git a/tests/test_unit/test_logging.py b/tests/test_unit/test_logging.py index 680c4c90..d0a8c3bf 100644 --- a/tests/test_unit/test_logging.py +++ b/tests/test_unit/test_logging.py @@ -2,7 +2,7 @@ import pytest -from movement.utils.logging import log_error, log_warning +from movement.utils.logging import log_error, log_to_attrs, log_warning log_messages = { "DEBUG": "This is a debug message", @@ -43,3 +43,29 @@ def test_log_warning(caplog): log_warning("This is a test warning") assert caplog.records[0].message == "This is a test warning" assert caplog.records[0].levelname == "WARNING" + + +@pytest.mark.parametrize("input_data", ["dataset", "dataarray"]) +def test_log_to_attrs(input_data, valid_poses_dataset): + """Test that the ``log_to_attrs()`` decorator appends + log entries to the output data's ``log`` attribute and + checks that ``attrs`` contains all expected values. + """ + + @log_to_attrs + def fake_func(data, arg, kwarg=None): + return data + + input_data = ( + valid_poses_dataset + if input_data == "dataset" + else valid_poses_dataset.position + ) + output_data = fake_func(input_data, "test1", kwarg="test2") + + assert "log" in output_data.attrs + assert output_data.attrs["log"][0]["operation"] == "fake_func" + assert ( + output_data.attrs["log"][0]["arg_1"] == "test1" + and output_data.attrs["log"][0]["kwarg"] == "test2" + ) diff --git a/tests/test_unit/test_move_accessor.py b/tests/test_unit/test_move_accessor.py index 76422c97..a5ae8355 100644 --- a/tests/test_unit/test_move_accessor.py +++ b/tests/test_unit/test_move_accessor.py @@ -21,9 +21,14 @@ def test_compute_kinematics_with_invalid_dataset( self, invalid_poses_dataset, kinematic_property ): """Test that computing a kinematic property of an invalid - pose dataset via accessor methods raises the appropriate error. + poses dataset via accessor methods raises the appropriate error. """ - with pytest.raises(AttributeError): + expected_exception = ( + RuntimeError + if isinstance(invalid_poses_dataset, xr.Dataset) + else AttributeError + ) + with pytest.raises(expected_exception): getattr( invalid_poses_dataset.move, f"compute_{kinematic_property}" )() @@ -31,7 +36,7 @@ def test_compute_kinematics_with_invalid_dataset( @pytest.mark.parametrize( "method", ["compute_invalid_property", "do_something"] ) - def test_invalid_compute(self, valid_poses_dataset, method): + def test_invalid_method_call(self, valid_poses_dataset, method): """Test that invalid accessor method calls raise an AttributeError.""" with pytest.raises(AttributeError): getattr(valid_poses_dataset.move, method)() diff --git a/tests/test_unit/test_reports.py b/tests/test_unit/test_reports.py new file mode 100644 index 00000000..51d441ea --- /dev/null +++ b/tests/test_unit/test_reports.py @@ -0,0 +1,31 @@ +import pytest + +from movement.utils.reports import report_nan_values + + +@pytest.mark.parametrize( + "data_selection", + [ + lambda ds: ds.position, # Entire dataset + lambda ds: ds.position.sel( + individuals="ind1" + ), # Missing "individuals" dim + lambda ds: ds.position.sel( + keypoints="key1" + ), # Missing "keypoints" dim + lambda ds: ds.position.sel( + individuals="ind1", keypoints="key1" + ), # Missing "individuals" and "keypoints" dims + ], +) +def test_report_nan_values( + capsys, valid_poses_dataset_with_nan, data_selection +): + """Test that the nan-value reporting function handles data + with missing ``individuals`` and/or ``keypoint`` dims, and + that the dataset name is included in the report. + """ + data = data_selection(valid_poses_dataset_with_nan) + assert data.name in report_nan_values( + data + ), "Dataset name should be in the output" From 292a374f41e922b12af7953c37781fdfaf38adc2 Mon Sep 17 00:00:00 2001 From: Adam Tyson Date: Mon, 22 Jul 2024 09:36:35 +0100 Subject: [PATCH 20/65] Add citation information (#240) * Add zenodo badge and citation to readme * Create CITATION.CFF * Use full names * Add citation file to manifest * Add citation to website * updated citation.cff file after validation by cffinit * remove 2nd date from citation entry * homepage citation is included from README * added Sofia to pyproject.toml authors * harmonise spelling of my own name --------- Co-authored-by: niksirbi --- CITATION.CFF | 43 +++++++++++++++++++++++++++++++++++++++++++ MANIFEST.in | 1 + README.md | 7 +++++++ docs/source/index.md | 6 ++++++ pyproject.toml | 3 ++- 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 CITATION.CFF diff --git a/CITATION.CFF b/CITATION.CFF new file mode 100644 index 00000000..cc243088 --- /dev/null +++ b/CITATION.CFF @@ -0,0 +1,43 @@ +cff-version: 1.2.0 +title: movement +message: >- + If you use movement in your work, please cite the following Zenodo DOI. +type: software +authors: + - given-names: Nikoloz + family-names: Sirmpilatze + orcid: 'https://orcid.org/0000-0003-1778-2427' + email: niko.sirbiladze@gmail.com + - given-names: Chang Huan + family-names: Lo + - given-names: Sofía + family-names: Miñano + - given-names: Brandon D. + family-names: Peri + - given-names: Dhruv + family-names: Sharma + - given-names: Laura + family-names: Porta + - given-names: Iván + family-names: Varela + - given-names: Adam L. + family-names: Tyson + email: code@adamltyson.com +identifiers: + - type: doi + value: 10.5281/zenodo.12755724 + description: 'A collection of archived snapshots of movement on Zenodo.' +repository-code: 'https://github.com/neuroinformatics-unit/movement' +url: 'https://movement.neuroinformatics.dev/' +abstract: >- + Python tools for analysing body movements across space and time. +keywords: + - behavior + - behaviour + - kinematics + - neuroscience + - animal + - motion + - tracking + - pose +license: BSD-3-Clause diff --git a/MANIFEST.in b/MANIFEST.in index ff091745..0fbedce8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include LICENSE include *.md +include CITATION.CFF exclude .pre-commit-config.yaml exclude .cruft.json diff --git a/README.md b/README.md index 5932c5a2..fd7aaba1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Code style: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/format.json)](https://github.com/astral-sh/ruff) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) [![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://neuroinformatics.zulipchat.com/#narrow/stream/406001-Movement/topic/Welcome!) +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.12755724.svg)](https://zenodo.org/doi/10.5281/zenodo.12755724) # movement @@ -49,6 +50,12 @@ To help you get started, we have prepared a detailed [contributing guide](https: You are welcome to chat with the team on [zulip](https://neuroinformatics.zulipchat.com/#narrow/stream/406001-Movement). You can also [open an issue](https://github.com/neuroinformatics-unit/movement/issues) to report a bug or request a new feature. +## Citation + +If you use `movement` in your work, please cite the following Zenodo DOI: + +> Nikoloz Sirmpilatze, Chang Huan Lo, Sofía Miñano, Brandon D. Peri, Dhruv Sharma, Laura Porta, Iván Varela & Adam L. Tyson (2024). neuroinformatics-unit/movement. Zenodo. https://zenodo.org/doi/10.5281/zenodo.12755724 + ## License ⚖️ [BSD 3-Clause](./LICENSE) diff --git a/docs/source/index.md b/docs/source/index.md index 8f578716..629027b9 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -42,6 +42,12 @@ Find out more on our [mission and scope](target-mission) statement and our [road ```{include} /snippets/status-warning.md ``` +## Citation +```{include} ../../README.md +:start-after: '## Citation' +:end-before: '## License' +``` + ```{toctree} :maxdepth: 2 :hidden: diff --git a/pyproject.toml b/pyproject.toml index 648959bf..130268bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,9 @@ [project] name = "movement" authors = [ - { name = "Niko Sirmpilatze", email = "niko.sirbiladze@gmail.com" }, + { name = "Nikoloz Sirmpilatze", email = "niko.sirbiladze@gmail.com" }, { name = "Chang Huan Lo", email = "changhuan.lo@ucl.ac.uk" }, + { name = "Sofía Miñano", email = "s.minano@ucl.ac.uk" }, ] description = "Analysis of body movement" readme = "README.md" From 4830fd5f5c4cd858c9b36623f3cad2127a15a308 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:21:41 +0100 Subject: [PATCH 21/65] Small edits to ValidBboxesDataset (1/4) (#230) * Add frame_array to ValidBboxesDataset * Edit log * Add test * Fix docstring * Add missing spaces in warning messages * Change default IDs to 0-based * Adapt sample_data tests to new metadata.yaml file in GIN repo * Refactor shape check * Suggestion for consistent names * Add attribute type * Check frames are contiguous * Add a column vector converter to frame_array * Revert "Add a column vector converter to frame_array" This reverts commit 3a99c1a2dbd2da477975360a22bcaa90225b82e5. --- movement/validators/datasets.py | 111 ++++++++++++------ tests/test_unit/test_sample_data.py | 1 + .../test_datasets_validators.py | 58 ++++++++- 3 files changed, 132 insertions(+), 38 deletions(-) diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py index b7cfdd8f..1449d88d 100644 --- a/movement/validators/datasets.py +++ b/movement/validators/datasets.py @@ -3,13 +3,14 @@ from collections.abc import Iterable from typing import Any +import attrs import numpy as np from attrs import converters, define, field, validators from movement.utils.logging import log_error, log_warning -def _list_of_str(value: str | Iterable[Any]) -> list[str]: +def _convert_to_list_of_str(value: str | Iterable[Any]) -> list[str]: """Try to coerce the value into a list of strings.""" if isinstance(value, str): log_warning( @@ -25,15 +26,7 @@ def _list_of_str(value: str | Iterable[Any]) -> list[str]: ) -def _ensure_type_ndarray(value: Any) -> None: - """Raise ValueError the value is a not numpy array.""" - if not isinstance(value, np.ndarray): - raise log_error( - ValueError, f"Expected a numpy array, but got {type(value)}." - ) - - -def _set_fps_to_none_if_invalid(fps: float | None) -> float | None: +def _convert_fps_to_none_if_invalid(fps: float | None) -> float | None: """Set fps to None if a non-positive float is passed.""" if fps is not None and fps <= 0: log_warning( @@ -44,7 +37,29 @@ def _set_fps_to_none_if_invalid(fps: float | None) -> float | None: return fps -def _validate_list_length(attribute, value: list | None, expected_length: int): +def _validate_type_ndarray(value: Any) -> None: + """Raise ValueError the value is a not numpy array.""" + if not isinstance(value, np.ndarray): + raise log_error( + ValueError, f"Expected a numpy array, but got {type(value)}." + ) + + +def _validate_array_shape( + attribute: attrs.Attribute, value: np.ndarray, expected_shape: tuple +): + """Raise ValueError if the value does not have the expected shape.""" + if value.shape != expected_shape: + raise log_error( + ValueError, + f"Expected '{attribute.name}' to have shape {expected_shape}, " + f"but got {value.shape}.", + ) + + +def _validate_list_length( + attribute: attrs.Attribute, value: list | None, expected_length: int +): """Raise a ValueError if the list does not have the expected length.""" if (value is not None) and (len(value) != expected_length): raise log_error( @@ -88,16 +103,16 @@ class ValidPosesDataset: confidence_array: np.ndarray | None = field(default=None) individual_names: list[str] | None = field( default=None, - converter=converters.optional(_list_of_str), + converter=converters.optional(_convert_to_list_of_str), ) keypoint_names: list[str] | None = field( default=None, - converter=converters.optional(_list_of_str), + converter=converters.optional(_convert_to_list_of_str), ) fps: float | None = field( default=None, converter=converters.pipe( # type: ignore - converters.optional(float), _set_fps_to_none_if_invalid + converters.optional(float), _convert_fps_to_none_if_invalid ), ) source_software: str | None = field( @@ -108,7 +123,7 @@ class ValidPosesDataset: # Add validators @position_array.validator def _validate_position_array(self, attribute, value): - _ensure_type_ndarray(value) + _validate_type_ndarray(value) if value.ndim != 4: raise log_error( ValueError, @@ -125,14 +140,11 @@ def _validate_position_array(self, attribute, value): @confidence_array.validator def _validate_confidence_array(self, attribute, value): if value is not None: - _ensure_type_ndarray(value) - expected_shape = self.position_array.shape[:-1] - if value.shape != expected_shape: - raise log_error( - ValueError, - f"Expected '{attribute.name}' to have shape " - f"{expected_shape}, but got {value.shape}.", - ) + _validate_type_ndarray(value) + + _validate_array_shape( + attribute, value, expected_shape=self.position_array.shape[:-1] + ) @individual_names.validator def _validate_individual_names(self, attribute, value): @@ -200,6 +212,11 @@ class ValidBboxesDataset: If None (default), bounding boxes are assigned names based on the size of the `position_array`. The names will be in the format of `id_`, where is an integer from 1 to `position_array.shape[1]`. + frame_array : np.ndarray, optional + Array of shape (n_frames, 1) containing the frame numbers for which + bounding boxes are defined. If None (default), frame numbers will + be assigned based on the first dimension of the `position_array`, + starting from 0. fps : float, optional Frames per second defining the sampling rate of the data. Defaults to None. @@ -218,13 +235,14 @@ class ValidBboxesDataset: individual_names: list[str] | None = field( default=None, converter=converters.optional( - _list_of_str + _convert_to_list_of_str ), # force into list of strings if not ) + frame_array: np.ndarray | None = field(default=None) fps: float | None = field( default=None, converter=converters.pipe( # type: ignore - converters.optional(float), _set_fps_to_none_if_invalid + converters.optional(float), _convert_fps_to_none_if_invalid ), ) source_software: str | None = field( @@ -236,7 +254,7 @@ class ValidBboxesDataset: @position_array.validator @shape_array.validator def _validate_position_and_shape_arrays(self, attribute, value): - _ensure_type_ndarray(value) + _validate_type_ndarray(value) # check last dimension (spatial) has 2 coordinates n_expected_spatial_coordinates = 2 @@ -268,14 +286,29 @@ def _validate_individual_names(self, attribute, value): @confidence_array.validator def _validate_confidence_array(self, attribute, value): if value is not None: - _ensure_type_ndarray(value) + _validate_type_ndarray(value) + + _validate_array_shape( + attribute, value, expected_shape=self.position_array.shape[:-1] + ) - expected_shape = self.position_array.shape[:-1] - if value.shape != expected_shape: + @frame_array.validator + def _validate_frame_array(self, attribute, value): + if value is not None: + _validate_type_ndarray(value) + + # should be a column vector (n_frames, 1) + _validate_array_shape( + attribute, + value, + expected_shape=(self.position_array.shape[0], 1), + ) + + # check frames are continuous: exactly one frame number per row + if not np.all(np.diff(value, axis=0) == 1): raise log_error( ValueError, - f"Expected '{attribute.name}' to have shape " - f"{expected_shape}, but got {value.shape}.", + f"Frame numbers in {attribute.name} are not continuous.", ) # Define defaults @@ -284,7 +317,7 @@ def __attrs_post_init__(self): If no confidence_array is provided, set it to an array of NaNs. If no individual names are provided, assign them unique IDs per frame, - starting with 1 ("id_1") + starting with 0 ("id_0"). """ if self.confidence_array is None: self.confidence_array = np.full( @@ -293,17 +326,25 @@ def __attrs_post_init__(self): dtype="float32", ) log_warning( - "Confidence array was not provided." + "Confidence array was not provided. " "Setting to an array of NaNs." ) if self.individual_names is None: self.individual_names = [ - f"id_{i+1}" for i in range(self.position_array.shape[1]) + f"id_{i}" for i in range(self.position_array.shape[1]) ] log_warning( "Individual names for the bounding boxes " "were not provided. " - "Setting to 1-based IDs that are unique per frame: \n" + "Setting to 0-based IDs that are unique per frame: \n" f"{self.individual_names}.\n" ) + + if self.frame_array is None: + n_frames = self.position_array.shape[0] + self.frame_array = np.arange(n_frames).reshape(-1, 1) + log_warning( + "Frame numbers were not provided. " + "Setting to an array of 0-based integers." + ) diff --git a/tests/test_unit/test_sample_data.py b/tests/test_unit/test_sample_data.py index 224545bb..9433d3d0 100644 --- a/tests/test_unit/test_sample_data.py +++ b/tests/test_unit/test_sample_data.py @@ -40,6 +40,7 @@ def validate_metadata(metadata: dict[str, dict]) -> None: "sha256sum", "type", "source_software", + "type", "fps", "species", "number_of_individuals", diff --git a/tests/test_unit/test_validators/test_datasets_validators.py b/tests/test_unit/test_validators/test_datasets_validators.py index f3f3856a..a882162e 100644 --- a/tests/test_unit/test_validators/test_datasets_validators.py +++ b/tests/test_unit/test_validators/test_datasets_validators.py @@ -333,7 +333,7 @@ def test_bboxes_dataset_validator_confidence_array( ): """Test that invalid confidence arrays raise the appropriate errors.""" with expected_exception as excinfo: - poses = ValidBboxesDataset( + ds = ValidBboxesDataset( position_array=request.getfixturevalue("valid_bboxes_inputs")[ "position_array" ], @@ -347,10 +347,62 @@ def test_bboxes_dataset_validator_confidence_array( ) if confidence_array is None: assert np.all( - np.isnan(poses.confidence_array) + np.isnan(ds.confidence_array) ) # assert it is a NaN array assert ( - poses.confidence_array.shape == poses.position_array.shape[:-1] + ds.confidence_array.shape == ds.position_array.shape[:-1] ) # assert shape matches position array else: assert str(excinfo.value) == log_message + + +@pytest.mark.parametrize( + "frame_array, expected_exception, log_message", + [ + ( + np.arange(10).reshape(-1, 2), + pytest.raises(ValueError), + "Expected 'frame_array' to have shape (10, 1), " "but got (5, 2).", + ), # frame_array should be a column vector + ( + [1, 2, 3], + pytest.raises(ValueError), + f"Expected a numpy array, but got {type(list())}.", + ), # not an ndarray, should raise ValueError + ( + np.array([1, 2, 3, 4, 6, 7, 8, 9, 10, 11]).reshape(-1, 1), + pytest.raises(ValueError), + "Frame numbers in frame_array are not continuous.", + ), # frame numbers are not continuous + ( + None, + does_not_raise(), + "", + ), # valid, should return an array of frame numbers starting from 0 + ], +) +def test_bboxes_dataset_validator_frame_array( + frame_array, expected_exception, log_message, request +): + """Test that invalid frame arrays raise the appropriate errors.""" + with expected_exception as excinfo: + ds = ValidBboxesDataset( + position_array=request.getfixturevalue("valid_bboxes_inputs")[ + "position_array" + ], + shape_array=request.getfixturevalue("valid_bboxes_inputs")[ + "shape_array" + ], + individual_names=request.getfixturevalue("valid_bboxes_inputs")[ + "individual_names" + ], + frame_array=frame_array, + ) + + if frame_array is None: + n_frames = ds.position_array.shape[0] + default_frame_array = np.arange(n_frames).reshape(-1, 1) + assert np.array_equal(ds.frame_array, default_frame_array) + assert ds.frame_array.shape == (ds.position_array.shape[0], 1) + else: + assert str(excinfo.value) == log_message From 242f5328c11f21ae605169a8dc780b6d855ff478 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:13:56 +0100 Subject: [PATCH 22/65] Add a ValidVIAtracksCSV class (2/4) (#219) * Add skeleton for ValidVIAtracksCSV class * Add skeleton for ValidVIAtracksCSV test * Draft VIA file validator * Change asserts to errors (WIP) * Remove 1-based integer checks (for track ID and frames). Replace assert by errors * Small edits * Add tests for VIA file (pending fixtures) * Add one fixture * Add frame number as invalid file attribute * Factor out valid header fixture * Add test for frame number wrongly encoded in the filename * Add unique frame numbers test. Check bbox shape. * Add test for region attribute not defined * Add test for track ID not castable as an integer * Add test for unique track IDs per frame * Small edits to comments and docstrings * Apply suggestions from code review Co-authored-by: Niko Sirmpilatze * Fix test duplicate from rebase * Rename symbols * csv to .csv * Small edits to comments --------- Co-authored-by: Niko Sirmpilatze --- movement/validators/files.py | 211 +++++++++++++++ tests/conftest.py | 247 ++++++++++++++++++ .../test_validators/test_files_validators.py | 108 +++++++- 3 files changed, 565 insertions(+), 1 deletion(-) diff --git a/movement/validators/files.py b/movement/validators/files.py index 5fefdc54..144fd829 100644 --- a/movement/validators/files.py +++ b/movement/validators/files.py @@ -1,10 +1,13 @@ """``attrs`` classes for validating file paths.""" +import ast import os +import re from pathlib import Path from typing import Literal import h5py +import pandas as pd from attrs import define, field, validators from movement.utils.logging import log_error @@ -198,3 +201,211 @@ def _csv_file_contains_expected_levels(self, attribute, value): ".csv header rows do not match the known format for " "DeepLabCut pose estimation output files.", ) + + +@define +class ValidVIATracksCSV: + """Class for validating VIA tracks .csv files. + + Parameters + ---------- + path : pathlib.Path or str + Path to the VIA tracks .csv file. + + Raises + ------ + ValueError + If the .csv file does not match the VIA tracks file requirements. + + """ + + path: Path = field(validator=validators.instance_of(Path)) + + @path.validator + def csv_file_contains_valid_header(self, attribute, value): + """Ensure the VIA tracks .csv file contains the expected header.""" + expected_header = [ + "filename", + "file_size", + "file_attributes", + "region_count", + "region_id", + "region_shape_attributes", + "region_attributes", + ] + + with open(value) as f: + header = f.readline().strip("\n").split(",") + + if header != expected_header: + raise log_error( + ValueError, + ".csv header row does not match the known format for " + "VIA tracks output files. " + f"Expected {expected_header} but got {header}.", + ) + + @path.validator + def csv_file_contains_valid_frame_numbers(self, attribute, value): + """Ensure that the VIA tracks .csv file contains valid frame numbers. + + This involves: + - Checking that frame numbers are included in `file_attributes` or + encoded in the image file `filename`. + - Checking the frame number can be cast as an integer. + - Checking that there are as many unique frame numbers as unique image + files. + + If the frame number is included as part of the image file name, it is + expected as an integer led by at least one zero, between "_" and ".", + followed by the file extension. + """ + df = pd.read_csv(value, sep=",", header=0) + + # Extract list of file attributes (dicts) + file_attributes_dicts = [ + ast.literal_eval(d) for d in df.file_attributes + ] + + # If 'frame' is a file_attribute for all files: + # extract frame number + list_frame_numbers = [] + if all(["frame" in d for d in file_attributes_dicts]): + for k_i, k in enumerate(file_attributes_dicts): + try: + list_frame_numbers.append(int(k["frame"])) + except Exception as e: + raise log_error( + ValueError, + f"{df.filename.iloc[k_i]} (row {k_i}): " + "'frame' file attribute cannot be cast as an integer. " + f"Please review the file attributes: {k}.", + ) from e + + # else: extract frame number from filename. + else: + pattern = r"_(0\d*)\.\w+$" + + for f_i, f in enumerate(df["filename"]): + regex_match = re.search(pattern, f) + if regex_match: # if there is a pattern match + list_frame_numbers.append( + int(regex_match.group(1)) # type: ignore + # the match will always be castable as integer + ) + else: + raise log_error( + ValueError, + f"{f} (row {f_i}): " + "a frame number could not be extracted from the " + "filename. If included in the filename, the frame " + "number is expected as a zero-padded integer between " + "an underscore '_' and the file extension " + "(e.g. img_00234.png).", + ) + + # Check we have as many unique frame numbers as unique image files + if len(set(list_frame_numbers)) != len(df.filename.unique()): + raise log_error( + ValueError, + "The number of unique frame numbers does not match the number " + "of unique image files. Please review the VIA tracks .csv " + "file and ensure a unique frame number is defined for each " + "file. ", + ) + + @path.validator + def csv_file_contains_tracked_bboxes(self, attribute, value): + """Ensure that the VIA tracks .csv contains tracked bounding boxes. + + This involves: + - Checking that the bounding boxes are defined as rectangles. + - Checking that the bounding boxes have all geometric parameters + (["x", "y", "width", "height"]). + - Checking that the bounding boxes have a track ID defined. + - Checking that the track ID can be cast as an integer. + """ + df = pd.read_csv(value, sep=",", header=0) + + for row in df.itertuples(): + row_region_shape_attrs = ast.literal_eval( + row.region_shape_attributes + ) + row_region_attrs = ast.literal_eval(row.region_attributes) + + # check annotation is a rectangle + if row_region_shape_attrs["name"] != "rect": + raise log_error( + ValueError, + f"{row.filename} (row {row.Index}): " + "bounding box shape must be 'rect' (rectangular) " + "but instead got " + f"'{row_region_shape_attrs['name']}'.", + ) + + # check all geometric parameters for the box are defined + if not all( + [ + key in row_region_shape_attrs + for key in ["x", "y", "width", "height"] + ] + ): + raise log_error( + ValueError, + f"{row.filename} (row {row.Index}): " + f"at least one bounding box shape parameter is missing. " + "Expected 'x', 'y', 'width', 'height' to exist as " + "'region_shape_attributes', but got " + f"'{list(row_region_shape_attrs.keys())}'.", + ) + + # check track ID is defined + if "track" not in row_region_attrs: + raise log_error( + ValueError, + f"{row.filename} (row {row.Index}): " + "bounding box does not have a 'track' attribute defined " + "under 'region_attributes'. " + "Please review the VIA tracks .csv file.", + ) + + # check track ID is castable as an integer + try: + int(row_region_attrs["track"]) + except Exception as e: + raise log_error( + ValueError, + f"{row.filename} (row {row.Index}): " + "the track ID for the bounding box cannot be cast " + "as an integer. Please review the VIA tracks .csv file.", + ) from e + + @path.validator + def csv_file_contains_unique_track_IDs_per_filename( + self, attribute, value + ): + """Ensure the VIA tracks .csv contains unique track IDs per filename. + + It checks that bounding boxes IDs are defined once per image file. + """ + df = pd.read_csv(value, sep=",", header=0) + + list_unique_filenames = list(set(df.filename)) + for file in list_unique_filenames: + df_one_filename = df.loc[df["filename"] == file] + + list_track_IDs_one_filename = [ + int(ast.literal_eval(row.region_attributes)["track"]) + for row in df_one_filename.itertuples() + ] + + if len(set(list_track_IDs_one_filename)) != len( + list_track_IDs_one_filename + ): + raise log_error( + ValueError, + f"{file}: " + "multiple bounding boxes in this file " + "have the same track ID. " + "Please review the VIA tracks .csv file.", + ) diff --git a/tests/conftest.py b/tests/conftest.py index dcb81962..fb4e38cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -330,6 +330,253 @@ def kinematic_property(request): return request.param +# VIA tracks CSV fixtures +@pytest.fixture +def via_tracks_csv_with_invalid_header(tmp_path): + """Return the file path for a file with invalid header.""" + file_path = tmp_path / "invalid_via_tracks.csv" + with open(file_path, "w") as f: + f.write("filename,file_size,file_attributes\n") + f.write("1,2,3") + return file_path + + +@pytest.fixture +def via_tracks_csv_with_valid_header(tmp_path): + file_path = tmp_path / "sample_via_tracks.csv" + with open(file_path, "w") as f: + f.write( + "filename," + "file_size," + "file_attributes," + "region_count," + "region_id," + "region_shape_attributes," + "region_attributes" + ) + f.write("\n") + return file_path + + +@pytest.fixture +def frame_number_in_file_attribute_not_integer( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with invalid frame + number defined as file_attribute. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_frame_A.png," + "26542080," + '"{""clip"":123, ""frame"":""FOO""}",' # frame number is a string + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) + return file_path + + +@pytest.fixture +def frame_number_in_filename_wrong_pattern( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with invalid frame + number defined in the frame's filename. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_frame_1.png," # frame not zero-padded + "26542080," + '"{""clip"":123}",' + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) + return file_path + + +@pytest.fixture +def more_frame_numbers_than_filenames( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with more + frame numbers than filenames. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test.png," + "26542080," + '"{""clip"":123, ""frame"":24}",' + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) + f.write("\n") + f.write( + "04.09.2023-04-Right_RE_test.png," # same filename as previous row + "26542080," + '"{""clip"":123, ""frame"":25}",' # different frame number + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) + return file_path + + +@pytest.fixture +def less_frame_numbers_than_filenames( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with with less + frame numbers than filenames. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_A.png," + "26542080," + '"{""clip"":123, ""frame"":24}",' + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) + f.write("\n") + f.write( + "04.09.2023-04-Right_RE_test_B.png," # different filename + "26542080," + '"{""clip"":123, ""frame"":24}",' # same frame as previous row + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) + return file_path + + +@pytest.fixture +def region_shape_attribute_not_rect( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with invalid shape in + region_shape_attributes. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_frame_01.png," + "26542080," + '"{""clip"":123}",' + "1," + "0," + '"{""name"":""circle"",""cx"":1049,""cy"":1006,""r"":125}",' + '"{""track"":""71""}"' + ) # annotation of circular shape + return file_path + + +@pytest.fixture +def region_shape_attribute_missing_x( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with missing `x` key in + region_shape_attributes. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_frame_01.png," + "26542080," + '"{""clip"":123}",' + "1," + "0," + '"{""name"":""rect"",""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) # region_shape_attributes is missing ""x"" key + return file_path + + +@pytest.fixture +def region_attribute_missing_track( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with missing track + attribute in region_attributes. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_frame_01.png," + "26542080," + '"{""clip"":123}",' + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""foo"":""71""}"' # missing ""track"" + ) + return file_path + + +@pytest.fixture +def track_id_not_castable_as_int( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with a track ID + attribute not castable as an integer. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_frame_01.png," + "26542080," + '"{""clip"":123}",' + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""FOO""}"' # ""track"" not castable as int + ) + return file_path + + +@pytest.fixture +def track_ids_not_unique_per_frame( + via_tracks_csv_with_valid_header, +): + """Return the file path for a VIA tracks .csv file with a track ID + that appears twice in the same frame. + """ + file_path = via_tracks_csv_with_valid_header + with open(file_path, "a") as f: + f.write( + "04.09.2023-04-Right_RE_test_frame_01.png," + "26542080," + '"{""clip"":123}",' + "1," + "0," + '"{""name"":""rect"",""x"":526.236,""y"":393.281,""width"":46,""height"":38}",' + '"{""track"":""71""}"' + ) + f.write("\n") + f.write( + "04.09.2023-04-Right_RE_test_frame_01.png," + "26542080," + '"{""clip"":123}",' + "1," + "0," + '"{""name"":""rect"",""x"":2567.627,""y"":466.888,""width"":40,""height"":37}",' + '"{""track"":""71""}"' # same track ID as the previous row + ) + return file_path + + class Helpers: """Generic helper methods for ``movement`` test modules.""" diff --git a/tests/test_unit/test_validators/test_files_validators.py b/tests/test_unit/test_validators/test_files_validators.py index 9261e8a2..403a67b3 100644 --- a/tests/test_unit/test_validators/test_files_validators.py +++ b/tests/test_unit/test_validators/test_files_validators.py @@ -1,6 +1,11 @@ import pytest -from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 +from movement.validators.files import ( + ValidDeepLabCutCSV, + ValidFile, + ValidHDF5, + ValidVIATracksCSV, +) @pytest.mark.parametrize( @@ -60,3 +65,104 @@ def test_deeplabcut_csv_validator_with_invalid_input( file_path = request.getfixturevalue(invalid_input) with expected_exception: ValidDeepLabCutCSV(file_path) + + +@pytest.mark.parametrize( + "invalid_input, log_message", + [ + ( + "via_tracks_csv_with_invalid_header", + ".csv header row does not match the known format for " + "VIA tracks output files. " + "Expected " + "['filename', 'file_size', 'file_attributes', " + "'region_count', 'region_id', 'region_shape_attributes', " + "'region_attributes'] " + "but got ['filename', 'file_size', 'file_attributes'].", + ), + ( + "frame_number_in_file_attribute_not_integer", + "04.09.2023-04-Right_RE_test_frame_A.png (row 0): " + "'frame' file attribute cannot be cast as an integer. " + "Please review the file attributes: " + "{'clip': 123, 'frame': 'FOO'}.", + ), + ( + "frame_number_in_filename_wrong_pattern", + "04.09.2023-04-Right_RE_test_frame_1.png (row 0): " + "a frame number could not be extracted from the filename. " + "If included in the filename, the frame number is " + "expected as a zero-padded integer between an " + "underscore '_' and the file extension " + "(e.g. img_00234.png).", + ), + ( + "more_frame_numbers_than_filenames", + "The number of unique frame numbers does not match the number " + "of unique image files. Please review the VIA tracks .csv file " + "and ensure a unique frame number is defined for each file. ", + ), + ( + "less_frame_numbers_than_filenames", + "The number of unique frame numbers does not match the number " + "of unique image files. Please review the VIA tracks .csv file " + "and ensure a unique frame number is defined for each file. ", + ), + ( + "region_shape_attribute_not_rect", + "04.09.2023-04-Right_RE_test_frame_01.png (row 0): " + "bounding box shape must be 'rect' (rectangular) " + "but instead got 'circle'.", + ), + ( + "region_shape_attribute_missing_x", + "04.09.2023-04-Right_RE_test_frame_01.png (row 0): " + "at least one bounding box shape parameter is missing. " + "Expected 'x', 'y', 'width', 'height' to exist as " + "'region_shape_attributes', but got " + "'['name', 'y', 'width', 'height']'.", + ), + ( + "region_attribute_missing_track", + "04.09.2023-04-Right_RE_test_frame_01.png (row 0): " + "bounding box does not have a 'track' attribute defined " + "under 'region_attributes'. " + "Please review the VIA tracks .csv file.", + ), + ( + "track_id_not_castable_as_int", + "04.09.2023-04-Right_RE_test_frame_01.png (row 0): " + "the track ID for the bounding box cannot be cast " + "as an integer. " + "Please review the VIA tracks .csv file.", + ), + ( + "track_ids_not_unique_per_frame", + "04.09.2023-04-Right_RE_test_frame_01.png: " + "multiple bounding boxes in this file have the same track ID. " + "Please review the VIA tracks .csv file.", + ), + ], +) +def test_via_tracks_csv_validator_with_invalid_input( + invalid_input, log_message, request +): + """Test that invalid VIA tracks CSV files raise the appropriate errors. + + Errors to check: + - error if .csv header is wrong + - error if frame number is not defined in the file + (frame number extracted either from the filename or from attributes) + - error if extracted frame numbers are not 1-based integers + - error if region_shape_attributes "name" is not "rect" + - error if not all region_attributes have key "track" + (i.e., all regions must have an ID assigned) + - error if IDs are unique per frame + (i.e., bboxes IDs must exist only once per frame) + - error if bboxes IDs are not 1-based integers + """ + file_path = request.getfixturevalue(invalid_input) + with pytest.raises(ValueError) as excinfo: + ValidVIATracksCSV(file_path) + + assert str(excinfo.value) == log_message From d943b29501b39ded0442631f3373a2ba5ab51a31 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:02:12 +0100 Subject: [PATCH 23/65] Getting started docs update for bboxes (#245) * Updates and suggestions to i/o docs section * Update input output with bboxes edits * Update dataset section with bboxes edits * Update sample data section with bboxes edits * pose --> poses datasets * Fix typo * Add from_numpy * Add example to export csv tracking data * Refer to _poses_ datasets in API reference for loading pose tracking data * Apply suggestions from code review Co-authored-by: Niko Sirmpilatze * Add bboxes dataset to CONTRIBUTING.md * Clarify 2D poses in dataset docs * Add tabs and shape as code * Change shape formatting back * Fix `ds_type` comment * Add line with `to_dataframe` method * Replace assign_attrs by dict syntax --------- Co-authored-by: Niko Sirmpilatze --- CONTRIBUTING.md | 16 +- docs/source/conf.py | 1 + docs/source/getting_started/index.md | 2 +- docs/source/getting_started/input_output.md | 162 ++++++++--- .../getting_started/movement_dataset.md | 252 +++++++++++++----- docs/source/getting_started/sample_data.md | 34 ++- movement/io/load_poses.py | 20 +- movement/validators/files.py | 6 +- 8 files changed, 353 insertions(+), 140 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 281c5eda..ec3ee0e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -296,12 +296,12 @@ GIN has a GitHub-like interface and git-like [CLI](gin:G-Node/Info/wiki/GIN+CLI+Setup#quickstart) functionalities. Currently, the data repository contains sample pose estimation data files -stored in the `poses` folder. For some of these files, we also host +stored in the `poses` folder, and tracked bounding boxes data files under the `bboxes` folder. For some of these files, we also host the associated video file (in the `videos` folder) and/or a single video frame (in the `frames`) folder. These can be used to develop and -test visualisations, e.g. overlaying pose data on video frames. +test visualisations, e.g. to overlay the data on video frames. The `metadata.yaml` file holds metadata for each sample dataset, -including information on data provenance as well as the mapping between pose data files and related +including information on data provenance as well as the mapping between data files and related video/frame files. ### Fetching data @@ -314,9 +314,9 @@ The relevant functionality is implemented in the `movement.sample_data.py` modul The most important parts of this module are: 1. The `SAMPLE_DATA` download manager object. -2. The `list_datasets()` function, which returns a list of the available pose datasets (file names of the pose data files). -3. The `fetch_dataset_paths()` function, which returns a dictionary containing local paths to the files associated with a particular sample dataset: `poses`, `frame`, `video`. If the relevant files are not already cached locally, they will be downloaded. -4. The `fetch_dataset()` function, which downloads the files associated with a given sample dataset (same as `fetch_dataset_paths()`) and additionally loads the pose data into `movement`, returning an `xarray.Dataset` object. The local paths to the associated video and frame files are stored as dataset attributes, with names `video_path` and `frame_path`, respectively. +2. The `list_datasets()` function, which returns a list of the available poses and bounding boxes datasets (file names of the data files). +3. The `fetch_dataset_paths()` function, which returns a dictionary containing local paths to the files associated with a particular sample dataset: `poses` or `bboxes`, `frame`, `video`. If the relevant files are not already cached locally, they will be downloaded. +4. The `fetch_dataset()` function, which downloads the files associated with a given sample dataset (same as `fetch_dataset_paths()`) and additionally loads the pose or bounding box data into `movement`, returning an `xarray.Dataset` object. If available, the local paths to the associated video and frame files are stored as dataset attributes, with names `video_path` and `frame_path`, respectively. By default, the downloaded files are stored in the `~/.movement/data` folder. This can be changed by setting the `DATA_DIR` variable in the `movement.sample_data.py` module. @@ -329,7 +329,7 @@ To add a new file, you will need to: 2. Ask to be added as a collaborator on the [movement data repository](gin:neuroinformatics/movement-test-data) (if not already) 3. Download the [GIN CLI](gin:G-Node/Info/wiki/GIN+CLI+Setup#quickstart) and set it up with your GIN credentials, by running `gin login` in a terminal. 4. Clone the movement data repository to your local machine, by running `gin get neuroinformatics/movement-test-data` in a terminal. -5. Add your new files to the `poses`, `videos`, `frames`, and/or `bboxes` folders as appropriate. Follow the existing file naming conventions as closely as possible. +5. Add your new files to the `poses`, `bboxes`, `videos` and/or `frames` folders as appropriate. Follow the existing file naming conventions as closely as possible. 6. Determine the sha256 checksum hash of each new file. You can do this in a terminal by running: ::::{tab-set} @@ -351,7 +351,7 @@ To add a new file, you will need to: ``` ::: :::: - For convenience, we've included a `get_sha256_hashes.py` script in the [movement data repository](gin:neuroinformatics/movement-test-data). If you run this from the root of the data repository, within a Python environment with `movement` installed, it will calculate the sha256 hashes for all files in the `poses`, `videos`, `frames`, and `bboxes` folders and write them to files named `poses_hashes.txt`, `videos_hashes.txt`, `frames_hashes.txt`, and `bboxes_hashes.txt`, respectively. + For convenience, we've included a `get_sha256_hashes.py` script in the [movement data repository](gin:neuroinformatics/movement-test-data). If you run this from the root of the data repository, within a Python environment with `movement` installed, it will calculate the sha256 hashes for all files in the `poses`, `bboxes`, `videos` and `frames` folders and write them to files named `poses_hashes.txt`, `bboxes_hashes.txt`, `videos_hashes.txt`, and `frames_hashes.txt` respectively. 7. Add metadata for your new files to `metadata.yaml`, including their sha256 hashes you've calculated. See the example entry below for guidance. diff --git a/docs/source/conf.py b/docs/source/conf.py index dc731d09..f6352943 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -194,4 +194,5 @@ "sphinx-gallery": "https://sphinx-gallery.github.io/stable/{{path}}", "xarray": "https://docs.xarray.dev/en/stable/{{path}}#{{fragment}}", "lp": "https://lightning-pose.readthedocs.io/en/stable/{{path}}#{{fragment}}", + "via": "https://www.robots.ox.ac.uk/~vgg/software/via/{{path}}#{{fragment}}", } diff --git a/docs/source/getting_started/index.md b/docs/source/getting_started/index.md index b98a529c..4795a9cd 100644 --- a/docs/source/getting_started/index.md +++ b/docs/source/getting_started/index.md @@ -2,7 +2,7 @@ Start by [installing the package](installation.md). -After that try [loading some of your own predicted poses](target-loading), +After that, try [loading some of your own tracked poses](target-loading-pose-tracks) or [bounding boxes' trajectories](target-loading-bbox-tracks), from one of the [supported formats](target-formats). Alternatively, you can use the [sample data](target-sample-data) provided with the package. diff --git a/docs/source/getting_started/input_output.md b/docs/source/getting_started/input_output.md index b4ad43f9..ccb16a7b 100644 --- a/docs/source/getting_started/input_output.md +++ b/docs/source/getting_started/input_output.md @@ -4,35 +4,40 @@ (target-formats)= ## Supported formats (target-supported-formats)= -`movement` can load pose tracks from various pose estimation frameworks. -Currently, these include: +`movement` supports the analysis of trajectories of keypoints (_pose tracks_) and of bounding boxes' centroids (_bounding boxes' tracks_). + +To analyse pose tracks, `movement` supports loading data from various frameworks: - [DeepLabCut](dlc:) (DLC) - [SLEAP](sleap:) (SLEAP) - [LightingPose](lp:) (LP) -:::{warning} -`movement` only deals with the predicted pose tracks output by these -software packages. It does not support the training or labelling of the data. +To analyse bounding boxes' tracks, `movement` currently supports the [VGG Image Annotator](via:) (VIA) format for [tracks annotation](via:docs/face_track_annotation.html). + +:::{note} +At the moment `movement` only deals with tracked data: either keypoints or bounding boxes whose identities are known from one frame to the next, for a consecutive set of frames. For the pose estimation case, this means it only deals with the predictions output by the software packages above. It currently does not support loading manually labelled data (since this is most often defined over a non-continuous set of frames). ::: -(target-loading)= +Below we explain how you can load pose tracks and bounding boxes' tracks into `movement`, and how you can export a `movement` poses dataset to different file formats. You can also try `movement` out on some [sample data](target-sample-data) +included with the package. + + +(target-loading-pose-tracks)= ## Loading pose tracks -The loading functionalities are provided by the +The pose tracks loading functionalities are provided by the {mod}`movement.io.load_poses` module, which can be imported as follows: ```python from movement.io import load_poses ``` -Depending on the source sofrware, one of the following functions can be used. +To read a pose tracks file into a [movement poses dataset](target-poses-and-bboxes-dataset), we provide specific functions for each of the supported formats. We additionally provide a more general `from_numpy()` method, with which we can build a [movement poses dataset](target-poses-and-bboxes-dataset) from a set of NumPy arrays. ::::{tab-set} :::{tab-item} SLEAP -Load from [SLEAP analysis files](sleap:tutorials/analysis) (.h5, recommended), -or from .slp files (experimental): +To load [SLEAP analysis files](sleap:tutorials/analysis) in .h5 format (recommended): ```python ds = load_poses.from_sleap_file("/path/to/file.analysis.h5", fps=30) @@ -41,9 +46,7 @@ ds = load_poses.from_file( "/path/to/file.analysis.h5", source_software="SLEAP", fps=30 ) ``` - -You can also load from SLEAP .slp files in the same way, but there are caveats -to that approach (see notes in {func}`movement.io.load_poses.from_sleap_file`). +To load [SLEAP analysis files](sleap:tutorials/analysis) in .slp format (experimental, see notes in {func}`movement.io.load_poses.from_sleap_file`): ```python ds = load_poses.from_sleap_file("/path/to/file.predictions.slp", fps=30) @@ -52,7 +55,7 @@ ds = load_poses.from_sleap_file("/path/to/file.predictions.slp", fps=30) :::{tab-item} DeepLabCut -Load from DeepLabCut files (.h5): +To load DeepLabCut files in .h5 format: ```python ds = load_poses.from_dlc_file("/path/to/file.h5", fps=30) @@ -62,8 +65,7 @@ ds = load_poses.from_file( ) ``` -You may also load .csv files -(assuming they are formatted as DeepLabCut expects them): +To load DeepLabCut files in .csv format: ```python ds = load_poses.from_dlc_file("/path/to/file.csv", fps=30) ``` @@ -71,7 +73,7 @@ ds = load_poses.from_dlc_file("/path/to/file.csv", fps=30) :::{tab-item} LightningPose -Load from LightningPose files (.csv): +To load LightningPose files in .csv format: ```python ds = load_poses.from_lp_file("/path/to/file.analysis.csv", fps=30) @@ -82,23 +84,91 @@ ds = load_poses.from_file( ``` ::: +:::{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 +import numpy as np + +ds = load_poses.from_numpy( + position_array=np.random.rand((100, 2, 3, 2)), + confidence_array=np.ones((100, 2, 3)), + individual_names=["Alice", "Bob"], + keypoint_names=["snout", "centre", "tail_base"], + fps=30, +) +``` +::: + :::: -The loaded data include the predicted positions for each individual and -keypoint as well as the associated point-wise confidence values, as reported by -the pose estimation software. See the [movement dataset](target-dataset) page -for more information on data structure. +The resulting poses data structure `ds` will include the predicted trajectories for each individual and +keypoint, as well as the associated point-wise confidence values reported by +the pose estimation software. -You can also try `movement` out on some [sample data](target-sample-data) -included with the package. +For more information on the poses data structure, see the [movement poses dataset](target-poses-and-bboxes-dataset) page. + + +(target-loading-bbox-tracks)= +## Loading bounding boxes' tracks +To load bounding boxes' tracks into a [movement bounding boxes dataset](target-poses-and-bboxes-dataset), we need the functions from the +{mod}`movement.io.load_bboxes` module. This module can be imported as: + +```python +from movement.io import load_bboxes +``` + +We currently support loading bounding boxes' tracks in the VGG Image Annotator (VIA) format only. However, like in the poses datasets, we additionally provide a `from_numpy()` method, with which we can build a [movement bounding boxes dataset](target-poses-and-bboxes-dataset) from a set of NumPy arrays. + +::::{tab-set} +:::{tab-item} VGG Image Annotator + +To load a VIA tracks .csv file: +```python +ds = load_bboxes.from_via_tracks_file("path/to/file.csv", fps=30) + +# or equivalently +ds = load_bboxes.from_file( + "path/to/file.csv", + source_software="VIA-tracks", + fps=30, +) +``` +::: + +:::{tab-item} From NumPy + +In the example below, we create random position data for two bounding boxes, ``id_0`` and ``id_1``, +both with the same width (40 pixels) and height (30 pixels). These are tracked in 2D space for 100 frames, which will be numbered in the resulting dataset from 0 to 99. The confidence score for all bounding boxes is set to 0.5. + +```python +import numpy as np + +ds = load_bboxes.from_numpy( + position_array=np.random.rand(100, 2, 2), + shape_array=np.ones((100, 2, 2)) * [40, 30], + confidence_array=np.ones((100, 2)) * 0.5, + individual_names=["id_0", "id_1"] +) +``` +::: + +:::: + +The resulting data structure `ds` will include the centroid trajectories for each tracked bounding box, the boxes' widths and heights, and their associated confidence values if provided. -(target-saving)= +For more information on the bounding boxes data structure, see the [movement bounding boxes dataset](target-poses-and-bboxes-dataset) page. + + +(target-saving-pose-tracks)= ## Saving pose tracks -[movement datasets](target-dataset) can be saved as a variety of +[movement poses datasets](target-poses-and-bboxes-dataset) can be saved in a variety of formats, including DeepLabCut-style files (.h5 or .csv) and [SLEAP-style analysis files](sleap:tutorials/analysis) (.h5). -First import the {mod}`movement.io.save_poses` module: +To export pose tracks from `movement`, first import the {mod}`movement.io.save_poses` module: ```python from movement.io import save_poses @@ -110,7 +180,7 @@ Then, depending on the desired format, use one of the following functions: ::::{tab-item} SLEAP -Save to SLEAP-style analysis files (.h5): +To save as a SLEAP analysis file in .h5 format: ```python save_poses.to_sleap_analysis_file(ds, "/path/to/file.h5") ``` @@ -129,7 +199,7 @@ each attribute and data variable represents, see the ::::{tab-item} DeepLabCut -Save to DeepLabCut-style files (.h5 or .csv): +To save as a DeepLabCut file, in .h5 or .csv format: ```python save_poses.to_dlc_file(ds, "/path/to/file.h5") # preferred format save_poses.to_dlc_file(ds, "/path/to/file.csv") @@ -143,13 +213,13 @@ save the data as separate single-animal DeepLabCut-style files. ::::{tab-item} LightningPose -Save to LightningPose files (.csv). +To save as a LightningPose file in .csv format: ```python save_poses.to_lp_file(ds, "/path/to/file.csv") ``` :::{note} -Because LightningPose saves pose estimation outputs in the same format as single-animal -DeepLabCut projects, the above command is equivalent to: +Because LightningPose follows the single-animal +DeepLabCut .csv format, the above command is equivalent to: ```python save_poses.to_dlc_file(ds, "/path/to/file.csv", split_individuals=True) ``` @@ -157,3 +227,33 @@ save_poses.to_dlc_file(ds, "/path/to/file.csv", split_individuals=True) :::: ::::: + + +(target-saving-bboxes-tracks)= +## Saving bounding boxes' tracks + +We currently do not provide explicit methods to export a movement bounding boxes dataset in a specific format. However, you can easily save the bounding boxes' trajectories to a .csv file using the standard Python library `csv`. + +Here is an example of how you can save a bounding boxes dataset to a .csv file: + +```python +# define name for output csv file +file = 'tracking_output.csv" + +# open the csv file in write mode +with open(filepath, mode="w", newline="") as file: + writer = csv.writer(file) + + # write the header + writer.writerow(["frame_idx", "bbox_ID", "x", "y", "width", "height", "confidence"]) + + # write the data + for individual in ds.individuals.data: + for frame in ds.time.data: + x, y = ds.position.sel(time=frame, individuals=individual).data + width, height = ds.shape.sel(time=frame, individuals=individual).data + confidence = ds.confidence.sel(time=frame, individuals=individual).data + writer.writerow([frame, individual, x, y, width, height, confidence]) + +``` +Alternatively, we can convert the `movement` bounding boxes' dataset to a pandas DataFrame with the {func}`.xarray.DataArray.to_dataframe()` method, wrangle the dataframe as required, and then apply the {func}`.pandas.DataFrame.to_csv()` method to save the data as a .csv file. diff --git a/docs/source/getting_started/movement_dataset.md b/docs/source/getting_started/movement_dataset.md index 9c85adc6..601ccc39 100644 --- a/docs/source/getting_started/movement_dataset.md +++ b/docs/source/getting_started/movement_dataset.md @@ -1,19 +1,18 @@ -(target-dataset)= -# The movement dataset +(target-poses-and-bboxes-dataset)= +# The movement datasets -When you load predicted pose tracks into `movement`, they are represented -as an {class}`xarray.Dataset` object, which is a container for multiple data -arrays. Each array is in turn represented as an {class}`xarray.DataArray` -object, which you can think of as a multi-dimensional {class}`numpy.ndarray` +In `movement`, poses or bounding boxes' tracks are represented +as an {class}`xarray.Dataset` object. + +An {class}`xarray.Dataset` object is a container for multiple arrays. Each array is an {class}`xarray.DataArray` object holding different aspects of the collected data (position, time, confidence scores...). You can think of a {class}`xarray.DataArray` object as a multi-dimensional {class}`numpy.ndarray` with pandas-style indexing and labelling. -So, a `movement` dataset is simply an {class}`xarray.Dataset` with a specific -structure to represent pose tracks, associated confidence scores and relevant -metadata. Each dataset consists of **data variables**, **dimensions**, -**coordinates** and **attributes**. +So a `movement` dataset is simply an {class}`xarray.Dataset` with a specific +structure to represent pose tracks or bounding boxes' tracks. Because pose data and bounding boxes data are somewhat different, `movement` provides two types of datasets: `poses` datasets and `bboxes` datasets. + +To discuss the specifics of both types of `movement` datasets, it is useful to clarify some concepts such as **data variables**, **dimensions**, +**coordinates** and **attributes**. In the next section, we will describe these concepts and the `movement` datasets' structure in some detail. -In the next section, we will describe the -structure of a `movement` dataset in some detail. To learn more about `xarray` data structures in general, see the relevant [documentation](xarray:user-guide/data-structures.html). @@ -22,71 +21,175 @@ To learn more about `xarray` data structures in general, see the relevant ![](../_static/dataset_structure.png) -You can always inspect the structure of a `movement` dataset `ds` by simply -printing it: +The structure of a `movement` dataset `ds` can be easily inspected by simply +printing it. + +::::{tab-set} + +:::{tab-item} Poses dataset +To inspect a sample poses dataset, we can run: +```python +from movement import sample_data + +ds = sample_data.fetch_dataset( + "SLEAP_three-mice_Aeon_proofread.analysis.h5", +) +print(ds) +``` + +and we would obtain an output such as: +``` + Size: 27kB +Dimensions: (time: 601, individuals: 3, keypoints: 1, space: 2) +Coordinates: + * time (time) float64 5kB 0.0 0.02 0.04 0.06 ... 11.96 11.98 12.0 + * individuals (individuals) Size: 19kB +Dimensions: (time: 5, individuals: 86, space: 2) +Coordinates: + * time (time) int64 40B 0 1 2 3 4 + * individuals (individuals) xr.Dataset: - """Create a ``movement`` dataset from NumPy arrays. + """Create a ``movement`` poses dataset from NumPy arrays. Parameters ---------- @@ -95,7 +95,7 @@ def from_file( source_software: Literal["DeepLabCut", "SLEAP", "LightningPose"], fps: float | None = None, ) -> xr.Dataset: - """Create a ``movement`` dataset from any supported file. + """Create a ``movement`` poses dataset from any supported file. Parameters ---------- @@ -148,7 +148,7 @@ def from_dlc_style_df( fps: float | None = None, source_software: Literal["DeepLabCut", "LightningPose"] = "DeepLabCut", ) -> xr.Dataset: - """Create a ``movement`` dataset from a DeepLabCut-style DataFrame. + """Create a ``movement`` poses dataset from a DeepLabCut-style DataFrame. Parameters ---------- @@ -214,7 +214,7 @@ def from_dlc_style_df( def from_sleap_file( file_path: Path | str, fps: float | None = None ) -> xr.Dataset: - """Create a ``movement`` dataset from a SLEAP file. + """Create a ``movement`` poses dataset from a SLEAP file. Parameters ---------- @@ -290,7 +290,7 @@ def from_sleap_file( def from_lp_file( file_path: Path | str, fps: float | None = None ) -> xr.Dataset: - """Create a ``movement`` dataset from a LightningPose file. + """Create a ``movement`` poses dataset from a LightningPose file. Parameters ---------- @@ -320,7 +320,7 @@ def from_lp_file( def from_dlc_file( file_path: Path | str, fps: float | None = None ) -> xr.Dataset: - """Create a ``movement`` dataset from a DeepLabCut file. + """Create a ``movement`` poses dataset from a DeepLabCut file. Parameters ---------- @@ -357,7 +357,7 @@ def _ds_from_lp_or_dlc_file( source_software: Literal["LightningPose", "DeepLabCut"], fps: float | None = None, ) -> xr.Dataset: - """Create a ``movement`` dataset from a LightningPose or DeepLabCut file. + """Create a ``movement`` poses dataset from a LightningPose or DLC file. Parameters ---------- @@ -406,7 +406,7 @@ def _ds_from_lp_or_dlc_file( def _ds_from_sleap_analysis_file( file_path: Path, fps: float | None ) -> xr.Dataset: - """Create a ``movement`` dataset from a SLEAP analysis (.h5) file. + """Create a ``movement`` poses dataset from a SLEAP analysis (.h5) file. Parameters ---------- @@ -454,7 +454,7 @@ def _ds_from_sleap_analysis_file( def _ds_from_sleap_labels_file( file_path: Path, fps: float | None ) -> xr.Dataset: - """Create a ``movement`` dataset from a SLEAP labels (.slp) file. + """Create a ``movement`` poses dataset from a SLEAP labels (.slp) file. Parameters ---------- @@ -630,7 +630,7 @@ def _df_from_dlc_h5(file_path: Path) -> pd.DataFrame: def _ds_from_valid_data(data: ValidPosesDataset) -> xr.Dataset: - """Create a ``movement`` dataset from validated pose tracking data. + """Create a ``movement`` poses dataset from validated pose tracking data. Parameters ---------- diff --git a/movement/validators/files.py b/movement/validators/files.py index 144fd829..84f1b61d 100644 --- a/movement/validators/files.py +++ b/movement/validators/files.py @@ -251,10 +251,10 @@ def csv_file_contains_valid_frame_numbers(self, attribute, value): This involves: - Checking that frame numbers are included in `file_attributes` or - encoded in the image file `filename`. + encoded in the image file `filename`. - Checking the frame number can be cast as an integer. - Checking that there are as many unique frame numbers as unique image - files. + files. If the frame number is included as part of the image file name, it is expected as an integer led by at least one zero, between "_" and ".", @@ -321,7 +321,7 @@ def csv_file_contains_tracked_bboxes(self, attribute, value): This involves: - Checking that the bounding boxes are defined as rectangles. - Checking that the bounding boxes have all geometric parameters - (["x", "y", "width", "height"]). + (["x", "y", "width", "height"]). - Checking that the bounding boxes have a track ID defined. - Checking that the track ID can be cast as an integer. """ From d10ec20e9d03fa8df4172145889e81428edd81f0 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Tue, 30 Jul 2024 16:20:17 +0100 Subject: [PATCH 24/65] Auto-generate API index page (#234) * Automate api_index.rst generation * Revert to using (updated) NIU build and publish docs actions * Include `make` commands in fancy tabs * Swap tab order * Mention index paths in the corresponding tabs --- .github/workflows/docs_build_and_deploy.yml | 2 + .gitignore | 1 + CONTRIBUTING.md | 85 +++++++++++++-------- docs/Makefile | 13 +++- docs/make.bat | 3 + docs/make_api_index.py | 44 +++++++++++ docs/source/_templates/api_index_head.rst | 13 ++++ docs/source/api_index.rst | 25 ------ 8 files changed, 128 insertions(+), 58 deletions(-) create mode 100644 docs/make_api_index.py create mode 100644 docs/source/_templates/api_index_head.rst delete mode 100644 docs/source/api_index.rst diff --git a/.github/workflows/docs_build_and_deploy.yml b/.github/workflows/docs_build_and_deploy.yml index fe65f42f..f9e3a5c8 100644 --- a/.github/workflows/docs_build_and_deploy.yml +++ b/.github/workflows/docs_build_and_deploy.yml @@ -30,6 +30,7 @@ jobs: - uses: neuroinformatics-unit/actions/build_sphinx_docs@main with: python-version: 3.11 + use-make: true deploy_sphinx_docs: name: Deploy Sphinx Docs @@ -42,3 +43,4 @@ jobs: - uses: neuroinformatics-unit/actions/deploy_sphinx_docs@main with: secret_input: ${{ secrets.GITHUB_TOKEN }} + use-make: true diff --git a/.gitignore b/.gitignore index 19c88937..c46b1d71 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ instance/ docs/build/ docs/source/examples/ docs/source/api/ +docs/source/api_index.rst sg_execution_times.rst # MkDocs documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec3ee0e7..e39412a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -209,32 +209,14 @@ If it is not yet defined and you have multiple external links pointing to the sa ### Updating the API reference -If your PR introduces new public modules, or renames existing ones, -make sure to add them to the `docs/source/api_index.rst` page, so that they are included in the [API reference](target-api), e.g.: - -```rst -API Reference -============= - -Information on specific functions, classes, and methods. - -.. rubric:: Modules - -.. autosummary:: - :toctree: api - :recursive: - :nosignatures: - - movement.move_accessor - movement.io.load_poses - movement.io.save_poses - movement.your_new_module -``` - -The API reference is auto-generated by the `sphinx-autodoc` and `sphinx-autosummary` plugins, based on docstrings. +The API reference is auto-generated by the `docs/make_api_index.py` script, and the `sphinx-autodoc` and `sphinx-autosummary` plugins. +The script generates the `docs/source/api_index.rst` file containing the list of modules to be included in the [API reference](target-api). +The plugins then generate the API reference pages for each module listed in `api_index.rst`, based on the docstrings in the source code. So make sure that all your public functions/classes/methods have valid docstrings following the [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) style. Our `pre-commit` hooks include some checks (`ruff` rules) that ensure the docstrings are formatted consistently. +If your PR introduces new modules that should *not* be documented in the [API reference](target-api), or if there are changes to existing modules that necessitate their removal from the documentation, make sure to update the `exclude_modules` list within the `docs/make_api_index.py` script to reflect these exclusions. + ### Updating the examples We use [sphinx-gallery](sphinx-gallery:) to create the [examples](target-examples). @@ -252,28 +234,68 @@ examples are run. See the relevant section of the ### Building the documentation locally We recommend that you build and view the documentation website locally, before you push it. -To do so, first install the requirements for building the documentation: +To do so, first navigate to `docs/`. +All subsequent commands should be run from within this directory. +```sh +cd docs +``` +Install the requirements for building the documentation: ```sh -pip install -r docs/requirements.txt +pip install -r requirements.txt ``` -Then, from the root of the repository, run: +Build the documentation: + +::::{tab-set} +:::{tab-item} Unix platforms with `make` +```sh +make html +``` +The local build can be viewed by opening `docs/build/html/index.html` in a browser. +::: + +:::{tab-item} All platforms ```sh -sphinx-build docs/source docs/build +python make_api_index.py && sphinx-build source build ``` +The local build can be viewed by opening `docs/build/index.html` in a browser. +::: +:::: -You can view the local build by opening `docs/build/index.html` in a browser. -To refresh the documentation, after making changes, remove the `docs/build` folder and re-run the above command: +To refresh the documentation after making changes, remove all generated files in `docs/`, +including the auto-generated API index `source/api_index.rst`, and those in `build/`, `source/api/`, and `source/examples/`. +Then, re-run the above command to rebuild the documentation. +::::{tab-set} +:::{tab-item} Unix platforms with `make` ```sh -rm -rf docs/build && sphinx-build docs/source docs/build +make clean html ``` +::: + +:::{tab-item} All platforms +```sh +rm -f source/api_index.rst && rm -rf build && rm -rf source/api && rm -rf source/examples +python make_api_index.py && sphinx-build source build +``` +::: +:::: To check that external links are correctly resolved, run: +::::{tab-set} +:::{tab-item} Unix platforms with `make` ```sh -sphinx-build docs/source docs/build -b linkcheck +make linkcheck ``` +::: + +:::{tab-item} All platforms +```sh +sphinx-build source build -b linkcheck +``` +::: +:::: If the linkcheck step incorrectly marks links with valid anchors as broken, you can skip checking the anchors in specific links by adding the URLs to `linkcheck_anchors_ignore_for_url` in `docs/source/conf.py`, e.g.: @@ -332,7 +354,6 @@ To add a new file, you will need to: 5. Add your new files to the `poses`, `bboxes`, `videos` and/or `frames` folders as appropriate. Follow the existing file naming conventions as closely as possible. 6. Determine the sha256 checksum hash of each new file. You can do this in a terminal by running: ::::{tab-set} - :::{tab-item} Ubuntu ```bash sha256sum diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf1..623d2906 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -14,7 +14,18 @@ help: .PHONY: help Makefile +# Generate the API index file +api_index.rst: + python make_api_index.py + +# Remove all generated files +clean: + rm -rf ./build + rm -f ./source/api_index.rst + rm -rf ./source/api + rm -rf ./source/examples + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile +%: Makefile api_index.rst @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat index dc1312ab..79a8b01a 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -25,6 +25,9 @@ if errorlevel 9009 ( if "%1" == "" goto help +echo "Generating API index..." +python make_api_index.py + %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end diff --git a/docs/make_api_index.py b/docs/make_api_index.py new file mode 100644 index 00000000..37223112 --- /dev/null +++ b/docs/make_api_index.py @@ -0,0 +1,44 @@ +"""Generate the API index page for all ``movement`` modules.""" + +import os + +# Modules to exclude from the API index +exclude_modules = ["cli_entrypoint"] + +# Set the current working directory to the directory of this script +script_dir = os.path.dirname(os.path.abspath(__file__)) +os.chdir(script_dir) + + +def make_api_index(): + """Create a doctree of all ``movement`` modules.""" + doctree = "\n" + + for root, _, files in os.walk("../movement"): + # Remove leading "../" + root = root[3:] + for file in sorted(files): + if file.endswith(".py") and not file.startswith("_"): + # Convert file path to module name + module_name = os.path.join(root, file) + module_name = module_name[:-3].replace(os.sep, ".") + # Check if the module should be excluded + if not any( + file.startswith(exclude_module) + for exclude_module in exclude_modules + ): + doctree += f" {module_name}\n" + + # Get the header + with open("./source/_templates/api_index_head.rst") as f: + api_head = f.read() + # Write api_index.rst with header + doctree + with open("./source/api_index.rst", "w") as f: + f.write("..\n This file is auto-generated.\n\n") + f.write(api_head) + f.write(doctree) + print(os.path.abspath("./source/api_index.rst")) + + +if __name__ == "__main__": + make_api_index() diff --git a/docs/source/_templates/api_index_head.rst b/docs/source/_templates/api_index_head.rst new file mode 100644 index 00000000..f1df7eb6 --- /dev/null +++ b/docs/source/_templates/api_index_head.rst @@ -0,0 +1,13 @@ +.. _target-api: + +API Reference +============= + +Information on specific functions, classes, and methods. + +.. rubric:: Modules + +.. autosummary:: + :toctree: api + :recursive: + :nosignatures: diff --git a/docs/source/api_index.rst b/docs/source/api_index.rst deleted file mode 100644 index 3960acf1..00000000 --- a/docs/source/api_index.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. _target-api: - -API Reference -============= - -Information on specific functions, classes, and methods. - -.. rubric:: Modules - -.. autosummary:: - :toctree: api - :recursive: - :nosignatures: - - movement.move_accessor - movement.io.load_poses - movement.io.save_poses - movement.filtering - movement.analysis.kinematics - movement.utils.vector - movement.utils.logging - movement.utils.reports - movement.sample_data - movement.validators.files - movement.validators.datasets From 01c3cf6de01d4433f13aaaac4156296d48482a51 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 31 Jul 2024 11:44:49 +0100 Subject: [PATCH 25/65] Load bboxes dataset from VIA tracks file (3/4) (#229) * Draft load_bboxes.py * Keep dev notebook in history * Add df reformatting * Add extraction of arrays of expected shape * Some refactoring * From numpy array to xarray * Add test file * Remove dev notebook * Add "type" to sample datasets metadata, to include bbox data * Add sample data test * Docstrings and small refactoring * Add frame_array to ValidBbboxesDataset * Some cosmetic edits * test_fetch_dataset passes for bbox sample file * test_load_from_VIA_tracks_file passes * test_fps_and_time_coords passes * test_from_file_delegates_correctly passes * Move bboxes fixture out of conftest * test_from_numpy_valid passes * SImplify via_tracks_file fixture * Add test for via_attribute_column_to_numpy function * Apply docstring rephrasing suggestion Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> * Make precommit happy * Add test to extract confidence function * Add test for extracting frame from via tracks df * refactor fixtures * Small edits and docstring review * Update API docs * Apply suggestions from code review Co-authored-by: Niko Sirmpilatze * Fix rebase artifacts * Edits to docstrings for consistency * Modify `_via_attribute_column_to_numpy`, `_extract_frame_number_from_via_tracks_df` and `_extract_confidence_from_via_tracks_df` to return one-dimensional arrays instead of forcing always two-dimensional * Small edits to docstring & comments * Add "ds_type": "poses" to poses dataset * Make time origin at frame 0 == 0 seconds * Add load_bboxes to API index * Remove pass and refactor dataset fetching if bboxes * Fix test for time coordinates if fps passed * Add docstring examples to explain time origin * Add `use_frame_numbers_from_file` when loading a bboxes dataset from file or VIA tracks file * Add examples to docstrings of `use_frame_numbers_from_file` * Change default behaviour to not use frame numbers from file * Small edits to docstrings for doc building * Remove ignore comment * Update one-line summary of load_bboxes module for API reference * Update one-line summary of load_bboxes module for API reference * Fix backticks * Swap examples in docstrings * Delete API index * Fix from_file example --------- Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Co-authored-by: Niko Sirmpilatze --- movement/io/load_bboxes.py | 648 ++++++++++++++++++ movement/io/load_poses.py | 1 + movement/sample_data.py | 19 +- movement/validators/datasets.py | 30 +- movement/validators/files.py | 19 +- tests/test_unit/test_load_bboxes.py | 421 ++++++++++++ tests/test_unit/test_sample_data.py | 5 + .../test_validators/test_files_validators.py | 4 +- 8 files changed, 1114 insertions(+), 33 deletions(-) create mode 100644 movement/io/load_bboxes.py create mode 100644 tests/test_unit/test_load_bboxes.py diff --git a/movement/io/load_bboxes.py b/movement/io/load_bboxes.py new file mode 100644 index 00000000..c6df5695 --- /dev/null +++ b/movement/io/load_bboxes.py @@ -0,0 +1,648 @@ +"""Load bounding boxes' tracking data into ``movement``.""" + +import ast +import logging +import re +from collections.abc import Callable +from pathlib import Path +from typing import Literal + +import numpy as np +import pandas as pd +import xarray as xr + +from movement import MovementDataset +from movement.utils.logging import log_error +from movement.validators.datasets import ValidBboxesDataset +from movement.validators.files import ValidFile, ValidVIATracksCSV + +logger = logging.getLogger(__name__) + + +def from_numpy( + position_array: np.ndarray, + shape_array: np.ndarray, + confidence_array: np.ndarray | None = None, + individual_names: list[str] | None = None, + frame_array: np.ndarray | None = None, + fps: float | None = None, + source_software: str | None = None, +) -> xr.Dataset: + """Create a ``movement`` bounding boxes dataset from NumPy arrays. + + Parameters + ---------- + position_array : np.ndarray + Array of shape (n_frames, n_individuals, n_space) + containing the tracks of the bounding boxes' centroids. + It will be converted to a :py:class:`xarray.DataArray` object + named "position". + shape_array : np.ndarray + Array of shape (n_frames, n_individuals, n_space) + containing the shape of the bounding boxes. The shape of a bounding + box is its width (extent along the x-axis of the image) and height + (extent along the y-axis of the image). It will be converted to a + :py:class:`xarray.DataArray` object named "shape". + confidence_array : np.ndarray, optional + Array of shape (n_frames, n_individuals) containing + the confidence scores of the bounding boxes. If None (default), the + confidence scores are set to an array of NaNs. It will be converted + to a :py:class:`xarray.DataArray` object named "confidence". + individual_names : list of str, optional + List of individual names for the tracked bounding boxes in the video. + If None (default), bounding boxes are assigned names based on the size + of the ``position_array``. The names will be in the format of + ``id_``, where is an integer from 0 to + ``position_array.shape[1]-1`` (i.e., "id_0", "id_1"...). + frame_array : np.ndarray, optional + Array of shape (n_frames, 1) containing the frame numbers for which + bounding boxes are defined. If None (default), frame numbers will + be assigned based on the first dimension of the ``position_array``, + starting from 0. If a specific array of frame numbers is provided, + these need to be consecutive integers. + fps : float, optional + The video sampling rate. If None (default), the ``time`` coordinates + of the resulting ``movement`` dataset will be in frame numbers. If + ``fps`` is provided, the ``time`` coordinates will be in seconds. If + the ``time`` coordinates are in seconds, they will indicate the + elapsed time from the capture of the first frame (assumed to be frame + 0). + source_software : str, optional + Name of the software that generated the data. Defaults to None. + + Returns + ------- + xarray.Dataset + ``movement`` dataset containing the position, shape, and confidence + scores of the tracked bounding boxes, and any associated metadata. + + Examples + -------- + Create random position data for two bounding boxes, ``id_0`` and ``id_1``, + with the same width (40 pixels) and height (30 pixels). These are tracked + in 2D space for 100 frames, which are numbered from the start frame 1200 + to the end frame 1299. The confidence score for all bounding boxes is set + to 0.5. + + >>> import numpy as np + >>> from movement.io import load_bboxes + >>> ds = load_bboxes.from_numpy( + ... position_array=np.random.rand(100, 2, 2), + ... shape_array=np.ones((100, 2, 2)) * [40, 30], + ... confidence_array=np.ones((100, 2)) * 0.5, + ... individual_names=["id_0", "id_1"], + ... frame_array=np.arange(1200, 1300).reshape(-1, 1), + ... ) + + Create a dataset with the same data as above, but with the time + coordinates in seconds. We use a video sampling rate of 60 fps. The time + coordinates in the resulting dataset will indicate the elapsed time from + the capture of the 0th frame. So for the frames 1200, 1201, 1203,... 1299 + the corresponding time coordinates in seconds will be 20, 20.0167, + 20.033,... 21.65 s. + + >>> ds = load_bboxes.from_numpy( + ... position_array=np.random.rand(100, 2, 2), + ... shape_array=np.ones((100, 2, 2)) * [40, 30], + ... confidence_array=np.ones((100, 2)) * 0.5, + ... individual_names=["id_0", "id_1"], + ... frame_array=np.arange(1200, 1300).reshape(-1, 1), + ... fps=60, + ... ) + + Create a dataset with the same data as above, but express the time + coordinate in frames, and assume the first tracked frame is frame 0. + To do this, we simply omit the ``frame_array`` input argument. + + >>> ds = load_bboxes.from_numpy( + ... position_array=np.random.rand(100, 2, 2), + ... shape_array=np.ones((100, 2, 2)) * [40, 30], + ... confidence_array=np.ones((100, 2)) * 0.5, + ... individual_names=["id_0", "id_1"], + ... ) + + Create a dataset with the same data as above, but express the time + coordinate in seconds, and assume the first tracked frame is captured + at time = 0 seconds. To do this, we omit the ``frame_array`` input argument + and pass an ``fps`` value. + + >>> ds = load_bboxes.from_numpy( + ... position_array=np.random.rand(100, 2, 2), + ... shape_array=np.ones((100, 2, 2)) * [40, 30], + ... confidence_array=np.ones((100, 2)) * 0.5, + ... individual_names=["id_0", "id_1"], + ... fps=60, + ... ) + + """ + valid_bboxes_data = ValidBboxesDataset( + position_array=position_array, + shape_array=shape_array, + confidence_array=confidence_array, + individual_names=individual_names, + frame_array=frame_array, + fps=fps, + source_software=source_software, + ) + return _ds_from_valid_data(valid_bboxes_data) + + +def from_file( + file_path: Path | str, + source_software: Literal["VIA-tracks"], + fps: float | None = None, + use_frame_numbers_from_file: bool = False, +) -> xr.Dataset: + """Create a ``movement`` bounding boxes dataset from a supported file. + + At the moment, we only support VIA-tracks .csv files. + + Parameters + ---------- + file_path : pathlib.Path or str + Path to the file containing the tracked bounding boxes. Currently + only VIA-tracks .csv files are supported. + source_software : "VIA-tracks". + The source software of the file. Currently only files from the + VIA 2.0.12 annotator [1]_ ("VIA-tracks") are supported. + See . + fps : float, optional + The video sampling rate. If None (default), the ``time`` coordinates + of the resulting ``movement`` dataset will be in frame numbers. If + ``fps`` is provided, the ``time`` coordinates will be in seconds. If + the ``time`` coordinates are in seconds, they will indicate the + elapsed time from the capture of the first frame (assumed to be frame + 0). + use_frame_numbers_from_file : bool, optional + If True, the frame numbers in the resulting dataset are + the same as the ones specified for each tracked bounding box in the + input file. This may be useful if the bounding boxes are tracked for a + subset of frames in a video, but you want to maintain the start of the + full video as the time origin. If False (default), the frame numbers + in the VIA tracks .csv file are instead mapped to a 0-based sequence of + consecutive integers. + + Returns + ------- + xarray.Dataset + ``movement`` dataset containing the position, shape, and confidence + scores of the tracked bounding boxes, and any associated metadata. + + See Also + -------- + movement.io.load_bboxes.from_via_tracks_file + + References + ---------- + .. [1] https://www.robots.ox.ac.uk/~vgg/software/via/ + + Examples + -------- + Create a dataset from the VIA tracks .csv file at "path/to/file.csv", with + the time coordinates in seconds, and assuming t = 0 seconds corresponds to + the first tracked frame in the file. + + >>> from movement.io import load_bboxes + >>> ds = load_bboxes.from_file( + >>> "path/to/file.csv", + >>> source_software="VIA-tracks", + >>> fps=30, + >>> ) + + """ + if source_software == "VIA-tracks": + return from_via_tracks_file( + file_path, + fps, + use_frame_numbers_from_file=use_frame_numbers_from_file, + ) + else: + raise log_error( + ValueError, f"Unsupported source software: {source_software}" + ) + + +def from_via_tracks_file( + file_path: Path | str, + fps: float | None = None, + use_frame_numbers_from_file: bool = False, +) -> xr.Dataset: + """Create a ``movement`` dataset from a VIA tracks .csv file. + + Parameters + ---------- + file_path : pathlib.Path or str + Path to the VIA tracks .csv file with the tracked bounding boxes. + For more information on the VIA tracks .csv file format, see the VIA + tutorial for tracking [1]_. + fps : float, optional + The video sampling rate. If None (default), the ``time`` coordinates + of the resulting ``movement`` dataset will be in frame numbers. If + ``fps`` is provided, the ``time`` coordinates will be in seconds. If + the ``time`` coordinates are in seconds, they will indicate the + elapsed time from the capture of the first frame (assumed to be frame + 0). + use_frame_numbers_from_file : bool, optional + If True, the frame numbers in the resulting dataset are + the same as the ones in the VIA tracks .csv file. This may be useful if + the bounding boxes are tracked for a subset of frames in a video, + but you want to maintain the start of the full video as the time + origin. If False (default), the frame numbers in the VIA tracks .csv + file are instead mapped to a 0-based sequence of consecutive integers. + + Returns + ------- + xarray.Dataset + ``movement`` dataset containing the position, shape, and confidence + scores of the tracked bounding boxes, and any associated metadata. + + Notes + ----- + The bounding boxes' IDs specified in the "track" field of the VIA + tracks .csv file are mapped to the "individual_name" column of the + ``movement`` dataset. The individual names follow the format ``id_``, + with N being the bounding box ID. + + References + ---------- + .. [1] https://www.robots.ox.ac.uk/~vgg/software/via/docs/face_track_annotation.html + + Examples + -------- + Create a dataset from the VIA tracks .csv file at "path/to/file.csv", with + the time coordinates in frames, and setting the first tracked frame in the + file as frame 0. + + >>> from movement.io import load_bboxes + >>> ds = load_bboxes.from_via_tracks_file( + ... "path/to/file.csv", + ... ) + + Create a dataset from the VIA tracks .csv file at "path/to/file.csv", with + the time coordinates in seconds, and assuming t = 0 seconds corresponds to + the first tracked frame in the file. + + >>> from movement.io import load_bboxes + >>> ds = load_bboxes.from_via_tracks_file( + ... "path/to/file.csv", + ... fps=30, + ... ) + + Create a dataset from the VIA tracks .csv file at "path/to/file.csv", with + the time coordinates in frames, and using the same frame numbers as + in the VIA tracks .csv file. + + >>> from movement.io import load_bboxes + >>> ds = load_bboxes.from_via_tracks_file( + ... "path/to/file.csv", + ... use_frame_numbers_from_file=True. + ... ) + + Create a dataset from the VIA tracks .csv file at "path/to/file.csv", with + the time coordinates in seconds, and assuming t = 0 seconds corresponds to + the 0th frame in the full video. + + >>> from movement.io import load_bboxes + >>> ds = load_bboxes.from_via_tracks_file( + ... "path/to/file.csv", + ... fps=30, + ... use_frame_numbers_from_file=True, + ... ) + + + """ + # General file validation + file = ValidFile( + file_path, expected_permission="r", expected_suffix=[".csv"] + ) + + # Specific VIA-tracks .csv file validation + via_file = ValidVIATracksCSV(file.path) + logger.debug(f"Validated VIA tracks .csv file {via_file.path}.") + + # Create an xarray.Dataset from the data + bboxes_arrays = _numpy_arrays_from_via_tracks_file(via_file.path) + ds = from_numpy( + position_array=bboxes_arrays["position_array"], + shape_array=bboxes_arrays["shape_array"], + confidence_array=bboxes_arrays["confidence_array"], + individual_names=[ + f"id_{id}" for id in bboxes_arrays["ID_array"].squeeze() + ], + frame_array=( + bboxes_arrays["frame_array"] + if use_frame_numbers_from_file + else None + ), + fps=fps, + source_software="VIA-tracks", + ) # it validates the dataset via ValidBboxesDataset + + # Add metadata as attributes + ds.attrs["source_software"] = "VIA-tracks" + ds.attrs["source_file"] = file.path.as_posix() + + logger.info(f"Loaded tracks of the bounding boxes from {via_file.path}:") + logger.info(ds) + return ds + + +def _numpy_arrays_from_via_tracks_file(file_path: Path) -> dict: + """Extract numpy arrays from the input VIA tracks .csv file. + + The extracted numpy arrays are returned in a dictionary with the following + keys: + - position_array (n_frames, n_individuals, n_space): + contains the trajectories of the bounding boxes' centroids. + - shape_array (n_frames, n_individuals, n_space): + contains the shape of the bounding boxes (width and height). + - confidence_array (n_frames, n_individuals): + contains the confidence score of each bounding box. + If no confidence scores are provided, they are set to an array of NaNs. + - ID_array (n_individuals, 1): + contains the integer IDs of the tracked bounding boxes. + - frame_array (n_frames, 1): + contains the frame numbers. + + Parameters + ---------- + file_path : pathlib.Path + Path to the VIA tracks .csv file containing the bounding boxes' tracks. + + Returns + ------- + dict + The validated bounding boxes' arrays. + + """ + # Extract 2D dataframe from input data + # (sort data by ID and frame number, and + # fill empty frame-ID pairs with nans) + df = _df_from_via_tracks_file(file_path) + + # Compute indices of the rows where the IDs switch + bool_ID_diff_from_prev = df["ID"].ne(df["ID"].shift()) # pandas series + indices_ID_switch = ( + bool_ID_diff_from_prev.loc[lambda x: x].index[1:].to_numpy() + ) + + # Stack position, shape and confidence arrays along ID axis + map_key_to_columns = { + "position_array": ["x", "y"], + "shape_array": ["w", "h"], + "confidence_array": ["confidence"], + } + array_dict = {} + for key in map_key_to_columns: + list_arrays = np.split( + df[map_key_to_columns[key]].to_numpy(), + indices_ID_switch, # indices along axis=0 + ) + + array_dict[key] = np.stack(list_arrays, axis=1).squeeze() + + # Add remaining arrays to dict + array_dict["ID_array"] = df["ID"].unique().reshape(-1, 1) + array_dict["frame_array"] = df["frame_number"].unique().reshape(-1, 1) + + return array_dict + + +def _df_from_via_tracks_file(file_path: Path) -> pd.DataFrame: + """Load VIA tracks .csv file as a dataframe. + + Read the VIA tracks .csv file as a pandas dataframe with columns: + - ID: the integer ID of the tracked bounding box. + - frame_number: the frame number of the tracked bounding box. + - x: the x-coordinate of the tracked bounding box centroid. + - y: the y-coordinate of the tracked bounding box centroid. + - w: the width of the tracked bounding box. + - h: the height of the tracked bounding box. + - confidence: the confidence score of the tracked bounding box. + + The dataframe is sorted by ID and frame number, and for each ID, + empty frames are filled in with NaNs. + """ + # Read VIA tracks .csv file as a pandas dataframe + df_file = pd.read_csv(file_path, sep=",", header=0) + + # Format to a 2D dataframe + df = pd.DataFrame( + { + "ID": _via_attribute_column_to_numpy( + df_file, "region_attributes", ["track"], int + ), + "frame_number": _extract_frame_number_from_via_tracks_df(df_file), + "x": _via_attribute_column_to_numpy( + df_file, "region_shape_attributes", ["x"], float + ), + "y": _via_attribute_column_to_numpy( + df_file, "region_shape_attributes", ["y"], float + ), + "w": _via_attribute_column_to_numpy( + df_file, "region_shape_attributes", ["width"], float + ), + "h": _via_attribute_column_to_numpy( + df_file, "region_shape_attributes", ["height"], float + ), + "confidence": _extract_confidence_from_via_tracks_df(df_file), + } + ) + + # Sort dataframe by ID and frame number + df = df.sort_values(by=["ID", "frame_number"]).reset_index(drop=True) + + # Fill in empty frames with nans + multi_index = pd.MultiIndex.from_product( + [df["ID"].unique(), df["frame_number"].unique()], + names=["ID", "frame_number"], + ) # desired index: all combinations of ID and frame number + + # Set index to (ID, frame number), fill in values with nans and + # reset to original index + df = ( + df.set_index(["ID", "frame_number"]).reindex(multi_index).reset_index() + ) + return df + + +def _extract_confidence_from_via_tracks_df(df) -> np.ndarray: + """Extract confidence scores from the VIA tracks input dataframe. + + Parameters + ---------- + df : pd.DataFrame + The VIA tracks input dataframe is the one obtained from + ``df = pd.read_csv(file_path, sep=",", header=0)``. + + Returns + ------- + np.ndarray + A numpy array of size (n_bboxes, ) containing the bounding boxes + confidence scores. + + """ + region_attributes_dicts = [ + ast.literal_eval(d) for d in df.region_attributes + ] + + # Check if confidence is defined as a region attribute, else set to NaN + if all(["confidence" in d for d in region_attributes_dicts]): + bbox_confidence = _via_attribute_column_to_numpy( + df, "region_attributes", ["confidence"], float + ) + else: + bbox_confidence = np.full((df.shape[0], 1), np.nan).squeeze() + + return bbox_confidence + + +def _extract_frame_number_from_via_tracks_df(df) -> np.ndarray: + """Extract frame numbers from the VIA tracks input dataframe. + + Parameters + ---------- + df : pd.DataFrame + The VIA tracks input dataframe is the one obtained from + ``df = pd.read_csv(file_path, sep=",", header=0)``. + + Returns + ------- + np.ndarray + A numpy array of size (n_frames, ) containing the frame numbers. + In the VIA tracks .csv file, the frame number is expected to be + defined as a 'file_attribute' , or encoded in the filename as an + integer number led by at least one zero, between "_" and ".", followed + by the file extension. + + """ + # Extract frame number from file_attributes if exists + file_attributes_dicts = [ast.literal_eval(d) for d in df.file_attributes] + if all(["frame" in d for d in file_attributes_dicts]): + frame_array = _via_attribute_column_to_numpy( + df, + via_column_name="file_attributes", + list_keys=["frame"], + cast_fn=int, + ) + # Else extract from filename + else: + pattern = r"_(0\d*)\.\w+$" + list_frame_numbers = [ + int(re.search(pattern, f).group(1)) # type: ignore + if re.search(pattern, f) + else np.nan + for f in df["filename"] + ] + + frame_array = np.array(list_frame_numbers) + + return frame_array + + +def _via_attribute_column_to_numpy( + df: pd.DataFrame, + via_column_name: str, + list_keys: list[str], + cast_fn: Callable = float, +) -> np.ndarray: + """Convert values from VIA attribute-type column to a numpy array. + + In the VIA tracks .csv file, the attribute-type columns are the columns + whose name includes the word ``attributes`` (i.e. ``file_attributes``, + ``region_shape_attributes`` or ``region_attributes``). These columns hold + dictionary data. + + Parameters + ---------- + df : pd.DataFrame + The pandas DataFrame containing the data from the VIA tracks .csv file. + This is the dataframe obtained from running + ``df = pd.read_csv(file_path, sep=",", header=0)``. + via_column_name : str + The name of a column in the VIA tracks .csv file whose values are + literal dictionaries (i.e. ``file_attributes``, + ``region_shape_attributes`` or ``region_attributes``). + list_keys : list[str] + The list of keys whose values we want to extract from the literal + dictionaries in the ``via_column_name`` column. + cast_fn : type, optional + The type function to cast the values to. By default ``float``. + + Returns + ------- + np.ndarray + A numpy array holding the extracted values. If ``len(list_keys) > 1`` + the array is two-dimensional with shape ``(N, len(list_keys))``, where + ``N`` is the number of rows in the input dataframe ``df``. If + ``len(list_keys) == 1``, the resulting array will be one-dimensional, + with shape (N, ). Note that the computed array is squeezed before + returning. + + """ + list_bbox_attr = [] + for _, row in df.iterrows(): + row_dict_data = ast.literal_eval(row[via_column_name]) + list_bbox_attr.append( + tuple(cast_fn(row_dict_data[reg]) for reg in list_keys) + ) + + bbox_attr_array = np.array(list_bbox_attr) + + return bbox_attr_array.squeeze() + + +def _ds_from_valid_data(data: ValidBboxesDataset) -> xr.Dataset: + """Convert a validated bounding boxes dataset to an xarray Dataset. + + Parameters + ---------- + data : movement.validators.datasets.ValidBboxesDataset + The validated bounding boxes dataset object. + + Returns + ------- + bounding boxes dataset containing the boxes tracks, + boxes shapes, confidence scores and associated metadata. + + """ + # Create the time coordinate + time_coords = data.frame_array.squeeze() # type: ignore + time_unit = "frames" + # if fps is provided: + # time_coords is expressed in seconds, with the time origin + # set as frame 0 == time 0 seconds + if data.fps: + # Compute elapsed time from frame 0. + # Ignoring type error because `data.frame_array` is not None after + # ValidBboxesDataset.__attrs_post_init__() # type: ignore + time_coords = np.array( + [frame / data.fps for frame in data.frame_array.squeeze()] # type: ignore + ) + time_unit = "seconds" + + # Convert data to an xarray.Dataset + # with dimensions ('time', 'individuals', 'space') + DIM_NAMES = tuple(a for a in MovementDataset.dim_names if a != "keypoints") + n_space = data.position_array.shape[-1] + return xr.Dataset( + data_vars={ + "position": xr.DataArray(data.position_array, dims=DIM_NAMES), + "shape": xr.DataArray(data.shape_array, dims=DIM_NAMES), + "confidence": xr.DataArray( + data.confidence_array, dims=DIM_NAMES[:-1] + ), + }, + coords={ + DIM_NAMES[0]: time_coords, + DIM_NAMES[1]: data.individual_names, + DIM_NAMES[2]: ["x", "y", "z"][:n_space], + }, + attrs={ + "fps": data.fps, + "time_unit": time_unit, + "source_software": data.source_software, + "source_file": None, + "ds_type": "bboxes", + }, + ) diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index 440caa63..e70e4825 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -674,5 +674,6 @@ def _ds_from_valid_data(data: ValidPosesDataset) -> xr.Dataset: "time_unit": time_unit, "source_software": data.source_software, "source_file": None, + "ds_type": "poses", }, ) diff --git a/movement/sample_data.py b/movement/sample_data.py index 96518c02..67a228cf 100644 --- a/movement/sample_data.py +++ b/movement/sample_data.py @@ -14,7 +14,7 @@ import yaml from requests.exceptions import RequestException -from movement.io import load_poses +from movement.io import load_bboxes, load_poses from movement.utils.logging import log_error, log_warning logger = logging.getLogger(__name__) @@ -289,14 +289,15 @@ def fetch_dataset( """ file_paths = fetch_dataset_paths(filename, with_video=with_video) - ds = load_poses.from_file( - file_paths["poses"], - source_software=metadata[filename]["source_software"], - fps=metadata[filename]["fps"], - ) - - # TODO: Add support for loading bounding boxes data. - # Implemented in PR 229: https://github.com/neuroinformatics-unit/movement/pull/229 + for key, load_module in zip( + ["poses", "bboxes"], [load_poses, load_bboxes], strict=False + ): + if file_paths.get(key): + ds = load_module.from_file( + file_paths[key], + source_software=metadata[filename]["source_software"], + fps=metadata[filename]["fps"], + ) ds.attrs["frame_path"] = file_paths["frame"] ds.attrs["video_path"] = file_paths["video"] diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py index 1449d88d..b25fd4a5 100644 --- a/movement/validators/datasets.py +++ b/movement/validators/datasets.py @@ -197,32 +197,33 @@ class ValidBboxesDataset: Attributes ---------- position_array : np.ndarray - Array of shape (n_frames, n_individual_names, n_space) - containing the bounding boxes' centroid positions. + Array of shape (n_frames, n_individuals, n_space) + containing the tracks of the bounding boxes' centroids. shape_array : np.ndarray - Array of shape (n_frames, n_individual_names, n_space) - containing the bounding boxes' width (extent along the - x-axis) and height (extent along the y-axis). + Array of shape (n_frames, n_individuals, n_space) + containing the shape of the bounding boxes. The shape of a bounding + box is its width (extent along the x-axis of the image) and height + (extent along the y-axis of the image). confidence_array : np.ndarray, optional - Array of shape (n_frames, n_individuals, n_keypoints) containing - the bounding boxes' confidence scores. If None (default), the - confidence scores will be set to an array of NaNs. + Array of shape (n_frames, n_individuals) containing + the confidence scores of the bounding boxes. If None (default), the + confidence scores are set to an array of NaNs. individual_names : list of str, optional List of individual names for the tracked bounding boxes in the video. If None (default), bounding boxes are assigned names based on the size - of the `position_array`. The names will be in the format of `id_`, - where is an integer from 1 to `position_array.shape[1]`. + of the ``position_array``. The names will be in the format of + ``id_``, where is an integer from 0 to + ``position_array.shape[1]-1``. frame_array : np.ndarray, optional Array of shape (n_frames, 1) containing the frame numbers for which bounding boxes are defined. If None (default), frame numbers will - be assigned based on the first dimension of the `position_array`, + be assigned based on the first dimension of the ``position_array``, starting from 0. fps : float, optional Frames per second defining the sampling rate of the data. Defaults to None. source_software : str, optional - Name of the software from which the bounding boxes were loaded. - Defaults to None. + Name of the software that generated the data. Defaults to None. """ @@ -319,6 +320,7 @@ def __attrs_post_init__(self): If no individual names are provided, assign them unique IDs per frame, starting with 0 ("id_0"). """ + # assign default confidence_array if self.confidence_array is None: self.confidence_array = np.full( (self.position_array.shape[:-1]), @@ -330,6 +332,7 @@ def __attrs_post_init__(self): "Setting to an array of NaNs." ) + # assign default individual_names if self.individual_names is None: self.individual_names = [ f"id_{i}" for i in range(self.position_array.shape[1]) @@ -341,6 +344,7 @@ def __attrs_post_init__(self): f"{self.individual_names}.\n" ) + # assign default frame_array if self.frame_array is None: n_frames = self.position_array.shape[0] self.frame_array = np.arange(n_frames).reshape(-1, 1) diff --git a/movement/validators/files.py b/movement/validators/files.py index 84f1b61d..7aec4d98 100644 --- a/movement/validators/files.py +++ b/movement/validators/files.py @@ -37,9 +37,9 @@ class ValidFile: PermissionError If the file does not have the expected access permission(s). FileNotFoundError - If the file does not exist when `expected_permission` is "r" or "rw". + If the file does not exist when ``expected_permission`` is "r" or "rw". FileExistsError - If the file exists when `expected_permission` is "w". + If the file exists when ``expected_permission`` is "w". ValueError If the file does not have one of the expected suffix(es). @@ -215,7 +215,7 @@ class ValidVIATracksCSV: Raises ------ ValueError - If the .csv file does not match the VIA tracks file requirements. + If the file does not match the VIA tracks .csv file requirements. """ @@ -241,7 +241,7 @@ def csv_file_contains_valid_header(self, attribute, value): raise log_error( ValueError, ".csv header row does not match the known format for " - "VIA tracks output files. " + "VIA tracks .csv files. " f"Expected {expected_header} but got {header}.", ) @@ -250,15 +250,16 @@ def csv_file_contains_valid_frame_numbers(self, attribute, value): """Ensure that the VIA tracks .csv file contains valid frame numbers. This involves: - - Checking that frame numbers are included in `file_attributes` or - encoded in the image file `filename`. + - Checking that frame numbers are included in ``file_attributes`` or + encoded in the image file ``filename``. - Checking the frame number can be cast as an integer. - Checking that there are as many unique frame numbers as unique image files. - If the frame number is included as part of the image file name, it is - expected as an integer led by at least one zero, between "_" and ".", - followed by the file extension. + If the frame number is included as part of the image file name, then + it is expected as an integer led by at least one zero, between "_" and + ".", followed by the file extension. + """ df = pd.read_csv(value, sep=",", header=0) diff --git a/tests/test_unit/test_load_bboxes.py b/tests/test_unit/test_load_bboxes.py new file mode 100644 index 00000000..58e6c876 --- /dev/null +++ b/tests/test_unit/test_load_bboxes.py @@ -0,0 +1,421 @@ +"""Test suite for the load_bboxes module.""" + +import ast +from unittest.mock import patch + +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from movement import MovementDataset +from movement.io import load_bboxes + + +@pytest.fixture() +def via_tracks_file(): + """Return the file path for a VIA tracks .csv file.""" + via_sample_file_name = "VIA_multiple-crabs_5-frames_labels.csv" + return pytest.DATA_PATHS.get(via_sample_file_name) + + +@pytest.fixture() +def valid_from_numpy_inputs_required_arrays(): + """Return a dictionary with valid numpy arrays for the `from_numpy()` + loader, excluding the optional `frame_array`. + """ + n_frames = 5 + n_individuals = 86 + n_space = 2 + individual_names_array = np.arange(n_individuals).reshape(-1, 1) + + rng = np.random.default_rng(seed=42) + + return { + "position_array": rng.random((n_frames, n_individuals, n_space)), + "shape_array": rng.random((n_frames, n_individuals, n_space)), + "confidence_array": rng.random((n_frames, n_individuals)), + "individual_names": [ + f"id_{id}" for id in individual_names_array.squeeze() + ], + } + + +@pytest.fixture() +def valid_from_numpy_inputs_all_arrays( + valid_from_numpy_inputs_required_arrays, +): + """Return a dictionary with valid numpy arrays for the from_numpy() loader, + including a `frame_array` that ranges from frame 1 to 5. + """ + n_frames = valid_from_numpy_inputs_required_arrays["position_array"].shape[ + 0 + ] + first_frame_number = 1 # should match sample file + + valid_from_numpy_inputs_required_arrays["frame_array"] = np.arange( + first_frame_number, first_frame_number + n_frames + ).reshape(-1, 1) + + return valid_from_numpy_inputs_required_arrays + + +@pytest.fixture() +def df_input_via_tracks_small(via_tracks_file): + """Return the first 3 rows of the VIA tracks .csv file as a dataframe.""" + df = pd.read_csv(via_tracks_file, sep=",", header=0) + return df.loc[:2, :] + + +@pytest.fixture() +def df_input_via_tracks_small_with_confidence(df_input_via_tracks_small): + """Return a dataframe with the first three rows of the VIA tracks .csv file + and add confidence values to the bounding boxes. + """ + df = update_attribute_column( + df_input=df_input_via_tracks_small, + attribute_column_name="region_attributes", + dict_to_append={"confidence": "0.5"}, + ) + + return df + + +@pytest.fixture() +def df_input_via_tracks_small_with_frame_number(df_input_via_tracks_small): + """Return a dataframe with the first three rows of the VIA tracks .csv file + and add frame number values to the bounding boxes. + """ + df = update_attribute_column( + df_input=df_input_via_tracks_small, + attribute_column_name="file_attributes", + dict_to_append={"frame": "1"}, + ) + + return df + + +def update_attribute_column(df_input, attribute_column_name, dict_to_append): + """Update an attributes column in the dataframe.""" + # copy the dataframe + df = df_input.copy() + + # get the attributes column and convert to dict + attributes_dicts = [ast.literal_eval(d) for d in df[attribute_column_name]] + + # update the dict + for d in attributes_dicts: + d.update(dict_to_append) + + # update the region_attributes column in the dataframe + df[attribute_column_name] = [str(d) for d in attributes_dicts] + return df + + +def assert_dataset( + dataset, file_path=None, expected_source_software=None, expected_fps=None +): + """Assert that the dataset is a proper ``movement`` Dataset.""" + assert isinstance(dataset, xr.Dataset) + + # Expected variables are present and of right shape/type + for var in ["position", "shape", "confidence"]: + assert var in dataset.data_vars + assert isinstance(dataset[var], xr.DataArray) + assert dataset.position.ndim == 3 + assert dataset.shape.ndim == 3 + assert dataset.confidence.shape == dataset.position.shape[:-1] + + # Check the dims and coords + DIM_NAMES = tuple(a for a in MovementDataset.dim_names if a != "keypoints") + assert all([i in dataset.dims for i in DIM_NAMES]) + for d, dim in enumerate(DIM_NAMES[1:]): + assert dataset.sizes[dim] == dataset.position.shape[d + 1] + assert all([isinstance(s, str) for s in dataset.coords[dim].values]) + assert all([i in dataset.coords["space"] for i in ["x", "y"]]) + + # Check the metadata attributes + assert ( + dataset.source_file is None + if file_path is None + else dataset.source_file == file_path.as_posix() + ) + assert ( + dataset.source_software is None + if expected_source_software is None + else dataset.source_software == expected_source_software + ) + assert ( + dataset.fps is None + if expected_fps is None + else dataset.fps == expected_fps + ) + + +def assert_time_coordinates(ds, fps, start_frame): + """Assert that the time coordinates are as expected, depending on + fps value and start_frame. + """ + # scale time coordinates with 1/fps if provided + scale = 1 / fps if fps else 1 + + # assert numpy array of time coordinates + np.testing.assert_allclose( + ds.coords["time"].data, + np.array( + [ + f * scale + for f in range( + start_frame, len(ds.coords["time"].data) + start_frame + ) + ] + ), + ) + + +@pytest.mark.parametrize("source_software", ["Unknown", "VIA-tracks"]) +@pytest.mark.parametrize("fps", [None, 30, 60.0]) +@pytest.mark.parametrize("use_frame_numbers_from_file", [True, False]) +def test_from_file(source_software, fps, use_frame_numbers_from_file): + """Test that the from_file() function delegates to the correct + loader function according to the source_software. + """ + software_to_loader = { + "VIA-tracks": "movement.io.load_bboxes.from_via_tracks_file", + } + + if source_software == "Unknown": + with pytest.raises(ValueError, match="Unsupported source"): + load_bboxes.from_file( + "some_file", + source_software, + fps, + use_frame_numbers_from_file=use_frame_numbers_from_file, + ) + else: + with patch(software_to_loader[source_software]) as mock_loader: + load_bboxes.from_file( + "some_file", + source_software, + fps, + use_frame_numbers_from_file=use_frame_numbers_from_file, + ) + mock_loader.assert_called_with( + "some_file", + fps, + use_frame_numbers_from_file=use_frame_numbers_from_file, + ) + + +@pytest.mark.parametrize("fps", [None, 30, 60.0]) +@pytest.mark.parametrize("use_frame_numbers_from_file", [True, False]) +def test_from_VIA_tracks_file( + via_tracks_file, fps, use_frame_numbers_from_file +): + """Test that loading tracked bounding box data from + a valid VIA tracks .csv file returns a proper Dataset + and that the time coordinates are as expected. + """ + # run general dataset checks + ds = load_bboxes.from_via_tracks_file( + via_tracks_file, fps, use_frame_numbers_from_file + ) + assert_dataset(ds, via_tracks_file, "VIA-tracks", fps) + + # check time coordinates are as expected + # in sample VIA tracks .csv file frame numbers start from 1 + start_frame = 1 if use_frame_numbers_from_file else 0 + assert_time_coordinates(ds, fps, start_frame) + + +@pytest.mark.parametrize( + "valid_from_numpy_inputs", + [ + "valid_from_numpy_inputs_required_arrays", + "valid_from_numpy_inputs_all_arrays", + ], +) +@pytest.mark.parametrize("fps", [None, 30, 60.0]) +@pytest.mark.parametrize("source_software", [None, "VIA-tracks"]) +def test_from_numpy(valid_from_numpy_inputs, fps, source_software, request): + """Test that loading bounding boxes trajectories from the input + numpy arrays returns a proper Dataset. + """ + # get the input arrays + from_numpy_inputs = request.getfixturevalue(valid_from_numpy_inputs) + + # run general dataset checks + ds = load_bboxes.from_numpy( + **from_numpy_inputs, + fps=fps, + source_software=source_software, + ) + assert_dataset( + ds, expected_source_software=source_software, expected_fps=fps + ) + + # check time coordinates are as expected + if "frame_array" in from_numpy_inputs: + start_frame = from_numpy_inputs["frame_array"][0, 0] + else: + start_frame = 0 + assert_time_coordinates(ds, fps, start_frame) + + +@pytest.mark.parametrize( + "via_column_name, list_keys, cast_fn, expected_attribute_array", + [ + ( + "file_attributes", + ["clip"], + int, + np.array([123] * 3), # .reshape(-1, 1), + ), + ( + "region_shape_attributes", + ["name"], + str, + np.array(["rect"] * 3), # .reshape(-1, 1), + ), + ( + "region_shape_attributes", + ["x", "y"], + float, + np.array( + [ + [526.2366942646654, 393.280914246804], + [2565, 468], + [759.6484377108334, 136.60946673708338], + ] + ).reshape(-1, 2), + ), + ( + "region_shape_attributes", + ["width", "height"], + float, + np.array([[46, 38], [41, 30], [29, 25]]).reshape(-1, 2), + ), + ( + "region_attributes", + ["track"], + int, + np.array([71, 70, 69]), # .reshape(-1, 1), + ), + ], +) +def test_via_attribute_column_to_numpy( + df_input_via_tracks_small, + via_column_name, + list_keys, + cast_fn, + expected_attribute_array, +): + """Test that the function correctly extracts the desired data from the VIA + attributes. + """ + attribute_array = load_bboxes._via_attribute_column_to_numpy( + df=df_input_via_tracks_small, + via_column_name=via_column_name, + list_keys=list_keys, + cast_fn=cast_fn, + ) + + assert np.array_equal(attribute_array, expected_attribute_array) + + +@pytest.mark.parametrize( + "df_input, expected_array", + [ + ("df_input_via_tracks_small", np.full((3,), np.nan)), + ( + "df_input_via_tracks_small_with_confidence", + np.array([0.5, 0.5, 0.5]), + ), + ], +) +def test_extract_confidence_from_via_tracks_df( + df_input, expected_array, request +): + """Test that the function correctly extracts the confidence values from + the VIA dataframe. + """ + df = request.getfixturevalue(df_input) + confidence_array = load_bboxes._extract_confidence_from_via_tracks_df(df) + + assert np.array_equal(confidence_array, expected_array, equal_nan=True) + + +@pytest.mark.parametrize( + "df_input, expected_array", + [ + ( + "df_input_via_tracks_small", + np.ones((3,)), + ), # extract from filename + ( + "df_input_via_tracks_small_with_frame_number", + np.array([1, 1, 1]), + ), # extract from file_attributes + ], +) +def test_extract_frame_number_from_via_tracks_df( + df_input, expected_array, request +): + """Test that the function correctly extracts the frame number values from + the VIA dataframe. + """ + df = request.getfixturevalue(df_input) + frame_array = load_bboxes._extract_frame_number_from_via_tracks_df(df) + + assert np.array_equal(frame_array, expected_array) + + +@pytest.mark.parametrize( + "fps, expected_fps, expected_time_unit", + [ + (None, None, "frames"), + (-5, None, "frames"), + (0, None, "frames"), + (30, 30.0, "seconds"), + (60.0, 60.0, "seconds"), + ], +) +@pytest.mark.parametrize("use_frame_numbers_from_file", [True, False]) +def test_fps_and_time_coords( + via_tracks_file, + fps, + expected_fps, + expected_time_unit, + use_frame_numbers_from_file, +): + """Test that fps conversion is as expected and time coordinates are set + according to the expected fps. + """ + ds = load_bboxes.from_via_tracks_file( + via_tracks_file, + fps=fps, + use_frame_numbers_from_file=use_frame_numbers_from_file, + ) + + # load dataset with frame numbers from file + ds_in_frames_from_file = load_bboxes.from_via_tracks_file( + via_tracks_file, + fps=None, + use_frame_numbers_from_file=True, + ) + + # check time unit + assert ds.time_unit == expected_time_unit + + # check fps is as expected + if expected_fps is None: + assert ds.fps is expected_fps + else: + assert ds.fps == expected_fps + + # check time coordinates + if use_frame_numbers_from_file: + start_frame = ds_in_frames_from_file.coords["time"].data[0] + else: + start_frame = 0 + assert_time_coordinates(ds, expected_fps, start_frame) diff --git a/tests/test_unit/test_sample_data.py b/tests/test_unit/test_sample_data.py index 9433d3d0..ad408126 100644 --- a/tests/test_unit/test_sample_data.py +++ b/tests/test_unit/test_sample_data.py @@ -31,6 +31,11 @@ def valid_sample_datasets(): "frame_file": None, "video_file": None, }, + "VIA_multiple-crabs_5-frames_labels.csv": { + "fps": None, + "frame_file": None, + "video_file": None, + }, } diff --git a/tests/test_unit/test_validators/test_files_validators.py b/tests/test_unit/test_validators/test_files_validators.py index 403a67b3..b9bc345c 100644 --- a/tests/test_unit/test_validators/test_files_validators.py +++ b/tests/test_unit/test_validators/test_files_validators.py @@ -73,7 +73,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( ( "via_tracks_csv_with_invalid_header", ".csv header row does not match the known format for " - "VIA tracks output files. " + "VIA tracks .csv files. " "Expected " "['filename', 'file_size', 'file_attributes', " "'region_count', 'region_id', 'region_shape_attributes', " @@ -147,7 +147,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( def test_via_tracks_csv_validator_with_invalid_input( invalid_input, log_message, request ): - """Test that invalid VIA tracks CSV files raise the appropriate errors. + """Test that invalid VIA tracks .csv files raise the appropriate errors. Errors to check: - error if .csv header is wrong From 8e3ab992c5106f22825fefcdc1d4ed9f8fcc6905 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Thu, 1 Aug 2024 15:15:51 +0100 Subject: [PATCH 26/65] Refactor names and fix docs formatting in `validators` (#251) * Fix indentation warnings * Use double backticks in docstrings * Fix indentation * Prepend _ to attrs validator functions in ValidVIATracksCSV to hide from API docs * Rename parameters --> attributes in docstring * Change path attr of ValidVIATracksCSV to be type pathlib.Path (consistency) * Add validation checks in the class-level docstring * Describe validation checks at dataset class-level * Shorten validator method names * Change ID to lowercase --------- Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- movement/validators/datasets.py | 57 ++++++++++++++++++++++++++------- movement/validators/files.py | 53 +++++++++++++++++++++--------- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py index b25fd4a5..fd31246d 100644 --- a/movement/validators/datasets.py +++ b/movement/validators/datasets.py @@ -71,7 +71,21 @@ def _validate_list_length( @define(kw_only=True) class ValidPosesDataset: - """Class for validating data intended for a ``movement`` dataset. + """Class for validating poses data intended for a ``movement`` dataset. + + The validator ensures that within the ``movement poses`` dataset: + + - The required ``position_array`` is a numpy array + with the last dimension containing 2 or 3 spatial coordinates. + - The optional ``confidence_array``, if provided, is a numpy array + with its shape matching the first three dimensions of the + ``position_array``; otherwise, it defaults to an array of NaNs. + - The optional ``individual_names`` and ``keypoint_names``, + if provided, match the number of individuals and keypoints + in the dataset, respectively; otherwise, default names are assigned. + - The optional ``fps`` is a positive float; otherwise, it defaults to None. + - The optional ``source_software`` is a string; otherwise, + it defaults to None. Attributes ---------- @@ -96,10 +110,18 @@ class ValidPosesDataset: Name of the software from which the poses were loaded. Defaults to None. + Raises + ------ + ValueError + If the dataset does not meet the ``movement poses`` + dataset requirements. + """ - # Define class attributes + # Required attributes position_array: np.ndarray = field() + + # Optional attributes confidence_array: np.ndarray | None = field(default=None) individual_names: list[str] | None = field( default=None, @@ -141,7 +163,6 @@ def _validate_position_array(self, attribute, value): def _validate_confidence_array(self, attribute, value): if value is not None: _validate_type_ndarray(value) - _validate_array_shape( attribute, value, expected_shape=self.position_array.shape[:-1] ) @@ -192,7 +213,22 @@ def __attrs_post_init__(self): class ValidBboxesDataset: """Class for validating bounding boxes' data for a ``movement`` dataset. - We consider 2D bounding boxes only. + The validator considers 2D bounding boxes only. It ensures that + within the ``movement bboxes`` dataset: + + - The required ``position_array`` and ``shape_array`` are numpy arrays, + with the last dimension containing 2 spatial coordinates. + - The optional ``confidence_array``, if provided, is a numpy array + with its shape matching the first two dimensions of the + ``position_array``; otherwise, it defaults to an array of NaNs. + - The optional ``individual_names``, if provided, match the number of + individuals in the dataset; otherwise, default names are assigned. + - The optional ``frame_array``, if provided, is a column vector + with the frame numbers; otherwise, it defaults to an array of + 0-based integers. + - The optional ``fps`` is a positive float; otherwise, it defaults to None. + - The optional ``source_software`` is a string; otherwise, it defaults to + None. Attributes ---------- @@ -225,6 +261,12 @@ class ValidBboxesDataset: source_software : str, optional Name of the software that generated the data. Defaults to None. + Raises + ------ + ValueError + If the dataset does not meet the ``movement bboxes`` dataset + requirements. + """ # Required attributes @@ -256,7 +298,6 @@ class ValidBboxesDataset: @shape_array.validator def _validate_position_and_shape_arrays(self, attribute, value): _validate_type_ndarray(value) - # check last dimension (spatial) has 2 coordinates n_expected_spatial_coordinates = 2 if value.shape[-1] != n_expected_spatial_coordinates: @@ -272,7 +313,6 @@ def _validate_individual_names(self, attribute, value): _validate_list_length( attribute, value, self.position_array.shape[1] ) - # check n_individual_names are unique # NOTE: combined with the requirement above, we are enforcing # unique IDs per frame @@ -288,7 +328,6 @@ def _validate_individual_names(self, attribute, value): def _validate_confidence_array(self, attribute, value): if value is not None: _validate_type_ndarray(value) - _validate_array_shape( attribute, value, expected_shape=self.position_array.shape[:-1] ) @@ -297,14 +336,12 @@ def _validate_confidence_array(self, attribute, value): def _validate_frame_array(self, attribute, value): if value is not None: _validate_type_ndarray(value) - # should be a column vector (n_frames, 1) _validate_array_shape( attribute, value, expected_shape=(self.position_array.shape[0], 1), ) - # check frames are continuous: exactly one frame number per row if not np.all(np.diff(value, axis=0) == 1): raise log_error( @@ -331,7 +368,6 @@ def __attrs_post_init__(self): "Confidence array was not provided. " "Setting to an array of NaNs." ) - # assign default individual_names if self.individual_names is None: self.individual_names = [ @@ -343,7 +379,6 @@ def __attrs_post_init__(self): "Setting to 0-based IDs that are unique per frame: \n" f"{self.individual_names}.\n" ) - # assign default frame_array if self.frame_array is None: n_frames = self.position_array.shape[0] diff --git a/movement/validators/files.py b/movement/validators/files.py index 7aec4d98..8d013a95 100644 --- a/movement/validators/files.py +++ b/movement/validators/files.py @@ -17,6 +17,14 @@ class ValidFile: """Class for validating file paths. + The validator ensures that the file: + + - is not a directory, + - exists if it is meant to be read, + - does not exist if it is meant to be written, + - has the expected access permission(s), and + - has one of the expected suffix(es). + Attributes ---------- path : str or pathlib.Path @@ -113,6 +121,11 @@ def _file_has_expected_suffix(self, attribute, value): class ValidHDF5: """Class for validating HDF5 files. + The validator ensures that the file: + + - is in HDF5 format, and + - contains the expected datasets. + Attributes ---------- path : pathlib.Path @@ -162,6 +175,9 @@ def _file_contains_expected_datasets(self, attribute, value): class ValidDeepLabCutCSV: """Class for validating DeepLabCut-style .csv files. + The validator ensures that the file contains the + expected index column levels. + Attributes ---------- path : pathlib.Path @@ -178,7 +194,7 @@ class ValidDeepLabCutCSV: path: Path = field(validator=validators.instance_of(Path)) @path.validator - def _csv_file_contains_expected_levels(self, attribute, value): + def _file_contains_expected_levels(self, attribute, value): """Ensure that the .csv file contains the expected index column levels. These are to be found among the top 4 rows of the file. @@ -207,9 +223,16 @@ def _csv_file_contains_expected_levels(self, attribute, value): class ValidVIATracksCSV: """Class for validating VIA tracks .csv files. - Parameters + The validator ensures that the file: + + - contains the expected header, + - contains valid frame numbers, + - contains tracked bounding boxes, and + - defines bounding boxes whose IDs are unique per image file. + + Attributes ---------- - path : pathlib.Path or str + path : pathlib.Path Path to the VIA tracks .csv file. Raises @@ -222,7 +245,7 @@ class ValidVIATracksCSV: path: Path = field(validator=validators.instance_of(Path)) @path.validator - def csv_file_contains_valid_header(self, attribute, value): + def _file_contains_valid_header(self, attribute, value): """Ensure the VIA tracks .csv file contains the expected header.""" expected_header = [ "filename", @@ -246,15 +269,16 @@ def csv_file_contains_valid_header(self, attribute, value): ) @path.validator - def csv_file_contains_valid_frame_numbers(self, attribute, value): + def _file_contains_valid_frame_numbers(self, attribute, value): """Ensure that the VIA tracks .csv file contains valid frame numbers. This involves: + - Checking that frame numbers are included in ``file_attributes`` or - encoded in the image file ``filename``. + encoded in the image file ``filename``. - Checking the frame number can be cast as an integer. - Checking that there are as many unique frame numbers as unique image - files. + files. If the frame number is included as part of the image file name, then it is expected as an integer led by at least one zero, between "_" and @@ -316,13 +340,14 @@ def csv_file_contains_valid_frame_numbers(self, attribute, value): ) @path.validator - def csv_file_contains_tracked_bboxes(self, attribute, value): + def _file_contains_tracked_bboxes(self, attribute, value): """Ensure that the VIA tracks .csv contains tracked bounding boxes. This involves: + - Checking that the bounding boxes are defined as rectangles. - Checking that the bounding boxes have all geometric parameters - (["x", "y", "width", "height"]). + (``["x", "y", "width", "height"]``). - Checking that the bounding boxes have a track ID defined. - Checking that the track ID can be cast as an integer. """ @@ -382,9 +407,7 @@ def csv_file_contains_tracked_bboxes(self, attribute, value): ) from e @path.validator - def csv_file_contains_unique_track_IDs_per_filename( - self, attribute, value - ): + def _file_contains_unique_track_ids_per_filename(self, attribute, value): """Ensure the VIA tracks .csv contains unique track IDs per filename. It checks that bounding boxes IDs are defined once per image file. @@ -395,13 +418,13 @@ def csv_file_contains_unique_track_IDs_per_filename( for file in list_unique_filenames: df_one_filename = df.loc[df["filename"] == file] - list_track_IDs_one_filename = [ + list_track_ids_one_filename = [ int(ast.literal_eval(row.region_attributes)["track"]) for row in df_one_filename.itertuples() ] - if len(set(list_track_IDs_one_filename)) != len( - list_track_IDs_one_filename + if len(set(list_track_ids_one_filename)) != len( + list_track_ids_one_filename ): raise log_error( ValueError, From 21225919d88b95fbdc5a86565bdf3e9a87500f92 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Tue, 6 Aug 2024 17:08:20 +0100 Subject: [PATCH 27/65] Pin pandas version (& numpy) if python 3.12 (#259) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 130268bd..73f99256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ license = { text = "BSD-3-Clause" } dependencies = [ "numpy", + "pandas<2.2.2;python_version>='3.12'", "pandas", "h5py", "attrs", From 78b806eeca23526de6dbf011e6685bdf798fed70 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:02:23 +0100 Subject: [PATCH 28/65] Fix `tox requires` section (#261) * Fix tox requires section * Remove comment --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 73f99256..b552fd42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,9 @@ check-hidden = true [tool.tox] legacy_tox_ini = """ [tox] -requires = tox-conda +requires = + tox-conda + tox-gh-actions envlist = py{310,311,312} isolated_build = True From 1f219cb07af5a5c55914a2e18cf0096daf108a9c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:36:58 +0100 Subject: [PATCH 29/65] [pre-commit.ci] pre-commit autoupdate (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.0 → v0.5.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.0...v0.5.6) - [github.com/pre-commit/mirrors-mypy: v1.10.1 → v1.11.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.1...v1.11.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9cb8559c..f764b7e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,12 +29,12 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.5.6 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.1 hooks: - id: mypy additional_dependencies: From 8c45183dd4f0beae7e9738287b084fafd8fff38c Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 7 Aug 2024 11:43:22 +0100 Subject: [PATCH 30/65] Adapt move accessor to bboxes datasets (#255) * Add test for ds.move.validate() * Adapt move accessor to bboxes * Adapt tests to new conftest * Remove comments from conftest * Adapt kinematics tests * Adapt save poses tests * Adapt integration tests * Fix docstring * Remove unnecessary fixture from conftest.py for this PR * Refactor invalid poses datasets and exceptions * Add backticks and other small edits * Add test and fixture for bboxes dataset in seconds * Refactor `validate()` in `move_accessor` * Add missing blank line after block quote * Use lowercase in function and variable names * Remove duplicity * Remove sort in error message * Rename class attributes * Remove asserts * Rename function for consistency * Revert "Remove sort in error message" This reverts commit fab0bc6051882422b74eb42b71b1c9bb9e98187d. * Suggestion to remove sorted * SImplify test parametrisation * Add invalid datasets with multiple dimensions on data vars missing * Remove whole-string check * Update `move_accessor.py` docstrings * Remove wrappers around `ds.rename` and `ds.drop_vars` in `conftest.py` --------- Co-authored-by: lochhh --- movement/io/load_bboxes.py | 11 +- movement/io/load_poses.py | 2 +- movement/move_accessor.py | 108 ++++++++---- tests/conftest.py | 146 ++++++++++++++-- .../test_kinematics_vector_transform.py | 2 +- tests/test_unit/test_kinematics.py | 2 +- tests/test_unit/test_load_bboxes.py | 4 +- tests/test_unit/test_load_poses.py | 2 +- tests/test_unit/test_move_accessor.py | 160 ++++++++++++++---- tests/test_unit/test_save_poses.py | 39 ++++- 10 files changed, 366 insertions(+), 110 deletions(-) diff --git a/movement/io/load_bboxes.py b/movement/io/load_bboxes.py index c6df5695..9c177230 100644 --- a/movement/io/load_bboxes.py +++ b/movement/io/load_bboxes.py @@ -352,6 +352,7 @@ def _numpy_arrays_from_via_tracks_file(file_path: Path) -> dict: The extracted numpy arrays are returned in a dictionary with the following keys: + - position_array (n_frames, n_individuals, n_space): contains the trajectories of the bounding boxes' centroids. - shape_array (n_frames, n_individuals, n_space): @@ -381,9 +382,9 @@ def _numpy_arrays_from_via_tracks_file(file_path: Path) -> dict: df = _df_from_via_tracks_file(file_path) # Compute indices of the rows where the IDs switch - bool_ID_diff_from_prev = df["ID"].ne(df["ID"].shift()) # pandas series - indices_ID_switch = ( - bool_ID_diff_from_prev.loc[lambda x: x].index[1:].to_numpy() + bool_id_diff_from_prev = df["ID"].ne(df["ID"].shift()) # pandas series + indices_id_switch = ( + bool_id_diff_from_prev.loc[lambda x: x].index[1:].to_numpy() ) # Stack position, shape and confidence arrays along ID axis @@ -396,7 +397,7 @@ def _numpy_arrays_from_via_tracks_file(file_path: Path) -> dict: for key in map_key_to_columns: list_arrays = np.split( df[map_key_to_columns[key]].to_numpy(), - indices_ID_switch, # indices along axis=0 + indices_id_switch, # indices along axis=0 ) array_dict[key] = np.stack(list_arrays, axis=1).squeeze() @@ -623,7 +624,7 @@ def _ds_from_valid_data(data: ValidBboxesDataset) -> xr.Dataset: # Convert data to an xarray.Dataset # with dimensions ('time', 'individuals', 'space') - DIM_NAMES = tuple(a for a in MovementDataset.dim_names if a != "keypoints") + DIM_NAMES = MovementDataset.dim_names["bboxes"] n_space = data.position_array.shape[-1] return xr.Dataset( data_vars={ diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index e70e4825..6ae2f9e5 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -654,7 +654,7 @@ def _ds_from_valid_data(data: ValidPosesDataset) -> xr.Dataset: time_coords = time_coords / data.fps time_unit = "seconds" - DIM_NAMES = MovementDataset.dim_names + DIM_NAMES = MovementDataset.dim_names["poses"] # Convert data to an xarray.Dataset return xr.Dataset( data_vars={ diff --git a/movement/move_accessor.py b/movement/move_accessor.py index e6252831..93388417 100644 --- a/movement/move_accessor.py +++ b/movement/move_accessor.py @@ -8,7 +8,7 @@ from movement import filtering from movement.analysis import kinematics from movement.utils.logging import log_error -from movement.validators.datasets import ValidPosesDataset +from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset logger = logging.getLogger(__name__) @@ -18,11 +18,11 @@ @xr.register_dataset_accessor("move") class MovementDataset: - """An :py:class:`xarray.Dataset` accessor for pose tracking data. + """An :py:class:`xarray.Dataset` accessor for ``movement`` data. A ``movement`` dataset is an :py:class:`xarray.Dataset` with a specific - structure to represent pose tracks, associated confidence scores and - relevant metadata. + structure to represent pose tracks or bounding boxes data, + associated confidence scores and relevant metadata. Methods/properties that extend the standard ``xarray`` functionality are defined in this class. To avoid conflicts with ``xarray``'s namespace, @@ -31,10 +31,12 @@ class MovementDataset: Attributes ---------- - dim_names : tuple - Names of the expected dimensions in the dataset. - var_names : tuple - Names of the expected data variables in the dataset. + dim_names : dict + A dictionary with the names of the expected dimensions in the dataset, + for each dataset type (``"poses"`` or ``"bboxes"``). + var_names : dict + A dictionary with the expected data variables in the dataset, for each + dataset type (``"poses"`` or ``"bboxes"``). References ---------- @@ -42,21 +44,22 @@ class MovementDataset: """ - dim_names: ClassVar[tuple] = ( - "time", - "individuals", - "keypoints", - "space", - ) - - var_names: ClassVar[tuple] = ( - "position", - "confidence", - ) + # Set class attributes for expected dimensions and data variables + dim_names: ClassVar[dict] = { + "poses": ("time", "individuals", "keypoints", "space"), + "bboxes": ("time", "individuals", "space"), + } + var_names: ClassVar[dict] = { + "poses": ("position", "confidence"), + "bboxes": ("position", "shape", "confidence"), + } def __init__(self, ds: xr.Dataset): """Initialize the MovementDataset.""" self._obj = ds + # Set instance attributes based on dataset type + self.dim_names_instance = self.dim_names[self._obj.ds_type] + self.var_names_instance = self.var_names[self._obj.ds_type] def __getattr__(self, name: str) -> xr.DataArray: """Forward requested but undefined attributes to relevant modules. @@ -240,29 +243,60 @@ def validate(self) -> None: This method checks if the dataset contains the expected dimensions, data variables, and metadata attributes. It also ensures that the - dataset contains valid poses. + dataset contains valid poses or bounding boxes data. + + Raises + ------ + ValueError + If the dataset is missing required dimensions, data variables, + or contains invalid poses or bounding boxes data. + """ fps = self._obj.attrs.get("fps", None) source_software = self._obj.attrs.get("source_software", None) try: - missing_dims = set(self.dim_names) - set(self._obj.dims) - missing_vars = set(self.var_names) - set(self._obj.data_vars) - if missing_dims: - raise ValueError( - f"Missing required dimensions: {missing_dims}" + self._validate_dims() + self._validate_data_vars() + if self._obj.ds_type == "poses": + ValidPosesDataset( + position_array=self._obj["position"].values, + confidence_array=self._obj["confidence"].values, + individual_names=self._obj.coords["individuals"].values, + keypoint_names=self._obj.coords["keypoints"].values, + fps=fps, + source_software=source_software, ) - if missing_vars: - raise ValueError( - f"Missing required data variables: {missing_vars}" + elif self._obj.ds_type == "bboxes": + # Define frame_array. + # Recover from time axis in seconds if necessary. + frame_array = self._obj.coords["time"].values.reshape(-1, 1) + if self._obj.attrs["time_unit"] == "seconds": + frame_array *= fps + ValidBboxesDataset( + position_array=self._obj["position"].values, + shape_array=self._obj["shape"].values, + confidence_array=self._obj["confidence"].values, + individual_names=self._obj.coords["individuals"].values, + frame_array=frame_array, + fps=fps, + source_software=source_software, ) - ValidPosesDataset( - position_array=self._obj[self.var_names[0]].values, - confidence_array=self._obj[self.var_names[1]].values, - individual_names=self._obj.coords[self.dim_names[1]].values, - keypoint_names=self._obj.coords[self.dim_names[2]].values, - fps=fps, - source_software=source_software, - ) except Exception as e: - error_msg = "The dataset does not contain valid poses. " + str(e) + error_msg = ( + f"The dataset does not contain valid {self._obj.ds_type}. {e}" + ) raise log_error(ValueError, error_msg) from e + + def _validate_dims(self) -> None: + missing_dims = set(self.dim_names_instance) - set(self._obj.dims) + if missing_dims: + raise ValueError( + f"Missing required dimensions: {sorted(missing_dims)}" + ) # sort for a reproducible error message + + def _validate_data_vars(self) -> None: + missing_vars = set(self.var_names_instance) - set(self._obj.data_vars) + if missing_vars: + raise ValueError( + f"Missing required data variables: {sorted(missing_vars)}" + ) # sort for a reproducible error message diff --git a/tests/conftest.py b/tests/conftest.py index fb4e38cc..77c8c73e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -213,6 +213,108 @@ def sleap_file(request): return pytest.DATA_PATHS.get(request.param) +@pytest.fixture +def valid_bboxes_array(): + """Return a dictionary of valid non-zero arrays for a + ValidBboxesDataset. + + Contains realistic data for 10 frames, 2 individuals, in 2D + with 5 low confidence bounding boxes. + """ + # define the shape of the arrays + n_frames, n_individuals, n_space = (10, 2, 2) + + # build a valid array for position + # make bbox with id_i move along x=((-1)**(i))*y line from the origin + # if i is even: along x = y line + # if i is odd: along x = -y line + # moving one unit along each axis in each frame + position = np.empty((n_frames, n_individuals, n_space)) + for i in range(n_individuals): + position[:, i, 0] = np.arange(n_frames) + position[:, i, 1] = (-1) ** i * np.arange(n_frames) + + # build a valid array for constant bbox shape (60, 40) + constant_shape = (60, 40) # width, height in pixels + shape = np.tile(constant_shape, (n_frames, n_individuals, 1)) + + # build an array of confidence values, all 0.9 + confidence = np.full((n_frames, n_individuals), 0.9) + + # set 5 low-confidence values + # - set 3 confidence values for bbox id_0 to 0.1 + # - set 2 confidence values for bbox id_1 to 0.1 + idx_start = 2 + confidence[idx_start : idx_start + 3, 0] = 0.1 + confidence[idx_start : idx_start + 2, 1] = 0.1 + + return { + "position": position, + "shape": shape, + "confidence": confidence, + "individual_names": ["id_" + str(id) for id in range(n_individuals)], + } + + +@pytest.fixture +def valid_bboxes_dataset( + valid_bboxes_array, +): + """Return a valid bboxes dataset with low confidence values and + time in frames. + """ + dim_names = MovementDataset.dim_names["bboxes"] + + position_array = valid_bboxes_array["position"] + shape_array = valid_bboxes_array["shape"] + confidence_array = valid_bboxes_array["confidence"] + + n_frames, n_individuals, _ = position_array.shape + + return xr.Dataset( + data_vars={ + "position": xr.DataArray(position_array, dims=dim_names), + "shape": xr.DataArray(shape_array, dims=dim_names), + "confidence": xr.DataArray(confidence_array, dims=dim_names[:-1]), + }, + coords={ + dim_names[0]: np.arange(n_frames), + dim_names[1]: [f"id_{id}" for id in range(n_individuals)], + dim_names[2]: ["x", "y"], + }, + attrs={ + "fps": None, + "time_unit": "frames", + "source_software": "test", + "source_file": "test_bboxes.csv", + "ds_type": "bboxes", + }, + ) + + +@pytest.fixture +def valid_bboxes_dataset_in_seconds(valid_bboxes_dataset): + """Return a valid bboxes dataset with time in seconds. + + The origin of time is assumed to be time = frame 0 = 0 seconds. + """ + fps = 60 + valid_bboxes_dataset["time"] = valid_bboxes_dataset.time / fps + valid_bboxes_dataset.attrs["time_unit"] = "seconds" + valid_bboxes_dataset.attrs["fps"] = fps + return valid_bboxes_dataset + + +@pytest.fixture +def valid_bboxes_dataset_with_nan(valid_bboxes_dataset): + """Return a valid bboxes dataset with NaN values in the position array.""" + # Set 3 NaN values in the position array for id_0 + valid_bboxes_dataset.position.loc[ + {"individuals": "id_0", "time": [3, 7, 8]} + ] = np.nan + return valid_bboxes_dataset + + @pytest.fixture def valid_position_array(): """Return a function that generates different kinds @@ -244,7 +346,7 @@ def _valid_position_array(array_type): @pytest.fixture def valid_poses_dataset(valid_position_array, request): """Return a valid pose tracks dataset.""" - dim_names = MovementDataset.dim_names + dim_names = MovementDataset.dim_names["poses"] # create a multi_individual_array by default unless overridden via param try: array_format = request.param @@ -274,6 +376,7 @@ def valid_poses_dataset(valid_position_array, request): "time_unit": "frames", "source_software": "SLEAP", "source_file": "test.h5", + "ds_type": "poses", }, ) @@ -300,28 +403,39 @@ def empty_dataset(): @pytest.fixture -def missing_var_dataset(valid_poses_dataset): - """Return a pose tracks dataset missing an expected variable.""" +def missing_var_poses_dataset(valid_poses_dataset): + """Return a poses dataset missing position variable.""" return valid_poses_dataset.drop_vars("position") @pytest.fixture -def missing_dim_dataset(valid_poses_dataset): - """Return a pose tracks dataset missing an expected dimension.""" +def missing_var_bboxes_dataset(valid_bboxes_dataset): + """Return a bboxes dataset missing position variable.""" + return valid_bboxes_dataset.drop_vars("position") + + +@pytest.fixture +def missing_two_vars_bboxes_dataset(valid_bboxes_dataset): + """Return a bboxes dataset missing position and shape variables.""" + return valid_bboxes_dataset.drop_vars(["position", "shape"]) + + +@pytest.fixture +def missing_dim_poses_dataset(valid_poses_dataset): + """Return a poses dataset missing the time dimension.""" return valid_poses_dataset.rename({"time": "tame"}) -@pytest.fixture( - params=[ - "not_a_dataset", - "empty_dataset", - "missing_var_dataset", - "missing_dim_dataset", - ] -) -def invalid_poses_dataset(request): - """Return an invalid pose tracks dataset.""" - return request.getfixturevalue(request.param) +@pytest.fixture +def missing_dim_bboxes_dataset(valid_bboxes_dataset): + """Return a bboxes dataset missing the time dimension.""" + return valid_bboxes_dataset.rename({"time": "tame"}) + + +@pytest.fixture +def missing_two_dims_bboxes_dataset(valid_bboxes_dataset): + """Return a bboxes dataset missing the time and space dimensions.""" + return valid_bboxes_dataset.rename({"time": "tame", "space": "spice"}) @pytest.fixture(params=["displacement", "velocity", "acceleration"]) diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index 78b4cf93..65318a08 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -16,7 +16,7 @@ class TestKinematicsVectorTransform: [ ("valid_poses_dataset", does_not_raise()), ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_dataset", pytest.raises(RuntimeError)), + ("missing_dim_poses_dataset", pytest.raises(RuntimeError)), ], ) def test_cart_and_pol_transform( diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index e43999a4..e5241bc8 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -53,7 +53,7 @@ def _expected_dataarray(property): kinematic_test_params = [ ("valid_poses_dataset", does_not_raise()), ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_dataset", pytest.raises(AttributeError)), + ("missing_dim_poses_dataset", pytest.raises(AttributeError)), ] @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) diff --git a/tests/test_unit/test_load_bboxes.py b/tests/test_unit/test_load_bboxes.py index 58e6c876..dbed362c 100644 --- a/tests/test_unit/test_load_bboxes.py +++ b/tests/test_unit/test_load_bboxes.py @@ -127,7 +127,7 @@ def assert_dataset( assert dataset.confidence.shape == dataset.position.shape[:-1] # Check the dims and coords - DIM_NAMES = tuple(a for a in MovementDataset.dim_names if a != "keypoints") + DIM_NAMES = MovementDataset.dim_names["bboxes"] assert all([i in dataset.dims for i in DIM_NAMES]) for d, dim in enumerate(DIM_NAMES[1:]): assert dataset.sizes[dim] == dataset.position.shape[d + 1] @@ -209,7 +209,7 @@ def test_from_file(source_software, fps, use_frame_numbers_from_file): @pytest.mark.parametrize("fps", [None, 30, 60.0]) @pytest.mark.parametrize("use_frame_numbers_from_file", [True, False]) -def test_from_VIA_tracks_file( +def test_from_via_tracks_file( via_tracks_file, fps, use_frame_numbers_from_file ): """Test that loading tracked bounding box data from diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 2c63500c..8fedcdcb 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -78,7 +78,7 @@ def assert_dataset( assert dataset.position.ndim == 4 assert dataset.confidence.shape == dataset.position.shape[:-1] # Check the dims and coords - DIM_NAMES = MovementDataset.dim_names + DIM_NAMES = MovementDataset.dim_names["poses"] assert all([i in dataset.dims for i in DIM_NAMES]) for d, dim in enumerate(DIM_NAMES[1:]): assert dataset.sizes[dim] == dataset.position.shape[d + 1] diff --git a/tests/test_unit/test_move_accessor.py b/tests/test_unit/test_move_accessor.py index a5ae8355..b87942e4 100644 --- a/tests/test_unit/test_move_accessor.py +++ b/tests/test_unit/test_move_accessor.py @@ -1,42 +1,128 @@ +from contextlib import nullcontext as does_not_raise + import pytest import xarray as xr -class TestMovementDataset: - """Test suite for the MovementDataset class.""" - - def test_compute_kinematics_with_valid_dataset( - self, valid_poses_dataset, kinematic_property - ): - """Test that computing a kinematic property of a valid - pose dataset via accessor methods returns an instance of - xr.DataArray. - """ - result = getattr( - valid_poses_dataset.move, f"compute_{kinematic_property}" - )() - assert isinstance(result, xr.DataArray) - - def test_compute_kinematics_with_invalid_dataset( - self, invalid_poses_dataset, kinematic_property - ): - """Test that computing a kinematic property of an invalid - poses dataset via accessor methods raises the appropriate error. - """ - expected_exception = ( - RuntimeError - if isinstance(invalid_poses_dataset, xr.Dataset) - else AttributeError - ) - with pytest.raises(expected_exception): - getattr( - invalid_poses_dataset.move, f"compute_{kinematic_property}" - )() - - @pytest.mark.parametrize( - "method", ["compute_invalid_property", "do_something"] +@pytest.mark.parametrize( + "valid_dataset", ("valid_poses_dataset", "valid_bboxes_dataset") +) +def test_compute_kinematics_with_valid_dataset( + valid_dataset, kinematic_property, request +): + """Test that computing a kinematic property of a valid + poses or bounding boxes dataset via accessor methods returns + an instance of xr.DataArray. + """ + valid_input_dataset = request.getfixturevalue(valid_dataset) + + result = getattr( + valid_input_dataset.move, f"compute_{kinematic_property}" + )() + assert isinstance(result, xr.DataArray) + + +@pytest.mark.parametrize( + "invalid_dataset", + ( + "not_a_dataset", + "empty_dataset", + "missing_var_poses_dataset", + "missing_var_bboxes_dataset", + "missing_dim_poses_dataset", + "missing_dim_bboxes_dataset", + ), +) +def test_compute_kinematics_with_invalid_dataset( + invalid_dataset, kinematic_property, request +): + """Test that computing a kinematic property of an invalid + poses or bounding boxes dataset via accessor methods raises + the appropriate error. + """ + invalid_dataset = request.getfixturevalue(invalid_dataset) + expected_exception = ( + RuntimeError + if isinstance(invalid_dataset, xr.Dataset) + else AttributeError ) - def test_invalid_method_call(self, valid_poses_dataset, method): - """Test that invalid accessor method calls raise an AttributeError.""" - with pytest.raises(AttributeError): - getattr(valid_poses_dataset.move, method)() + with pytest.raises(expected_exception): + getattr(invalid_dataset.move, f"compute_{kinematic_property}")() + + +@pytest.mark.parametrize( + "method", ["compute_invalid_property", "do_something"] +) +@pytest.mark.parametrize( + "valid_dataset", ("valid_poses_dataset", "valid_bboxes_dataset") +) +def test_invalid_move_method_call(valid_dataset, method, request): + """Test that invalid accessor method calls raise an AttributeError.""" + valid_input_dataset = request.getfixturevalue(valid_dataset) + with pytest.raises(AttributeError): + getattr(valid_input_dataset.move, method)() + + +@pytest.mark.parametrize( + "input_dataset, expected_exception, expected_patterns", + ( + ( + "valid_poses_dataset", + does_not_raise(), + [], + ), + ( + "valid_bboxes_dataset", + does_not_raise(), + [], + ), + ( + "valid_bboxes_dataset_in_seconds", + does_not_raise(), + [], + ), + ( + "missing_dim_poses_dataset", + pytest.raises(ValueError), + ["Missing required dimensions:", "['time']"], + ), + ( + "missing_dim_bboxes_dataset", + pytest.raises(ValueError), + ["Missing required dimensions:", "['time']"], + ), + ( + "missing_two_dims_bboxes_dataset", + pytest.raises(ValueError), + ["Missing required dimensions:", "['space', 'time']"], + ), + ( + "missing_var_poses_dataset", + pytest.raises(ValueError), + ["Missing required data variables:", "['position']"], + ), + ( + "missing_var_bboxes_dataset", + pytest.raises(ValueError), + ["Missing required data variables:", "['position']"], + ), + ( + "missing_two_vars_bboxes_dataset", + pytest.raises(ValueError), + ["Missing required data variables:", "['position', 'shape']"], + ), + ), +) +def test_move_validate( + input_dataset, expected_exception, expected_patterns, request +): + """Test the validate method returns the expected message.""" + input_dataset = request.getfixturevalue(input_dataset) + + with expected_exception as excinfo: + input_dataset.move.validate() + + if expected_patterns: + error_message = str(excinfo.value) + assert input_dataset.ds_type in error_message + assert all([pattern in error_message for pattern in expected_patterns]) diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index 1efd3a47..0f606e31 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -53,6 +53,13 @@ class TestSavePoses: }, ] + invalid_poses_datasets_and_exceptions = [ + ("not_a_dataset", ValueError), + ("empty_dataset", RuntimeError), + ("missing_var_poses_dataset", ValueError), + ("missing_dim_poses_dataset", ValueError), + ] + @pytest.fixture(params=output_files) def output_file_params(self, request): """Return a dictionary containing parameters for testing saving @@ -126,15 +133,19 @@ def test_to_dlc_file_valid_dataset( file_path = val.get("file_path") if isinstance(val, dict) else val save_poses.to_dlc_file(valid_poses_dataset, file_path) + @pytest.mark.parametrize( + "invalid_poses_dataset, expected_exception", + invalid_poses_datasets_and_exceptions, + ) def test_to_dlc_file_invalid_dataset( - self, invalid_poses_dataset, tmp_path + self, invalid_poses_dataset, expected_exception, tmp_path, request ): """Test that saving an invalid pose dataset to a valid DeepLabCut-style file returns the appropriate errors. """ - with pytest.raises(ValueError): + with pytest.raises(expected_exception): save_poses.to_dlc_file( - invalid_poses_dataset, + request.getfixturevalue(invalid_poses_dataset), tmp_path / "test.h5", split_individuals=False, ) @@ -252,13 +263,19 @@ def test_to_lp_file_valid_dataset( file_path = val.get("file_path") if isinstance(val, dict) else val save_poses.to_lp_file(valid_poses_dataset, file_path) - def test_to_lp_file_invalid_dataset(self, invalid_poses_dataset, tmp_path): + @pytest.mark.parametrize( + "invalid_poses_dataset, expected_exception", + invalid_poses_datasets_and_exceptions, + ) + def test_to_lp_file_invalid_dataset( + self, invalid_poses_dataset, expected_exception, tmp_path, request + ): """Test that saving an invalid pose dataset to a valid LightningPose-style file returns the appropriate errors. """ - with pytest.raises(ValueError): + with pytest.raises(expected_exception): save_poses.to_lp_file( - invalid_poses_dataset, + request.getfixturevalue(invalid_poses_dataset), tmp_path / "test.csv", ) @@ -274,15 +291,19 @@ def test_to_sleap_analysis_file_valid_dataset( file_path = val.get("file_path") if isinstance(val, dict) else val save_poses.to_sleap_analysis_file(valid_poses_dataset, file_path) + @pytest.mark.parametrize( + "invalid_poses_dataset, expected_exception", + invalid_poses_datasets_and_exceptions, + ) def test_to_sleap_analysis_file_invalid_dataset( - self, invalid_poses_dataset, new_h5_file + self, invalid_poses_dataset, expected_exception, new_h5_file, request ): """Test that saving an invalid pose dataset to a valid SLEAP-style file returns the appropriate errors. """ - with pytest.raises(ValueError): + with pytest.raises(expected_exception): save_poses.to_sleap_analysis_file( - invalid_poses_dataset, + request.getfixturevalue(invalid_poses_dataset), new_h5_file, ) From e7ccfe50f65b38dc1a05fc78e1a324df808d6198 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Thu, 8 Aug 2024 21:17:29 +0100 Subject: [PATCH 31/65] Update setup docs (#264) * Update python version in contributing guide * Add conda link to roadmap * Document conda installation * Update python version in `environment.yml` * Add conda update instructions * Suggestion nested tabs users / devs * Split install and update * Restructure installation guide * Recommend fresh env when updating --------- Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- CONTRIBUTING.md | 13 ++-- README.md | 11 +-- docs/source/community/roadmaps.md | 2 +- docs/source/environment.yml | 2 +- docs/source/getting_started/installation.md | 82 ++++++++++----------- 5 files changed, 48 insertions(+), 62 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e39412a6..99f1a9df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,15 +12,12 @@ development environment for movement. In the following we assume you have First, create and activate a `conda` environment with some prerequisites: ```sh -conda create -n movement-dev -c conda-forge python=3.10 pytables +conda create -n movement-dev -c conda-forge python=3.11 pytables conda activate movement-dev ``` -The above method ensures that you will get packages that often can't be -installed via `pip`, including [hdf5](https://www.hdfgroup.org/solutions/hdf5/). - -To install movement for development, clone the GitHub repository, -and then run from inside the repository: +To install movement for development, clone the [GitHub repository](movement-github:), +and then run from within the repository: ```sh pip install -e .[dev] # works on most shells @@ -338,7 +335,7 @@ The most important parts of this module are: 1. The `SAMPLE_DATA` download manager object. 2. The `list_datasets()` function, which returns a list of the available poses and bounding boxes datasets (file names of the data files). 3. The `fetch_dataset_paths()` function, which returns a dictionary containing local paths to the files associated with a particular sample dataset: `poses` or `bboxes`, `frame`, `video`. If the relevant files are not already cached locally, they will be downloaded. -4. The `fetch_dataset()` function, which downloads the files associated with a given sample dataset (same as `fetch_dataset_paths()`) and additionally loads the pose or bounding box data into `movement`, returning an `xarray.Dataset` object. If available, the local paths to the associated video and frame files are stored as dataset attributes, with names `video_path` and `frame_path`, respectively. +4. The `fetch_dataset()` function, which downloads the files associated with a given sample dataset (same as `fetch_dataset_paths()`) and additionally loads the pose or bounding box data into movement, returning an `xarray.Dataset` object. If available, the local paths to the associated video and frame files are stored as dataset attributes, with names `video_path` and `frame_path`, respectively. By default, the downloaded files are stored in the `~/.movement/data` folder. This can be changed by setting the `DATA_DIR` variable in the `movement.sample_data.py` module. @@ -372,7 +369,7 @@ To add a new file, you will need to: ``` ::: :::: - For convenience, we've included a `get_sha256_hashes.py` script in the [movement data repository](gin:neuroinformatics/movement-test-data). If you run this from the root of the data repository, within a Python environment with `movement` installed, it will calculate the sha256 hashes for all files in the `poses`, `bboxes`, `videos` and `frames` folders and write them to files named `poses_hashes.txt`, `bboxes_hashes.txt`, `videos_hashes.txt`, and `frames_hashes.txt` respectively. + For convenience, we've included a `get_sha256_hashes.py` script in the [movement data repository](gin:neuroinformatics/movement-test-data). If you run this from the root of the data repository, within a Python environment with movement installed, it will calculate the sha256 hashes for all files in the `poses`, `bboxes`, `videos` and `frames` folders and write them to files named `poses_hashes.txt`, `bboxes_hashes.txt`, `videos_hashes.txt`, and `frames_hashes.txt` respectively. 7. Add metadata for your new files to `metadata.yaml`, including their sha256 hashes you've calculated. See the example entry below for guidance. diff --git a/README.md b/README.md index fd7aaba1..5b1dec34 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,12 @@ A Python toolbox for analysing body movements across space and time, to aid the ## Quick install -First, create and activate a conda environment with the required dependencies: +Create and activate a conda environment with movement installed: ``` -conda create -n movement-env -c conda-forge python=3.11 pytables +conda create -n movement-env -c conda-forge movement conda activate movement-env ``` -Then install the `movement` package: -``` -pip install movement -``` - > [!Note] > Read the [documentation](https://movement.neuroinformatics.dev) for more information, including [full installation instructions](https://movement.neuroinformatics.dev/getting_started/installation.html) and [examples](https://movement.neuroinformatics.dev/examples/index.html). @@ -52,7 +47,7 @@ You are welcome to chat with the team on [zulip](https://neuroinformatics.zulipc ## Citation -If you use `movement` in your work, please cite the following Zenodo DOI: +If you use movement in your work, please cite the following Zenodo DOI: > Nikoloz Sirmpilatze, Chang Huan Lo, Sofía Miñano, Brandon D. Peri, Dhruv Sharma, Laura Porta, Iván Varela & Adam L. Tyson (2024). neuroinformatics-unit/movement. Zenodo. https://zenodo.org/doi/10.5281/zenodo.12755724 diff --git a/docs/source/community/roadmaps.md b/docs/source/community/roadmaps.md index 69b6000e..78b1bd67 100644 --- a/docs/source/community/roadmaps.md +++ b/docs/source/community/roadmaps.md @@ -24,5 +24,5 @@ We plan to release version `v0.1` of movement in early 2024, providing a minimal - [x] Ability to compute velocity and acceleration from pose tracks. - [x] Public website with [documentation](target-movement). - [x] Package released on [PyPI](https://pypi.org/project/movement/). -- [ ] Package released on [conda-forge](https://conda-forge.org/). +- [x] Package released on [conda-forge](https://anaconda.org/conda-forge/movement). - [ ] Ability to visualise pose tracks using [napari](napari:). We aim to represent pose tracks via napari's [Points](napari:howtos/layers/points) and [Tracks](napari:howtos/layers/tracks) layers and overlay them on video frames. diff --git a/docs/source/environment.yml b/docs/source/environment.yml index 00c7d126..b84ac374 100644 --- a/docs/source/environment.yml +++ b/docs/source/environment.yml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - - python=3.10 + - python=3.11 - pytables - pip: - movement diff --git a/docs/source/getting_started/installation.md b/docs/source/getting_started/installation.md index d1937944..404acceb 100644 --- a/docs/source/getting_started/installation.md +++ b/docs/source/getting_started/installation.md @@ -1,68 +1,62 @@ (target-installation)= # Installation -## Create a conda environment - +## Install the package :::{admonition} Use a conda environment :class: note -We recommend you install movement inside a [conda](conda:) -or [mamba](mamba:) environment, to avoid dependency conflicts with other packages. -In the following we assume you have `conda` installed, -but the same commands will also work with `mamba`/`micromamba`. +To avoid dependency conflicts with other packages, it is best practice to install Python packages within a virtual environment. +We recommend using [conda](conda:) or [mamba](mamba:) to create and manage this environment, as they simplify the installation process. +The following instructions assume that you have conda installed, but the same commands will also work with `mamba`/`micromamba`. ::: -First, create and activate an environment with some prerequisites. -You can call your environment whatever you like, we've used `movement-env`. +### Users +To install movement in a new environment, follow one of the options below. +We will use `movement-env` as the environment name, but you can choose any name you prefer. +::::{tab-set} +:::{tab-item} Conda +Create and activate an environment with movement installed: ```sh -conda create -n movement-env -c conda-forge python=3.11 pytables +conda create -n movement-env -c conda-forge movement conda activate movement-env ``` - -## Install the package - -Then install the `movement` package as described below. - -::::{tab-set} - -:::{tab-item} Users -To get the latest release from PyPI: - +::: +:::{tab-item} Pip +Create and activate an environment with some prerequisites: ```sh -pip install movement +conda create -n movement-env -c conda-forge python=3.11 pytables +conda activate movement-env ``` -If you have an older version of `movement` installed in the same environment, -you can update to the latest version with: - +Install the latest movement release from PyPI: ```sh -pip install --upgrade movement +pip install movement ``` ::: +:::: -:::{tab-item} Developers -To get the latest development version, clone the -[GitHub repository](movement-github:) -and then run from inside the repository: +### Developers +If you are a developer looking to contribute to movement, please refer to our [contributing guide](target-contributing) for detailed setup instructions and guidelines. +## Check the installation +To verify that the installation was successful, run (with `movement-env` activated): ```sh -pip install -e .[dev] # works on most shells -pip install -e '.[dev]' # works on zsh (the default shell on macOS) +movement info ``` +You should see a printout including the version numbers of movement +and some of its dependencies. -This will install the package in editable mode, including all `dev` dependencies. -Please see the [contributing guide](target-contributing) for more information. -::: - -:::: - -## Check the installation - -To verify that the installation was successful, you can run the following -command (with the `movement-env` activated): +## Update the package +To update movement to the latest version, we recommend installing it in a new environment, +as this prevents potential compatibility issues caused by changes in dependency versions. +To uninstall an existing environment named `movement-env`: ```sh -movement info +conda env remove -n movement-env ``` - -You should see a printout including the version numbers of `movement` -and some of its dependencies. +:::{tip} +If you are unsure about the environment name, you can get a list of the environments on your system with: +```sh +conda env list +``` +::: +Once the environment has been removed, you can create a new one following the [installation instructions](#install-the-package) above. From 0cfded50620b61c0d3b6a5037d2d889278557185 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Tue, 13 Aug 2024 13:29:18 +0100 Subject: [PATCH 32/65] Revert "Pin pandas version (& numpy) if python 3.12 (#259)" (#272) This reverts commit 21225919d88b95fbdc5a86565bdf3e9a87500f92. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b552fd42..27348c29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ license = { text = "BSD-3-Clause" } dependencies = [ "numpy", - "pandas<2.2.2;python_version>='3.12'", "pandas", "h5py", "attrs", From d3fac95d17fad34844a3650aa20e608a0b7a6e27 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Wed, 14 Aug 2024 09:37:13 +0100 Subject: [PATCH 33/65] Configure intersphinx (#269) * Configure intersphinx * Document cross-referencing Python objects * Fix `interpolate_over_time` docstring * Restructure cross-referencing guide * Use simpler syntax for cross-referencing * Simplify cross-referencing guide * Link to internal references --- CONTRIBUTING.md | 66 +++++++++++++++++++++++++----- docs/source/_static/css/custom.css | 9 ++++ docs/source/conf.py | 11 ++++- examples/compute_kinematics.py | 6 +-- examples/filter_and_interpolate.py | 26 ++++++------ examples/smooth.py | 14 +++---- movement/filtering.py | 14 +++---- movement/io/load_bboxes.py | 6 +-- movement/io/load_poses.py | 4 +- movement/io/save_poses.py | 2 +- movement/move_accessor.py | 14 +++---- movement/utils/logging.py | 4 +- 12 files changed, 118 insertions(+), 58 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99f1a9df..78144ac0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,13 +159,12 @@ The version number is automatically determined from the latest tag on the _main_ The documentation is hosted via [GitHub pages](https://pages.github.com/) at [movement.neuroinformatics.dev](target-movement). Its source files are located in the `docs` folder of this repository. -They are written in either [reStructuredText](https://docutils.sourceforge.io/rst.html) or -[markdown](myst-parser:syntax/typography.html). +They are written in either [Markdown](myst-parser:syntax/typography.html) +or [reStructuredText](https://docutils.sourceforge.io/rst.html). The `index.md` file corresponds to the homepage of the documentation website. -Other `.rst` or `.md` files are linked to the homepage via the `toctree` directive. +Other `.md` or `.rst` files are linked to the homepage via the `toctree` directive. -We use [Sphinx](https://www.sphinx-doc.org/en/master/) and the -[PyData Sphinx Theme](https://pydata-sphinx-theme.readthedocs.io/en/stable/index.html) +We use [Sphinx](sphinx-doc:) and the [PyData Sphinx Theme](https://pydata-sphinx-theme.readthedocs.io/en/stable/index.html) to build the source files into HTML output. This is handled by a GitHub actions workflow (`.github/workflows/docs_build_and_deploy.yml`). The build job is triggered on each PR, ensuring that the documentation build is not broken by new changes. @@ -196,17 +195,16 @@ existing_file my_new_file ``` -#### Adding external links -If you are adding references to an external link (e.g. `https://github.com/neuroinformatics-unit/movement/issues/1`) in a `.md` file, you will need to check if a matching URL scheme (e.g. `https://github.com/neuroinformatics-unit/movement/`) is defined in `myst_url_schemes` in `docs/source/conf.py`. If it is, the following `[](scheme:loc)` syntax will be converted to the [full URL](movement-github:issues/1) during the build process: +#### Linking to external URLs +If you are adding references to an external URL (e.g. `https://github.com/neuroinformatics-unit/movement/issues/1`) in a `.md` file, you will need to check if a matching URL scheme (e.g. `https://github.com/neuroinformatics-unit/movement/`) is defined in `myst_url_schemes` in `docs/source/conf.py`. If it is, the following `[](scheme:loc)` syntax will be converted to the [full URL](movement-github:issues/1) during the build process: ```markdown [link text](movement-github:issues/1) ``` -If it is not yet defined and you have multiple external links pointing to the same base URL, you will need to [add the URL scheme](myst-parser:syntax/cross-referencing.html#customising-external-url-resolution) to `myst_url_schemes` in `docs/source/conf.py`. - +If it is not yet defined and you have multiple external URLs pointing to the same base URL, you will need to [add the URL scheme](myst-parser:syntax/cross-referencing.html#customising-external-url-resolution) to `myst_url_schemes` in `docs/source/conf.py`. ### Updating the API reference -The API reference is auto-generated by the `docs/make_api_index.py` script, and the `sphinx-autodoc` and `sphinx-autosummary` plugins. +The [API reference](target-api) is auto-generated by the `docs/make_api_index.py` script, and the [sphinx-autodoc](sphinx-doc:extensions/autodoc.html) and [sphinx-autosummary](sphinx-doc:extensions/autosummary.html) extensions. The script generates the `docs/source/api_index.rst` file containing the list of modules to be included in the [API reference](target-api). The plugins then generate the API reference pages for each module listed in `api_index.rst`, based on the docstrings in the source code. So make sure that all your public functions/classes/methods have valid docstrings following the [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) style. @@ -221,7 +219,7 @@ To add new examples, you will need to create a new `.py` file in `examples/`. The file should be structured as specified in the relevant [sphinx-gallery documentation](sphinx-gallery:syntax). -We are using sphinx-gallery's [integration with binder](https://sphinx-gallery.github.io/stable/configuration.html#binder-links) +We are using sphinx-gallery's [integration with binder](sphinx-gallery:configuration#binder-links) to provide interactive versions of the examples. If your examples rely on packages that are not among movement's dependencies, you will need to add them to the `docs/source/environment.yml` file. @@ -229,6 +227,52 @@ That file is used by binder to create the conda environment in which the examples are run. See the relevant section of the [binder documentation](https://mybinder.readthedocs.io/en/latest/using/config_files.html). +### Cross-referencing Python objects +:::{note} +Docstrings in the `.py` files for the [API reference](target-api) and the [examples](target-examples) are converted into `.rst` files, so these should use reStructuredText syntax. +::: + +#### Internal references +::::{tab-set} +:::{tab-item} Markdown +For referencing movement objects in `.md` files, use the `` {role}`target` `` syntax with the appropriate [Python object role](sphinx-doc:domains/python.html#cross-referencing-python-objects). + +For example, to reference the {mod}`movement.io.load_poses` module, use: +```markdown +{mod}`movement.io.load_poses` +``` +::: +:::{tab-item} RestructuredText +For referencing movement objects in `.rst` files, use the `` :role:`target` `` syntax with the appropriate [Python object role](sphinx-doc:domains/python.html#cross-referencing-python-objects). + +For example, to reference the {mod}`movement.io.load_poses` module, use: +```rst +:mod:`movement.io.load_poses` +``` +::: +:::: + +#### External references +For referencing external Python objects using [intersphinx](sphinx-doc:extensions/intersphinx.html), +ensure the mapping between module names and their documentation URLs is defined in [`intersphinx_mapping`](sphinx-doc:extensions/intersphinx.html#confval-intersphinx_mapping) in `docs/source/conf.py`. +Once the module is included in the mapping, use the same syntax as for [internal references](#internal-references). + +::::{tab-set} +:::{tab-item} Markdown +For example, to reference the {meth}`xarray.Dataset.update` method, use: +```markdown +{meth}`xarray.Dataset.update` +``` +::: + +:::{tab-item} RestructuredText +For example, to reference the {meth}`xarray.Dataset.update` method, use: +```rst +:meth:`xarray.Dataset.update` +``` +::: +:::: + ### Building the documentation locally We recommend that you build and view the documentation website locally, before you push it. To do so, first navigate to `docs/`. diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css index 40e09e63..ce6b5ae3 100644 --- a/docs/source/_static/css/custom.css +++ b/docs/source/_static/css/custom.css @@ -30,3 +30,12 @@ display: flex; flex-wrap: wrap; justify-content: space-between; } + +/* Disable decoration for all but movement backrefs */ +a[class^="sphx-glr-backref-module-"], +a[class^="sphx-glr-backref-type-"] { + text-decoration: none; +} +a[class^="sphx-glr-backref-module-movement"] { + text-decoration: underline; +} diff --git a/docs/source/conf.py b/docs/source/conf.py index f6352943..9b051fb0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,7 +68,7 @@ "tasklist", ] # Automatically add anchors to markdown headings -myst_heading_anchors = 3 +myst_heading_anchors = 4 # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -108,6 +108,7 @@ "binderhub_url": "https://mybinder.org", "dependencies": ["environment.yml"], }, + "reference_url": {"movement": None}, "remove_config_comments": True, # do not render config params set as # sphinx_gallery_config [= value] } @@ -191,8 +192,14 @@ "napari": "https://napari.org/dev/{{path}}", "setuptools-scm": "https://setuptools-scm.readthedocs.io/en/latest/{{path}}#{{fragment}}", "sleap": "https://sleap.ai/{{path}}#{{fragment}}", - "sphinx-gallery": "https://sphinx-gallery.github.io/stable/{{path}}", + "sphinx-doc": "https://www.sphinx-doc.org/en/master/usage/{{path}}#{{fragment}}", + "sphinx-gallery": "https://sphinx-gallery.github.io/stable/{{path}}#{{fragment}}", "xarray": "https://docs.xarray.dev/en/stable/{{path}}#{{fragment}}", "lp": "https://lightning-pose.readthedocs.io/en/stable/{{path}}#{{fragment}}", "via": "https://www.robots.ox.ac.uk/~vgg/software/via/{{path}}#{{fragment}}", } + +intersphinx_mapping = { + "xarray": ("https://docs.xarray.dev/en/stable/", None), + "scipy": ("https://docs.scipy.org/doc/scipy/reference/", None), +} diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py index 8cea0912..1ab2731d 100644 --- a/examples/compute_kinematics.py +++ b/examples/compute_kinematics.py @@ -105,7 +105,7 @@ # %% # We can also easily plot the components of the position vector against time # using ``xarray``'s built-in plotting methods. We use -# :py:meth:`xarray.DataArray.squeeze` to +# :meth:`xarray.DataArray.squeeze` to # remove the dimension of length 1 from the data (the ``keypoints`` dimension). position.squeeze().plot.line(x="time", row="individuals", aspect=2, size=2.5) plt.gcf().show() @@ -131,7 +131,7 @@ # %% # Notice that we could also compute the displacement (and all the other -# kinematic variables) using the :py:mod:`movement.analysis.kinematics` module: +# kinematic variables) using the :mod:`movement.analysis.kinematics` module: # %% import movement.analysis.kinematics as kin @@ -284,7 +284,7 @@ # %% # We can plot the components of the velocity vector against time # using ``xarray``'s built-in plotting methods. We use -# :py:meth:`xarray.DataArray.squeeze` to +# :meth:`xarray.DataArray.squeeze` to # remove the dimension of length 1 from the data (the ``keypoints`` dimension). velocity.squeeze().plot.line(x="time", row="individuals", aspect=2, size=2.5) diff --git a/examples/filter_and_interpolate.py b/examples/filter_and_interpolate.py index dbe33044..71384ca7 100644 --- a/examples/filter_and_interpolate.py +++ b/examples/filter_and_interpolate.py @@ -27,7 +27,7 @@ # Visualise the pose tracks # ------------------------- # Since the data contains only a single wasp, we use -# :py:meth:`xarray.DataArray.squeeze` to remove +# :meth:`xarray.DataArray.squeeze` to remove # the dimension of length 1 from the data (the ``individuals`` dimension). ds.position.squeeze().plot.line( @@ -51,7 +51,7 @@ # it's always a good idea to inspect the actual confidence values in the data. # # Let's first look at a histogram of the confidence scores. As before, we use -# :py:meth:`xarray.DataArray.squeeze` to remove the ``individuals`` dimension +# :meth:`xarray.DataArray.squeeze` to remove the ``individuals`` dimension # from the data. ds.confidence.squeeze().plot.hist(bins=20) @@ -74,7 +74,7 @@ # Filter out points with low confidence # ------------------------------------- # Using the -# :py:meth:`filter_by_confidence()\ +# :meth:`filter_by_confidence()\ # ` # method of the ``move`` accessor, # we can filter out points with confidence scores below a certain threshold. @@ -82,20 +82,20 @@ # provided. # This method will also report the number of NaN values in the dataset before # and after the filtering operation by default (``print_report=True``). -# We will use :py:meth:`xarray.Dataset.update` to update ``ds`` in-place +# We will use :meth:`xarray.Dataset.update` to update ``ds`` in-place # with the filtered ``position``. ds.update({"position": ds.move.filter_by_confidence()}) # %% # .. note:: -# The ``move`` accessor :py:meth:`filter_by_confidence()\ +# The ``move`` accessor :meth:`filter_by_confidence()\ # ` # method is a convenience method that applies -# :py:func:`movement.filtering.filter_by_confidence`, +# :func:`movement.filtering.filter_by_confidence`, # which takes ``position`` and ``confidence`` as arguments. # The equivalent function call using the -# :py:mod:`movement.filtering` module would be: +# :mod:`movement.filtering` module would be: # # .. code-block:: python # @@ -121,7 +121,7 @@ # Interpolate over missing values # ------------------------------- # Using the -# :py:meth:`interpolate_over_time()\ +# :meth:`interpolate_over_time()\ # ` # method of the ``move`` accessor, # we can interpolate over the gaps we've introduced in the pose tracks. @@ -135,13 +135,13 @@ # %% # .. note:: -# The ``move`` accessor :py:meth:`interpolate_over_time()\ +# The ``move`` accessor :meth:`interpolate_over_time()\ # ` # is also a convenience method that applies -# :py:func:`movement.filtering.interpolate_over_time` +# :func:`movement.filtering.interpolate_over_time` # to the ``position`` data variable. # The equivalent function call using the -# :py:mod:`movement.filtering` module would be: +# :mod:`movement.filtering` module would be: # # .. code-block:: python # @@ -176,7 +176,7 @@ # %% # Filtering multiple data variables # --------------------------------- -# All :py:mod:`movement.filtering` functions are available via the +# All :mod:`movement.filtering` functions are available via the # ``move`` accessor. These ``move`` accessor methods operate on the # ``position`` data variable in the dataset ``ds`` by default. # There is also an additional argument ``data_vars`` that allows us to @@ -192,7 +192,7 @@ # in ``ds``, based on the confidence scores, we can specify # ``data_vars=["position", "velocity"]`` in the method call. # As the filtered data variables are returned as a dictionary, we can once -# again use :py:meth:`xarray.Dataset.update` to update ``ds`` in-place +# again use :meth:`xarray.Dataset.update` to update ``ds`` in-place # with the filtered data variables. ds["velocity"] = ds.move.compute_velocity() diff --git a/examples/smooth.py b/examples/smooth.py index f9b969b5..316d9444 100644 --- a/examples/smooth.py +++ b/examples/smooth.py @@ -109,7 +109,7 @@ def plot_raw_and_smooth_timeseries_and_psd( # Smoothing with a median filter # ------------------------------ # Using the -# :py:meth:`median_filter()\ +# :meth:`median_filter()\ # ` # method of the ``move`` accessor, # we apply a rolling window median filter over a 0.1-second window @@ -125,13 +125,13 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # .. note:: -# The ``move`` accessor :py:meth:`median_filter()\ +# The ``move`` accessor :meth:`median_filter()\ # ` # method is a convenience method that applies -# :py:func:`movement.filtering.median_filter` +# :func:`movement.filtering.median_filter` # to the ``position`` data variable. # The equivalent function call using the -# :py:mod:`movement.filtering` module would be: +# :mod:`movement.filtering` module would be: # # .. code-block:: python # @@ -249,11 +249,11 @@ def plot_raw_and_smooth_timeseries_and_psd( # Smoothing with a Savitzky-Golay filter # -------------------------------------- # Here we use the -# :py:meth:`savgol_filter()\ +# :meth:`savgol_filter()\ # ` # method of the ``move`` accessor, which is a convenience method that applies -# :py:func:`movement.filtering.savgol_filter` -# (a wrapper around :py:func:`scipy.signal.savgol_filter`), +# :func:`movement.filtering.savgol_filter` +# (a wrapper around :func:`scipy.signal.savgol_filter`), # to the ``position`` data variable. # The Savitzky-Golay filter is a polynomial smoothing filter that can be # applied to time series data on a rolling window basis. diff --git a/movement/filtering.py b/movement/filtering.py index 573e8635..9230acaa 100644 --- a/movement/filtering.py +++ b/movement/filtering.py @@ -66,7 +66,7 @@ def interpolate_over_time( ) -> xr.DataArray: """Fill in NaN values by interpolating over the ``time`` dimension. - This method uses :py:meth:`xarray.DataArray.interpolate_na` under the + This method uses :meth:`xarray.DataArray.interpolate_na` under the hood and passes the ``method`` and ``max_gap`` parameters to it. See the xarray documentation for more details on these parameters. @@ -88,14 +88,14 @@ def interpolate_over_time( Returns ------- - xr.DataArray + xarray.DataArray The data where NaN values have been interpolated over using the parameters provided. Notes ----- The ``max_gap`` parameter differs slightly from that in - :py:meth:`xarray.DataArray.interpolate_na`, in which the gap size + :meth:`xarray.DataArray.interpolate_na`, in which the gap size is defined as the difference between the ``time`` coordinate values at the first data point after a gap and the last value before a gap. @@ -134,7 +134,7 @@ def median_filter( a value (otherwise result is NaN). The default, None, is equivalent to setting ``min_periods`` equal to the size of the window. This argument is directly passed to the ``min_periods`` parameter of - :py:meth:`xarray.DataArray.rolling`. + :meth:`xarray.DataArray.rolling`. print_report : bool Whether to print a report on the number of NaNs in the dataset before and after filtering. Default is ``True``. @@ -205,7 +205,7 @@ def savgol_filter( before and after filtering. Default is ``True``. **kwargs : dict Additional keyword arguments are passed to - :py:func:`scipy.signal.savgol_filter`. + :func:`scipy.signal.savgol_filter`. Note that the ``axis`` keyword argument may not be overridden. @@ -217,7 +217,7 @@ def savgol_filter( Notes ----- - Uses the :py:func:`scipy.signal.savgol_filter` function to apply a + Uses the :func:`scipy.signal.savgol_filter` function to apply a Savitzky-Golay filter to the input data. See the SciPy documentation for more information on that function. Whenever one or more NaNs are present in a filter window of the @@ -225,7 +225,7 @@ def savgol_filter( stretch of NaNs present in the input data will be propagated proportionally to the size of the window (specifically, by ``floor(window/2)``). Note that, unlike - :py:func:`movement.filtering.median_filter()`, there is no ``min_periods`` + :func:`movement.filtering.median_filter`, there is no ``min_periods`` option to control this behaviour. """ diff --git a/movement/io/load_bboxes.py b/movement/io/load_bboxes.py index 9c177230..6971de0f 100644 --- a/movement/io/load_bboxes.py +++ b/movement/io/load_bboxes.py @@ -35,19 +35,19 @@ def from_numpy( position_array : np.ndarray Array of shape (n_frames, n_individuals, n_space) containing the tracks of the bounding boxes' centroids. - It will be converted to a :py:class:`xarray.DataArray` object + It will be converted to a :class:`xarray.DataArray` object named "position". shape_array : np.ndarray Array of shape (n_frames, n_individuals, n_space) containing the shape of the bounding boxes. The shape of a bounding box is its width (extent along the x-axis of the image) and height (extent along the y-axis of the image). It will be converted to a - :py:class:`xarray.DataArray` object named "shape". + :class:`xarray.DataArray` object named "shape". confidence_array : np.ndarray, optional Array of shape (n_frames, n_individuals) containing the confidence scores of the bounding boxes. If None (default), the confidence scores are set to an array of NaNs. It will be converted - to a :py:class:`xarray.DataArray` object named "confidence". + to a :class:`xarray.DataArray` object named "confidence". individual_names : list of str, optional List of individual names for the tracked bounding boxes in the video. If None (default), bounding boxes are assigned names based on the size diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index 6ae2f9e5..2b1a25d8 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -34,11 +34,11 @@ def from_numpy( position_array : np.ndarray Array of shape (n_frames, n_individuals, n_keypoints, n_space) containing the poses. It will be converted to a - :py:class:`xarray.DataArray` object named "position". + :class:`xarray.DataArray` object named "position". confidence_array : np.ndarray, optional Array of shape (n_frames, n_individuals, n_keypoints) containing the point-wise confidence scores. It will be converted to a - :py:class:`xarray.DataArray` object named "confidence". + :class:`xarray.DataArray` object named "confidence". If None (default), the scores will be set to an array of NaNs. individual_names : list of str, optional List of unique names for the individuals in the video. If None diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py index 07ba0bd0..bc2c0e1c 100644 --- a/movement/io/save_poses.py +++ b/movement/io/save_poses.py @@ -241,7 +241,7 @@ def to_lp_file( ----- LightningPose saves pose estimation outputs as .csv files, using the same format as single-animal DeepLabCut projects. Therefore, under the hood, - this function calls :py:func:`movement.io.save_poses.to_dlc_file` + this function calls :func:`movement.io.save_poses.to_dlc_file` with ``split_individuals=True``. This setting means that each individual is saved to a separate file, with the individual's name appended to the file path, just before the file extension, diff --git a/movement/move_accessor.py b/movement/move_accessor.py index 93388417..64b17651 100644 --- a/movement/move_accessor.py +++ b/movement/move_accessor.py @@ -1,4 +1,4 @@ -"""Accessor for extending :py:class:`xarray.Dataset` objects.""" +"""Accessor for extending :class:`xarray.Dataset` objects.""" import logging from typing import ClassVar @@ -18,9 +18,9 @@ @xr.register_dataset_accessor("move") class MovementDataset: - """An :py:class:`xarray.Dataset` accessor for ``movement`` data. + """An :class:`xarray.Dataset` accessor for ``movement`` data. - A ``movement`` dataset is an :py:class:`xarray.Dataset` with a specific + A ``movement`` dataset is an :class:`xarray.Dataset` with a specific structure to represent pose tracks or bounding boxes data, associated confidence scores and relevant metadata. @@ -66,8 +66,8 @@ def __getattr__(self, name: str) -> xr.DataArray: This method currently only forwards kinematic property computation and filtering operations to the respective functions in - :py:mod:`movement.analysis.kinematics` and - :py:mod:`movement.filtering`. + :mod:`movement.analysis.kinematics` and + :mod:`movement.filtering`. Parameters ---------- @@ -106,7 +106,7 @@ def kinematics_wrapper( """Provide convenience method for computing kinematic properties. This method forwards kinematic property computation - to the respective functions in :py:mod:`movement.analysis.kinematics`. + to the respective functions in :mod:`movement.analysis.kinematics`. Parameters ---------- @@ -161,7 +161,7 @@ def filtering_wrapper( """Provide convenience method for filtering data variables. This method forwards filtering and/or smoothing to the respective - functions in :py:mod:`movement.filtering`. The data variables to + functions in :mod:`movement.filtering`. The data variables to filter can be specified in ``data_vars``. If ``data_vars`` is not specified, the ``position`` data variable is selected by default. diff --git a/movement/utils/logging.py b/movement/utils/logging.py index 14add0a4..0174e5ff 100644 --- a/movement/utils/logging.py +++ b/movement/utils/logging.py @@ -113,8 +113,8 @@ def log_to_attrs(func): """Log the operation performed by the wrapped function. This decorator appends log entries to the data's ``log`` - attribute. The wrapped function must accept an :py:class:`xarray.Dataset` - or :py:class:`xarray.DataArray` as its first argument and return an + attribute. The wrapped function must accept an :class:`xarray.Dataset` + or :class:`xarray.DataArray` as its first argument and return an object of the same type. """ From a17e099d695d59de023373366f4d61138d448129 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:16:09 +0100 Subject: [PATCH 34/65] Suggestion to treat warnings as errors in sphinx-build (#256) * Stop sphinx-build on warning * Add clarification to workflow * Add flags to contributing guide * Remove comment in workflow file * Move flags to SPHINXOPTS --- CONTRIBUTING.md | 6 +++--- docs/Makefile | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78144ac0..ac925113 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -297,7 +297,7 @@ The local build can be viewed by opening `docs/build/html/index.html` in a brows :::{tab-item} All platforms ```sh -python make_api_index.py && sphinx-build source build +python make_api_index.py && sphinx-build source build -W --keep-going ``` The local build can be viewed by opening `docs/build/index.html` in a browser. ::: @@ -317,7 +317,7 @@ make clean html :::{tab-item} All platforms ```sh rm -f source/api_index.rst && rm -rf build && rm -rf source/api && rm -rf source/examples -python make_api_index.py && sphinx-build source build +python make_api_index.py && sphinx-build source build -W --keep-going ``` ::: :::: @@ -333,7 +333,7 @@ make linkcheck :::{tab-item} All platforms ```sh -sphinx-build source build -b linkcheck +sphinx-build source build -b linkcheck -W --keep-going ``` ::: :::: diff --git a/docs/Makefile b/docs/Makefile index 623d2906..529f6650 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,7 +3,9 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= +# -W: if there are warnings, treat them as errors and exit with status 1. +# --keep-going: run sphinx-build to completion and exit with status 1 if errors. +SPHINXOPTS ?= -W --keep-going SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build From 9ba430212c5b9faa0e1dc72ada493666e54c4c1b Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Fri, 16 Aug 2024 18:41:08 +0100 Subject: [PATCH 35/65] Refactor derivative (#270) * Suggestion to rename internal method for clarity * Use `xr.DataArray.differentiate()` * Update docstrings --------- Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- movement/analysis/kinematics.py | 58 ++++++++++++++++-------------- tests/test_unit/test_kinematics.py | 4 +-- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index ed826cc1..15375a53 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -1,6 +1,5 @@ """Compute kinematic variables like velocity and acceleration.""" -import numpy as np import xarray as xr from movement.utils.logging import log_error @@ -9,15 +8,17 @@ def compute_displacement(data: xr.DataArray) -> xr.DataArray: """Compute displacement between consecutive positions. - This is the difference between consecutive positions of each keypoint for - each individual across time. At each time point ``t``, it's defined as a - vector in cartesian ``(x,y)`` coordinates, pointing from the previous + Displacement is the difference between consecutive positions + of each keypoint for each individual across ``time``. + At each time point ``t``, it is defined as a vector in + cartesian ``(x,y)`` coordinates, pointing from the previous ``(t-1)`` to the current ``(t)`` position. Parameters ---------- data : xarray.DataArray - The input data containing ``time`` as a dimension. + The input data containing position information, with + ``time`` as a dimension. Returns ------- @@ -35,50 +36,62 @@ def compute_velocity(data: xr.DataArray) -> xr.DataArray: """Compute the velocity in cartesian ``(x,y)`` coordinates. Velocity is the first derivative of position for each keypoint - and individual across time. It's computed using numerical differentiation - and assumes equidistant time spacing. + and individual across ``time``, computed with the second order + accurate central differences. Parameters ---------- data : xarray.DataArray - The input data containing ``time`` as a dimension. + The input data containing position information, with + ``time`` as a dimension. Returns ------- xarray.DataArray An xarray DataArray containing the computed velocity. + See Also + -------- + :py:meth:`xarray.DataArray.differentiate` : The underlying method used. + """ - return _compute_approximate_derivative(data, order=1) + return _compute_approximate_time_derivative(data, order=1) def compute_acceleration(data: xr.DataArray) -> xr.DataArray: """Compute acceleration in cartesian ``(x,y)`` coordinates. - Acceleration represents the second derivative of position for each keypoint - and individual across time. It's computed using numerical differentiation - and assumes equidistant time spacing. + Acceleration is the second derivative of position for each keypoint + and individual across ``time``, computed with the second order + accurate central differences. Parameters ---------- data : xarray.DataArray - The input data containing ``time`` as a dimension. + The input data containing position information, with + ``time`` as a dimension. Returns ------- xarray.DataArray An xarray DataArray containing the computed acceleration. + See Also + -------- + :py:meth:`xarray.DataArray.differentiate` : The underlying method used. + """ - return _compute_approximate_derivative(data, order=2) + return _compute_approximate_time_derivative(data, order=2) -def _compute_approximate_derivative( +def _compute_approximate_time_derivative( data: xr.DataArray, order: int ) -> xr.DataArray: """Compute the derivative using numerical differentiation. - This assumes equidistant time spacing. + This function uses :py:meth:`xarray.DataArray.differentiate`, + which differentiates the array with the second order + accurate central differences. Parameters ---------- @@ -102,15 +115,8 @@ def _compute_approximate_derivative( raise log_error(ValueError, "Order must be a positive integer.") _validate_time_dimension(data) result = data - dt = data["time"].values[1] - data["time"].values[0] for _ in range(order): - result = xr.apply_ufunc( - np.gradient, - result, - dt, - kwargs={"axis": 0}, - ) - result = result.reindex_like(data) + result = result.differentiate("time") return result @@ -124,11 +130,11 @@ def _validate_time_dimension(data: xr.DataArray) -> None: Raises ------ - AttributeError + ValueError If the input data does not contain a ``time`` dimension. """ if "time" not in data.dims: raise log_error( - AttributeError, "Input data must contain 'time' as a dimension." + ValueError, "Input data must contain 'time' as a dimension." ) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index e5241bc8..1f75a824 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -53,7 +53,7 @@ def _expected_dataarray(property): kinematic_test_params = [ ("valid_poses_dataset", does_not_raise()), ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_poses_dataset", pytest.raises(AttributeError)), + ("missing_dim_poses_dataset", pytest.raises(ValueError)), ] @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) @@ -109,4 +109,4 @@ def test_approximate_derivative_with_invalid_order(self, order): ValueError if isinstance(order, int) else TypeError ) with pytest.raises(expected_exception): - kinematics._compute_approximate_derivative(data, order=order) + kinematics._compute_approximate_time_derivative(data, order=order) From 96c3cd7dddcff5fa8a57fde2498b11b30cad1481 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Fri, 16 Aug 2024 18:54:58 +0100 Subject: [PATCH 36/65] Add utilities for vector magnitude and normalisation (#243) * added utility for vector magnitude * added utility for normalising vectors * use magnitude utility in kinematics example * use `magnitude` in `cart2pol` * reorder functions in vector.py * rename `magnitude` to `compute_norm` * define normalisation in polar coordinates too * Update comment phrasing Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> * generalise assertion about null vector normalisation * renamed `normalize` to `convert_to_unit` * extend test for conversion to unit vectors * refactor erro raising into separate func --------- Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- examples/compute_kinematics.py | 34 +++++------ movement/utils/vector.py | 102 +++++++++++++++++++++++++++++++-- tests/test_unit/test_vector.py | 67 ++++++++++++++++++++++ 3 files changed, 176 insertions(+), 27 deletions(-) diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py index 1ab2731d..b3fefd4a 100644 --- a/examples/compute_kinematics.py +++ b/examples/compute_kinematics.py @@ -10,14 +10,13 @@ # Imports # ------- -import numpy as np - # For interactive plots: install ipympl with `pip install ipympl` and uncomment # the following line in your notebook # %matplotlib widget from matplotlib import pyplot as plt from movement import sample_data +from movement.utils.vector import compute_norm # %% # Load sample dataset @@ -255,13 +254,12 @@ # mouse along its trajectory. # length of each displacement vector -displacement_vectors_lengths = np.linalg.norm( - displacement.sel(individuals=mouse_name, space=["x", "y"]).squeeze(), - axis=1, +displacement_vectors_lengths = compute_norm( + displacement.sel(individuals=mouse_name) ) -# sum of all displacement vectors -total_displacement = np.sum(displacement_vectors_lengths, axis=0) # in pixels +# sum the lengths of all displacement vectors (in pixels) +total_displacement = displacement_vectors_lengths.sum(dim="time").values[0] print( f"The mouse {mouse_name}'s trajectory is {total_displacement:.2f} " @@ -299,14 +297,12 @@ # uses second order central differences. # %% -# We can also visualise the speed, as the norm of the velocity vector: +# We can also visualise the speed, as the magnitude (norm) +# of the velocity vector: fig, axes = plt.subplots(3, 1, sharex=True, sharey=True) for mouse_name, ax in zip(velocity.individuals.values, axes, strict=False): - # compute the norm of the velocity vector for one mouse - speed_one_mouse = np.linalg.norm( - velocity.sel(individuals=mouse_name, space=["x", "y"]).squeeze(), - axis=1, - ) + # compute the magnitude of the velocity vector for one mouse + speed_one_mouse = compute_norm(velocity.sel(individuals=mouse_name)) # plot speed against time ax.plot(speed_one_mouse) ax.set_title(mouse_name) @@ -379,16 +375,12 @@ fig.tight_layout() # %% -# The norm of the acceleration vector is the magnitude of the -# acceleration. -# We can also represent this for each individual. +# The can also represent the magnitude (norm) of the acceleration vector +# for each individual: fig, axes = plt.subplots(3, 1, sharex=True, sharey=True) for mouse_name, ax in zip(accel.individuals.values, axes, strict=False): - # compute norm of the acceleration vector for one mouse - accel_one_mouse = np.linalg.norm( - accel.sel(individuals=mouse_name, space=["x", "y"]).squeeze(), - axis=1, - ) + # compute magnitude of the acceleration vector for one mouse + accel_one_mouse = compute_norm(accel.sel(individuals=mouse_name)) # plot acceleration against time ax.plot(accel_one_mouse) diff --git a/movement/utils/vector.py b/movement/utils/vector.py index c35990eb..0d5d88c8 100644 --- a/movement/utils/vector.py +++ b/movement/utils/vector.py @@ -6,6 +6,93 @@ from movement.utils.logging import log_error +def compute_norm(data: xr.DataArray) -> xr.DataArray: + """Compute the norm of the vectors along the spatial dimension. + + The norm of a vector is its magnitude, also called Euclidean norm, 2-norm + or Euclidean length. Note that if the input data is expressed in polar + coordinates, the magnitude of a vector is the same as its radial coordinate + ``rho``. + + Parameters + ---------- + data : xarray.DataArray + The input data array containing either ``space`` or ``space_pol`` + as a dimension. + + Returns + ------- + xarray.DataArray + A data array holding the norm of the input vectors. + Note that this output array has no spatial dimension but preserves + all other dimensions of the input data array (see Notes). + + Notes + ----- + If the input data array is a ``position`` array, this function will compute + the magnitude of the position vectors, for every individual and keypoint, + at every timestep. If the input data array is a ``shape`` array of a + bounding boxes dataset, it will compute the magnitude of the shape + vectors (i.e., the diagonal of the bounding box), + for every individual and at every timestep. + + + """ + if "space" in data.dims: + _validate_dimension_coordinates(data, {"space": ["x", "y"]}) + return xr.apply_ufunc( + np.linalg.norm, + data, + input_core_dims=[["space"]], + kwargs={"axis": -1}, + ) + elif "space_pol" in data.dims: + _validate_dimension_coordinates(data, {"space_pol": ["rho", "phi"]}) + return data.sel(space_pol="rho", drop=True) + else: + _raise_error_for_missing_spatial_dim() + + +def convert_to_unit(data: xr.DataArray) -> xr.DataArray: + """Convert the vectors along the spatial dimension into unit vectors. + + A unit vector is a vector pointing in the same direction as the original + vector but with norm = 1. + + Parameters + ---------- + data : xarray.DataArray + The input data array containing either ``space`` or ``space_pol`` + as a dimension. + + Returns + ------- + xarray.DataArray + A data array holding the unit vectors of the input data array + (all input dimensions are preserved). + + Notes + ----- + Note that the unit vector for the null vector is undefined, since the null + vector has 0 norm and no direction associated with it. + + """ + if "space" in data.dims: + _validate_dimension_coordinates(data, {"space": ["x", "y"]}) + return data / compute_norm(data) + elif "space_pol" in data.dims: + _validate_dimension_coordinates(data, {"space_pol": ["rho", "phi"]}) + # Set both rho and phi values to NaN at null vectors (where rho = 0) + new_data = xr.where(data.sel(space_pol="rho") == 0, np.nan, data) + # Set the rho values to 1 for non-null vectors (phi is preserved) + new_data.loc[{"space_pol": "rho"}] = xr.where( + new_data.sel(space_pol="rho").isnull(), np.nan, 1 + ) + return new_data + else: + _raise_error_for_missing_spatial_dim() + + def cart2pol(data: xr.DataArray) -> xr.DataArray: """Transform Cartesian coordinates to polar. @@ -25,12 +112,7 @@ def cart2pol(data: xr.DataArray) -> xr.DataArray: """ _validate_dimension_coordinates(data, {"space": ["x", "y"]}) - rho = xr.apply_ufunc( - np.linalg.norm, - data, - input_core_dims=[["space"]], - kwargs={"axis": -1}, - ) + rho = compute_norm(data) phi = xr.apply_ufunc( np.arctan2, data.sel(space="y"), @@ -122,3 +204,11 @@ def _validate_dimension_coordinates( ) if error_message: raise log_error(ValueError, error_message) + + +def _raise_error_for_missing_spatial_dim() -> None: + raise log_error( + ValueError, + "Input data array must contain either 'space' or 'space_pol' " + "as dimensions.", + ) diff --git a/tests/test_unit/test_vector.py b/tests/test_unit/test_vector.py index 88dd85b5..8787a468 100644 --- a/tests/test_unit/test_vector.py +++ b/tests/test_unit/test_vector.py @@ -121,3 +121,70 @@ def test_pol2cart(self, ds, expected_exception, request): with expected_exception: result = vector.pol2cart(ds.pol) xr.testing.assert_allclose(result, ds.cart) + + @pytest.mark.parametrize( + "ds, expected_exception", + [ + ("cart_pol_dataset", does_not_raise()), + ("cart_pol_dataset_with_nan", does_not_raise()), + ("cart_pol_dataset_missing_cart_dim", pytest.raises(ValueError)), + ( + "cart_pol_dataset_missing_cart_coords", + pytest.raises(ValueError), + ), + ], + ) + def test_compute_norm(self, ds, expected_exception, request): + """Test vector norm computation with known values.""" + ds = request.getfixturevalue(ds) + with expected_exception: + # validate the norm computation + result = vector.compute_norm(ds.cart) + expected = np.sqrt( + ds.cart.sel(space="x") ** 2 + ds.cart.sel(space="y") ** 2 + ) + xr.testing.assert_allclose(result, expected) + + # result should be the same from Cartesian and polar coordinates + xr.testing.assert_allclose(result, vector.compute_norm(ds.pol)) + + # The result should only contain the time dimension. + assert result.dims == ("time",) + + @pytest.mark.parametrize( + "ds, expected_exception", + [ + ("cart_pol_dataset", does_not_raise()), + ("cart_pol_dataset_with_nan", does_not_raise()), + ("cart_pol_dataset_missing_cart_dim", pytest.raises(ValueError)), + ], + ) + def test_convert_to_unit(self, ds, expected_exception, request): + """Test conversion to unit vectors (normalisation).""" + ds = request.getfixturevalue(ds) + with expected_exception: + # normalise both the Cartesian and the polar data to unit vectors + unit_cart = vector.convert_to_unit(ds.cart) + unit_pol = vector.convert_to_unit(ds.pol) + # they should yield the same result, just in different coordinates + xr.testing.assert_allclose(unit_cart, vector.pol2cart(unit_pol)) + xr.testing.assert_allclose(unit_pol, vector.cart2pol(unit_cart)) + + # since we established that polar vs Cartesian unit vectors are + # equivalent, it's enough to do other assertions on either one + + # the normalised data should have the same dimensions as the input + assert unit_cart.dims == ds.cart.dims + + # unit vector should be NaN if the input vector was null or NaN + is_null_vec = (ds.cart == 0).all("space") # null vec: x=0, y=0 + is_nan_vec = ds.cart.isnull().any("space") # any NaN in x or y + expected_nan_idxs = is_null_vec | is_nan_vec + assert unit_cart.where(expected_nan_idxs).isnull().all() + + # For non-NaN unit vectors in polar coordinates, the rho values + # should be 1 and the phi values should be the same as the input + expected_unit_pol = ds.pol.copy() + expected_unit_pol.loc[{"space_pol": "rho"}] = 1 + expected_unit_pol = expected_unit_pol.where(~expected_nan_idxs) + xr.testing.assert_allclose(unit_pol, expected_unit_pol) From abeaff10c1742d2f15d97d3668b566313b6ce4b7 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Fri, 16 Aug 2024 20:52:25 +0100 Subject: [PATCH 37/65] Simplify and expand filtering tests for bboxes (#267) * Extend filtering tests * Fix docstrings for test cases * Review docstrings for bboxes references * Move dict definition * Fix time unit odd scaling --- movement/filtering.py | 30 ++-- tests/test_unit/test_filtering.py | 290 +++++++++++++++++++++--------- 2 files changed, 222 insertions(+), 98 deletions(-) diff --git a/movement/filtering.py b/movement/filtering.py index 9230acaa..8432c133 100644 --- a/movement/filtering.py +++ b/movement/filtering.py @@ -1,4 +1,4 @@ -"""Filter and interpolate pose tracks in ``movement`` datasets.""" +"""Filter and interpolate tracks in ``movement`` datasets.""" import xarray as xr from scipy import signal @@ -40,14 +40,14 @@ def filter_by_confidence( Notes ----- - The point-wise confidence values reported by various pose estimation - frameworks are not standardised, and the range of values can vary. - For example, DeepLabCut reports a likelihood value between 0 and 1, whereas - the point confidence reported by SLEAP can range above 1. - Therefore, the default threshold value will not be appropriate for all - datasets and does not have the same meaning across pose estimation - frameworks. We advise users to inspect the confidence values - in their dataset and adjust the threshold accordingly. + For the poses dataset case, note that the point-wise confidence values + reported by various pose estimation frameworks are not standardised, and + the range of values can vary. For example, DeepLabCut reports a likelihood + value between 0 and 1, whereas the point confidence reported by SLEAP can + range above 1. Therefore, the default threshold value will not be + appropriate for all datasets and does not have the same meaning across + pose estimation frameworks. We advise users to inspect the confidence + values in their dataset and adjust the threshold accordingly. """ data_filtered = data.where(confidence >= threshold) @@ -127,7 +127,7 @@ def median_filter( data : xarray.DataArray The input data to be smoothed. window : int - The size of the filter window, representing the fixed number + The size of the smoothing window, representing the fixed number of observations used for each window. min_periods : int Minimum number of observations in the window required to have @@ -137,7 +137,7 @@ def median_filter( :meth:`xarray.DataArray.rolling`. print_report : bool Whether to print a report on the number of NaNs in the dataset - before and after filtering. Default is ``True``. + before and after smoothing. Default is ``True``. Returns ------- @@ -146,7 +146,7 @@ def median_filter( Notes ----- - By default, whenever one or more NaNs are present in the filter window, + By default, whenever one or more NaNs are present in the smoothing window, a NaN is returned to the output array. As a result, any stretch of NaNs present in the input data will be propagated proportionally to the size of the window (specifically, by @@ -194,7 +194,7 @@ def savgol_filter( data : xarray.DataArray The input data to be smoothed. window : int - The size of the filter window, representing the fixed number + The size of the smoothing window, representing the fixed number of observations used for each window. polyorder : int The order of the polynomial used to fit the samples. Must be @@ -202,7 +202,7 @@ def savgol_filter( 2 is used. print_report : bool Whether to print a report on the number of NaNs in the dataset - before and after filtering. Default is ``True``. + before and after smoothing. Default is ``True``. **kwargs : dict Additional keyword arguments are passed to :func:`scipy.signal.savgol_filter`. @@ -220,7 +220,7 @@ def savgol_filter( Uses the :func:`scipy.signal.savgol_filter` function to apply a Savitzky-Golay filter to the input data. See the SciPy documentation for more information on that function. - Whenever one or more NaNs are present in a filter window of the + Whenever one or more NaNs are present in a smoothing window of the input data, a NaN is returned to the output array. As a result, any stretch of NaNs present in the input data will be propagated proportionally to the size of the window (specifically, by diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 0336f0c1..1957a770 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -10,115 +10,239 @@ savgol_filter, ) +# Dataset fixtures +list_valid_datasets_without_nans = [ + "valid_poses_dataset", + "valid_bboxes_dataset", +] +list_valid_datasets_with_nans = [ + f"{dataset}_with_nan" for dataset in list_valid_datasets_without_nans +] +list_all_valid_datasets = ( + list_valid_datasets_without_nans + list_valid_datasets_with_nans +) + @pytest.mark.parametrize( - "max_gap, expected_n_nans", [(None, 0), (1, 8), (2, 0)] + "valid_dataset_with_nan", + list_valid_datasets_with_nans, +) +@pytest.mark.parametrize( + "max_gap, expected_n_nans_in_position", [(None, 0), (0, 3), (1, 2), (2, 0)] ) -def test_interpolate_over_time( - valid_poses_dataset_with_nan, helpers, max_gap, expected_n_nans +def test_interpolate_over_time_on_position( + valid_dataset_with_nan, + max_gap, + expected_n_nans_in_position, + helpers, + request, ): - """Test that the number of NaNs decreases after interpolating + """Test that the number of NaNs decreases after linearly interpolating over time and that the resulting number of NaNs is as expected for different values of ``max_gap``. """ - # First dataset with time unit in frames - data_in_frames = valid_poses_dataset_with_nan.position - # Create second dataset with time unit in seconds - data_in_seconds = data_in_frames.copy() - data_in_seconds["time"] = data_in_seconds["time"] * 0.1 - data_interp_frames = interpolate_over_time(data_in_frames, max_gap=max_gap) - data_interp_seconds = interpolate_over_time( - data_in_seconds, max_gap=max_gap + valid_dataset_in_frames = request.getfixturevalue(valid_dataset_with_nan) + + # Get position array with time unit in frames & seconds + # assuming 10 fps = 0.1 s per frame + valid_dataset_in_seconds = valid_dataset_in_frames.copy() + valid_dataset_in_seconds.coords["time"] = ( + valid_dataset_in_seconds.coords["time"] * 0.1 ) - n_nans_before = helpers.count_nans(data_in_frames) - n_nans_after_frames = helpers.count_nans(data_interp_frames) - n_nans_after_seconds = helpers.count_nans(data_interp_seconds) + position = { + "frames": valid_dataset_in_frames.position, + "seconds": valid_dataset_in_seconds.position, + } + + # Count number of NaNs before and after interpolating position + n_nans_before = helpers.count_nans(position["frames"]) + n_nans_after_per_time_unit = {} + for time_unit in ["frames", "seconds"]: + # interpolate + position_interp = interpolate_over_time( + position[time_unit], method="linear", max_gap=max_gap + ) + # count nans + n_nans_after_per_time_unit[time_unit] = helpers.count_nans( + position_interp + ) + # The number of NaNs should be the same for both datasets # as max_gap is based on number of missing observations (NaNs) - assert n_nans_after_frames == n_nans_after_seconds - assert n_nans_after_frames < n_nans_before - assert n_nans_after_frames == expected_n_nans + assert ( + n_nans_after_per_time_unit["frames"] + == n_nans_after_per_time_unit["seconds"] + ) + + # The number of NaNs should decrease after interpolation + n_nans_after = n_nans_after_per_time_unit["frames"] + if max_gap == 0: + assert n_nans_after == n_nans_before + else: + assert n_nans_after < n_nans_before + + # The number of NaNs after interpolating should be as expected + assert n_nans_after == ( + valid_dataset_in_frames.dims["space"] + * valid_dataset_in_frames.dims.get("keypoints", 1) + # in bboxes dataset there is no keypoints dimension + * expected_n_nans_in_position + ) -def test_filter_by_confidence(valid_poses_dataset, helpers): +@pytest.mark.parametrize( + "valid_dataset_no_nans, n_low_confidence_kpts", + [ + ("valid_poses_dataset", 20), + ("valid_bboxes_dataset", 5), + ], +) +def test_filter_by_confidence_on_position( + valid_dataset_no_nans, n_low_confidence_kpts, helpers, request +): """Test that points below the default 0.6 confidence threshold are converted to NaN. """ - data = valid_poses_dataset.position - confidence = valid_poses_dataset.confidence - data_filtered = filter_by_confidence(data, confidence) - n_nans = helpers.count_nans(data_filtered) - assert isinstance(data_filtered, xr.DataArray) - # 5 timepoints * 2 individuals * 2 keypoints * 2 space dimensions - # have confidence below 0.6 - assert n_nans == 40 - - -@pytest.mark.parametrize("window_size", [2, 4]) -def test_median_filter(valid_poses_dataset_with_nan, window_size): - """Test that applying the median filter returns - a different xr.DataArray than the input data. - """ - data = valid_poses_dataset_with_nan.position - data_smoothed = median_filter(data, window_size) - del data_smoothed.attrs["log"] - assert isinstance(data_smoothed, xr.DataArray) and not ( - data_smoothed.equals(data) + # Filter position by confidence + valid_input_dataset = request.getfixturevalue(valid_dataset_no_nans) + position_filtered = filter_by_confidence( + valid_input_dataset.position, + confidence=valid_input_dataset.confidence, + threshold=0.6, ) + # Count number of NaNs in the full array + n_nans = helpers.count_nans(position_filtered) -def test_median_filter_with_nans(valid_poses_dataset_with_nan, helpers): - """Test NaN behaviour of the median filter. The input data - contains NaNs in all keypoints of the first individual at timepoints - 3, 7, and 8 (0-indexed, 10 total timepoints). The median filter - should propagate NaNs within the windows of the filter, - but it should not introduce any NaNs for the second individual. - """ - data = valid_poses_dataset_with_nan.position - data_smoothed = median_filter(data, window=3) - # All points of the first individual are converted to NaNs except - # at timepoints 0, 1, and 5. - assert not ( - data_smoothed.isel(individuals=0, time=[0, 1, 5]).isnull().any() - ) - # 7 timepoints * 1 individual * 2 keypoints * 2 space dimensions - assert helpers.count_nans(data_smoothed) == 28 - # No NaNs should be introduced for the second individual - assert not data_smoothed.isel(individuals=1).isnull().any() + # expected number of nans for poses: + # 5 timepoints * 2 individuals * 2 keypoints + # Note: we count the number of nans in the array, so we multiply + # the number of low confidence keypoints by the number of + # space dimensions + assert isinstance(position_filtered, xr.DataArray) + assert n_nans == valid_input_dataset.dims["space"] * n_low_confidence_kpts -@pytest.mark.parametrize("window, polyorder", [(2, 1), (4, 2)]) -def test_savgol_filter(valid_poses_dataset_with_nan, window, polyorder): - """Test that applying the Savitzky-Golay filter returns - a different xr.DataArray than the input data. +@pytest.mark.parametrize( + "valid_dataset", + list_all_valid_datasets, +) +@pytest.mark.parametrize( + ("filter_func, filter_kwargs"), + [ + (median_filter, {"window": 2}), + (median_filter, {"window": 4}), + (savgol_filter, {"window": 2, "polyorder": 1}), + (savgol_filter, {"window": 4, "polyorder": 2}), + ], +) +def test_filter_on_position( + filter_func, filter_kwargs, valid_dataset, request +): + """Test that applying a filter to the position data returns + a different xr.DataArray than the input position data. """ - data = valid_poses_dataset_with_nan.position - data_smoothed = savgol_filter(data, window, polyorder=polyorder) - del data_smoothed.attrs["log"] - assert isinstance(data_smoothed, xr.DataArray) and not ( - data_smoothed.equals(data) + # Filter position + valid_input_dataset = request.getfixturevalue(valid_dataset) + position_filtered = filter_func( + valid_input_dataset.position, **filter_kwargs ) + del position_filtered.attrs["log"] + + # filtered array is an xr.DataArray + assert isinstance(position_filtered, xr.DataArray) + + # filtered data should not be equal to the original data + assert not position_filtered.equals(valid_input_dataset.position) + -def test_savgol_filter_with_nans(valid_poses_dataset_with_nan, helpers): - """Test NaN behaviour of the Savitzky-Golay filter. The input data - contains NaN values in all keypoints of the first individual at times - 3, 7, and 8 (0-indexed, 10 total timepoints). - The Savitzky-Golay filter should propagate NaNs within the windows of - the filter, but it should not introduce any NaNs for the second individual. +# Expected number of nans in the position array per individual, +# for each dataset +expected_n_nans_in_position_per_indiv = { + "valid_poses_dataset": {0: 0, 1: 0}, + # filtering should not introduce nans if input has no nans + "valid_bboxes_dataset": {0: 0, 1: 0}, + # filtering should not introduce nans if input has no nans + "valid_poses_dataset_with_nan": {0: 7, 1: 0}, + # individual with index 0 has 7 frames with nans in position after + # filtering individual with index 1 has no nans after filtering + "valid_bboxes_dataset_with_nan": {0: 7, 1: 0}, + # individual with index 0 has 7 frames with nans in position after + # filtering individual with index 0 has no nans after filtering +} + + +@pytest.mark.parametrize( + ("valid_dataset, expected_n_nans_in_position_per_indiv"), + [(k, v) for k, v in expected_n_nans_in_position_per_indiv.items()], +) +@pytest.mark.parametrize( + ("filter_func, filter_kwargs"), + [ + (median_filter, {"window": 3}), + (savgol_filter, {"window": 3, "polyorder": 2}), + ], +) +def test_filter_with_nans_on_position( + filter_func, + filter_kwargs, + valid_dataset, + expected_n_nans_in_position_per_indiv, + helpers, + request, +): + """Test NaN behaviour of the selected filter. The median and SG filters + should set all values to NaN if one element of the sliding window is NaN. """ - data = valid_poses_dataset_with_nan.position - data_smoothed = savgol_filter(data, window=3, polyorder=2) - # There should be 28 NaNs in total for the first individual, i.e. - # at 7 timepoints, 2 keypoints, 2 space dimensions - # all except for timepoints 0, 1 and 5 - assert helpers.count_nans(data_smoothed) == 28 - assert not ( - data_smoothed.isel(individuals=0, time=[0, 1, 5]).isnull().any() + + def _assert_n_nans_in_position_per_individual( + valid_input_dataset, + position_filtered, + expected_n_nans_in_position_per_indiv, + ): + # compute n nans in position after filtering per individual + n_nans_after_filtering_per_indiv = { + i: helpers.count_nans(position_filtered.isel(individuals=i)) + for i in range(valid_input_dataset.dims["individuals"]) + } + + # check number of nans per indiv is as expected + for i in range(valid_input_dataset.dims["individuals"]): + assert n_nans_after_filtering_per_indiv[i] == ( + expected_n_nans_in_position_per_indiv[i] + * valid_input_dataset.dims["space"] + * valid_input_dataset.dims.get("keypoints", 1) + ) + + # Filter position + valid_input_dataset = request.getfixturevalue(valid_dataset) + position_filtered = filter_func( + valid_input_dataset.position, **filter_kwargs ) - assert not data_smoothed.isel(individuals=1).isnull().any() + # check number of nans per indiv is as expected + _assert_n_nans_in_position_per_individual( + valid_input_dataset, + position_filtered, + expected_n_nans_in_position_per_indiv, + ) + + # if input had nans, + # individual 1's position at exact timepoints 0, 1 and 5 is not nan + n_nans_input = helpers.count_nans(valid_input_dataset.position) + if n_nans_input != 0: + assert not ( + position_filtered.isel(individuals=0, time=[0, 1, 5]) + .isnull() + .any() + ) + +@pytest.mark.parametrize( + "valid_dataset", + list_all_valid_datasets, +) @pytest.mark.parametrize( "override_kwargs", [ @@ -128,7 +252,7 @@ def test_savgol_filter_with_nans(valid_poses_dataset_with_nan, helpers): ], ) def test_savgol_filter_kwargs_override( - valid_poses_dataset_with_nan, override_kwargs + valid_dataset, override_kwargs, request ): """Test that overriding keyword arguments in the Savitzky-Golay filter works, except for the ``axis`` argument, which should raise a ValueError. @@ -140,7 +264,7 @@ def test_savgol_filter_kwargs_override( ) with expected_exception: savgol_filter( - valid_poses_dataset_with_nan.position, + request.getfixturevalue(valid_dataset).position, window=3, **override_kwargs, ) From 38246d922d8626379ef4659b86a56868e6a1a0df Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:58:46 +0200 Subject: [PATCH 38/65] Update test_and_deploy.yml (#275) Add `nodefaults` channel to prevent from using the `defaults` channel. See [docs](https://docs.conda.io/projects/conda/en/4.6.1/user-guide/tasks/manage-environments.html#:~:text=You%20can%20exclude%20the%20default%20channels%20by%20adding%20nodefaults%20to%20the%20channels%20list.) --- .github/workflows/test_and_deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 009baa63..4b7b4c11 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -52,7 +52,7 @@ jobs: with: python-version: ${{ matrix.python-version }} auto-update-conda: true - channels: conda-forge + channels: conda-forge,nodefaults activate-environment: movement-env - uses: neuroinformatics-unit/actions/test@v2 with: From 3ee58361b99f5fdc0397f8e54ad07c4edf057142 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Tue, 27 Aug 2024 18:41:26 +0100 Subject: [PATCH 39/65] Extend tests for bboxes datasets (#246) * Add valid_bboxes_dataset fixture * Change keys in valid bboxes fixtures * Parametrize test_filter_by_confidence to include both types of datasets (WIP) * Parametrize tests for move accessor with valid datasets to include bboxes * Remove class for move accessor tests (suggestion) * Add valid bboxes dataset to kinematic tests (WIP) * Rename valid_bboxes_arrays for testing shape only * Define a realistic valid_bboxes_dataset * Extend move accessor tests to invalid bboxes datasets * Extend test filter by confidence to bboxes * Extend test_interpolate_over_time to bboxes datasets * Extend test_median_filter * Extend tests for median filter * Fix save_poses tests (adapt to new invalid dataset fixtures) * Fix kinematics tests for poses datasets (adapt to new fixture for invalid datasets) * Fix kinematics integration tests for invalid poses datasets * Extend test_reports to bboxes dataset and make report check explicit * Expand test_log_to_attrs to bboxes * Extend savgol basic test to bboxes * Extend test_savgol_filter_kwargs_override to bboxes * Extend test_savgol_filter_with_nans to bboxes * Edit test names * Prep extending kinematics (WIP) * Reduce duplication in filtering tests * Make sonarcloud happy * Reduce duplication (hopefully) * Combine median and savgol filter tests * Fix merge artifacts * Refactor filtering tests * Simplify and expand kinematic tests * Suggestion to rename internal method for clarity * Review of test_vectors (WIP) * Remove changes in vector tests (in separate PR) * Apply suggestions from code review Co-authored-by: Niko Sirmpilatze * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove duplicate line * Suggestions from code review * Change order of comments in multiline fixtures * Fix parameterised fixture comments --------- Co-authored-by: Niko Sirmpilatze Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/conftest.py | 31 +++- tests/test_unit/test_logging.py | 41 ++++-- tests/test_unit/test_reports.py | 138 +++++++++++++++--- .../test_datasets_validators.py | 114 +++++++-------- 4 files changed, 228 insertions(+), 96 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 77c8c73e..f2c77bed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,6 +39,7 @@ def setup_logging(tmp_path): ) +# --------- File validator fixtures --------------------------------- @pytest.fixture def unreadable_file(tmp_path): """Return a dictionary containing the file path and @@ -213,6 +214,29 @@ def sleap_file(request): return pytest.DATA_PATHS.get(request.param) +# ------------ Dataset validator fixtures --------------------------------- + + +@pytest.fixture +def valid_bboxes_arrays_all_zeros(): + """Return a dictionary of valid zero arrays (in terms of shape) for a + ValidBboxesDataset. + """ + # define the shape of the arrays + n_frames, n_individuals, n_space = (10, 2, 2) + + # build a valid array for position or shape with all zeros + valid_bbox_array_all_zeros = np.zeros((n_frames, n_individuals, n_space)) + + # return as a dict + return { + "position": valid_bbox_array_all_zeros, + "shape": valid_bbox_array_all_zeros, + "individual_names": ["id_" + str(id) for id in range(n_individuals)], + } + + +# --------------------- Bboxes dataset fixtures ---------------------------- @pytest.fixture def valid_bboxes_array(): """Return a dictionary of valid non-zero arrays for a @@ -315,6 +339,7 @@ def valid_bboxes_dataset_with_nan(valid_bboxes_dataset): return valid_bboxes_dataset +# --------------------- Poses dataset fixtures ---------------------------- @pytest.fixture def valid_position_array(): """Return a function that generates different kinds @@ -390,6 +415,7 @@ def valid_poses_dataset_with_nan(valid_poses_dataset): return valid_poses_dataset +# -------------------- Invalid datasets fixtures ------------------------------ @pytest.fixture def not_a_dataset(): """Return data that is not a pose tracks dataset.""" @@ -444,7 +470,7 @@ def kinematic_property(request): return request.param -# VIA tracks CSV fixtures +# ---------------- VIA tracks CSV file fixtures ---------------------------- @pytest.fixture def via_tracks_csv_with_invalid_header(tmp_path): """Return the file path for a file with invalid header.""" @@ -705,6 +731,9 @@ def count_consecutive_nans(da): return (da.isnull().astype(int).diff("time") == 1).sum().item() +# ----------------- Helper fixture ----------------- + + @pytest.fixture def helpers(): """Return an instance of the ``Helpers`` class.""" diff --git a/tests/test_unit/test_logging.py b/tests/test_unit/test_logging.py index d0a8c3bf..348a3687 100644 --- a/tests/test_unit/test_logging.py +++ b/tests/test_unit/test_logging.py @@ -1,6 +1,7 @@ import logging import pytest +import xarray as xr from movement.utils.logging import log_error, log_to_attrs, log_warning @@ -45,27 +46,41 @@ def test_log_warning(caplog): assert caplog.records[0].levelname == "WARNING" -@pytest.mark.parametrize("input_data", ["dataset", "dataarray"]) -def test_log_to_attrs(input_data, valid_poses_dataset): +@pytest.mark.parametrize( + "input_data", + [ + "valid_poses_dataset", + "valid_bboxes_dataset", + ], +) +@pytest.mark.parametrize( + "selector_fn, expected_selector_type", + [ + (lambda ds: ds, xr.Dataset), # take full dataset + (lambda ds: ds.position, xr.DataArray), # take position data array + ], +) +def test_log_to_attrs( + input_data, selector_fn, expected_selector_type, request +): """Test that the ``log_to_attrs()`` decorator appends - log entries to the output data's ``log`` attribute and - checks that ``attrs`` contains all expected values. + log entries to the dataset's or the data array's ``log`` + attribute and check that ``attrs`` contains all the expected values. """ + # a fake operation on the dataset to log @log_to_attrs def fake_func(data, arg, kwarg=None): return data - input_data = ( - valid_poses_dataset - if input_data == "dataset" - else valid_poses_dataset.position - ) + # apply operation to dataset or data array + dataset = request.getfixturevalue(input_data) + input_data = selector_fn(dataset) output_data = fake_func(input_data, "test1", kwarg="test2") + # check the log in the dataset is as expected + assert isinstance(output_data, expected_selector_type) assert "log" in output_data.attrs assert output_data.attrs["log"][0]["operation"] == "fake_func" - assert ( - output_data.attrs["log"][0]["arg_1"] == "test1" - and output_data.attrs["log"][0]["kwarg"] == "test2" - ) + assert output_data.attrs["log"][0]["arg_1"] == "test1" + assert output_data.attrs["log"][0]["kwarg"] == "test2" diff --git a/tests/test_unit/test_reports.py b/tests/test_unit/test_reports.py index 51d441ea..79c3bc89 100644 --- a/tests/test_unit/test_reports.py +++ b/tests/test_unit/test_reports.py @@ -4,28 +4,126 @@ @pytest.mark.parametrize( - "data_selection", + "valid_dataset", [ - lambda ds: ds.position, # Entire dataset - lambda ds: ds.position.sel( - individuals="ind1" - ), # Missing "individuals" dim - lambda ds: ds.position.sel( - keypoints="key1" - ), # Missing "keypoints" dim - lambda ds: ds.position.sel( - individuals="ind1", keypoints="key1" - ), # Missing "individuals" and "keypoints" dims + "valid_poses_dataset", + "valid_bboxes_dataset", + "valid_poses_dataset_with_nan", + "valid_bboxes_dataset_with_nan", ], ) -def test_report_nan_values( - capsys, valid_poses_dataset_with_nan, data_selection +@pytest.mark.parametrize( + "data_selection, list_expected_individuals_indices", + [ + (lambda ds: ds.position, [0, 1]), # full position data array + ( + lambda ds: ds.position.isel(individuals=0), + [0], + ), # position of individual 0 only + ], +) +def test_report_nan_values_in_position_selecting_individual( + valid_dataset, + data_selection, + list_expected_individuals_indices, + request, ): - """Test that the nan-value reporting function handles data - with missing ``individuals`` and/or ``keypoint`` dims, and - that the dataset name is included in the report. + """Test that the nan-value reporting function handles position data + with specific ``individuals`` , and that the data array name (position) + and only the relevant individuals are included in the report. """ - data = data_selection(valid_poses_dataset_with_nan) - assert data.name in report_nan_values( - data - ), "Dataset name should be in the output" + # extract relevant position data + input_dataset = request.getfixturevalue(valid_dataset) + output_data_array = data_selection(input_dataset) + + # produce report + report_str = report_nan_values(output_data_array) + + # check report of nan values includes name of data array + assert output_data_array.name in report_str + + # check report of nan values includes selected individuals only + list_expected_individuals = [ + input_dataset["individuals"][idx].item() + for idx in list_expected_individuals_indices + ] + list_not_expected_individuals = [ + indiv.item() + for indiv in input_dataset["individuals"] + if indiv.item() not in list_expected_individuals + ] + assert all([ind in report_str for ind in list_expected_individuals]) + assert all( + [ind not in report_str for ind in list_not_expected_individuals] + ) + + +@pytest.mark.parametrize( + "valid_dataset", + [ + "valid_poses_dataset", + "valid_poses_dataset_with_nan", + ], +) +@pytest.mark.parametrize( + "data_selection, list_expected_keypoints, list_expected_individuals", + [ + ( + lambda ds: ds.position, + ["key1", "key2"], + ["ind1", "ind2"], + ), # Report nans in position for all keypoints and individuals + ( + lambda ds: ds.position.sel(keypoints="key1"), + [], + ["ind1", "ind2"], + ), # Report nans in position for keypoint "key1", for all individuals + # Note: if only one keypoint exists, it is not explicitly reported + ( + lambda ds: ds.position.sel(individuals="ind1", keypoints="key1"), + [], + ["ind1"], + ), # Report nans in position for individual "ind1" and keypoint "key1" + # Note: if only one keypoint exists, it is not explicitly reported + ], +) +def test_report_nan_values_in_position_selecting_keypoint( + valid_dataset, + data_selection, + list_expected_keypoints, + list_expected_individuals, + request, +): + """Test that the nan-value reporting function handles position data + with specific ``keypoints`` , and that the data array name (position) + and only the relevant keypoints are included in the report. + """ + # extract relevant position data + input_dataset = request.getfixturevalue(valid_dataset) + output_data_array = data_selection(input_dataset) + + # produce report + report_str = report_nan_values(output_data_array) + + # check report of nan values includes name of data array + assert output_data_array.name in report_str + + # check report of nan values includes only selected keypoints + list_not_expected_keypoints = [ + indiv.item() + for indiv in input_dataset["keypoints"] + if indiv.item() not in list_expected_keypoints + ] + assert all([kpt in report_str for kpt in list_expected_keypoints]) + assert all([kpt not in report_str for kpt in list_not_expected_keypoints]) + + # check report of nan values includes selected individuals only + list_not_expected_individuals = [ + indiv.item() + for indiv in input_dataset["individuals"] + if indiv.item() not in list_expected_individuals + ] + assert all([ind in report_str for ind in list_expected_individuals]) + assert all( + [ind not in report_str for ind in list_not_expected_individuals] + ) diff --git a/tests/test_unit/test_validators/test_datasets_validators.py b/tests/test_unit/test_validators/test_datasets_validators.py index a882162e..493f1d46 100644 --- a/tests/test_unit/test_validators/test_datasets_validators.py +++ b/tests/test_unit/test_validators/test_datasets_validators.py @@ -76,29 +76,14 @@ def position_array_params(request): ), # not an ndarray ( np.zeros((10, 2, 3)), - f"Expected '{key}' to have 2 spatial " "coordinates, but got 3.", + f"Expected '{key}_array' to have 2 spatial " + "coordinates, but got 3.", ), # last dim not 2 ] - for key in ["position_array", "shape_array"] + for key in ["position", "shape"] } -@pytest.fixture -def valid_bboxes_inputs(): - """Return a dictionary with valid inputs for a ValidBboxesDataset.""" - n_frames, n_individuals, n_space = (10, 2, 2) - # valid array for position or shape - valid_bbox_array = np.zeros((n_frames, n_individuals, n_space)) - - return { - "position_array": valid_bbox_array, - "shape_array": valid_bbox_array, - "individual_names": [ - "id_" + str(id) for id in range(valid_bbox_array.shape[1]) - ], - } - - # Tests pose dataset @pytest.mark.parametrize( "invalid_position_array, log_message", @@ -223,7 +208,7 @@ def test_poses_dataset_validator_source_software( # Tests bboxes dataset @pytest.mark.parametrize( "invalid_position_array, log_message", - invalid_bboxes_arrays_and_expected_log["position_array"], + invalid_bboxes_arrays_and_expected_log["position"], ) def test_bboxes_dataset_validator_with_invalid_position_array( invalid_position_array, log_message, request @@ -232,19 +217,19 @@ def test_bboxes_dataset_validator_with_invalid_position_array( with pytest.raises(ValueError) as excinfo: ValidBboxesDataset( position_array=invalid_position_array, - shape_array=request.getfixturevalue("valid_bboxes_inputs")[ - "shape_array" - ], - individual_names=request.getfixturevalue("valid_bboxes_inputs")[ - "individual_names" - ], + shape_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["shape"], + individual_names=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["individual_names"], ) assert str(excinfo.value) == log_message @pytest.mark.parametrize( "invalid_shape_array, log_message", - invalid_bboxes_arrays_and_expected_log["shape_array"], + invalid_bboxes_arrays_and_expected_log["shape"], ) def test_bboxes_dataset_validator_with_invalid_shape_array( invalid_shape_array, log_message, request @@ -252,13 +237,13 @@ def test_bboxes_dataset_validator_with_invalid_shape_array( """Test that invalid shape arrays raise an error.""" with pytest.raises(ValueError) as excinfo: ValidBboxesDataset( - position_array=request.getfixturevalue("valid_bboxes_inputs")[ - "position_array" - ], + position_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["position"], shape_array=invalid_shape_array, - individual_names=request.getfixturevalue("valid_bboxes_inputs")[ - "individual_names" - ], + individual_names=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["individual_names"], ) assert str(excinfo.value) == log_message @@ -274,15 +259,19 @@ def test_bboxes_dataset_validator_with_invalid_shape_array( ( [1, 2, 3], pytest.raises(ValueError), - "Expected 'individual_names' to have length 2, but got 3.", + "Expected 'individual_names' to have length 2, " + f"but got {len([1, 2, 3])}.", ), # length doesn't match position_array.shape[1] + # from valid_bboxes_arrays_all_zeros fixture ( ["id_1", "id_1"], pytest.raises(ValueError), "individual_names passed to the dataset are not unique. " "There are 2 elements in the list, but " "only 1 are unique.", - ), # some IDs are not unique + ), # some IDs are not unique. + # Note: length of individual_names list should match + # n_individuals in valid_bboxes_arrays_all_zeros fixture ], ) def test_bboxes_dataset_validator_individual_names( @@ -291,12 +280,12 @@ def test_bboxes_dataset_validator_individual_names( """Test individual_names inputs.""" with expected_exception as excinfo: ds = ValidBboxesDataset( - position_array=request.getfixturevalue("valid_bboxes_inputs")[ - "position_array" - ], - shape_array=request.getfixturevalue("valid_bboxes_inputs")[ - "shape_array" - ], + position_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["position"], + shape_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["shape"], individual_names=list_individual_names, ) if list_individual_names is None: @@ -313,13 +302,14 @@ def test_bboxes_dataset_validator_individual_names( ( np.ones((10, 3, 2)), pytest.raises(ValueError), - "Expected 'confidence_array' to have shape (10, 2), " - "but got (10, 3, 2).", - ), # will not match position_array shape + f"Expected 'confidence_array' to have shape (10, 2), " + f"but got {np.ones((10, 3, 2)).shape}.", + ), # will not match shape of position_array in + # valid_bboxes_arrays_all_zeros fixture ( [1, 2, 3], pytest.raises(ValueError), - f"Expected a numpy array, but got {type(list())}.", + f"Expected a numpy array, but got {type([1, 2, 3])}.", ), # not an ndarray, should raise ValueError ( None, @@ -334,15 +324,15 @@ def test_bboxes_dataset_validator_confidence_array( """Test that invalid confidence arrays raise the appropriate errors.""" with expected_exception as excinfo: ds = ValidBboxesDataset( - position_array=request.getfixturevalue("valid_bboxes_inputs")[ - "position_array" - ], - shape_array=request.getfixturevalue("valid_bboxes_inputs")[ - "shape_array" - ], - individual_names=request.getfixturevalue("valid_bboxes_inputs")[ - "individual_names" - ], + position_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["position"], + shape_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["shape"], + individual_names=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["individual_names"], confidence_array=confidence_array, ) if confidence_array is None: @@ -387,15 +377,15 @@ def test_bboxes_dataset_validator_frame_array( """Test that invalid frame arrays raise the appropriate errors.""" with expected_exception as excinfo: ds = ValidBboxesDataset( - position_array=request.getfixturevalue("valid_bboxes_inputs")[ - "position_array" - ], - shape_array=request.getfixturevalue("valid_bboxes_inputs")[ - "shape_array" - ], - individual_names=request.getfixturevalue("valid_bboxes_inputs")[ - "individual_names" - ], + position_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["position"], + shape_array=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["shape"], + individual_names=request.getfixturevalue( + "valid_bboxes_arrays_all_zeros" + )["individual_names"], frame_array=frame_array, ) From 3df12a9149f0742742ad5e7e940c090f53d60488 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Wed, 28 Aug 2024 17:23:09 +0100 Subject: [PATCH 40/65] updated movement overview figure (#288) --- docs/source/_static/movement_overview.png | Bin 159391 -> 162665 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/source/_static/movement_overview.png b/docs/source/_static/movement_overview.png index 8af12daa0891bb317b3c28a0e89c0323365528f8..33c5927a5d408175a0de81a00090564988d918cb 100644 GIT binary patch literal 162665 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 literal 159391 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 From 96129514d7de0fa717f6809b11a68f6c06244996 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Thu, 29 Aug 2024 10:58:27 +0100 Subject: [PATCH 41/65] added new movement datasets figure, with caption (#286) * added new movement datasets figure, with caption * update caption according to review comment --------- Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- docs/source/_static/dataset_structure.png | Bin 114563 -> 245698 bytes .../getting_started/movement_dataset.md | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/source/_static/dataset_structure.png b/docs/source/_static/dataset_structure.png index 4e6a17d903b5a84dd5bf6b3c35409bb12b1b413c..13506196d8bfdfd194b556068618156356837502 100644 GIT binary patch literal 245698 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

$mbK>G!I_QRPWzCjg9NLrh0m#A72~(_>ylw zXto9zjDy|ixLZ60C#se%Qz2UqNfr42 zSDwZAUwPKFHsV*yNLbHKuI@OTM5<3|EpCf!j|nXUVfu~<|I^;f$Z`W;tzE%QhOT1{ zy=m4MlCKCl>D!+%P;)U|_$EQj!y;)iijFZ-%GIgyBYi4&OS${X>4yz>hUZkw4PPl| zROIFgdEs^r!%Nc6)P`r#-48#GR?pw!HO<0(fH42z^0u=?@T#EyTNp78 zib!uu_XW?&3AWGe6Mv^mLJRkCrHl-8y9oQ+nVA_6@4+lZ$Bi=%u!5j9Rlps#?@TO@ zR8sFlKDx6#57BHcJF?ABXWUyw3JlwqXD<*Pdn?1i_Bd3qJL;OEzX|oe^UqAa3qYo5 zEX$1nI%g#JXNkYPst}4ueeLHK$R@rhjqb}bRFy_U`&@?6?Nx+2_lD3& zwM03Q^3r|{h@o@z=g&A6@6kSn8fhRt;~?FE4y3>3E%8MGtw|y(s70Jbd5eK+Q;4QxC|9f9QUOjuLH*@E@sR+kM=d&9TRs? zhhrW{!Pazc7u%ENrr{uM4m`Fr@zpP2tBG9fEpg|@+3ZN3E1LzcpQJKg;S?%H+D#;L?MPn^JhRwz?7%3Hl(k9 zoA4fq){Cu@2i6q#`vWO?nj9`^4OFI8IicP>x+l`YSF|W83=V2kdlu+>Jd?9JBT><6 zy-;guADH^5R!vRUsb8P8=X>}cJNWhP7Hn1 zqUy239Iw?-i53pc<&xY2a>3eV9NaCWdT2L^_r^=5;lP2?@Vo; zN=i$eYY->)SkCWw-~%BP3o-Vu?xXYdcnEsN$I(<&RbeznzI`(^d?)a*;}K1Ee6&!@ zEo=HTvMjNCbvaLp5-4e=LG)2guYZrF)LO)NntAFv|PdqIDjkZybH$ z4Nv&j%~6^u@R!Ac>zX!)=ORg7K7r?-O^7(ucBFB*FP8F6)YXh&O9}_3o>qPfqj91v zGHd?0aLOKh%}`>{|N&tGU#dhQT{|z z4l?+ZZMM{rP-GfmC?;IPaph-#bZBn*>UjW>S)De59!%0bw`p1$h z2+SV%PB2z+JS%%;d$jKA3Vv%PYm1Pw7B2+cJ zED^Oa-=Rv(O5?U-*}%lTFZTB@yo&*Lvw6)0w?0AvDJ?~=2nnY1hT*|+feTQaeXm!m@1ND*yn^0lU<~=!{gFindQYl9Km0W%d7SgI4 z?uZLTwCa<>XL(E1pBE3kWCT$Sr0B7JOyF*8RH~(iS<|G?puz_V6|gBo*TYc^*qb;M z;byjVz_o$FCsh^S*xrta+w5Nt)Uj{;6SP^zzv0r-)$MtDxUmCkP)V51tE*W-j%j?l zUNDqyK87A)LLg50eK@p7(?;ZP*8y0cRoQx(pzk z7Zbz4tR%n=RCrPJ;T^JXKm6NXYulRko|JDRbWRZ#NQ1HZ){roT6EX1^T)Ui>(V=NOC-BYkU@asug-X0rDyvnhniPy91H9W?BGIQ4tE8Mwn~3DYZ_aZTNv z!ViWY+dGYZ%2=YGvRslbRsHBPFnL~flNHDzZOsU5ru-1q1Nxw4d|DGxy(~_^n4X{C zK3=&uRpGlg{wjdkFD@%1bSncBeZU*_yj=?hCmf*8^dZR{!Ep?5oZ%Q50y<4_e|tB* z>nBIHoaaT<_!H#L6fBni-8?5~=x{F}+%B7ZcTDA8UU#L$SZxZT)Xr=IFH& zoL~X^zh8fVC^6+cW32K$XPd(Vt@RrBbt6(yPw(kY(6%cPX$PH5fElXwod!-YSaiRH62G9AP9Qd z&HCJ~>Y*223Qul9qb+RKS}()V^CSwUmr3U~`=H?Z^g?dd?WG7rjsnY3GJrt|Fx{o0Uuu!a!x6#|_=_ zO3Mn^1Pk%D@oHL=yqO&X9%O(_*G%x*i;IgL(f+*suxt@?<7T^2=JVq<16QA63|W4Z zX=T1nJ=tKn_&SNEZfcG`R$TGu;CmhMcZBm@v=cI`VALlG*F{x@vc#VQJ>`k>plhT_ zo=K@x?vM^?q(MR&$xS2D(zO98=|=Kfc+Pp>bMO7eVE6|E z2kiB%XU$*Dx%7MasSRPo^SKWLLQJEjKP!Nj_=tIV(X2Bep0KA7QB)%^g%Z%w_QNS6 zJ3(@}6va*If6);o0~ubLw9%L?-}I0F(wO-C?Vf!s@iy` zQR&KF|1u1G#espvbp;*oYT{Z;g&RbxipTht#63Rrb>$}1Tc%yi3f6*`d}eNr++CiM zDI&;M8NtK^y7}5;Yl%%nS^@sxozFSrTRgsuwMDeoI^y7{$-_}$ew3eW7ga}vpP*{^ z`Q zAH%Kr)xw-)p!x3)w+QP!j{O{06=$2B=V;H970TW|ceB9)q)CJ_-}6n6^`tkwddcAf z1(MizG#R_2fnry|=YmFr0ssdy$GAx}Qy2yo>6DOoI>^Tn5a~&9Y9O(XEzj;!ujAN} zSfoYhibi;~27!7gQID{^yR|1Xyjly5iTsq^`cuOA>?BrZFC4!_Dcy zLHx*BZG)F9qV?$J**KnZys14u&mJcOY_DB7MfA;4O`UJg!e{J<69KhoLSTRV#S6x? z+uQdy7j?c8P1=;8S+*UgmT*BG6%;?GPi(km8zPh}gn8KP=9!|6npDCEfnEC|)wA{t z_bd8i)^@x+(s`EL0(d2i|tuz;%)+i6_538WuXpQDLGI36PQ#f}fupyZxN@i1gTvD9u>-o`OKlr6^1j3dLKhYgummwRHv>iZWd<2-^e>i{ierjDJtq4?BqeX!yv}{%q&}fyQyVB&G5`3)wQl_lTKx6=W&3?)_UiTWeH$r-v9S~E zuQNd(*L#z9w`bEig`WIG*XZ*k#2RsdndYi5H&o^oq-OKLM(o03-{TI=;RiRENCDQ* zdmh3QM2-|Ki}_o!vkF2jIshI8Qn+m<`OO%IzHDHqojsx`v#Nty2jTT0L+4e;%cQcI z7*Xy(u^0LZ`e}?%Pc_3lZs*h9aidCJk)JCbCLq19Y~mdIq=(~~7re^-3FwQ-M1lf5 z4DPm{nw|z@k#NGLF;3dJ1j4B(R+~1SpT053xpE-R?}P0XYQZE~6nj39sG`9fMrnh+ z*Tf!?xdAn#pg~3Z1f{kB8^R$2nU&>}%7QFAwNz95z94@-fTpzHy{7zD` zTv@x$Hng_Jzuy+Sr+y%9rp6w}KT)Ud`bJ>T4bS*HE~DrtTTr|lP$XW(Hq=-!W9DP< zHZrgw+^h;ya8FN|-P43Rbcs*A2ETmRtUd1CJ&ZVkO{|$TKa(LuH0_7?IGnC)ZCAN5 zAxj&xN-DX7gItLv#4Rc46Wpu^LS4YoehJ{IJq1>9zuIhDkLd*VJavM)Lf+1PAh_hO zW~HZ!$$Qw3^NC0X7`LtTC~)BNGI*#(>~|yVR)D5zjs$5P>>k_QoMFfLRE1|Q`@om0 zAmV3`c{s??>x8sq2G5P-JWKV(8f}Zq!ry?J?mCDjjFI35(5&DELT&5-*=D%tgi?+- z@$G-y#s9I>^J-l})}N*yEaX>Vxe33aA(1Y5U(|R%UkwCvbDCN1L^7i8QjMQvU71j9 zavAv5q)iC5RatQD3%bgv<(3u$mjOal!q21x#>V4>nb4R_J5-kDiHII7FKBjC*~~%;V&6g2Dk?( zO{Vkf{d`uQf9(t7GD$kU$GFg|1e7v1!)67#X&Raor+ym|3S9^zBmTE;URXp4{yG6- ztk9Qd){)Y5oBBJ#!(O|Md=SlKo&-PS?<*IQK+rhisTmOb=wYw#Pm&?AT7%3ax`4R8 z0{MO+)-!$UT$>i)@+8n@*5j4T7^Crri-5O?VFA9M@n-D=C@9oeAE}m@t+=sKH92ym zOfV%SRB78DD!23Ewuu$VXUBLGk!n%p=aB~ql@JkS$(y!fe0XtKM2d+gRn({Z{4G{L*tw%>DyeBU*W!>F?gi_zA>E1MR*6%XjDKv36MJ)7i+rDpXW; zTcV|L44>_gZG1*jS@xpW0u_;mB&uST$xvZ5SpxmGkQKQc!qMNe5S`LyL=(nwFd%J+ zEwGf9){fkI;r0YXEiUfLW#7wa%A;54pBblFLd7tiDw`WcOc=6Ug4@p**7o=^ zFK2-PrwqrUmi5q?S-9`#^EGac1LV5NbJ1^12#E@)+ePFsF#2hqf4l$)BLU#d6Kr4q zG&STJkEfS`IptNM@Ak~J=^~xJpv@Bw9P0R+W3p3n^KE>ai$Dxd5rj}HFY%4;fUA}^ zF;_x*EFsjg{%a%%8+wN2w%e6)Y6rHaLJJD^KMB(jDqK9zsj%+A!&4pJ#cDUiPe3&` zPWOWt9=`rv$vWt9e|v$87EFU8+1I`&4CmhwUb!7JKQqJs%*A9O-H$Oa(33G5+zOc) z^5yPe4f8DwPS1dKacb?#d#Z(-W#6zLfl3VRQGRX$9qh{Px0hz}Ar$kWg{3p>464QC z_NABKeh@k&ViK(TDPoYQfkMk5Z(_xapf$V(2#LrErd&X0l#BHzgK4Af69|OyT#a0O z(3sy7XEgavVdC8z2=x_$!E)A)d4L!iFAGBiCLFs$m$19fVPEmk;5F?s4Nh4skc-B3 z$T!Xd!{vzqQ@R;3cpvn9`5nBMEW)OUZ&%DQzaaoP^IET|_X29UpcUBlo;*xIpjl7@ z*VB+&Im}|+9R}tKH0ni4sk3JA2JHZ)`wdw%nbU8kw4sm{-fL_fukTJ|Em2$}7BGjS zWYP*%Z*rETT-hNn6KLu zI(`ujV`5pBkp0|aC2NYv4HP>?5DBRIq|ZT<5<|vITB&1IM}gKui4#PK(Uqq$XaqK9 z{6%l0;tsmHx=Ka&X)2R5A>my2==P3&HyF+xpJV>kHe4TsM|5hmB zNCrrl@#wMHpTtXwXM_%Y6DPQVHjed|a;%Cipr;jndadwD%@+I2c4r^ip z4&f`J7+)Fy7qo2RqZOU+KkR{uBk|cdxYV-$BZQifaTCyaart;06a;n(q)*N}Q|0I) z%FEo`O~QcEK!NV{oL5XLcN8^7UOry=ER*}AFnv%=*H_pxeJl6?XDZ%#(*jc7bhP8(%Wx?6@P;gqds=@WY06SG zHH^^kvtxhtn$!$LjG$Y|#)njK-?(8?T^Z}ZwJINmMt_3UdCOvzhKQF3F>jvPAq(;J zY-g}$P;-SV)$Q>)&273hG#*FnF~+^GKCkTG!0NaZsfr*@=+*Wl0#tIZD%z9^FtFX# z(;0?NS!&&15Ne9e5NHPVpxM3A{feaHCg>d2w=5$vwy4t%>l+W5+bv?k`wwCTP0@OtBpTy#8S@0kZ z7=Ke4@D!vPjwbCpEkX5SW$9ErVR|zT$hSXp5j4}sHEPmrWCa>Sx_w4}oa45-Y@I2a zHeMVha#X1k#fm*X7AUshvJ(u-62B;9nMvjF11-O4ku2S-$A8gR3AbLhQAaltegzeHc%N z()m$IwC1-vuR;^r9G8#DwhxUZ8am5zz$T&CCu-mDUtN0K)~-@ln=m8zrz9-!4LiFC zt!o1w1Fg$3kHG6nP!OdnillDaqC)kv%3G+?8oaO=UE~^9HXtTG3hdS4W{HXJ67V^l z<`8@dV9D1R{mao%%O~w*vAnKxsZWl5RCGGrlB6q)?8H)2PbL5H4PdXWo#B_KCIY3> zeluHrd7oG@GK2e17J!wI<@?Hwd~^VvD&Q2Zqh}c-Z<`u$6MQYv&6ZN*S6=K^Ir7sBnK`q znl_=N4^7qyhcQ?JQkM+^Exa@+z44+ubtR=%4|RhVUCL9BxU`!8{g zWY}4hT}xG!E%np<_%c|)D7C8$brhR7^0hyZ#>bHVyg7gB$4wV@Rb3ORmu%1;1vOLx z6c7H(dTGrMds6ZnhV5KphnwI`PIQ3)IwHL0XOIf7IKSe=h5?$G3^X;mqq_56E6}CH zH8cmcKm@`zBVykABfkGu_T z+-4k1eQMCTTxLltZxknmtgh#H^J{9if8aQA_4kZ73=(j>lsXZ zLBS&_B!3EfFFPBXRHY7Q@FUjN)<6r*RoUu{KLjKQXm%(8e(ksO?UW8l385}0X!+@H zaDM+T@~AyWEv!K1x|T&@P>OD@RfM&qt%71aQu?Rx-$pEc%G>&Ik*t}h_ zqK~ad8mOOs3EO^e3d+{?_4Va%?p36iw1BH$d5lk_4cO(>EwEht&-SYs(~KCmC|~xL zO*zq{&oB|WtCX0DYuU!O@F6i{lNFuL_8-$wqWhJ-X-zfLKAa^p;X`@M)*)OZnKQ38 z_u%&Y5;3ILf8>~h11%@BoEDNndSe9G|jrDnOcBKGa>zICOiZVtt_9o!D(WM-bY)kh-Uu4v6A9mW|ytfnrGhSyAqXtfcm- z^Ue`!y%Kf+i*}z?3)DMjjFv3#q#VV?4oit$Gr%UoN zQoM?Khp{T=B+O5KB-nQJ2Nk}r=Yc6*?@p526>L~YHV@|&)|YdTk;N`Y zf6CyRDl7{h$T#`?YV4DTtQj%i|7Z-Sm0U%gt_oc38U0ZZt?)jq~b$oO>0+%I% zGAG9{{(BBssou#c?JvH+-{g7GPS(Bg+yDv@^F`R6Qvcq@rGL;Q50wj8f?=rE6XTFK z@nR0L;)#4m>;F}47c^(;Je}=RNkrRS_|gAU;(5WySgu_5ysz1ONa8=@Iz`LgM~Ozq zdRb*DK#qD_ZsZ3dj3?lz%OHbTe4q|}0Ckb@_&XHmwu|Hk(m9o{f9KB~#mjdB$`d5;5s$FKhq9Ilz zz5j^oIEcRHHTQ=&AO6UyeY%$RTggxU2!qIVB}R5jBKy@awQM3c&U7TnAsiH zg|7$D*njkMeSWYOb4#LpfDIutzSmGkL~ddN%~>s7&+00I&gAt3nBDh zPRdb$*gyKYuW^kpfmEYWjwLgol)ui#Shmb#--sA!it%lJ3HH`qDHw9q#Sdiz%YGjc*nr zYaBR+jZadt*9Jn{{jD8|?hY2Y1EsQzzG%|SNqg`&(Bhq<1OzE!1k^Oe4fe$@pa;6k zi7Yqr0qXOcDN&YCd6SjG?I4zUA1iD=74)Qxg-m9EcxD0=a#CeL+(U;38<=fR zoY1w4t=GWEPnpOc!IG5vx&%fAdfWWjUtLpEMEkeL%PJ+49Unkfs(~Cirg#4|D%KNX z>na?C*IE+w$$k1?ny-JTa_yW#dXAx<)ZiC4rGstSiHW|bsQE|Algiw~P^9HQ2#KbJ zQdbu|zmBq^VFS?>mrQ9t@(hn%(3 zs7jXN@(p~0)aJaH#5cGEn~+hrFv*-9*`)`SHfOAu)5k@=JpW}m9Xpt;jA%U#f(^HHo4H0 z`^JeWJ}%k8G2A8L{NDEb;u-J=z~7`R@6LPOP=DU5)~ChqL3C0>2UrXG`H`mAOut*B z`hT)m+b6qhJZv)+@hY!rtP7XYQ=>8lgdZ8;xqM_Etj0kAYv!63a3yBO89b2x2|2X; zP9!S&{@nhs71!%YfYb-Zt!RV)sbqC6Eg>No-N`w~FBXw)i&YdI3O0el(PO2=W|79!_JXWQR0~L z)=56@E$RIH{2IH7MVLS7_cmO~RX*%k??&{p4u8GWjP{pahoSLLazDSu5t<2^-uJh7 z){ECs`yNfd8JTq_NKu!he|BjQ$4+xgKRB(BmTHiM`NyEuBXJ$J!;$9ff~A=I-Sbh~ zw~)Uitqf8-S0XEPur&QV-+y=m^UldK-7i=Y7TgM5&Fa2)QAjV3rDvc2vc6z7jPoh3J#&hS48Qs=Nz z?rY^vt2&6#Z5r)+AN_92ar4IqQcczNa-Ojt=7%6VgwTBLKiX!b^vUuQc12nY%laNmf<-DU~N_oFfkg530K!&XzyRLxNtO}^S z-)LyAAZi%8tDPN$=z+)7`-JN1b2?#XcoD&5ZtgWw@7wg_T9g|X-Cl70C8)}S_*|I- zwV?NdM1H+m!&im_R>`8|j|4sBj-SBgw4LU+;sBD|rrct`fMR9;8u_ z)QzVfjwrw&{^AjU8ytC1RI>u-1V+4XaG%o){^WdbexopFK-z(U|7YNGhX7x6F!j3~ zHN?>G=%O!9^f{wo9*YU>J6#3P$f0x!Tck;~#fmpoW5=6vitt?Bzr-SYd=Lm^^#R$~ zk;gQH5ww9~B*_85?pDSJaY@7YzjSF1IR;@HeX25?{vXj#-fQUC9^^$yikBqD z`dIxbKJZJMkn(n|p8b@V*g1~~vw93hC|8EG=3hqkBtgC+Y5rhuoK!p!93F>jfLKP0 zeesC92g)0!{nMX7*}pRLttp`;QzHLmv&p|P+oWtbRzW6smeo*y_;nPV7sf@hG-WvE zbIw~3Y92zS{2?IbDg4&7usb6@+j_U-vOV~ z)YGH%$TW9cf7u3Kpeyt9{u<+4bPa zNU+2OMp-+1u9)*JE!4-Dz`}r4pNw64`k))jLUzfgxm<^0w))zoc&d@h2C>YB&sLE? z_<=Nx*V7oUGJg+VBa&q?aqNHOYI=3nQy{Uj0C%+M9l?-UmqYq3g1n(!FDkOVx7oH) z#zj};;p~UG5B#6mbRC5&#kA<-66Z@UK5BMj+;IXV3)pR{Lc!tP^?_9KdalTGkn?-# zd-ZCe)q5HT7LRq%*wd2~kb};LZ?bZ5n1DsBVQXw_Tl(!@8BLQgV1lD`D8~@seX%cr zg8+VjK1l%tPo$FHg2@!-ev?_)0JvR&|C>)`V|+1);xxu^7RF&B2Kzg zi6bd1={&v2vXuW-wlfm@-4r?SZ&YJn%_%*NfT<8n<5;i~jxcHZz5$T;42!%{);O4vVgul zX8RmG=`U#E$TDbD+A@X8;*;8cDcm6@tLb1PW4FS3+%!GR=pmIgD^v7!-L7Vk2Qev1 zY>1EP7-Iq$rY{a2449UpnY?EMe;#nB@3~XnAlMc!_IuzZQk&j9+xQSmterk&&EQ=~ z=>0(@)Z65^pb!RH)qGg4V{i2MH|D1!&YxaXpQ>QWCTH&aKq~MZ?FX+`l&lMg(FGM3 z@?&{2stfggC#v6)t3N=z%_&VdY%SF>WzwGpmGx53P?OB(8v5=)pBqBW8-Zk#R9Bpu(Qjovcgmfaj64X*$({%0r$|{4SxC-pxX`t}pkNQw=`y{Sey)(Ez;Zs$nG~9V(08>Rn zJs(JtL8{0WXgYksoWITDar*Pwt~nKOR~x&79aCJH*PXuN-*C~l`cw#qfne+QdGs|P z!LJ1n4^GSIK^{D7Fp!{YAkd|-kRmm06@Yw^hnPey>K$uY=NTMFo6l@XT>lT$nSLTW zevYG}avD}FF7j}{u-)W9!k*u)YB|In$Te{RqxvSrTw9ab_ zkF+K3#M6|&nK@}=H?#x9d1NlA&HgB0z)3SC#r?D;cHvlBN|O>PY56*KP(5~TFs68Q z|8NL}QV~1tb=>E;877s~2F&|iN=C0=tun91UZB~7(c~% zkjAk>`>G2FK;924^h<3m_m9nRX*w7`aT*6Zcv<_?=xS0cW7gB?Q3)W&9D-ZG$P{Gv zV;36Fvftp|`HFT-J#PbWE8d)?kTFue^i#Er}MB^)BM}Hq?RN45LP5 z+J?EGN;ssH*9!=aeESre5}?RA9b8m>*(Y9Vj6usA4D@q5d=AnxX^d5dzK$St5tfLg zKMHbBVe0Y>4>_*Dkp~-NW|n5@oD`S?<`v{L=q5J~f|LkO9uz%<%6Q1i^c$2LvJFta4ZW&YLz#)?Rv!;LZ$2g!TsFrn5dc0kDdxk0h4Ch^> zp$G1e6=}&(C8zh!^`A%w4G_<(=A$3$&CReaaub2Zh|nbjkW4+0<3^N#8LZ9>$)O?w zQrba2 z3#_=z(R^XMw8^`Mk<#|_R1OwzD5xbzM5E(r^o#C$#KK&P_{v4+N@%OR_k^VIRN&u$ z=)sdm?eIQ91IbyuY!UIJ6)X~Y1Rm$7VU=&ROOYPwiHm#;{o8=Ojxnb=M0Yl{6w<8- z{?nR4Me*qx+nJYGnhPa|@|XI*1$5`S5n4I8J1yLdOi!X4$I};+Bl@>dSMRd|=7D}| zo?Gu%dn0^la^NU*mi6W0{=#Bmrs$o60&Hy>rfF8tSZvuJX)y;lWSmI~nJlhvwTK$* zm)lhGl#|~+L{!1OV&-Qj89Dg2P1LMCbz_L$#`sSicMGLycCBV z`j4RA9S06i;JiR>sD2)+g2P3`8cUyAyl&A1SV1uV<}Rc`CsRcF&b&fxZiLa|qi#og zO)i<@3XFzzr^Fxw6__l#^A~h-S6PU}Y&6ElPcV`c;@8#FlNGHG1<_gyibM%yVPY&S z%Vbj!TK=cGqSsPdfRqYP3^ODjWJLVl_JFiKt@0IisKl7tkDW*VTQ5Ih6R~*^%HGx$ zY$q}j=F{?e<@l3^1(=^-)Pg83-=#WE*9NKY!IljTZk&4uVgx#d}c@APj=zyWS|tC*gZkalqlrK8I9R8ihn+!N^Z<9DLU_2P5K(_ zT7zGE;lHdG4b>fi42<{}Qc9Iq&VuvLK6k7bQ-(?!hXG)V;%UYH^*qo^(qVMbxkAz= z&So|wG2Kig}PPU-*x7BM`6?c!h}0cM!S=i*YL z?{lv`NKHH_DN{rbSWHJCv;%V|d!a8_jUVvy3r=GPB+&V?$UzTI8iYM3KfR3ot0SPU zv3-sCk8&P1c|MGB&_l3^1T$J|VhrMYMq0R7>JqtC(HgA*nm;ttZ!r`he_|#gI$j>6 zm{ea|2`_K7r$hJs*;cyJcztZ2bQxGTcFgck(3(Z?A^ zLsqx*?pxq$!dnh+8=(W+Xz3#l#yp>reaBWwd+69ha=`*Th9%F<%-kt6V+s&ru-V(5 z|MW!@qnb6_<6vvKp{>p5=g!sg$A<{;2dmen`0MLaViOljpH_pgLJ*K1Fe`T0#Gkk|!bhS%0j&syJu zNVaf)F@!pP8Z~7e>w<)#dQlvoP1m)!pHJI1ylOnt)3ZfkRh+g%hiAdzgFWz%*_q$7 z^RR`f%N1**3p`j$KgV;BLp>^f$6dO>CspEAc?AQZm}#5tSF-@Owvx@>bIeD#b^nn* zEWD3Dc%krNyjF)uut&(|g?P2123U|5!pg-W6BGnE9E6qE z+ecbFQE2pb<{zU%C377lzmkK38q@dCB9b3R{KwIF$$bGX76vZr7XVb6rvB>H3B8BA z>ZWaS+_7Kk9NxtKayDjflq&+940aZLN>H$e=Ck(|`Rp??KHvu}ZZ=142fbed^? z+2lTdRo121rL6hV4BP*q)JN^L*KBEAeXjT0V-DBt0(7DYj}V!+=+h1EhYW z1!%iar;T?2kGk z*Rp6HncU!B4c|imPnHF@qQrLFUdkHTH}P5ToJ%Vc!%zp0FDAjJcxz&p#O~OiheZk= zmayU{a*XpUuInB!JuawgrNWw%iQ$(tpljpH^dOf@3d1qPha}(~_UFa;9GWzUvS_)m(2kcrG+*rt=HV2~8b!q-I z2-j%fLK|qmzqyX3H4cuU^S}WMu;QCuO4PE+Z-csw zg5J@&o&J*r=rUquV_N|#LkQW;Ux91v02)KU@_>g7$TToCz}X<8XKiV@9p*a3Y3t?% z0X1BB`XoVKj8S#a6`|cgzVP<1=&Zu(r7Z?nqD-O69>ER{DmFWaMF+mv3mxH z-DCB5;Sv+CclS3i2yY}*8UAYrU{8$)*4B8m` zji-Oy#l;huJoi_K7c?h&5P4Zuj8$418$YE0y&P{27J4C5?Db;TXumD+?)dEeFZ5ba zr`hs@J8BoVU zRJyWxq^ zP`31l2g){lwj&y;(_lwhc;rqIU+CcKN?Kq_1W2g(S-M`a0)AQ{T^f(ZA+n5`ro(TD zwyi_>9kJaxwh1_%0NMQ0l4jmN02x>|qYYXS|r!QPHU`#oAVcgN1czYqBw*oHenen1KxYlRefBlFqlD3H&wd#)Vp zQZ;)&+^?Uhn_|}=4XwSTokqtA?m!25l)nS{UXoO%2>-`}(DNkPKF*QN3kLnFu@Gw5 zc5)0UNPt_7r5dP#xKA)Y3c_mD`BlMabUuBM)Vc;5HAf)+v`^pw2u=MJ+qIHn32LHMkFPyFIQ5A= z2FVZHJtSo-{OeE@`yrTowB>t47Hmv47T66;vChvE$i#zPMi=KTvtnVi3soTR1T5oR z%Z`nWtlVL@kOG_x_rSvNEI_$>q^xmD>a+I%HW-e`dI)j+F(`^n-2v>!`uTM&gr?l# zipUVZFo&6kq5UrA&-!aTtUvdGD7}6S`3LV_aNqAosv1^w2^>Mxj;o(D#YiQw5Sgj-uHZW zFn`Zt9FDnLkQMMrz+>S-#4i|US6JsJ384nri*`;7kkoPo2_NwFx>O6~9*gWAgO0%b zwRBu4!QKc%b+q}!*@`kyTg`%{zl>qsH4gKss+7I!C5avd-1$ z57>xW;hE$hunryc#SBUcm@76Jw%_e28tgV&ZWeOicX^*pgo5C3v9H&1e+O>AQfu_R z7nRJ7&E0g6;;AUp&76^JP)WWgs_?oKkj1i8wK|5R#^K zSYd1P3Hg^jLi+TFwVg)Lgk@lNJv#+>3Gxw9=F}Up5kpVC&wnj7{>kC36ntre>guD^ zL|RyuJ{a`JQQ@`#UwW5Te)BAW5U+o>#`4xK8KggzH3Ufw?(5;sF#I3#tA{`J%-_C6 z1Urcm-X_oUB(;Sk^k?r-vv0(MJAOos9UI>L<+9KIl#*h#&YRQrA}5yzS0qeI*bzX#Nm{E+|oT+hAkS_bML{Is^36j4i;tskQ_u`47kM_ri<@2VY!}-i*H~^ z41iOPewXSFQP#q#5=-gFat4W4Cp7;|ZE!7lY2NIt55J9m+bjB5dmMUT6d?cA0BMC+ zynY_1B#gxI`*9npT&%{Sz8HT)kj5*>QkUyt{Fl~tcKojm)T8HZJFY5&)vN$sA1-+x zz*=C3M%Q98MbJH%t@3IkYEWQlKeUSZv;xS1ivBT3yxLJx8|AXcli$KFC;he+VgTy%sd*!g6V!Ce$@tKSI_5goJugl(+SF_GyBrq` zHh48}Dc|0jv)quL>%BRFb(e}XD7b=09O6`;%@;&Yv^R9e#H}PHoXqvQIK^&T;u%2z zl2Yq*SNW%;^VyxMdWRmzyW26~g$yQpjVStnr_Nmv@_pmY9}|QNluQx~q>&84it_Rm z5CVBmj#;GkrcM{9mX3}TkQ0RtS|&W%`t0dkvr}I)2ZM5iS(i0997^qj0+U&2 zovXr6_iD_(?TkQp!rSp_E+s|9?eFvl2Y&c!x?dJg^AbX?hZq`51oy0k7_KX?;z^h( zQoH8@wsYH?702tN=4gYQS(^?se2~amV=uGU)ClTRqVui_cY0(i2N-VSB1MZnta52E z;&miuC-%$3gySCIP0;+^zQ)$V&TY_m%c%e&kHvrBTeB{N`d`59Sp#)%7=t`KYAfe2 zzXpBZCC=$q7;KhDz%;l%XvYpQIy|4Zr}o@kzQ0|Dhq>E7xZCfpd!gK^O^P5b_7Kgq zHrfKxBj!rR4XX|Bw0QP&^&~e=`PLxg+>k{*6HFF{N__SP%83`~qB>op53QP_-Y5Ua zxHX^Ah~HORe*9N@FopS2>a-m$fCiB8Kk;RA&WBEY-rLjk5AzN7292utSfNJ)kFzs` z;wTF5fpeD0xaqmX1kfcwT$(yMUEt4-p}yUEUikV9ZYv#3Kv~UnUxFR12D!86X?_m@ zG-%9k912vd-%vh>VI>p500f`$OPgjM9_d?H7@g4YI&IrGR7FwpPNBlY zu0K7$pq9kBdFYU24qcZhMbl(u>*9eS{f{rSwMcVA2UK$q=bT|p_`6@B=v^#7cAi2u zw!Iir65jYrzFxiY4(KB>idQujB}za^0ZMN|ZVn%>*v+977$yHfVf`}4%fuwlq!Nr= z{%~_wuG0Wy)(;m;-q^rR?Uaa0BM@enLx&Adff{=rwqRp5yXU9hva%S3%m`Nn7A5g6By1`d0LcyNLYC;s|Y{Q%KcK;}t=Z|Ca~ z&zO{etp8GmV2lb=5HK;KqcOXbqoSkkM7>neYTEqMMmC?Trwzn59Y0?7SF2b(;t-X( z%-%QJ$FAt|K55zwu6qg=95hIJHiem5J|*k;L}Ro+NllYH)1iEfYr~6cH|?-49JtXa z+;9BF@J}p8@K4iq=&M?>g#I=U(A(wp5gG#raGZ=~>L&Z$SCHE2LPQ%UDHz!^B!Vj~ zrERTqEUBmd2fEEK_@o*FLXH6vEayOzs`~mh0*qw%fZ+k)dQgf16G7+Il^{uoz>zjM z=T$1FfldpUrO;jIhRCinD-n9dO9OZ*XONfdBl;zS!+>B!#mbVSvT%1l>s_T9rnt=<6zUt zk2rO^S=Xg&f8b-Dth?a8Wzo9JDyJLXw`$gJ+H&Rj8WD6_x~0E#IrE?B2&FJ5`%=@0 zv#S1llf1_)+J0~%tQo27_jb2xek7SAZLr`=YmMJK(Xy{YKJ+Gjkqw=u%(7m^&r^jl zYq7SThC`!*0~^B|VZm03E22R~wF#0+3vYvgxw|3kkC(%0M+w3oucD(-sd^49tc#hx zOy19z`IUN#n#pXa-tGrWJwxeyFKbtd>PNR^*BTiu3erBI*7aC$869q1hba#mt*7JE zgO)_aw}vLH77ewuEb9bM?urwQ9Z?Q;dup00aif(xY&H~oXsu-8rELyaTTHQ^e*BRT z`t~W;VIw8yw(Hgx3i~*bCmdPON;GcvxnB(;4Vi3VriobB5n{{*WGm}QDhobx8?JJ8 z6^7<-b459HO}<5nb=#1Yy=+tSV(3K0MaU%(cvbt2Xpk$yfg*HlOjX^k@AvJU$s#XB zU(#+Hvg2AWrW%|ROzj8Bni5j4h>%R@E>l6#ihL^0D8k`-^ckhk#`YW$HteXOi{7iN zIP!RLa-ZowGMnB76aR26)($(Zk}877Ab^_wNV#>_{_I7n$B6q3RfGX`W=iEK)SoAC zXE&HNT+Y7I#Qd%Db{tAjNN%o7hm*IN$dQLI7;H{XN-DvCC&5kD^AOvuoYt9pec~bf zNL2+VC_XyCfj6Q(8`#MT2&}9$% zO+M3%sx;=Cn3pJF?&Iup(6sb63{Zu~$6 z_C|ylSu6mfG-T1qyg%vU`bA-Jm?!{(aNd-ojm}}4`(EylrepY&C$Pq~r20phCPg*A zLHAWJ_X{PgnZ4)B#OR$^ZmW?vGPrRLFOqtwm7=KZYEVMrzr^MrEq;IAcN7pqhvkE_%gf6)a_8i?@~DI8 zpOwBF(-TNsG^ZF@)jttY?m4~RBFlPrL%KmIV%b4GD0xnm@aZx{>!hox>f6nbQ)@(pthxV=L=40y|N*a_-t#R%Dr@= zLsdC;oarjN)FNax5Zp9)G;UV*L;bmEYZ{~;7IYPsj*V@5bKVu(P^&^Yil3(?1A{ z)~q%SYtmOt!E?+qnC6%{F4(w~*3T@^d#;*6X^dt4Y}t>u%HllZmDlHpCj`EyfuCGv zKD?l(T$b(|omYsFq2Z>$rxoxH$KZ6-^tEBgbQMN)*L~s4NvBikp(4UN;#$56jEex=+G}Uul!e#YB&Dq;MLgj zJx`MJDX2{|49tjamlkeZ|JPF9Yb8dceRkyUNR+}G!f9J6l{?C^l96AknnW`>JIr9b zhQ6ifP|9fkFyc~Yx?{3$Ey9exZ42go3e^=c{OtDgm3P>%h=vyfTyCzV2QCg1%3HDP z*%5HfpvE)ns=l?7W*AN7rpCu8lJYRDOC-m_m2)uLvS5xcISCn=;Nb{v7w6&cchWGz z2+PZ@!L3_F8wVoYW=sM5?(C3#+c#dUowPFoRBx6pky$I1>09{y8I8z!t>5Uebz0RU z6K?NTbs0Z{^bpk}BzDRTY}JI#3=AMfI!yUh6xP^}3^AO_MF`fCUD%8LNVn3-=Bpt{ zR^vA13Z3c9FN_e|-FMDmLTk1*QoG5;<^RBP>wDDJW!I1*D!b2Di`h3fYU%9HleXgE z!lH4b`lM$SC!qv!UI<-N?-~mRQ7id%!|cm3f(1&q)bBm3F?p3dpz73w$-OhMJL9(# zH%FXa(x&|;{vziKGeLydlv(YBm%W`Uq}N;S2-#mnhMN`+E||OkKxtYM3}a*CPcGlI zpY+^_@yv2+CL<13mtsUr!3mEfEYCkPUPspz2)M5Wf(z@37!{B0{zT zbLvIkvQK+BtS^jtOPH=ho2oRNMjRSu47!13OrJKF)*{1-ze>Q3@(*ZjLS5XL(=mM+ zu}A~4VzC(+dTjX_m76`|6F)<8E)F5nR+%pNoJrc*kv-IwxM6+CUz&+3_BvbxKAlxH zeH}Bk4^EXgIoozuh>wS@ki_8F;#8r6`S&^ZwpkHw*G8kFj=w)NGUnx5$ zBVJqK3mrX@i;rq;%`aW5X61wqzA zCWd2z@%fz@-3(RJ4_2?1yf`*%G}FXvg zY9;-pg_JZ|Vjux$Rw4X0Uo=B_`yTm8uta=fh5^<#so6TTz{i zlzRFRVYOMYl>H8x$;+QMWMN|3nC29rW%^P9pDmlI<=rDC`s7^0NySDBeD#`150b}q zqK}USgA%x<<$K*2^24X3a;k1ST+cf?(gw~0WsiQp17k-YfoGOVeOJ?b zF*Y;wYOCDYQSRs~$NJN-A>Sq;*QX_@9t=_hR%C&-tMAz z8-qS~pYzooIQv8I}+ zlil@%tPCzXvYtYkR8wJWX4ta%(!}-He|!zc3Zc)Dgp?HWP=8}$qM}-Uijz`NX#}$b ztLRvHqP$^o!!MI#(1;rxC}q9-2L=9M9M9`vg4i+roK=2|!MUxP)ipvu_Q ztu3bBzCK(Gq>H)}%5#t78h#O?Hqt=8<-X(TSDIH=c;x?&uCD;9a(nltyJOScCEeXA zDJk78CDKU81_6-}q(PLBE~SwYY3T+jX(gq;wLRzjp`5cCXv_q35+_!<=7VI?xNx4#>gAD{7= zZ+IC!oHWwZoBc{nIqC`V^&*1k5h>`%xtfdj(j>e_oEb-)w^X2SR4_S0j}((rkV(^Z zg@Yi48ENe)0OSq!BB&Bc!TVtTX^(>@yXy87rla_g;^i+|=8#`3%@3|rJY(an$oS1e z4^V`f(vN+%Fm1g9tBu921We2WDzXk_WvtXYH{6v7i((c`tA7&JZk}ewQmj^Vp@&Eg zsCDk=Qj_iapD3Grdr#lmN}xa%3nmrfs;bDSyzQKQAyw=|1UO6N0k*b5Id~Lk*fDdq za^)Q)E`+0aH)m!(=G4!ZmeMnxNr#{kk9 z=Y?80gGCUj(0+`f^3vAzD{&)DMbL&a8mSRKL(PRYHk3`dY!YB1mtCj>Zbypc!_j9J(WpU(7lnRWkth>*Z&Gg6@H=; zJ{nRiS8*SaqQOgaqtJ8Wn41%L$~G#G4$*h$>RO`pFLVPU%kpO^#%H`s@GPb7@oLUk z@|{p#YsjUY)2;$g{s7e|0!I>t4kMb7%KXDEa2o-VniQ}d*s1%DyA+}gd-Js~aiDA% zaS~Cd=VC%64zH-+v%h*GJ#fW2{kGo9iJ4MUf*PC*sBj`VBm6WwN<}*0z3^apzYETU zJC0~uMGeIN-9wW3`;uP-HBjG7;cd_0ZJ9DSK@n65YBCb&Qj8RZ7_2;<Q(co?I#IsB{io`py(_g z`%O8dZKOJx>_zt3p2o%gI;;T@>>Xr99oy?QVmUmd#r9x|&N&+eaP-6IbiOu3PN8$- zJX)(jOlQ1E#T#iy({as0&5| z6udfuBr2&RQ?T>`l&nC_q}a56(|ga|i0n;?nr_$iE68bNJyd~KLOe{yilYdhA55+3 zoSij#Z&yuV?#f!RO@UL?8`+h8l))*vfLZR*Ay^F7A76HOc>{$@jc7%$J+H?h*KA8) zD1ZOl8*tB?ZVue+qHFl%V3&RJxSLe4%q0|TK5+61Z8mLNlVOxQkw2_Ym}#B__?{0e z!3pcG-`Q!;bBGh&qp`y_=!hyPD9~FeB|drxm;82B&+@;4 z_4ID!^?d%iqOE-qdQPLSwiwmV^|=q(n=u~h9C`y=1l7h%#3V5)FJljKkZt+U_xmP| zjCE$LkdNxS#$A;t7<#5&xxAYWEI02%e%x;R>!BJ^>%%CQBm4Aj-S}wEExgB>-yWUB zECu5bDOx?_(f~jgiMW6bpNAlhmIx!Qc8T3T*Xt%E)CsdH7Yi4c0jLlAjGN(>S5-qg z*&>SXaDh?+8Y=)5wKQ(5$M%D?npdrdM#rb;zSe60{H;H)>**k>IpX6m-)zF=u6tCy zf@`3`;JQ_spFXuPXg$$V{}1r_1)*Cj0BmHSilmM)ug59=0fBxge{LIwV!&$IFzan6_cI@8R*OYDir1Q%l^C zE>TO1nQf<0mlqWLjc~65q5Hc{_c%p<;TH8}>3OR&5iN{~OO(hlvT_;k16DF7rnhq_ zqk4&ubzb3R=Z$?k|LbV~yxhl5F#bij9z7Ada>Pxgt6ZdW^TV*Cdu$93*zg4wgkVc1 zp<=Z3p2cpCu`5B*5$_LBfj&t`9u)l-^q;DX{W>zgS}R5;+qY;y8g3*R$TgJ7>vu=x z>dv(`w>YCx(qy?U!SVoS5S%^B#xhcQ`gSME4xg|g3%?E?wtetK?xa^zi;hEC7o|-l ztbzPi;vXmJoesp{vnCh20ix@G!{Ra%-Z!Nl=<{&eRj?RP-Fx-3!G3cT2q@EAbndQo z9YndISHu(;jQ1C`GH!GhDo|n6osZ^9pSZleE0ee|u09?n{XK}SAcbq~z#BjJ30~hQ zfIZ6YD`~qxd`Vg<8n;wm-xmGvfDhf)zO0faJy!pysK=B#0HA6rg)h-&XdY8x0ck2n z(NFv_-XJU03r--&)a~6vabO>qU2gY z7Vq5ck_r_>H~oJxw{(;vZ1&QPN(DckhJW%9DsLo-m@$Q}G-|0l+v`kkkU9g8(M$Jc z*!at?n{#c^oM&@{+)Dtt%2nQCybfK9njiLs?3)r^`ihGbVKuSgny}c=8M8Lkl07%C z`*}j?K$kK@_okRmjqhA2-O6c8kPeRAh`)J+8eoRn_ecq@smw?+^8?Sl&z{%Jr)V97 z2?>Wt;D^BpG@}`5!B?vYkV`h?6j49YL!ze~xx5r99A^!Z$}8+P;sScgbt4@^3Yz;$ zd!h_&e*DqYFGT6-(6i9wIdvF09`oO9WsaBsC^k~m(3|*ruIhN_(@M`CwOpyobE8Nt zI7Pi3U-8arrRHG*lZ1w*U2-IC5qosqE3Z$<`yD8~Dt7PaO-p+?w zoYhK^{7Np?Ag{S%$@2S!2*(^lXsV2BtIF6*6PW?V8-T_T*d0VXq@02%G%+hj{l~}E z?9c)7*VdjYmv>FGSc+q}BPC^!m@zR7E@ssEuUrFaqMWXZWgl;!kpk&uc@Db!nj7zr zq;3IZ-$<(c&m?s5^z>_)KjNh&4($ba1Dc1U?62v;uNrqWR7u^B?IAd|{m}yZZ;~d- zeP{WVl5;qb>rk(rddtA@J&d%$lk&&dYaL{j?!g3yEmrK$ibRSH>KjeQ=A9MmbUu_Ro)^zz6I3*IpDzZzRN|Dk z=`^{r@BX*MdT`oHljilv&!36$|A%lq7{TE{N=>cANFe(2PJh?>qai%zN&+}>ZLap% zB9B6$v&Gm;N3A+FL;0c82FJZATbWQmjKW+0!{6fb(hQ`9?YRl@U@kb=&RYQWwuk$r` zka+3evN~db4GMx81D3-c&DXNK*#F@ZlucICgVEwuBG(lJ;a!%)S`Gj!0H&f|*(tLo za7EH_sW@z&njZ|}rGUe!lZ7h+Fb29YJocu!recyZ9tXVbKb%>d(Bl?yp;&JHnUwkZ z79*+ju*>RMLDe>fQD4QWW7)c?1%;7Pl6ze({95lpi`;;4;*l?wqfo9}pgXq~*nPA#`s{w$ytt1_2a@aNt- zl`@`q#804g$wZvO;gGVYp+fx z_xGwmh}k()!paRrt^0+`+r$$W<$q>-uOoo`EQX4iKLRHeiN#wCzsm1m>0jEBWN$;0 zK`b90p<@LG5hGn#qK69qLMW=SZ3CQ(OLWf??f_rhDQzo(Kn0rL8_OO~N&xalI4yxa zZFA5EqRMaSEp^~duWx})jR1^wr6L8m3Ive;R{}hShxGGk=-sc0e)6;kAsZ+%4cK?w zalv3pUP%W9;1vuE4A%%lw;`cRU;t!qx3JAlDUIdkngDpZkuAeI#OhiNz(E?b_FApK zcILF{OPc{{r}bp?3dL=nvHNKbV@m6q+MOTOJ+5N~3#OsKd-3E0wRj?~XUK*OLnkb} zy$=)vUH4#|Qt;`3cZhS}4)_=5VE4KFA3WqiRa!|!R2efomB<{(@mT{{h8=o9qzA%t zR-o7zki}Z0LlYfBgW!~(pe{baq5i>Vd4~1T^#Q44@Gw-GU9n17jv#G`hr6_84yiOX zVwkpQ!E|oT5L%N=)%*(3(1!uv>KA{o5?ldL24lQVrDx{l5aaoQ&haVSh(QFp_Nas2 zu@eggTF2+n#PCT3+kUxK&CNaAzq&R~D2*k&CM>t>v==WCt4Dn`x(mwXPaF(~@~kA+ z{6j3>_vb zBWcgRBsvJ9X+113EEM@{nZI^eAK_#@<_576emC^P8?XLw^Z2Kb62hodo+Jl6rmCc_ z^)1YBUHsjeECIe`J#dvCcuh1-@^;`H)PF~jfR#`2`{o$@c|mcjRY%?42ZHsdpVae8 zpAyZtORo+M`fF55>v_!gg{9QV((u#W$V8kL>hER7(&`0UghasX)1OPXF^0fgzl+fO3wDjCzh~@?A>p*o ztujA$v`+&qQHd5$MEm&X^=(XiWvSB{!?!#VE=h5~rYtDLYD!AUiB>2@vi1iCMI~)3e zVcOULZDseCE3qQ*X2cA37WRIJ zD=Dfbwdi$MqR2LHh%RIUFn@O6zS#&}vr%ZA#GnmuJ4%B|7>7Fd0g$P{Px94Y+<5C4 z`VTkGsvY37#{po&F#Ad;C zJ&amGgp!%w60E+7U%L?>iDQh&Fh9B;B-7gXKdb!l1n1A8^4o?}Av_Z!_`}?Kvbwz4 z0F=HJgCz8kn!n#w(96F4TzFBk9UWOvnpz!$QS#rfQa)#ij`{?odJZzsoAkRH7UzxdIP}(bI^+0){_bqg;Hmy zHTHt942m)z+MEp<0*{846{X}9ZfdK(G+9k@TeUglR?IKguparfjH4}VN#D_!^&ILF z5q7jghhNXPo(PVi_=qp=)Hm30XLq0ec751{_)#*%v`rvB=s`rx${o&HZ(LSeIzh1) zL4Fbb5P6E5Yb6$3^_xPvSTUnB0w3TcN42&pIj|U3OX1BLD;E9q`jX;QHGF<_aT!fQd|aN&O(HkjU@wM428R%+3L(m=!RI!8U-y zh^ch$apWzySDvwS-l}HX$AdqP)E03@G&o3mIIl|`csN;6G8?$|!xAI_P z?gI{Gj=Tfn_pLS+PI)WAhdnC9ob^Da8k{=#ZbnU& z;%or=!|AOde*pRM*)K<-FFSl}3jt4rRjR#4hbpJ*PG48GiHl+Y>8_BKPN*tp9k2`!2wP6+x{Mu zO-Iu7c)2;LL@q#f^=mWlZInlMDE;*VxjyHD{>C4Vu;Yr*G$V+ZI3_cZ*=K%UcGoBN z(9M#eLBH2YOB3kt>^?Lw0N0xrQ4BBJ=gzkt+PW06+h7)SGfbe%hG^Qy$r*4~g<{WS z>8q8LP%N*UipKIQXp5w0D8du*QY<>dFq{>Uw?O-b?oZZdz=7Y=ZCEYIfESfG)X4wh z#NwS7&KIAF?h8fjbmwJ6VsCg{4o(IE2$qYC3zQ6nyeu9jF?;sOL5~#{;XbWxdX@LE zLPGbqSQ&*4tLtjD7sB=;)46Ja3{pOc&6Yj=s2CWz*sv#aeTzD83r+ku^}Tu<{6#;s z(knV91}42G^;-}i4TYMG3=G7U)jq0MstnzVoY$>Cbses<6g@`DE>QI8@ZNpYO9wJK zpQg}X)X+27;m>QwJlzdH5(9i8GFMVYtPL}}L4P-;tXt+2_I;2ITwav*C37n_Ay>g@ z#qvRmN_?L|qxQ{O9Sm$K{9y=D`wXftYj{g@4p0&Qpq=>v$FfWL^4FS32WuyOK=0JR zF{Gf3f4jQwRy~*1S;uGk@W)~%=PTdMnyk0@!`dn^^-9?y5So<6=k$3i&++YL&v%dT zy`3=5xGavl>QAF#g{=D@q6F)vRA?Wif-_$91&3g3IvBHt!xbdLftnT$L@dLG=ih0A zq*mH1h-LBA5G-5pzzrbka&}w_V}?#9V)C*iK{96Y}s?y`Z(?^j?y zC?_Pa{puFAXMZcn_ND>5CqwC^*YFvhSzf=n7X{+&6Tu))G?)C7B3Y7=rUfv~Ltmrx z)MM;*FomyLT~0~$o40iGN_kA7>jyCbfd7M)fjKCL=@Ae}l?~;GVQcgHfe)He+5yF7 zR+~%J^}aHYUnP}F2(p#O61chBgSRe-HvfiTi=>qoy)H-A@!dY%aHxoW+2=Go|E)8V z*AM8{QwtdiXYE)15uun`LXov4_ zU_J`h+;a;BJS5Od;q}r49zaYKIM7tFr(J#llFgtz4)m2M?F1jO*9NkE4f@Fx4hr_j z5TXapdHbn|!Dy%5)9`WrS$yA`tXMXlgF!F?GBZnLzohYGs^=cNDiF>5A{kVQgr?qv z(BEsK5}6C1+e&qcZK=c(5E3v9OzoIxUw*R@u8M%Bt=jhLInWUI4E=}6Ku@V;r#NC1 z-K~}t{c4C-=-P;X0T+g%pBwP!pd~_qw&uN!LmJ%$GMq1hTzN68GqF{)rBM*EpOsQ+ z5JrIH(u-D|Mq6A@DO$;nAD zMcBs12Ap<{ir!Z?HU~dHBYgN{5u9gMrsI`2Agg%NNA6VC2S+NdgNN723GJ+0UuP#2$V#V>VYeGgmFu3vb!q&1OxMU`4 z1)OyaKqA81*2^LSb1d#8d&f6T)A!eEFdx={oha1N`C2vtV$9_rhW|}ffSjwvHXe18 zkdP1zUpT(LWh(vp`LhDsM`j|7gHKv#eiER85A>@F`Q>s`?F6~nK{=x>cRed9n$`7s@GEk6pzA`lOynnreId6nF!^L#~P!0KP_+)QipaAi%EbX_f zXcG9aZl(O0Jw>3QEWJudO3H8-`GP6%qHxig0we0&?(ix|3TSCr8DU@wvK{bK#^;av z7V~JVe{77(>^jxI&Z3jmt-mbNoi}N7bU8~0ma0kH&R#oK+w!|pM`9~svXfLR+_y8n zC)aWMu17jM*4|IqKq8@2LoVq}MZ^MQVM#niVA`KDTU39W{E!(hJLlb%%QW<*qEO!C zxAZ>?;s-N9Tq{vOAr=aJzRxH&(P0i^7#wM||L&bXIMKn75D-xVA~xp`L53otRYUba zJfCho{3lW4li@Qpd^;-LLL4!;t00^qt#;rAmub7FL~&X@)uo700BojZ3K>*hT9O`q z{rGbj=rQ-fgkkwNZ~DLXwSuh?_21g5_yB#-Xo*1)6 zS8WN6casB&acO1p-}3G5F_5K2)j{Fi|IP{m9I=Iwv#q}I(qFi0P38&4oZk`_@B&#z zD#fVZlQQH4YVqhs0_lt{j^J*~l)^|du_MxYmKfWEmrQX(bmWWk$FTrz2VIpTCd)>_ z^ETHMFQ{vf9W~`j$*GDyINru_<0q`d`LJUgWd0;Bi>wqo4a3%V-!HjKH&)Q&p#e9+ z%F!`aU^X`ybV=k_U#~+PuQtk=*j>v81WjW6QA0l)eRN=?Qo+<_iTNfaQ23&DNz1xUUTukn&Zz1}Ne6RjGSIaJY z)(&UhC^lB#dH`q0iG4CoJ3lk-*VW=_IDVJQgbE$I*H|O`wAULTruR z^Xzx5O#^Ck0z&pI0^TYLu-w!S;Qtfrx&G$D2-|!e6(j3T>4G`>3vg+at6(WOAO#lW zg*;D-nau3Cz}pf+{kY>z3&K75ES4L67vHeMiSm2TkkXoG%;P8J zS9_E}Ta+R7sUg!KP&cbS9bAq+$vE*blY{Cgu>}4;XxS7a4cHmIsVWFsR zl6U^P%&e*5J(keg2UC9pt7jKY!h81}FW;EM`7IrE0{9d9#=shY)^21 z;)q_rm?$KFuCU-8=#R0+7}Z)4F~iyIuAS8I-iUIdTg6Ca;?lkUx&6jqynMa+jn&hS zbenwO6nJ@ndf;W+*g%U0qE#{AD;e4Q4co|olwrH-b~3;(tj%!OoY4Ukx^Ewk0(%k$ zmvi@ZJjlG`Nn-v(R>Z9x5E9!=q4tu3HoWhIDKmJQSg?@5>?|pL3Yyf-anFQ7K5)7& zG67wcp?&TspvGB-G-X+z=@D`&*0^O@eY$D0&sotO?1pAw2%RXx$0_JIda`;*{`vc4 z$7)wk()hti(d5(ivMONCfOT^kK>q2IlfsN|+3y?FH8aT9OtgSb5xzvIF zR3?c8U?L(1Flm@SYEu&Yalci*vz=z*a^uVm>Y%YW4OrvUM5zKc?Kn;!sQs2uR{T|e z3SiHGV1TCUmrSH^`@VtiqniBiG{ztVwr`F?DfTGVV97hoCs49KSovZ9afUY5V}Ha& z*ITIkzXB?j43raJ&+`m$<_xNC{fF_D3>_AAL#N{s}K$Sc9~{ zv%|%%(U5a~Ykj77(I8VMaN%QlIBKymSBfj3M_C9foE%m6lIaGy^dJGyutSORd!?*d zq}b@jswq2pbjVMr&ZHzx3&ll5s#PMrpgR4nWxnhW-!uj|PSIamzn%Q?9kE22{@Zhp=HrX8jg!^s%Eu*18PMl5RTj#u zMB%h(XfZv-rw@C+ZQyCLEmL`KuP0 zza!eUg1O|=8i32>D*-M82ynqZe%hPWzKs)`C`q6WgN~QDICN`tDTZDO&(g{F$Bgqu z|3|niai#OEKFm`p-;fATy^lI#mQoa7L9Ghh(O5u-aA0N(!5gR7eAH#V(~ghm9y060 zGMEqm$Gt?629Qw3#PUb!=~FWBG=H4H(zB^(vfr%q^JyJ)jVv<3dqPy)1AM)(}ERIWbEoeB#zXVc}ILh81p!R*0Rh~@NWgE5?!+%lQiuau}j|FaF zL3}`Cr0bjYWaLo3ADZyV_ffyorK9m(a*Sd&q57+UJIZxTkzNydDap;sM2(72Tw^bhJ%x|D_1|E09y#4JU%=Wb)287m@a{xi6EOu%EgRz)3X4xJiJ!X zh2)_i5cp@gfb78hm6S%zT|7*5GovC|eR{;rzz5tTv3-zN-`o2%JHM#sI6qksef(2x z?vLT|R}j;2w>|%FjA&6i@WYr;<3(SRs~_*ADv@`!s7V?%RGY;wH>Q6ByskeH)$~=+ zZg><3s3==n559SH;o0%s>eJ%Jb)zfh$ODiYULg;EKlc{U1MhUOn>wpe0OX10eWlhm za-n~IRQ)%n3=*R1$*7}H85Hv&jncCSY*-F>0+N|;MxDOUyZn<%d*~)efR=~^P8^)k zUzb;-q2|tTFZwrtu>yx7l;Tw$yK=;0U@0eG^B-b>>_ek~dbFX;liy>gFicvq$uc`@ zs^9{pc#d=MK-{&F*|v_}K5we&vWT3O%-kFuWHNgn6^c(6&eUM=vuUq<68S z5SLo5)?ne#;?uPdR8m%kUEew(xg<+8w1U>(^RGrD7+qSW+@I3GpHl^iM0iC#N8ilx z31`IzhS|e+>kSu4sV;uxshz@utBEXuiT{pl%xIn{wMEM=+gB>`|^>rmM_8^KFOaS=PP*H(6zNcyaq2>tB-425y8@jXCf*@{~%{9Jm z-=V2&W#|9tZHYOkm(pbCmH}=g9eGB*=hRUTgr4m*2y^UKXBJjx7bP#6c8z?I8@4ZJ zF=-qy@qhOIEX3xp#{R<|l3Wppq@hIG8CP?v@6%y-g4yA4o%Z5wTz25T2{$*lDQeXQ z*vbcm5+oppjRW9hUMcJ|3mvbn^^=NR7!12F?hPWZJmsv?u#x)7l{;JkI+mvVcv}}0 z)OYyHl@~R>W6mkRgo)l@v%hVm`S{z4$58lN-@@-5mJ@FpvqX>gecb z&2JbQ+T39N?|pQ8yOe2r5SjbMczV^+l9@!nDOzf3>g5`4s2?*v4|yW2_XxO)-2(#y z@1;T)XQmNDs!1wu-9cQP?yb}v7O0RQ8HxR5ckny^j^mLsI~9lY#8&%m99As< zPJt9YKr~SJZ4^1-gdBZH_$VG>~r)O-@T*9;g1*58g zYh8!mK`=n#_aXI>gGff>ITe~7os7y9x|4HmgG&}@FWQIo}<`^k^m%n z{myq0f!MI8OWFN{thqvyCjdpf`+)&rO<9Lh)5X*Jro5gQjfBey z11&NY_Q?3f#Nt6;2m@%W0*5a!<|X%8*G63_S~!qF1|afb zE(x*>oZt09)96h=NC!AEw>R$HkOy~X(-a0^gHs(D(mbtdr@<&M1mw_{`JxjwgqMP{}vM(bO1*J0JkG+ z0m-4b+=Q|=wFn$0wDAN&TP#3JEIVIRE7XU&zQ2@@0DEy>o9GNUD8xf6!D;_?-Q+-0 zEcW3%NQzPDdy^4CrCD=u#@n!6iyO;+wz3pdPC*+DxZlcMTx{Z!@RB-;_~Y2~N7(X3 zY?k~UK4;epQF$bBGZEfaUp+PZmxh+;6QoDDVp#JoVS+;7X@?vw5gjmvfjNSe)E@zh zwvu9j-Woa3tb7TSyPFVcHhdl4h=_>Gbtt#mLa4ImRjp9%k+Wh;i9^7C$P%YyL-hZp z4gmL%eX|;r=y>_RQu3cfF$RZ;_bWIbJ+VX{uT`A>@(FVRwfo!| zyrfUhyv1_9-+e&wF>ia`_{;q3QefoZ$}*z#x0n~|4l7>^pAw0=9aNJJtHdEd8X@H( z2fKc}p|jTR(JA9apZd{t9(}mn&@Zx0vD3&RNCj#nunr5olhQymzt!kbqY$w+tZ$PU z55HP=)t?%4sDQLKY2mozu8mY)lF@StxM-^}FjtgCE0^+0BzxF*OD@?kv9JW(=>B)o z{2g<8uCuJpX&m*;%7w7UGi>XhyB4sceCPafd{@k3V;9X~v-kLCA#{tV=~~v6hJtE0#Q>_V?z@PZt^3Xi$*U6D{bv&=PArJ z+a2ovyJ-VdSSMc5Re`2`jyvtFpEqj*6E-_OVpBk@&mjTYs~BUzIDL8Ak$SnJ9a6U} z_?++nZUSWP&%I*^|Jng79ofP;fG?L_YOKVspFTkpzBN=-zDxg6ca zS|r2mzBd2-y20cfS_wB+N3#tY3`}Qg_&;{3U%& zTAsu}xN5oW#VTR>u`IB>ZEPIzABZ2{IZvYPuup;9BApO0ur_i>U7x}j&INK#y%*X=WDPa ztZ^o5$ec}FFY6aKJ>$Kwuwb~{`*dsy>qR@vpjHN@?lmk01xg)H!<|N$6S_03*YsJ= zWUHbttf7uUKT%ibbtcQq({2uAz#KGa@n)at?q6K1Tjzl=v>g)lG^K_%4cL(}@y|)P z%^6E?iM4^Z8cA>De@XOrDyHFIsTjOXiQEs;5tBjZqBjvoB=PcZrTc+FKXPWSsP1h@ z%)(Zt4J$Xh`sZ{3Z%~Qe5P`S(hNQQ{D}J~O6lHEfR4{6)hSv7k zvx|~ZvlcJKu(*2V^3e!`yi&{Et5ew}sC-2NvRR|AN^(u>CxZrIQ~*kznBVe3=w&sc zq+)swts}ow-N44HPs0i!(gs6$t>4kG2_lNJr=j{Ek!PXA?lB zy4T#4|86iFQ$ozj-sb^@#*2;f5N~Z&KgP(G0+Wj*4IiyEEcQA37C3qOm$9&MaU(XG zCk$wNcs`pHdd16Gikg|3Il8#$Inukq7SO(86JGm0mykSuGj7f4BTPg$84z)+$n7#0 zit6=qt6c(FmHJ=7nK-w^u!Q1Au?L-d(*{ECa;Mi)2lZR`yu$_GRA?(!)-M>{jMAL0 zf9iF+39%+Nrb|3)>Ca-j;}LzsBS0MK$mJ%@V~@j(io28FqEX8H7_)l~xkQ;xfCso% zfX0I&$?C?Rn^sUjr;i@WZC#%^208&iZ#`;iKC6jfFAdTrEo82*NMb@>{c$2vw=oR! zitauHT>~8GVMu4*S3fs16l8SKbSVG`gQcQ2t4gacrx=ACATGL;>jF*r!;9f>UiEhy z6mLDj0SEv85hi(mk@(GN0Lqv9Vc*)>jwDq2I|v3m+)ZKMdfJ4DyCuY1Cs2wGU%g0= zX{;3BAW!&&=nH?&_aGcysnR%MjLj!Tbm}|12l@fUhG6D-gvH&@47DUr`}nJGl>G-o zl~S0%)nvl=1a51Cdpf91t{PmHyp1YIorZ4=yXebXDwUo|V10PQbc6K~j zuGkSs*QNnRa4(cZ{b3hY4=A^N{~TQRn~ybawzNFVe(INqeEy4!nJCkJYzj;KW>RrKU>~W`$VpWqL56FCM7PX#q#8qn= zBZO2!!k=UG0hu{QZ?}eht5>)pU>Zs0YMrtB1lBAC$HT{0SmxH^JVF5Mg?njU-Z0o8 zTFQR?gDdn%XITm|j}O#52I4gXLTDw0e2IzycxY+BL&5H<<-7^$n6Saq>|9;7R7GM; z>^$oNqhD5Omiu6B)reZve`Qoh9{oXVk8@T@IrwxT1=x04>fDzf7;=r2cjgc)dbb27 zsc)9yl7`$-?>4fTZtgE~*Gw!pmZ=0Vr;{PTec74LL}3$N)1}oFmx|XP=Tt{=)aVr0bP@b>eUbkb^Zc5>&LQAjWBYJ_ zY2>w&Q|VIi@JL?`;ZPhQzIY>oXC!YU)+_-j9%*%T4HNY!X03rOmQa2S6PHEaYt6@q z6N-C|m1mzmtQ-y5Nd}St)dtDl1=`)UYOfYRb$ODamyYtia7f&Zk z1dH>;jo$+TFFp=e?|NTfd|`^?s{MsaL(Au(zr$35^mKLo#h8) zjsVrRzS+cr|ezx`OudzKFUf_u=n9}&}h zbz+EiGX-5YOXF#oc6TZM#g?hhtbIjv*!z~(E0i;$+^j=)0Y^iT0g%+L&5`@?;dO{2 z)WfWW`srrC&F(ta&89Belu#vWxP0x>7l?5UQEG0?Yfaq47pozRE&gr_&S0`CNOu6* zF(!~`#2H;vv)>wBtjh_3&GEvGFC3eg_&GZha=oyE00#!{Ny4p59H@N%uwAI=``|$t z_=d|>cnV_4^t7^QpEtd_Z0Rc#<=C~qtciJ&rsTpWn4(+%I7j0=b02uRLSkRyPq4FT zLP{L)^}&c8<0XSJK^#(41=UY3XROE%mOd3UY3BhtNHI6w!DQX3{Ei)b2^?!|lvNM# z6+KiMjms5Q{jD!f%J(g|s#$#^F&B-D3r{2*R|{s!v@2#T*>KgUh#?-@PaJ3It^ z2Jmp8pGqW0^GV02So_nY#F&u;{Z8(qr4J_ z0zle$y9)8ok?3fP##v7Z(BLY{DNP>J$yuO`U3YEs7>d`3G{K4G$wjpjy=;|`4VQjt zCopR%FnbBEo9K#;pD+`Z<{vB5TW#yX37V(qiZMKVuCKK^J7P^h`4YjDSJjG#yJ;e} zMlt=X4?SNj$4mH5FyRTwRP_gV5g;Scf0uxHX=$9!jUbkz37WyQiF%u^y~rsjUR${T5Uo$hd@Qc9GQ^~xx48-%>G?8$)II%e&yUc#R>|q?YSOwQ7#S)~ z@;Rh54k?C_u13+9JxE@lmr-Zbr&ffv=(FIYs%wP-2ViZC1jd4`T)dPvOnIXjBPDJo zZf*x3B9H@P9&44Es5^5^8@jNSV6YJstP zojyGs!t1L?iQM@)zqYkiB4P1TX@?14L6c5VCY<)AZj%G9nDj@uoSyJP=k1S(LbYix zr@C%?n1imjwRreM!)s8VMVafWjlDu>m>MP}Z+~&OU`9%{r>r>V&Pr@jkbwf1tir@Y z`e@8q7;@P)fO-5Y+Upcc`Q&5@-;~VM9iO?2Zco|7);q}vIK@+gsVp-$^ONyTb0$cP zvEcetSMKS~{6dezdDeDxt+ipZmS+cMuj#W{Qe-Lx(b_7C2OW_!*ZBrmMX4VqZs_FMJ|7JXjbH`OR<|a*V9@!*pyGBg))h z0)t2Za`*tPE#VahTUog5ldj>rp`G970qw0A*lNQNoo+$~>4gZbzPKhM*vmp>gdp)7PsF}vzye+zPWvfrr>=dX2E zalpTg#3&-@_k}pzHax2&GA4Gx_p=*ZGqKfY#)LS~oMbD!3dav{Qf26(K}r#W6vekEwIKWsh9(d$PLY%kOJcyHRf=1b#Zx72iq2 zKss1ly;Jm`rV%-0@me@={pGmn?99)IZ1R?3JAU_ECSIW67}UPwn89qT&}SOaA=cw% zt5G!2V32Ii=c`$&!i!A)^PFG3uNT+;t^+Iu zB*=4->ZnTQ&n>8`Hzqof+RzH+x-}(}sbeBB-`;56NU%0#&y*`#}p+lKoC_ymwSQ|iuUe1En^QflAD6@0bc2j z$oA|aHvc6DtMABu4q9vh(M6(?trBF#ib72iC!O;vTv@2%;k49-y7k28Kj#x%yY1%1 z1J&?z-*icf#f*tdBpNQ-KZ2C;e~kVNnG2K`KJjptC_2h}U<$3Me@-yPj33%TZy3)G zj^JACKNZp7lZ*4JkI3<0>Gs%dlXvyEmi%Ym&sXhu!5))S)geZZ%q!emm}C_)?6&u0 zc`7FRzR*g@1>7JLnX(oL+IV#^3|pmKu(?UZr7mr(%paA~3}~>>XY^Y0CL|`p8@XdM z=RV;OUOUvpDlBw;_C;td(sqjNxFhM&dL0G+O`A6!F*C8b7qJ|J)=^ylc8Q{I@%GCx zb+y<1hlUD1W-PI;uyaYaILD+?Y3M|Fd}apq$RW@A=N6vp^|R#FWX>&5Dsvp1mJ=rM z6S~dYDFVy}qs`@+Qwzr`kRJ~5@DuhQ=L|aBYiCtbnm;fAG<&wtCaf2Ek%ofcA(RtN z#g2&7`~5pjX*ikOM;K;v20D_}1m)lsIQhqqDc~fY3a!FM=ODnDn3$;4cwpDeGT5HG z8vpjl_E?&cQlDcs6;mlDaGk5I5u_@!tr;rpm2M~NJ{q}Lv(*0`F0TT0bzckrQe2B5 z(JUT*ol-rfPLDRu0IS%1eeO*?)-|2a3J}9Oaw1yhRRLNd1pv(b!gCP^b&TSMz$B53 z*iJD2y+hN4R~HQws*V<=OL3D@k=8SG-@%Pq>+I*x*9Bg1qdp=y799M}CjBHMaXqNv)> zsMAp`B8)F3bZ*R%QdG$WBU{E5u!JSc^@m;I;hOx_FV}j-Lmc zLL;lh#XV0Jk>9yRfvo|qh)geJL-uV>vUCIq_e0ZbcY7V|`=@b_5K>jK8v-!x9Y{Q` zr(>U&FJ-!tn&)CU!6R4p!jmy=Ye`p+LWh+V=oT<`*% z6d(XIgG%nOb71gF$Y5iu-=g3mV${kj4aywry2)(%HbS0J7v0AM`4okCYYTFfdOMGy zH&JyvdS+)3pqu_r7646aB>(mc&xgoTkJgX2Ju}OnA;!GJIuBh_h!@@~9fl}sz{r6M ztbn4)9(;Ho7I^J@bFrJ`arOyfRa1W&t7GjkFtz+ko9%31Xh>cW843>f#r7Pt%8osz zZ{YPvq|kHJLX7z)0-syKp6Jea(zb<@t`aqNMA9$5>^!eL? zmC;3WK@BkmKmtte-Pp^!y^gkUU~!M}xSFg8pV;2-3`i;zy}zey{6XY*RE?N-u5`SX zCT=-JDAHJW`Fh5Df<8O&QH9-Ah#f%S*Tmriim+oha+Nh+4wMC65s_Fu2Tca@_vbh$zg^e+ufbjsZ*rhETur=^`wlyV^55v-K4vUUr5z=VGP@x28nMGMXP_GFWUHz`Hl9 zR5alH{JT@FIB#-SK~FDrY-m6=U5v!Ykjy>!4m;p3gt}Ky-+0`Pb~rU(+YUkgeRX8X z!#0#Abdvo~k>fS+k3@gyK`0BS*erSeA6r)eR^_&Bk(LH&Q9?jUKtu_pI|UT!6cLba z3F$6DLP|nHr9rwIL8PP`l#uR*H#eU9-o5X=@8jD?_fg?rd#yRg9AnJ6J{48Ghm99n zgCu0dB-LY0Os#jLF{bf1sR7mOXB)Wj|6^p#*U(C0!;oGM!$HUyCKjA!0)No!*11PiMYJtq8E z0V6=^D~@pqDOdc{&39+{bm;J9p@%=$6DO)fEKQqfvLst>X!?fqV{J}(?_$=Y!kGK% z=8-RQ%*34_=^Kh%rYbZiO6wAUgNk)$x9s(5gLf7MebPA(LOdI`%~Qty7~|mTNnNb5 zhlcNcOgHC=4(`reC0vdRp8fspZ66kA>kD{QCV2;tiFE-`X=I4H8XtUlzhqC}SY;q* zv7o@kip1k2w=8gXKdJQW62_5szR+{V}x#aTs zpRecv(mV9MU8S5XyYF?9n%BN@8e*3)dr&8+q>h~twQczKdgNUD`26hC*I^;M^FkE~ zpBEI;^h7w*@l9tZ5h`_MMj8%S6>{85HLs|~?FCQC)#j*^UJz_a`KlaxJBw>A50l@cI!n$Lpm#&&yh!rRi z$YY&GgdjcB3791K51NsXKPkRhWh-U2F+@A_Jw98rxo067oLai;bdfH-c~K^D9c?r zF_HC3xKV(>BceW&^oM)tlyey=vy;hEzG^R;K+xmV;l&Y zkj5r!;NQZtw0C|kW>qmAj@$nI;~%=-Wx1HK{KHk%I7@C=#xYmzDS`B9?WV4CS+jfe zK3;UXTRAOmS6h9Qjy1NMgCfs!XwDM~jNfOSq%r7H;Z%(e;^YwYgk~hLWRDil58H*B ze%l%h=j$ZC!37z!zu#M(4Q~C0X@E~EnzD2LcP*Nk0S`!wW=s58y<>R~n%Nj6RGFY6 zNnL#6HttU9XO0F{zbWAl6-Y)KHymdGvY(pRMwH7 zv`im64V_V`r@fYs0_Ev`+Ms#WyBJKC+;C|hvul723G^J0bimN1In{6g!6=lV(qK}k zZ%F4F{LYXeazX5gp$9be4h!7v7c&xhVfN%|ndM}#Sb!d?gt90{5KVtL)O&M$Ljwao z&`_KtR7LVlzH?VA2gE$jwLX+U#V2Mc8#~0^zAnw<0`R@^z|}^z>4GD!v~y zld!Y1YYXv$&>?j4t@^O4CuIp#>5HT&S#~@uywU-oNWdQ+CPPU8h58E zZ@#jN4j1-5mz2=JHDNiT!J^wEe|=?Y^hxlCS*9O4T7o2p3;DmB^62%@#f}_-(-Woh z%4+%0OE=EE8>~Wy|9U#bjCd5x8*cDpVU}v*R!`QgzdGFAPYP>hW|;*u?MSJ$!qAzHMM-@MT;8e1sA6In?;)@P@?X2LZ$< z$@TY>EPpl3UZKsN#AkqMOax#dlWPJK8X| zCg>1pQCOLH%6T3a2M=#GsRvda1#GXEVI@6mb#`ik%Y0jVt-#O#)@$t;r>J#}1 zD*0E|fih_U%Aec87;kgfJNvbpIPKr7r{>8Zk*Cpv!p#{Rz6=8<@x&L4Zq&LqiOL%9 zT|Ew-xePuDb$7gV-t$sKemb$mXsHT_fvcA#mDedUCAQsN@qDMNwHeXUNy}d=Dgygn z&AlJ&5GZ89HI9zScJ%S?z;mp=VOB~xFff4l1l82YWMyUbOEs?`g@lAuIB(IpxVUf% z2y|a)bKUbgdn0tQ@rvZi@bPiUn+ICfjkMEcZ$g*jnJg9XuxjZ=^&x~Cj?vFumh*kd zWss`Fde4MTUwwJ|@$&be@hQMF=XU3YT+&M11bdcqv+%^E~655|3A znnGj34O2ajp&sMJJDMc^j4gK;qerNU$~!t8*hr134StQiNQ;Z+nvjl2e)wo)xL4X+ z2oo7mlC@wFOQhA+-Gr4-ao?u^2c`-s6&*1oIDbw$~9D0W7qD* zE;8pkY`zjiAAx_)8Er)d;77|M>s@duO}Sjpbn+0Jjedx%&n>BNeJ!Han{+&IkRb>W zZaKT@OzSBtNB$JEdGh2M2xkxW_FkT`i5OjjED_B2N&c0CuoY+e(cW{uqf?MXL=kvC zx6_1sE zdw4+_+8GF7e*(`v`)S z(Vz;;N)(h$9lP*Sf@eU+g2k(OwjnZ3m-N&wxC19RT}fHl>(y|6-pa|r`efn)38A>< zH88f7wa`|jas_cyo7pi(^OM5OBG>WG16vJKO({Vr2M?Wsf&v9TKYgJ6CHwTj3^MWQ z=Z2P+j~J%xR$Nsrx6$zov6b0B)Bk+zZ!cxUr$>WX+p z!-r?7%XDv&-<7Be-p{%<}!xpqfp6P5Ni%KJYRhq)Tl>|v^2Dg98_O@D@_lO z_AZooCUPt^YOl81wz{qQ;pVuUK^Xjmfs}zH9Y~6R=tY*=9c`f8_YDsEou9usZTC2p zQp5;Q|38d?dDCZM2`?(rEKHYLyn_0DmmlAW`#vd*%(@WPHsWco3E=;u&{0zv0`UrL zz2I8dOf#!f!e-HA)uwTU zs*9#sPM|d4wMLhNl9Ezdj+6FMn{M$3v!o9OKET4+@A3CQqi#RiSpI(vSN5W@mk;SUsVA^3B%Q4t6V|7!hxV&>)kYR}g}NkUyge9gX%p zDLeYTb3_o*ssyaWChxU*kfe2ZK4hPCu{{2mQ;#>*Gs-ev^2D1&qaW0Q$#P zP6i+xlf0;o8~2APD=c1(shS4|_qLHwYtpC$nw8z$98lHJ=v;(hqAZPDuDZwtM+tDCs0u^%R4{4Tu3?1x7|Nk*xWX!ThY- zIpsrB{Ffn)hPVlCF!9HaBT^e_)08lg9y~>o|20*21K5Knl^MB8Do@dTro2IM>f2j0 z9bvO1PGl`fmr(zl-dK!7PxQ^-V@S27LA51yZAIktNCh*W?!K;VJRD5gYlk!Vks!Xn zXF2r8b4nipS&HX&??)yNu^?NHmxU3B1!ZXLVVyc#Q3zs9G^TL)DH%MS8~HJpRJm`< zT&%_IzoBXLFwO7|Q!k~juBHlozZ(Yy1K5_CU;?9PdUQa-M*fJVd@f(T5~nq_DK;vbdY%TJg4 z111BW4~Ey|S4*hpn#f)`C=(;EzuqLgge7_F?2|nv85P|-83RKKCSqb^N&;?FLjvx@8zVpWj!j|_j1APL;Wm)Y0! z`8bEBE~~v%d5*^J@WvBrgw};GU+c296&qW)I(p*tU5*LqK7LLqRZ+{AQcc6kMs7YV zw-Z@)N)W#XiXh|@oe#_Ojk_k8_-z}Q@WcL1DusYa4->7fJ}~s^(1FrS@*^Q)WJd>E zgLzb3TtFhqn~7wq&O=7+s(+>J`l>LGiyC~6=oOm=Q=-$cYR!<`_YZ=8=BnCfQ<|3I zjS)QzmAn>SzxQnH$p(eLh?p1(KRCCz8qK z<^Go@>V6jU?{(t!?jv9l31Eot!M)@<64(=whYeMB0T?HglqpB?-%4-V+$IHt2j$BZ z{mRXQt#)dkbfze*Bnjiimqn9B_&&|G6yv^K(N6o(bdUIaJnZq(oSZ2OD!My4)O&vZoI8i{S}VT))-0hAmbj(2Xa2cCR3QZc}jk8a|_ z1Gx!O!v;~0^&415Eu2vNF5t?|&HeV8r}(o=;MLDE&`1l246bBv)PFnOdTS^<;}aH; z8%uTj6jgNsVTN`L=A=%tinp0mxxX9V0SgN!mmB`UhLCJy@NfV&-nT(`Zcvaw+f&zk zPd2!Wakt05f0QL{UH{FEbR-wAXy%*(;&>|5)`vs)FVP#Y`HD3PXtoe*na%oslV z@Ex=6+Hu~48tvTJSR`f%pS&;N>u5bIqU}q1)A3H);L`|uRlFinYKf}Ik0qO#nJZ7# z3hMQni`l?(lVUE7({SW&UL}w5n<)OS-s=Vs~&ScLvXje zgG=H!9L%}v%gWk#rOyJ?^j`h=(noeP0t0SCKV=s3H#i)fXHB^WG7~rD?*X4{5YhxQ z-abkp5dEv-GA1|DLiUCklQ_+(m6a51tW(I3897;Q{c)sjU$aK4ZRAKBe6Oth3?)I+ zgEj9jxj8pSB%1aU)r0=k?@e1I^5pOrlRz6wXB@r#)kTw8rcZlLaBKJ>!mfjOuMZ1e zAs__(dHV6GB<Pnz4zR*)T$2(%}9U(BZ(vX3dKO!xN0th-v^;Vb%J6trT)0 zW3hA0?#owW`787)eSWZC*#v=bz0MX92>s4&5P0^}coIxNcH6`N(*tAVS5M?ixt9hc z*r&Gyo-$%TmAv<7<-pkDk2`F0@Rh*^2QX)nASou+d~LejqUHXB2ZA0trXkuj* z07?@eKabA0G+bebUC{B#9_m@cyMk8^TY|3rO5^cEP_D%!6@)dOKUlMe?w3e|HG{pM zhU2k!P|(TyBLZlYtRCA+4DdUVY$!&>@DgB%^Xo zJ6fgU`%1gd(ZT<&8$YZr{5(99U+YojjO|fLELy6N81>(hu{(VgsYYfRrreHQI{#pW zTfGWiNhLvd0kxd#rd09eWl^vU&>76F(7bWrV?YjeJTSF^)eWv%Px)KyI3b0wiugFW+@+)t2Fi^4`+W8t z29>MwjQ4;3x${0*r(sR&7IL2RnV}u_;c{JZ?tiP!jpZFGviC_Bj1ktUXFu$NyTyS~ z@`c}2U_8rg3{9B4KT>}otWrRRgAOb#IBdaTcZE&JdZHSftPhUd=vz9V^7S~|R@>BB z^1Qedqd$HcyK3qszm&;faWBz&nI=*;b$}(6=M&ry5~#Gq-YFNAsBs9Aw3~GYqPkU! zGd~_n1d)}bjuu3MtWP*98eU6^_s!&}lsGkOw*R~;9A8^Gt~9?|FPpT}9!5KdEcWcj zHSHj5iqCm@3^(L_Q~xT}nOwZSk7lyScuJUhcbN0{!OvN`<0tGN+1$bf!cNoL_-Nm8 zweG~`c8{evDuwQc&=%s+)=??95VnEw)lG@EHil2NV9 zxAU#7<>(Kqi}|(xp;$Xl4(t9Ot^{If%r|cftsLv6&5)I;&7PsnoNKogQ$K!$sisH` z+Y95GJ#@3y?}hl@JLfTC(q2{#-d#_v1A7-1?JkT+k({*y2y2DH^`{voC@Q^9Pm#es zHZTxDKv8?JO4gwgz@oA0JRC3hFE2o`$vu8R3Sj*aj|y1sdu!q?Wb_qTGX$j{S=K(A znjBiTC{eHYrSWs*t8@LUqp|Aj zCQp=@$3jr~5(VY{BZe1#t)Quz!Ot7KQrj4~FdSjwy!*J!UylKu@c8o5n6KsK1un8% z+rNJsvNIzQ{Y@?%l*>4HgbBch4(mA?zA?Zf$*eAZuw_+C9zzH&N6^mn4Q=UPfpQV^ z8L}pb71t>$SazdS=n&e6w%fhrpIJn_RCrXcmw!iFn#0fv5G2ARj|K>-*t>g;OSZ(m zYiViq_4O4kpb(p0XS7WQw*>DEJh$@~7%oDG$O~q-<@3Sd*ZOWjM(Gn*nsC+DiD!#K z#XqX~&Hqu&EB`;$d^ToS_fn3zHpv)S)^dg7j?`(RfO4|g(?q$npGBVv3a0HhrOE>I zH1eJ$J$g+9c?PBy1QiIq(xnkGqP$e{RQn03qHtFoA3{HoUV81>E$#JV0$7HIgyO|C zqmu8rDA(RKr!yfj$__({$s3|$MK}DgoblgWjFodsqYLP|{{HCp_V%fHdAMO=VW0By zpw-ZGcm=JQ69OlUQ*)!`Bn`>aT!mphCKeX(HocN>hbghdY-TfuVNP>UNlJ2aOE)dZ zYp9P2hkDJ3KWl&hpgyKuhF*Ww>EZ4j%UyRgKW!gbDfj+H+Mq^i(d1PplDpOaquu^jWIqorxT6D3*w)I5PbwR~HLr2E{$^rhYYB>_rmsDU z!Jbuv;2zP9gBn{<4n3mrJIGh*-d&aTG!{41x0S-@~KF zQ>~YhY7#YnfW+Gwf`4bzY!GbDZw?QIKllG3)vPw<>~;D4qSEsUH72gVxdFZ+9}%>? zXI_<+5)(LB)Cl#yNze7R@tl5{c_V3e;db=u@GRS)q(a#||N9Zx zfQm0oIL`cHFJedsA3Y9#x~{<lE|_UUV&gMGf)Da-oe{9|<9 z9gT_7gU5Q4P02wR&mK*&S)=Io^B|9wHVb30*tJYtIx(;$L)3KL zaUH95diMa*&%jmGun&MkRcG68X|VlYAb4jDK7Sl=rkp-vjAcl9EWWOiGw8gy=7G?W zYEs;Ti#}Y5UZ0LHn;`)9j^U3e-1%N}KDJ_dG9Y-0JTa|HZYg+XSx1VZlRWNi~Y#FRV}YkNY284IcDUvQqw7_XdCe zXQmUCaC+VkbV1%Gu9qZ59-qhKz|EYjEJnde-kT9GcWbX2-oNy0D-wc0HC}r| zF&ky7BxTNaIG|9Y44f(qyTyU`>G3!&b}|XHFUTBL0-R;TAt;^;7dVhf?%+=wg#CsqVzeTc>Z6%atnAR zzorQKO)1ecG}5|MlIFs_n8jS&^~VV`*O9>;(!1%fJH6fn_B9htG}AiN%omJcSw_V^ zcb)=g3HuL0BGt`+za+VaDTS_urQ-PXUTISS5)Dz%Bho4Wa%1D;&^GZ*fRD-OkR%SL z<{9U(8GQ244gyT9daU~#y^x6cCCwc9u7#3nTXToCtC5yES;T0SG-+P|Vc#rWEu%TM zCe73H+q}MkOEqcPhS$}0aY5u7k{cR(>wM+|I848EXDaEQk62e@GB<=h_ztMNtlCM0ZHe?yYN3$&BE;uP?1A9j$Q9@ zbfFc|e(Mm|jguU1rhy@MBAn!I>C*2_1SCYgI~@MUifz4TwFvG2@2Ra-*T9%YYL1(4 z?xOs)CDg7Fc)RP-a+9~LX+0O^kl6(buxw`IX){J3jy8e_X`C!z!ZrT6n%{VShQW1w z&RX!qOV~5Tu1lxuU*yE^0<|8j`c7^LS};&-f8P-*llzYkbn0rT$e)*t9^zI^Uz{Jn z#Wg0DmOqvfu5Mj6nb;y?B~ogU4YlK{_59Dz14~#*k`ku4yH(&6lgLd2AMY1Y5=Aus zEvrTEkqvHmquw}#bC@n9lL3T$Gd4$PgGLyyfwZuYK`Z-2PUW$a=U~AF<Tq2tDN7 z5}z4pD)8KRjAQ(zJ9b=Y=g94p@j8!H+`Eb+QY^pTv9V4v|G1HoJ8qFaiQ= zB^*QAJpB%Dg=%jQBX}#`0nyQe&=wKxf^57lJ@gwJY5zx&#*`{g{&ZZC+rbPH#xT0a<+o5(#`g%Te7-X8`02dfvo1NW={P=*a;LY$6(|!fBl# z4|>Ig*F={jql=q?Y=hlB?GSBPpw z3pgF5Zu|9}4ZQ7tQCvT=8DFoFtvq)&HHZ*OSm;Eif_OLh1ONQ#PNrYLdIOguUc^mr z^i++yuxdpj5uu9`G;G)Y%cS17^#a45<<1nZBiqZDc-KCQO@PCtU=CtbMv zP=Fx;IrVRfQ&uKS2(Ll)X#_?l2$~*!r02f*#OqN`gyD7*Rpq^{e&Bb@&K|p*wixh5|GM z_D8hOpAJ{-FSa~6o@n%M*|-&$eHrq^IkbGl03NaH)zvKPRJ)(-H?2*D1JyZeJM59x zugeiU^S07Y;&hsWf9t=g#n!*#N>tf1Vd;DgymM<|x^K@jK!J_JAAwazFmG zkY-X^Q3-w6WxO)1yRzjxgfj`;*&+{$uJf#)f(2HF95C%LY6w>PFF-3PR+k|_jbwKF zb9S2aNm7puBZkl50U5%tuyWI#Fr*a`AD%dm+TPHzIwUEqq3y94c`^R&ASQdavXFgd z3!W_{?~!ta?Yq0=INmQtb1|82`AQ%>4v%<>@A4X%o3njRQOg;G=&P{1w>O(~@C3Zd z?WdyaWJUOOCEPAgcxMzcUaTQZGwh1FM|k9E=gE zepTpk;T2uO?)2_PkoYr!HL{5Iv_2EKUjoiuA1DY}Sy}g=oL8hR#20VVBa0BU>;$WD z!oEecaIzhWftmcTLhCbWIo;b+54Z9lVvhzXlru1kU|#E~-`A;4RwO84xI8Dw-GRqC z+I_y>GH15q3Yi^&gTf>YN;PdDu{lC3d5ik0%#} z^bL56h2IpY`rLStNP-st5C+f^WFZ*V1hQ_mG2x791@cR&_PE3San^Jh{Cw8okC5_& zbZY$SwXxF1vRbcX%W!A^vGr@x_fIGdWP-_EQ=vdt8Q$7|$|mW0 zCd$G5;%P1#?CyVXIL`#hJOu36ae%tOwbsC5L4o2;c7+cCiZ{Q%_8RqHVUw08RKyXO zJr89?vMnL%`5@4Bu?RkT6(su0!uk8$O=G@^yQkpWQ6Q;*5Q2?2tB8h&i@TMLyt1-# zuPhvhW}(wre6W@PS5!D^p7`cYaQfz4NPx1kGTz3hUB$$ap7q#4ARX_o|JO8;{BNcS zM6ECSydm4Lnd|qgX(?y1+XnVvCHWgG1jlJx~{{A3nSg(HiqUBpdi_k0Rv(M%2 zDrOsvzJ+wj$59OR^)X(z&@AUlM3 ze5R7r*3=BX*y_@oSt)=vqI7(Ii>uJD@!QQhP&K>3HQ>hKR~~Qtf{80R9~z31TluJ| zqQW2slD(uh_}d2epIK`^K9#L2y|Oa?VqQIVeF4M2+2FiI zAGmjDTd)0^zoP-76s!{*gjsujHH#eCB(-1m{4ff1#-6`#Y%D=FTMW~iW+*xi1I(5k zCVX@F>p_f%C`jB4j!X`0e^ypN0fwaN_EvJvAvG=S3bBNj0r@#9r5eHa*w+6<+ z22Lfn=QXvo0O7KZFpK8Td$O)FphwvZkGJ58+u}0v5~Yy|!@>4R3`29PXLZ{CC38O4 znjHL%8+<#|0KR!0SIL=z=X}I|B=NoL&$zQbat?1}oYUbVfoTc4Y{zS>(d&-~IYg{9 znttcw6H;7jXU+#g@zl%%ZGkKfq30Z)O4{0y1hw~>-3Sd&e)}ryJvib2Q3fEB=9W@fJ1W_aB+??v;~arpOciARc^* zjOB!pADo4xo~pf`#8faAez8(}5T$tjdtL8Zf!GAIWym z^|{r)=AVNTxMl)1IlhbY`}4%m33t&se>$fn&++J6n4U?9E+HQxEfj!-LfCH;d49v6 z#zaLO|Ex(tWxFE=N{_2!O6)m_CB@Wbf|! z|24>Ic5eRug9XW%$4tg@jnEd zzFEHPR(YeqMx4vuVv`$On&d)y@>8#*yuhLj%$>Fq29bXKDYyaLm3W!R1^_h3-R2= zg%T{p(1NRQPC`v&qA)ej(SA(i92uQr92De9tk>*f?(5P<6Zgg7J~uSxAi{^ z=g1n19O~7I`NiJ7PpSwR4LBG$*OhgrB{iN8oJ`RiK48T$UdoMmE1U7YV$df(sL!E3 zPWx5U-04KsG%H1Pl$Uu3@;xW1p}n)Oniyh!*N1E$PMv2{H!kPO?_j0e5fY*vFS8JX zpC6{mG&CSV^aXsnJ8}T7kR*NwtfQkNfDSFsQ#{~T>gwtZ-HAL~-8|F!Z{7$#Pl-fI zeacc~G5%m_@3>u&6vQuiMMZDKVA2rqs)(d134eZ?UR6a2cYD74k<@s4`UsX0@z)4{8A%40yBPmm!5IYEtl3ha2{vRuQhA<-0rS>L_`o?{^CySY(4dGZ7t&&Zw`#%PTBLNIp` z?3B-|)8TU|9L)vI4RdO>F|SvZ>DO4sO<@&f)aY*+(3fk#WxBcD7uPpET|V11QAUUU z!4O?_HvAtc65UH7;O+`P7E@AS2!gp`OB+f zHGwf~e7u!Hy^Eh)c&-yfHT1m78X(Wel%g40d@ZOqWwoK}yoM~8^uBrXw#+K~9T6P~ z4Mkm_LRFcDVyx@ACkgr^%?1y)2{BqRKTDYdH6hf+%5&0#b3>`a=N zR+$sy4KH!B9(_)I2ph$0AM^6UNXrp3Xg+-b3zp154Y2Lt=K-o6Z4JF#GY?UkwcC6Z zE+rMO3S4}hp~4KsD?Qx#@UgTO&6sXn5-Y&%)YaxnEfvN2jTEHx5AhC zt*OV~=Wg%?ZD~lqfiac0 z4sU&Rxhg*mPmGOW!H(1Cl-@Y2$_KPbo2=MuT+(P*L_EPwPk^cPE^P4Y4p(;pKuTh=QAIqzd_i2qbd~^K2rDZNM6-&15i9$> zqUtJZlrlEfVI8Yrbe2P2#^x?WAQ>m_-%$eBdyy-00DrQR@i{ zw0c*&k3A33rnG~w+TXYxthrCbSA`dpjzS{6Z7q3)49K5#=*ty`!ywd!xNB^=);TE0&6H*Zua%XH-sFTN@kh{ug`uxKwspHY&v=3afXR?>DHc zhQQ}NJ#|7^evNwex&Tjdq+gr5Dt20%pZng)X3ex-?;P3k{w+<{M?GRu8Eh!P4$ABJ z+zVsH`dqNM=K@#i!j(!iGX23P$i?emq)7P6HYEDI=nn~3FiI?{5huA;`0$D!TbB|s z8Ze*eL-K23naMQqSB0ZCpAe%#pG+?|F1S$BnHEZID+b$W{XFM+llD?HxbcqGXp9%L zX7W9d8K1Yt*67NZUj6c=Q&~eJ{PqWv#(~E|wG0KfqSqQ<-TrfzKW{d#s5QTVOslNk z`N)`QvNDoPgL^> ziy=V00;Y8+r2t|BDCu=@*KoQb^Ti@gp^mLoae`g+p-sJ+Ma};PyDLQ32B z?>PC|mEL`Q$_Suk&b}(?^brL5)8UI_Jp!yVICayFx~@p<0k^X!uV&fabUk>M`sE8H zN8QM~xD?eL-7DR$Xr~VxuU(u&k@7`>*QrI9JNfESy0(48(^9hONxc!}N&FWi57bvJ zfByWLTE6i+;7bQSF0L|_zT zRSG(G2kR<7e?Dt4e=v8n*pmtu`bg!tJAXJB+p29dJLONGN%2XU`FVw~eX@0Sp$s~C zgt*f)GKlpUvmKuvh)L+6++-!|-I=mM=-m2}W3lvlInzSbEnJ*VN%g*5n<(^N70bV~sLF4i-|8*# zq`?I8t!IT_LRlOXS|g3DmDSZ%-+^V{bS83ga!;mUB~^#fV+fMG;XX8&5Lw9BwS_%A zJo=LK9y9}31+&-BpTzJAzXn#T_@>>$_ju&I@S>aQL~kKM;SxQSX{d*cpv|r}Bob?1 zffTZ~FXv-yyj@6c&s)~do1yf++xT<_V>;mUn}hbz8Oi&154cxdksp zo!5SeKrv;i;*BA@x=f8*_V%RHv=?hZ!}FL26OTQ@w5UDQml%GBM)gmAn(`o*!gJlK zb)2peeDdWBvig+j(CDa4b;NyzkHsk7G&2Ad(a6|(GDf}&Kky!2$wXdl=sfm^F24cp z%||H*C^SCGn9_vo1cw$sf~~b$*C+8ke2F{vXWFOj=FeK#rq1lqJyhx%j$*6s+`}8~ zzD_%GV8CNFq1;BD>B@^)dZPdJDPpdz!nl&ZWbE%3XzVITbX%U>NKC=Vs;b~}0~}XX zRki&=T1(TdlS+-Twc>lYWPh#Vj|cPyyxiPRx!B7p@QUy6hqzJMsN- zSH1b6r4znN%ezYK{Q6~s1{ddNOD_AB7S#o-Yikl*@!w?=@{FuwM(;8O!(~3007BG> z$7&z-fG?5bULh7(zFH0KYm3?72q1L~3EW(v|w$bY7^gsb{ zwJ}XdSm7LP{g_nUH-^W6A(NqS^!ukzoU<)K50ZFo9`&%kan^mP9C5L8ae_(xxQ8t4 zpIq8hL|t9Ug)}X4VQ4Hxj}(;kBwoMu+mqWiyCz-!1%qQ_vhDIOG_n$%>!(wGtH79P z(`{zBYmX?1Sb?YzIGU>KkLBeXHVz6_I7RT(;-4$}`RAt4KlpZq#m+pj(K&k4`DOt9 z+O!fZG!ye3kr#f1LtkvE1l>?0VkR(D{`lwbHJ7oF z>hLim167YHWtC~zHDnUkPJ-cs(8_e#HAYAarq>$G(ADDg4vyR)y#twBQ00j)W;`+I zkj#j5a&iK(9V$fNZjgG7^dWn^j31;5#0Crxjv|)hjW&$Oh=aWh2Gr4R7AupHn!dZ)?jaOx+^kK`kcSA7eLj44(^DmDt=Anlih*Gclr3P1~eC0pk&Zs zLdQDh?(T4)$FAV|rO~ZQo?*V%4g1Nxpz70JC*`u!&YY}Z-w??;qc(h?>N0S=!p(be0PmW#v|zX zOPB)_R>WFkkUz_W)*)$g3jLPpHoNw-`QRca!v}s^c6x@2$-4Ujb1qiJqc$9w1`Con z*vkbxUm1~RE)*mYN?ocR<`*C^Yds%qzGGCO^5eJ zclowZmybgWykfl|EQP8nXeuCPC{QOJy@!UZkA0Z$OwT;9wY(bjh3O)pB!Rk-K!VU| zV@d*k3os0tH0*WSGv(rFg6Tj31uHxO2%`4hcd9934dmxLug&lUW-Kf3HE4K^I;D^!`TCLmlJ8h?$+89VwfM zgv1`l8nI!r@!j%WR!j*aD|(e9DDn44q7`{$c5~J=WBU zMEwgigLB?yl>{j6|X&GkHZGd z<<6ZuyHn5!j;j$1D<~;&x-zh*;Jugf6EM#t(G)FP*w zh_o-`J-RrhAP)o8WVIxKOFkz*ziqVSlft!dWJ2c5BGgwV`IZNfTZ)=mNrIKL{(M(@nf>@MZ>c|5H)H1L6{bWS_%P%&EU75%;!)X{@255~r)k^IjPDLB{WckL zD1Sxt)?P@RD$L~dwhUtReywfL$H*GMkIDEh$dM6lGK2^N^fb`sOu^ncY6Wz_0vK@_ z7#O6blDO#%Z{_HnVZl6ZI7qMZ8AwC=2y>pY_?d7g94Ibhz&3#|*zd*k)Z{pA)4|r; zdv~=jb}2h=UQ^m1X%6el=eep5WJ$BK9}33)eCe>Rn2t`Fh)jNzXM^FYz@J=UKO20} z;kfpxOawdBpy`3>_8M*XVY$so$z8YV&F-w>E5r}3s#gX03_4(vlqw$T@dmuhRzf<)^MoNbz2D#m)Yd^axe;#Pmj3+`stwOv5`VVa*>t4J(a7SJ+ zkrPIG6bEa>OB{eb>M0AFOw?Q1%v4j&zHhqlkHHj)d6?etDjdU(DnL#5^^0*uK8q)p zkk;J8UPp>QIjgCGUZZ{I5o{V0(VpM7Y1P$-9Wb`>Vx5W#E2wD=X4>xC% zY9rvHma7gVFHBy2YHBa%u5q1zpmIQ}q^686X8J8lmsL$h7^sn-6t2l9o0=X%cnhNjiUj|~)mO(=xour<8UaC&l9mz)K?Fe>5d;wwrMm

6S)7x}*h^F6r)&l9m=}rBk}Ux%Iu@8|TL#=ibBKJh9fCV~#n-TYyLp*5_EDjEZm zr$q7}`5W}j`(F`->#{8kjz0J8x5(NK{rFXr^=j-;AfFYM|n;s_-pHkT}? z>*7b4_=JRl;k3J!rQOd^h&PDJ4FzT_A^nkFJ%~wwFOO+$PKrI94O}36Embwf^9K1FxL{&aMUZH#plgNzuCwmBH&JF=mel z(!Yc#nw$G#P)jYw1l5Y^*8IcgPup+i<{Dk$xt@knI96MLRyASnH&N%-%QNczKDN=C z!*Tw1BGe1-bZ7jh>Iii_?)nH01$cj9iv6%TM>o9bGx)+6;sFoIbCt}}((6Eg0~12& z(`CoT*oQn=0@QP#4|ASYHhFoW0E@;!#%&1~2CX12@^n86krd9gEuak~ATt$WG_fAy zwcqz!ztu8#`L$g1693=_n$-g#%7*{2I|_PV=UN|c_uR{}t@^P)c$kj%O%umURBi$4 z*H6k$cuam%*9b(swi@cg1YeFh2Jc`M;qv+?KdM%1f@Ot>Kz|4N7IvRI zazk7^JdZrcIzp+tPmh_BzHrly`PMCDsaIWH{diIM zo&zRT$6UOzeCygi-L)eQks~gYoE>!bmXpl~K>w8Ul4MsXEwqung>#>m@_FWsvkw1O zrx8&sQ%Zq*Q))eOt}m7|e!q)+fflS(K3;A{=K3t9W#{@W8;1>ePs=r-@bqi4N>$nI zW@$$*K8 z87`l7Q- z(+^Ws$UN#QFb~%xBP>w41$8{zevuJ&+}aijJ2*I~Gqpi7U(1h0Q%NfSn~|A}3A4_)fj-kty*jO~p;wn|E&gOQ9D$P~IG6b2OLyN5Rgx;`9hce=w( zq}JC{3-vSntryLYCk)-)X;cce+p<#JadfR)ma3PM(@smG15x2jO18jM4uge%ynYPe zgCFHY*yzhri{LQ?{tXcmc73e|jNf8%(%g+Sc$g7SnRm4@-avh3YkO~G8qQ*EUS58! zjOgP{4|*}g1B?Ez9q=M`i1GIQcO}9{GPRUxLlgzBcDNo1npA`iYPMMckN&ZRhnuEx z=jK97g+Rrq7&zN=s3$w2J`;5-8IX0M%gz1%P-1a>>q(Lq1Z$ufl$6x>w^mobnc29R zINBN;--1<2EQxH?K2ms>o694U^SAVbqzytpMwwLqoF0Xxji&#@T>;rb!_PW8I$FU| zRzeBQmpLDS#?1+Pm570*y7Kf4kqq6eCkI7bj`X-v8`I| znM|hhBT|?qKvq_876u?ePM=zez_+HS-6>5uXJ0bwVq(O17V3UHEwe;*yD0)an^C5l zQ4l8tnuGmI-I4e=Ur43XInK3UsEZBraI6(qFa9Vub2^-Igu~8+5kbX885yr8&VhCu zN>zh_&VytYHwgTYL{NLhJG%l5AMxcH`Rg9>b`B0k81gSn&`2L&fB+^!K5OO33OqZ# zM%}ftu@mO)Y=w2+lzbMm-=f)&jn%cn#!!$>fyCpNtF62{_;~V{+eItTfKuk!BH`NZ zkU10GdiKer@J9drG4C0zgs3&jJX1Z=q$7(t%AXB{t$Zh~)elGo>#B)J@UN7Y^KLg< z6|hM4(O{-N`*5Xt%g_N6b)p7NsiY?gn4ND6THe^`?N;434aXPP8oSxeO&vAwOar+M z-WA|wXj~Lfizyqw&ooh+@@^9oNNR`7EqCzd-RkP9H^t+-EKkU~N2M-t+GV45G@$#7RKk`W}8;E6f>(>6XAcjIwa@=u^huTZ~;(~#j5CcCyk zK*YDUx(czaIG3482DQYg+E({(N$}<-mInQNzcm$(e`@=SSdl zl4<*}n}p8ztCj!J5RU-O^6qh9ra%p~8msp7xf2JzAnScs;XW3z@md1Nxq^L1Y0yv! zeu&X0 zh7Oxka@OI)?>Dg7ryXNSRdL{WOTfFx+;{)XHPh_tcmOiG9&KIX)4FaycD?3&UCIE1 z-<|JcfvM2WBiv;7la+^@I}x7uaI)>F#WF^4E^(J^^v{Y(z3GJZ8ba-)3ynxil0b5D zAM2V=2s}fC$324E%F^s|%u$Gv-Ugfc$2wg{3prWIE}xqHVTKd1!vMO}#yC*jYnV{yy@~;O~iRh?k%W&&NLW>z}E^aO!g*6z?(_Eji#CQ z(k^F)SfNgMKA=^)lhA*cf$v`qQpo?n--(x<$3*&`&OOsa8XoMFFS-Kp;KW3HC7SD< z5DAzzSD|I^D0uUKHY)qac<^sBl++$`r*BP9+%52jPB;`8dbz|%9qz&{`TVuxgv^^n z0Rz28#muO&ExSS+%*^VE@z;74l9!MNu>?E{Y!CMjrXlP?pC^|Y<&Yr))gQ7Ki-!qb z8hn{ZoN3;AA`p04@l!#|q$x?zQQ3`(63vKukerW`uoc8cA*M^ zA4VB2b}4o&vH+%%Nj=o# zQcH~q3JQWoV&x`lsch(S1dQ|$=ucu)IL-HH_T7pv2>} zRpZ2bKYpx!1r9XbtX>-b><7&tbov#&{M@#PR_%}OMsG@aZFG8#%^5INpfwGQJR-*I z_p8;Z$RiSLbSMbWbqDq0mYj2`Cr3t<6a$(^q!MW*0VU_8U)$DQAZSm+`rC7*4jWoY zH?WwqB5%@f3$fcMtEed8$46neVC_6&AKas-_SX3QqnzTH3t#V7Xu~b)frc7?oyWvy zw%gZd1wYlbUvWL<0CmdTW>4=!&b!7!$_iJ-%hHzGV(+itzY)|1^9*>Cp;C{%qm}BP z5#8y?*QOpWK`L`Z2(cCl8w_mFQGYtp#ATBC#%h7>Ek@u693b%U%n!O24AtOhB4`_w zy%d;f^Nar42ea#f`bAF7Ck!;y)hjX{T=i*Y5pl9*0tBI;^+uv!mLRKAS|CT_^EK$i zMX_jv&|xdGae}{)7-uj-7s2zuX??sne<@MlKO9MNR+ucrZOb-Mw9NCxp_2Mh|z@=ydJ zcmX@ZGGi0{0LF!Y(l^E7&Mfqy3*K}{v8cP20x_bqX6f3P1;A2*SH6CX2LEb^Xl1%a zTuB8GGi3XvfnEeECBRF&F(0+$)?ICEY}AMb%dtF5to#qmaf^cOM@ zOJL+-fhh=iWFld^lxind4MozE$n3o`{o1Zytn@hGhVSp(+V;@@XaV4aKO}XR)<_vo zsxKt)ZzC~wQuzV}^Dt9dbdV8EtQM;a#c5?(qM+^VSS!R^VqDR4&dc`^+mlCQjw0nbv^nV8+n~S&GX3^iXx2YYqYW$Vql)La*8j#b9b+6y(${e zI#c*p*JAsjR}Td^Vgyi6&)t8>kp(;gfqnK)_+PmO>>LcuV|qx6jg7PrDdB{_ngU~c z9{25LECgxCx6Ye8v5jX;4?jQeOS&5Shpu=x8ee3oKgAe}9vI2nP~sI0j~W0fU3PIW z_>THZL?_$u$CI?uNXA!kH6TGlblOzi3T=TZdH)_xH$dN!t62Aq&YGR}{sNetPBE#Q zP!R_-WrQ{wQlTP79^DLR4I<+-dgBzRhj%x&{bn0*YW({>rLV*B=Qb+c!$hA=HQV;` zvQe0ub2^Z?pd|j^l*lKlpPI`G>+O+3j{si8S2t!jU`!qze0M45>&A)h=U?8%VSgFE z%PvDCtQg@Dg&zWcuiyb@k|oGyT}5=1A2Z*$62*80NJr#EQ8{!Ad*g0syuk3)R^GP0 z*y&4<4#&|Df$WIuqk<*yWUYRn3Z%qX9d zd>{q7HO2|-(DEt=`cBppdPdOxElh=M-ltaO)^b|cF$#DiN_Z4IJ85osuwmMmP`!AH zgiPBVm!1)hm)9FZUEjCjqugSmJetbz)&9W{@IN!1sBsFL0

lg~o~F50IH9_CjU- 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%0Drzy&3!R}`1^N# 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 z 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 vY^tGr{VFqMm1O3f`^+2;@9e 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 z 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

d-(p)1cGrjAe#-NhNqUuHGJX{$Ia4o9lSgF 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 diff --git a/docs/source/getting_started/movement_dataset.md b/docs/source/getting_started/movement_dataset.md index 601ccc39..6b2ef5e9 100644 --- a/docs/source/getting_started/movement_dataset.md +++ b/docs/source/getting_started/movement_dataset.md @@ -19,7 +19,11 @@ To learn more about `xarray` data structures in general, see the relevant ## Dataset structure -![](../_static/dataset_structure.png) +```{figure} ../_static/dataset_structure.png +:alt: movement dataset structure + +An {class}`xarray.Dataset` is a collection of several data arrays that share some dimensions. The schematic shows the data arrays that make up the `poses` and `bboxes` datasets in `movement`. +``` The structure of a `movement` dataset `ds` can be easily inspected by simply printing it. From f47d6e7ba9cb5af67c16fac6fb0d0ae4c550f100 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Thu, 29 Aug 2024 11:02:03 +0100 Subject: [PATCH 42/65] Refactor filtering test (#287) --- tests/test_unit/test_filtering.py | 38 +++++++++++++------------------ 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 1957a770..1bc59348 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -157,25 +157,19 @@ def test_filter_on_position( assert not position_filtered.equals(valid_input_dataset.position) -# Expected number of nans in the position array per individual, -# for each dataset -expected_n_nans_in_position_per_indiv = { - "valid_poses_dataset": {0: 0, 1: 0}, - # filtering should not introduce nans if input has no nans - "valid_bboxes_dataset": {0: 0, 1: 0}, - # filtering should not introduce nans if input has no nans - "valid_poses_dataset_with_nan": {0: 7, 1: 0}, - # individual with index 0 has 7 frames with nans in position after - # filtering individual with index 1 has no nans after filtering - "valid_bboxes_dataset_with_nan": {0: 7, 1: 0}, - # individual with index 0 has 7 frames with nans in position after - # filtering individual with index 0 has no nans after filtering -} - - +# Expected number of nans in the position array per +# individual, after applying a filter with window size 3 @pytest.mark.parametrize( - ("valid_dataset, expected_n_nans_in_position_per_indiv"), - [(k, v) for k, v in expected_n_nans_in_position_per_indiv.items()], + ("valid_dataset, expected_nans_in_filtered_position_per_indiv"), + [ + ( + "valid_poses_dataset", + {0: 0, 1: 0}, + ), # filtering should not introduce nans if input has no nans + ("valid_bboxes_dataset", {0: 0, 1: 0}), + ("valid_poses_dataset_with_nan", {0: 7, 1: 0}), + ("valid_bboxes_dataset_with_nan", {0: 7, 1: 0}), + ], ) @pytest.mark.parametrize( ("filter_func, filter_kwargs"), @@ -188,7 +182,7 @@ def test_filter_with_nans_on_position( filter_func, filter_kwargs, valid_dataset, - expected_n_nans_in_position_per_indiv, + expected_nans_in_filtered_position_per_indiv, helpers, request, ): @@ -199,7 +193,7 @@ def test_filter_with_nans_on_position( def _assert_n_nans_in_position_per_individual( valid_input_dataset, position_filtered, - expected_n_nans_in_position_per_indiv, + expected_nans_in_filt_position_per_indiv, ): # compute n nans in position after filtering per individual n_nans_after_filtering_per_indiv = { @@ -210,7 +204,7 @@ def _assert_n_nans_in_position_per_individual( # check number of nans per indiv is as expected for i in range(valid_input_dataset.dims["individuals"]): assert n_nans_after_filtering_per_indiv[i] == ( - expected_n_nans_in_position_per_indiv[i] + expected_nans_in_filt_position_per_indiv[i] * valid_input_dataset.dims["space"] * valid_input_dataset.dims.get("keypoints", 1) ) @@ -225,7 +219,7 @@ def _assert_n_nans_in_position_per_individual( _assert_n_nans_in_position_per_individual( valid_input_dataset, position_filtered, - expected_n_nans_in_position_per_indiv, + expected_nans_in_filtered_position_per_indiv, ) # if input had nans, From a98ff451aac660ece31bb6d888c5fc85073f0ea4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Sep 2024 17:28:22 +0100 Subject: [PATCH 43/65] [pre-commit.ci] pre-commit autoupdate (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.6 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.6...v0.6.3) - [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f764b7e3..7443f398 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,12 +29,12 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.6 + rev: v0.6.3 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 + rev: v1.11.2 hooks: - id: mypy additional_dependencies: From 95965f8c22a9206b84670290e06d52eeec1a40d3 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Fri, 6 Sep 2024 09:31:28 +0100 Subject: [PATCH 44/65] Simplify and expand kinematic tests for bboxes (#265) * Simplify and expand kinematic tests * Suggestion to rename internal method for clarity * Update docstrings for API reference docs * Add test for values * Refactor test for uniform linear motion * Add notes to dosctrings * Fix kinematics tests * Add fixture with uniform linear motion for poses * Add poses dataset to linear uniform motion test * Add test for dataset with nans * Edits to docstrings * Remove circular fixture * Small edits to fixture comments * Edits to comments in tests * Small edits * Clarify vector vs array in docstrings and make consistent where required * Add missing docstring in test and small edits * Remove TODOs * Fix offset in fixture for uniform linear motion poses * Apply suggestions from code review Co-authored-by: Chang Huan Lo * Differentiation method Co-authored-by: Chang Huan Lo * Docstrings fixes * :py:meth: to :meth: * Combine into one paragraph * Add uniform linear motion to doscstring of fixtures * Simplify valid_poses_array_uniform_linear_motion with suggestions * kinematic_variable --> kinematic_array * Simplify test_kinematics_uniform_linear_motion with suggestion from review * Update tests/test_unit/test_kinematics.py Co-authored-by: Chang Huan Lo * Update tests/test_unit/test_kinematics.py Co-authored-by: Chang Huan Lo * Cosmetic edits to test * Change docstring to time-derivative --------- Co-authored-by: Chang Huan Lo --- movement/analysis/kinematics.py | 107 +++++++---- tests/conftest.py | 129 +++++++++++-- tests/test_unit/test_kinematics.py | 278 ++++++++++++++++++----------- 3 files changed, 365 insertions(+), 149 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 15375a53..ed2b4b30 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -6,24 +6,40 @@ def compute_displacement(data: xr.DataArray) -> xr.DataArray: - """Compute displacement between consecutive positions. + """Compute displacement array in cartesian coordinates. - Displacement is the difference between consecutive positions - of each keypoint for each individual across ``time``. - At each time point ``t``, it is defined as a vector in - cartesian ``(x,y)`` coordinates, pointing from the previous - ``(t-1)`` to the current ``(t)`` position. + The displacement array is defined as the difference between the position + array at time point ``t`` and the position array at time point ``t-1``. + + As a result, for a given individual and keypoint, the displacement vector + at time point ``t``, is the vector pointing from the previous + ``(t-1)`` to the current ``(t)`` position, in cartesian coordinates. Parameters ---------- data : xarray.DataArray - The input data containing position information, with - ``time`` as a dimension. + The input data array containing position vectors in cartesian + coordinates, with ``time`` as a dimension. Returns ------- xarray.DataArray - An xarray DataArray containing the computed displacement. + An xarray DataArray containing displacement vectors in cartesian + coordinates. + + Notes + ----- + For the ``position`` array of a ``poses`` dataset, the ``displacement`` + array will hold the displacement vectors for every keypoint and every + individual. + + For the ``position`` array of a ``bboxes`` dataset, the ``displacement`` + array will hold the displacement vectors for the centroid of every + individual bounding box. + + For the ``shape`` array of a ``bboxes`` dataset, the + ``displacement`` array will hold vectors with the change in width and + height per bounding box, between consecutive time points. """ _validate_time_dimension(data) @@ -33,52 +49,73 @@ def compute_displacement(data: xr.DataArray) -> xr.DataArray: def compute_velocity(data: xr.DataArray) -> xr.DataArray: - """Compute the velocity in cartesian ``(x,y)`` coordinates. + """Compute velocity array in cartesian coordinates. - Velocity is the first derivative of position for each keypoint - and individual across ``time``, computed with the second order - accurate central differences. + The velocity array is the first time-derivative of the position + array. It is computed by applying the second-order accurate central + differences method on the position array. Parameters ---------- data : xarray.DataArray - The input data containing position information, with - ``time`` as a dimension. + The input data array containing position vectors in cartesian + coordinates, with ``time`` as a dimension. Returns ------- xarray.DataArray - An xarray DataArray containing the computed velocity. + An xarray DataArray containing velocity vectors in cartesian + coordinates. + + Notes + ----- + For the ``position`` array of a ``poses`` dataset, the ``velocity`` array + will hold the velocity vectors for every keypoint and every individual. + + For the ``position`` array of a ``bboxes`` dataset, the ``velocity`` array + will hold the velocity vectors for the centroid of every individual + bounding box. See Also -------- - :py:meth:`xarray.DataArray.differentiate` : The underlying method used. + :meth:`xarray.DataArray.differentiate` : The underlying method used. """ return _compute_approximate_time_derivative(data, order=1) def compute_acceleration(data: xr.DataArray) -> xr.DataArray: - """Compute acceleration in cartesian ``(x,y)`` coordinates. + """Compute acceleration array in cartesian coordinates. - Acceleration is the second derivative of position for each keypoint - and individual across ``time``, computed with the second order - accurate central differences. + The acceleration array is the second time-derivative of the + position array. It is computed by applying the second-order accurate + central differences method on the velocity array. Parameters ---------- data : xarray.DataArray - The input data containing position information, with - ``time`` as a dimension. + The input data array containing position vectors in cartesian + coordinates, with``time`` as a dimension. Returns ------- xarray.DataArray - An xarray DataArray containing the computed acceleration. + An xarray DataArray containing acceleration vectors in cartesian + coordinates. + + Notes + ----- + For the ``position`` array of a ``poses`` dataset, the ``acceleration`` + array will hold the acceleration vectors for every keypoint and every + individual. + + For the ``position`` array of a ``bboxes`` dataset, the ``acceleration`` + array will hold the acceleration vectors for the centroid of every + individual bounding box. See Also -------- - :py:meth:`xarray.DataArray.differentiate` : The underlying method used. + :meth:`xarray.DataArray.differentiate` : The underlying method used. """ return _compute_approximate_time_derivative(data, order=2) @@ -87,24 +124,26 @@ def compute_acceleration(data: xr.DataArray) -> xr.DataArray: def _compute_approximate_time_derivative( data: xr.DataArray, order: int ) -> xr.DataArray: - """Compute the derivative using numerical differentiation. + """Compute the time-derivative of an array using numerical differentiation. - This function uses :py:meth:`xarray.DataArray.differentiate`, - which differentiates the array with the second order - accurate central differences. + This function uses :meth:`xarray.DataArray.differentiate`, + which differentiates the array with the second-order + accurate central differences method. Parameters ---------- data : xarray.DataArray - The input data containing ``time`` as a dimension. + The input data array containing ``time`` as a dimension. order : int - The order of the derivative. 1 for velocity, 2 for - acceleration. Value must be a positive integer. + The order of the time-derivative. For an input containing position + data, use 1 to compute velocity, and 2 to compute acceleration. Value + must be a positive integer. Returns ------- xarray.DataArray - An xarray DataArray containing the derived variable. + An xarray DataArray containing the time-derivative of the + input data. """ if not isinstance(order, int): @@ -113,7 +152,9 @@ def _compute_approximate_time_derivative( ) if order <= 0: raise log_error(ValueError, "Order must be a positive integer.") + _validate_time_dimension(data) + result = data for _ in range(order): result = result.differentiate("time") diff --git a/tests/conftest.py b/tests/conftest.py index f2c77bed..272e5eaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -238,12 +238,18 @@ def valid_bboxes_arrays_all_zeros(): # --------------------- Bboxes dataset fixtures ---------------------------- @pytest.fixture -def valid_bboxes_array(): - """Return a dictionary of valid non-zero arrays for a - ValidBboxesDataset. - - Contains realistic data for 10 frames, 2 individuals, in 2D - with 5 low confidence bounding boxes. +def valid_bboxes_arrays(): + """Return a dictionary of valid arrays for a + ValidBboxesDataset representing a uniform linear motion. + + It represents 2 individuals for 10 frames, in 2D space. + - Individual 0 moves along the x=y line from the origin. + - Individual 1 moves along the x=-y line line from the origin. + + All confidence values are set to 0.9 except the following which are set + to 0.1: + - Individual 0 at frames 2, 3, 4 + - Individual 1 at frames 2, 3 """ # define the shape of the arrays n_frames, n_individuals, n_space = (10, 2, 2) @@ -276,22 +282,21 @@ def valid_bboxes_array(): "position": position, "shape": shape, "confidence": confidence, - "individual_names": ["id_" + str(id) for id in range(n_individuals)], } @pytest.fixture def valid_bboxes_dataset( - valid_bboxes_array, + valid_bboxes_arrays, ): - """Return a valid bboxes dataset with low confidence values and - time in frames. + """Return a valid bboxes dataset for two individuals moving in uniform + linear motion, with 5 frames with low confidence values and time in frames. """ dim_names = MovementDataset.dim_names["bboxes"] - position_array = valid_bboxes_array["position"] - shape_array = valid_bboxes_array["shape"] - confidence_array = valid_bboxes_array["confidence"] + position_array = valid_bboxes_arrays["position"] + shape_array = valid_bboxes_arrays["shape"] + confidence_array = valid_bboxes_arrays["confidence"] n_frames, n_individuals, _ = position_array.shape @@ -409,12 +414,110 @@ def valid_poses_dataset(valid_position_array, request): @pytest.fixture def valid_poses_dataset_with_nan(valid_poses_dataset): """Return a valid pose tracks dataset with NaN values.""" + # Sets position for all keypoints in individual ind1 to NaN + # at timepoints 3, 7, 8 valid_poses_dataset.position.loc[ {"individuals": "ind1", "time": [3, 7, 8]} ] = np.nan return valid_poses_dataset +@pytest.fixture +def valid_poses_array_uniform_linear_motion(): + """Return a dictionary of valid arrays for a + ValidPosesDataset representing a uniform linear motion. + + It represents 2 individuals with 3 keypoints, for 10 frames, in 2D space. + - Individual 0 moves along the x=y line from the origin. + - Individual 1 moves along the x=-y line line from the origin. + + All confidence values for all keypoints are set to 0.9 except + for the keypoints at the following frames which are set to 0.1: + - Individual 0 at frames 2, 3, 4 + - Individual 1 at frames 2, 3 + """ + # define the shape of the arrays + n_frames, n_individuals, n_keypoints, n_space = (10, 2, 3, 2) + + # define centroid (index=0) trajectory in position array + # for each individual, the centroid moves along + # the x=+/-y line, starting from the origin. + # - individual 0 moves along x = y line + # - individual 1 moves along x = -y line + # They move one unit along x and y axes in each frame + frames = np.arange(n_frames) + position = np.empty((n_frames, n_individuals, n_keypoints, n_space)) + position[:, :, 0, 0] = frames[:, None] # reshape to (n_frames, 1) + position[:, 0, 0, 1] = frames + position[:, 1, 0, 1] = -frames + + # define trajectory of left and right keypoints + # for individual 0, at each timepoint: + # - the left keypoint (index=1) is at x_centroid, y_centroid + 1 + # - the right keypoint (index=2) is at x_centroid + 1, y_centroid + # for individual 1, at each timepoint: + # - the left keypoint (index=1) is at x_centroid - 1, y_centroid + # - the right keypoint (index=2) is at x_centroid, y_centroid + 1 + offsets = [ + [(0, 1), (1, 0)], # individual 0: left, right keypoints (x,y) offsets + [(-1, 0), (0, 1)], # individual 1: left, right keypoints (x,y) offsets + ] + for i in range(n_individuals): + for kpt in range(1, n_keypoints): + position[:, i, kpt, 0] = ( + position[:, i, 0, 0] + offsets[i][kpt - 1][0] + ) + position[:, i, kpt, 1] = ( + position[:, i, 0, 1] + offsets[i][kpt - 1][1] + ) + + # build an array of confidence values, all 0.9 + confidence = np.full((n_frames, n_individuals, n_keypoints), 0.9) + # set 5 low-confidence values + # - set 3 confidence values for individual id_0's centroid to 0.1 + # - set 2 confidence values for individual id_1's centroid to 0.1 + idx_start = 2 + confidence[idx_start : idx_start + 3, 0, 0] = 0.1 + confidence[idx_start : idx_start + 2, 1, 0] = 0.1 + + return {"position": position, "confidence": confidence} + + +@pytest.fixture +def valid_poses_dataset_uniform_linear_motion( + valid_poses_array_uniform_linear_motion, +): + """Return a valid poses dataset for two individuals moving in uniform + linear motion, with 5 frames with low confidence values and time in frames. + """ + dim_names = MovementDataset.dim_names["poses"] + + position_array = valid_poses_array_uniform_linear_motion["position"] + confidence_array = valid_poses_array_uniform_linear_motion["confidence"] + + n_frames, n_individuals, _, _ = position_array.shape + + return xr.Dataset( + data_vars={ + "position": xr.DataArray(position_array, dims=dim_names), + "confidence": xr.DataArray(confidence_array, dims=dim_names[:-1]), + }, + coords={ + dim_names[0]: np.arange(n_frames), + dim_names[1]: [f"id_{i}" for i in range(1, n_individuals + 1)], + dim_names[2]: ["centroid", "left", "right"], + dim_names[3]: ["x", "y"], + }, + attrs={ + "fps": None, + "time_unit": "frames", + "source_software": "test", + "source_file": "test_poses.h5", + "ds_type": "poses", + }, + ) + + # -------------------- Invalid datasets fixtures ------------------------------ @pytest.fixture def not_a_dataset(): diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 1f75a824..66822bfd 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -1,5 +1,3 @@ -from contextlib import nullcontext as does_not_raise - import numpy as np import pytest import xarray as xr @@ -7,106 +5,180 @@ from movement.analysis import kinematics -class TestKinematics: - """Test suite for the kinematics module.""" - - @pytest.fixture - def expected_dataarray(self, valid_poses_dataset): - """Return a function to generate the expected dataarray - for different kinematic properties. - """ - - def _expected_dataarray(property): - """Return an xarray.DataArray with default values and - the expected dimensions and coordinates. - """ - # Expected x,y values for velocity - x_vals = np.array( - [1.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 17.0] - ) - y_vals = np.full((10, 2, 2, 1), 4.0) - if property == "acceleration": - x_vals = np.array( - [1.0, 1.5, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 1.5, 1.0] - ) - y_vals = np.full((10, 2, 2, 1), 0) - elif property == "displacement": - x_vals = np.array( - [0.0, 1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0, 17.0] - ) - y_vals[0] = 0 - - x_vals = x_vals.reshape(-1, 1, 1, 1) - # Repeat the x_vals to match the shape of the position - x_vals = np.tile(x_vals, (1, 2, 2, 1)) - return xr.DataArray( - np.concatenate( - [x_vals, y_vals], - axis=-1, - ), - dims=valid_poses_dataset.dims, - coords=valid_poses_dataset.coords, - ) - - return _expected_dataarray - - kinematic_test_params = [ - ("valid_poses_dataset", does_not_raise()), - ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_poses_dataset", pytest.raises(ValueError)), - ] +@pytest.mark.parametrize( + "valid_dataset_uniform_linear_motion", + [ + "valid_poses_dataset_uniform_linear_motion", + "valid_bboxes_dataset", + ], +) +@pytest.mark.parametrize( + "kinematic_variable, expected_kinematics", + [ + ( + "displacement", + [ + np.vstack([np.zeros((1, 2)), np.ones((9, 2))]), # Individual 0 + np.multiply( + np.vstack([np.zeros((1, 2)), np.ones((9, 2))]), + np.array([1, -1]), + ), # Individual 1 + ], + ), + ( + "velocity", + [ + np.ones((10, 2)), # Individual 0 + np.multiply( + np.ones((10, 2)), np.array([1, -1]) + ), # Individual 1 + ], + ), + ( + "acceleration", + [ + np.zeros((10, 2)), # Individual 0 + np.zeros((10, 2)), # Individual 1 + ], + ), + ], +) +def test_kinematics_uniform_linear_motion( + valid_dataset_uniform_linear_motion, + kinematic_variable, + expected_kinematics, # 2D: n_frames, n_space_dims + request, +): + """Test computed kinematics for a uniform linear motion case. + + Uniform linear motion means the individuals move along a line + at constant velocity. + + We consider 2 individuals ("id_0" and "id_1"), + tracked for 10 frames, along x and y: + - id_0 moves along x=y line from the origin + - id_1 moves along x=-y line from the origin + - they both move one unit (pixel) along each axis in each frame + + If the dataset is a poses dataset, we consider 3 keypoints per individual + (centroid, left, right), that are always in front of the centroid keypoint + at 45deg from the trajectory. + """ + # Compute kinematic array from input dataset + position = request.getfixturevalue( + valid_dataset_uniform_linear_motion + ).position + kinematic_array = getattr(kinematics, f"compute_{kinematic_variable}")( + position + ) - @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) - def test_displacement( - self, ds, expected_exception, expected_dataarray, request - ): - """Test displacement computation.""" - ds = request.getfixturevalue(ds) - with expected_exception: - result = kinematics.compute_displacement(ds.position) - expected = expected_dataarray("displacement") - if ds.position.isnull().any(): - expected.loc[ - {"individuals": "ind1", "time": [3, 4, 7, 8, 9]} - ] = np.nan - xr.testing.assert_allclose(result, expected) - - @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) - def test_velocity( - self, ds, expected_exception, expected_dataarray, request - ): - """Test velocity computation.""" - ds = request.getfixturevalue(ds) - with expected_exception: - result = kinematics.compute_velocity(ds.position) - expected = expected_dataarray("velocity") - if ds.position.isnull().any(): - expected.loc[ - {"individuals": "ind1", "time": [2, 4, 6, 7, 8, 9]} - ] = np.nan - xr.testing.assert_allclose(result, expected) - - @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) - def test_acceleration( - self, ds, expected_exception, expected_dataarray, request - ): - """Test acceleration computation.""" - ds = request.getfixturevalue(ds) - with expected_exception: - result = kinematics.compute_acceleration(ds.position) - expected = expected_dataarray("acceleration") - if ds.position.isnull().any(): - expected.loc[ - {"individuals": "ind1", "time": [1, 3, 5, 6, 7, 8, 9]} - ] = np.nan - xr.testing.assert_allclose(result, expected) - - @pytest.mark.parametrize("order", [0, -1, 1.0, "1"]) - def test_approximate_derivative_with_invalid_order(self, order): - """Test that an error is raised when the order is non-positive.""" - data = np.arange(10) - expected_exception = ( - ValueError if isinstance(order, int) else TypeError + # Build expected data array from the expected numpy array + expected_array = xr.DataArray( + np.stack(expected_kinematics, axis=1), + # Stack along the "individuals" axis + dims=["time", "individuals", "space"], + ) + if "keypoints" in position.coords: + expected_array = expected_array.expand_dims( + {"keypoints": position.coords["keypoints"].size} ) - with pytest.raises(expected_exception): - kinematics._compute_approximate_time_derivative(data, order=order) + expected_array = expected_array.transpose( + "time", "individuals", "keypoints", "space" + ) + + # Compare the values of the kinematic_array against the expected_array + np.testing.assert_allclose(kinematic_array.values, expected_array.values) + + +@pytest.mark.parametrize( + "valid_dataset_with_nan", + [ + "valid_poses_dataset_with_nan", + "valid_bboxes_dataset_with_nan", + ], +) +@pytest.mark.parametrize( + "kinematic_variable, expected_nans_per_individual", + [ + ("displacement", [5, 0]), # individual 0, individual 1 + ("velocity", [6, 0]), + ("acceleration", [7, 0]), + ], +) +def test_kinematics_with_dataset_with_nans( + valid_dataset_with_nan, + kinematic_variable, + expected_nans_per_individual, + helpers, + request, +): + """Test kinematics computation for a dataset with nans. + + We test that the kinematics can be computed and that the number + of nan values in the kinematic array is as expected. + + """ + # compute kinematic array + valid_dataset = request.getfixturevalue(valid_dataset_with_nan) + position = valid_dataset.position + kinematic_array = getattr(kinematics, f"compute_{kinematic_variable}")( + position + ) + + # compute n nans in kinematic array per individual + n_nans_kinematics_per_indiv = [ + helpers.count_nans(kinematic_array.isel(individuals=i)) + for i in range(valid_dataset.dims["individuals"]) + ] + + # expected nans per individual adjusted for space and keypoints dimensions + expected_nans_adjusted = [ + n + * valid_dataset.dims["space"] + * valid_dataset.dims.get("keypoints", 1) + for n in expected_nans_per_individual + ] + # check number of nans per individual is as expected in kinematic array + np.testing.assert_array_equal( + n_nans_kinematics_per_indiv, expected_nans_adjusted + ) + + +@pytest.mark.parametrize( + "invalid_dataset, expected_exception", + [ + ("not_a_dataset", pytest.raises(AttributeError)), + ("empty_dataset", pytest.raises(AttributeError)), + ("missing_var_poses_dataset", pytest.raises(AttributeError)), + ("missing_var_bboxes_dataset", pytest.raises(AttributeError)), + ("missing_dim_poses_dataset", pytest.raises(ValueError)), + ("missing_dim_bboxes_dataset", pytest.raises(ValueError)), + ], +) +@pytest.mark.parametrize( + "kinematic_variable", + [ + "displacement", + "velocity", + "acceleration", + ], +) +def test_kinematics_with_invalid_dataset( + invalid_dataset, + expected_exception, + kinematic_variable, + request, +): + """Test kinematics computation with an invalid dataset.""" + with expected_exception: + position = request.getfixturevalue(invalid_dataset).position + getattr(kinematics, f"compute_{kinematic_variable}")(position) + + +@pytest.mark.parametrize("order", [0, -1, 1.0, "1"]) +def test_approximate_derivative_with_invalid_order(order): + """Test that an error is raised when the order is non-positive.""" + data = np.arange(10) + expected_exception = ValueError if isinstance(order, int) else TypeError + with pytest.raises(expected_exception): + kinematics._compute_approximate_time_derivative(data, order=order) From f4f46dfc48dd1dde5959b01fb0a17fad93070ae9 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Fri, 6 Sep 2024 10:38:28 +0100 Subject: [PATCH 45/65] Fix xarray FutureWarning about dims vs sizes (#297) * use ds.sizes insted of ds.dims * Change .dims to .sizes in kinematics tests --------- Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- tests/test_unit/test_filtering.py | 14 +++++++------- tests/test_unit/test_kinematics.py | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 1bc59348..4b400287 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -83,8 +83,8 @@ def test_interpolate_over_time_on_position( # The number of NaNs after interpolating should be as expected assert n_nans_after == ( - valid_dataset_in_frames.dims["space"] - * valid_dataset_in_frames.dims.get("keypoints", 1) + valid_dataset_in_frames.sizes["space"] + * valid_dataset_in_frames.sizes.get("keypoints", 1) # in bboxes dataset there is no keypoints dimension * expected_n_nans_in_position ) @@ -120,7 +120,7 @@ def test_filter_by_confidence_on_position( # the number of low confidence keypoints by the number of # space dimensions assert isinstance(position_filtered, xr.DataArray) - assert n_nans == valid_input_dataset.dims["space"] * n_low_confidence_kpts + assert n_nans == valid_input_dataset.sizes["space"] * n_low_confidence_kpts @pytest.mark.parametrize( @@ -198,15 +198,15 @@ def _assert_n_nans_in_position_per_individual( # compute n nans in position after filtering per individual n_nans_after_filtering_per_indiv = { i: helpers.count_nans(position_filtered.isel(individuals=i)) - for i in range(valid_input_dataset.dims["individuals"]) + for i in range(valid_input_dataset.sizes["individuals"]) } # check number of nans per indiv is as expected - for i in range(valid_input_dataset.dims["individuals"]): + for i in range(valid_input_dataset.sizes["individuals"]): assert n_nans_after_filtering_per_indiv[i] == ( expected_nans_in_filt_position_per_indiv[i] - * valid_input_dataset.dims["space"] - * valid_input_dataset.dims.get("keypoints", 1) + * valid_input_dataset.sizes["space"] + * valid_input_dataset.sizes.get("keypoints", 1) ) # Filter position diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 66822bfd..7641aeeb 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -128,14 +128,14 @@ def test_kinematics_with_dataset_with_nans( # compute n nans in kinematic array per individual n_nans_kinematics_per_indiv = [ helpers.count_nans(kinematic_array.isel(individuals=i)) - for i in range(valid_dataset.dims["individuals"]) + for i in range(valid_dataset.sizes["individuals"]) ] # expected nans per individual adjusted for space and keypoints dimensions expected_nans_adjusted = [ n - * valid_dataset.dims["space"] - * valid_dataset.dims.get("keypoints", 1) + * valid_dataset.sizes["space"] + * valid_dataset.sizes.get("keypoints", 1) for n in expected_nans_per_individual ] # check number of nans per individual is as expected in kinematic array From 644c1b16eb5568b779701bb119a2988bbf523522 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Fri, 13 Sep 2024 14:25:21 +0100 Subject: [PATCH 46/65] Add bbox centroid fix (#303) * Add bbox centroid fix * Add tests * Remove loop from assert * Clarify docstring * Remove spaces * Update tests/test_unit/test_load_bboxes.py Co-authored-by: Niko Sirmpilatze --------- Co-authored-by: Niko Sirmpilatze --- movement/io/load_bboxes.py | 13 ++++++-- tests/test_unit/test_load_bboxes.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/movement/io/load_bboxes.py b/movement/io/load_bboxes.py index 6971de0f..8550a2e8 100644 --- a/movement/io/load_bboxes.py +++ b/movement/io/load_bboxes.py @@ -402,6 +402,11 @@ def _numpy_arrays_from_via_tracks_file(file_path: Path) -> dict: array_dict[key] = np.stack(list_arrays, axis=1).squeeze() + # Transform position_array to represent centroid of bbox, + # rather than top-left corner + # (top left corner: corner of the bbox with minimum x and y coordinates) + array_dict["position_array"] += array_dict["shape_array"] / 2 + # Add remaining arrays to dict array_dict["ID_array"] = df["ID"].unique().reshape(-1, 1) array_dict["frame_array"] = df["frame_number"].unique().reshape(-1, 1) @@ -415,14 +420,16 @@ def _df_from_via_tracks_file(file_path: Path) -> pd.DataFrame: Read the VIA tracks .csv file as a pandas dataframe with columns: - ID: the integer ID of the tracked bounding box. - frame_number: the frame number of the tracked bounding box. - - x: the x-coordinate of the tracked bounding box centroid. - - y: the y-coordinate of the tracked bounding box centroid. + - x: the x-coordinate of the tracked bounding box's top-left corner. + - y: the y-coordinate of the tracked bounding box's top-left corner. - w: the width of the tracked bounding box. - h: the height of the tracked bounding box. - confidence: the confidence score of the tracked bounding box. The dataframe is sorted by ID and frame number, and for each ID, - empty frames are filled in with NaNs. + empty frames are filled in with NaNs. The coordinates of the bboxes + are assumed to be in the image coordinate system (i.e., the top-left + corner of a bbox is its corner with minimum x and y coordinates). """ # Read VIA tracks .csv file as a pandas dataframe df_file = pd.read_csv(file_path, sep=",", header=0) diff --git a/tests/test_unit/test_load_bboxes.py b/tests/test_unit/test_load_bboxes.py index dbed362c..474e6118 100644 --- a/tests/test_unit/test_load_bboxes.py +++ b/tests/test_unit/test_load_bboxes.py @@ -419,3 +419,54 @@ def test_fps_and_time_coords( else: start_frame = 0 assert_time_coordinates(ds, expected_fps, start_frame) + + +def test_df_from_via_tracks_file(via_tracks_file): + """Test that the helper function correctly reads the VIA tracks .csv file + as a dataframe. + """ + df = load_bboxes._df_from_via_tracks_file(via_tracks_file) + + assert isinstance(df, pd.DataFrame) + assert len(df.frame_number.unique()) == 5 + assert ( + df.shape[0] == len(df.ID.unique()) * 5 + ) # all individuals in all frames (even if nan) + assert list(df.columns) == [ + "ID", + "frame_number", + "x", + "y", + "w", + "h", + "confidence", + ] + + +def test_position_numpy_array_from_via_tracks_file(via_tracks_file): + """Test the extracted position array from the VIA tracks .csv file + represents the centroid of the bbox. + """ + # Extract numpy arrays from VIA tracks .csv file + bboxes_arrays = load_bboxes._numpy_arrays_from_via_tracks_file( + via_tracks_file + ) + + # Read VIA tracks .csv file as a dataframe + df = load_bboxes._df_from_via_tracks_file(via_tracks_file) + + # Compute centroid positions from the dataframe + # (go thru in the same order as ID array) + list_derived_centroids = [] + for id in bboxes_arrays["ID_array"]: + df_one_id = df[df["ID"] == id.item()] + centroid_position = np.array( + [df_one_id.x + df_one_id.w / 2, df_one_id.y + df_one_id.h / 2] + ).T # frames, xy + list_derived_centroids.append(centroid_position) + + # Compare to extracted position array + assert np.allclose( + bboxes_arrays["position_array"], # frames, individuals, xy + np.stack(list_derived_centroids, axis=1), + ) From 9c80786455fe0375d689430102eebe3bfa645ad2 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Tue, 17 Sep 2024 14:17:42 +0100 Subject: [PATCH 47/65] General validator function for checking dimensions and coordinates (#294) * replace time dim validator with more generic validator * constrain kinematic functions to cartesian coordinates * renamed new validator to validate_dims_coords * add examples in validator docstring * unit tests for the new validator * Apply suggestions from code review do note validate x,y space coordinates specifically. Co-authored-by: Chang Huan Lo * reuse fixture valid_poses_dataset_uniform_linear_motion * combine two unit tests into one * expose public `compute_time_derivative` function * Refactor test * Update refs to `compute_time_derivative` --------- Co-authored-by: Chang Huan Lo --- movement/analysis/kinematics.py | 66 ++++++++----------- movement/utils/vector.py | 55 ++-------------- movement/validators/arrays.py | 61 +++++++++++++++++ tests/test_unit/test_kinematics.py | 2 +- .../test_validators/test_array_validators.py | 56 ++++++++++++++++ 5 files changed, 151 insertions(+), 89 deletions(-) create mode 100644 movement/validators/arrays.py create mode 100644 tests/test_unit/test_validators/test_array_validators.py diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index ed2b4b30..b2bbbf9b 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -3,6 +3,7 @@ import xarray as xr from movement.utils.logging import log_error +from movement.validators.arrays import validate_dims_coords def compute_displacement(data: xr.DataArray) -> xr.DataArray: @@ -18,8 +19,8 @@ def compute_displacement(data: xr.DataArray) -> xr.DataArray: Parameters ---------- data : xarray.DataArray - The input data array containing position vectors in cartesian - coordinates, with ``time`` as a dimension. + The input data containing position information, with ``time`` + and ``space`` (in Cartesian coordinates) as required dimensions. Returns ------- @@ -42,7 +43,7 @@ def compute_displacement(data: xr.DataArray) -> xr.DataArray: height per bounding box, between consecutive time points. """ - _validate_time_dimension(data) + validate_dims_coords(data, {"time": [], "space": []}) result = data.diff(dim="time") result = result.reindex(data.coords, fill_value=0) return result @@ -58,8 +59,8 @@ def compute_velocity(data: xr.DataArray) -> xr.DataArray: Parameters ---------- data : xarray.DataArray - The input data array containing position vectors in cartesian - coordinates, with ``time`` as a dimension. + The input data containing position information, with ``time`` + and ``space`` (in Cartesian coordinates) as required dimensions. Returns ------- @@ -78,10 +79,13 @@ def compute_velocity(data: xr.DataArray) -> xr.DataArray: See Also -------- - :meth:`xarray.DataArray.differentiate` : The underlying method used. + compute_time_derivative : The underlying function used. """ - return _compute_approximate_time_derivative(data, order=1) + # validate only presence of Cartesian space dimension + # (presence of time dimension will be checked in compute_time_derivative) + validate_dims_coords(data, {"space": []}) + return compute_time_derivative(data, order=1) def compute_acceleration(data: xr.DataArray) -> xr.DataArray: @@ -94,8 +98,8 @@ def compute_acceleration(data: xr.DataArray) -> xr.DataArray: Parameters ---------- data : xarray.DataArray - The input data array containing position vectors in cartesian - coordinates, with``time`` as a dimension. + The input data containing position information, with ``time`` + and ``space`` (in Cartesian coordinates) as required dimensions. Returns ------- @@ -115,15 +119,16 @@ def compute_acceleration(data: xr.DataArray) -> xr.DataArray: See Also -------- - :meth:`xarray.DataArray.differentiate` : The underlying method used. + compute_time_derivative : The underlying function used. """ - return _compute_approximate_time_derivative(data, order=2) + # validate only presence of Cartesian space dimension + # (presence of time dimension will be checked in compute_time_derivative) + validate_dims_coords(data, {"space": []}) + return compute_time_derivative(data, order=2) -def _compute_approximate_time_derivative( - data: xr.DataArray, order: int -) -> xr.DataArray: +def compute_time_derivative(data: xr.DataArray, order: int) -> xr.DataArray: """Compute the time-derivative of an array using numerical differentiation. This function uses :meth:`xarray.DataArray.differentiate`, @@ -133,7 +138,7 @@ def _compute_approximate_time_derivative( Parameters ---------- data : xarray.DataArray - The input data array containing ``time`` as a dimension. + The input data containing ``time`` as a required dimension. order : int The order of the time-derivative. For an input containing position data, use 1 to compute velocity, and 2 to compute acceleration. Value @@ -142,8 +147,11 @@ def _compute_approximate_time_derivative( Returns ------- xarray.DataArray - An xarray DataArray containing the time-derivative of the - input data. + An xarray DataArray containing the time-derivative of the input data. + + See Also + -------- + :meth:`xarray.DataArray.differentiate` : The underlying method used. """ if not isinstance(order, int): @@ -152,30 +160,8 @@ def _compute_approximate_time_derivative( ) if order <= 0: raise log_error(ValueError, "Order must be a positive integer.") - - _validate_time_dimension(data) - + validate_dims_coords(data, {"time": []}) result = data for _ in range(order): result = result.differentiate("time") return result - - -def _validate_time_dimension(data: xr.DataArray) -> None: - """Validate the input data contains a ``time`` dimension. - - Parameters - ---------- - data : xarray.DataArray - The input data to validate. - - Raises - ------ - ValueError - If the input data does not contain a ``time`` dimension. - - """ - if "time" not in data.dims: - raise log_error( - ValueError, "Input data must contain 'time' as a dimension." - ) diff --git a/movement/utils/vector.py b/movement/utils/vector.py index 0d5d88c8..c91e43ec 100644 --- a/movement/utils/vector.py +++ b/movement/utils/vector.py @@ -4,6 +4,7 @@ import xarray as xr from movement.utils.logging import log_error +from movement.validators.arrays import validate_dims_coords def compute_norm(data: xr.DataArray) -> xr.DataArray: @@ -39,7 +40,7 @@ def compute_norm(data: xr.DataArray) -> xr.DataArray: """ if "space" in data.dims: - _validate_dimension_coordinates(data, {"space": ["x", "y"]}) + validate_dims_coords(data, {"space": ["x", "y"]}) return xr.apply_ufunc( np.linalg.norm, data, @@ -47,7 +48,7 @@ def compute_norm(data: xr.DataArray) -> xr.DataArray: kwargs={"axis": -1}, ) elif "space_pol" in data.dims: - _validate_dimension_coordinates(data, {"space_pol": ["rho", "phi"]}) + validate_dims_coords(data, {"space_pol": ["rho", "phi"]}) return data.sel(space_pol="rho", drop=True) else: _raise_error_for_missing_spatial_dim() @@ -78,10 +79,10 @@ def convert_to_unit(data: xr.DataArray) -> xr.DataArray: """ if "space" in data.dims: - _validate_dimension_coordinates(data, {"space": ["x", "y"]}) + validate_dims_coords(data, {"space": ["x", "y"]}) return data / compute_norm(data) elif "space_pol" in data.dims: - _validate_dimension_coordinates(data, {"space_pol": ["rho", "phi"]}) + validate_dims_coords(data, {"space_pol": ["rho", "phi"]}) # Set both rho and phi values to NaN at null vectors (where rho = 0) new_data = xr.where(data.sel(space_pol="rho") == 0, np.nan, data) # Set the rho values to 1 for non-null vectors (phi is preserved) @@ -111,7 +112,7 @@ def cart2pol(data: xr.DataArray) -> xr.DataArray: ``phi`` returned are in radians, in the range ``[-pi, pi]``. """ - _validate_dimension_coordinates(data, {"space": ["x", "y"]}) + validate_dims_coords(data, {"space": ["x", "y"]}) rho = compute_norm(data) phi = xr.apply_ufunc( np.arctan2, @@ -147,7 +148,7 @@ def pol2cart(data: xr.DataArray) -> xr.DataArray: in the dimension coordinate. """ - _validate_dimension_coordinates(data, {"space_pol": ["rho", "phi"]}) + validate_dims_coords(data, {"space_pol": ["rho", "phi"]}) rho = data.sel(space_pol="rho") phi = data.sel(space_pol="phi") x = rho * np.cos(phi) @@ -164,48 +165,6 @@ def pol2cart(data: xr.DataArray) -> xr.DataArray: ).transpose(*dims) -def _validate_dimension_coordinates( - data: xr.DataArray, required_dim_coords: dict -) -> None: - """Validate the input data array. - - Ensure that it contains the required dimensions and coordinates. - - Parameters - ---------- - data : xarray.DataArray - The input data to validate. - required_dim_coords : dict - A dictionary of required dimensions and their corresponding - coordinate values. - - Raises - ------ - ValueError - If the input data does not contain the required dimension(s) - and/or the required coordinate(s). - - """ - missing_dims = [dim for dim in required_dim_coords if dim not in data.dims] - error_message = "" - if missing_dims: - error_message += ( - f"Input data must contain {missing_dims} as dimensions.\n" - ) - missing_coords = [] - for dim, coords in required_dim_coords.items(): - missing_coords = [ - coord for coord in coords if coord not in data.coords.get(dim, []) - ] - if missing_coords: - error_message += ( - "Input data must contain " - f"{missing_coords} in the '{dim}' coordinates." - ) - if error_message: - raise log_error(ValueError, error_message) - - def _raise_error_for_missing_spatial_dim() -> None: raise log_error( ValueError, diff --git a/movement/validators/arrays.py b/movement/validators/arrays.py new file mode 100644 index 00000000..76847571 --- /dev/null +++ b/movement/validators/arrays.py @@ -0,0 +1,61 @@ +"""Validators for data arrays.""" + +import xarray as xr + +from movement.utils.logging import log_error + + +def validate_dims_coords( + data: xr.DataArray, required_dim_coords: dict +) -> None: + """Validate dimensions and coordinates in a data array. + + This function raises a ValueError if the specified dimensions and + coordinates are not present in the input data array. + + Parameters + ---------- + data : xarray.DataArray + The input data array to validate. + required_dim_coords : dict + A dictionary of required dimensions and their corresponding + coordinate values. If you don't need to specify any + coordinate values, you can pass an empty list. + + Examples + -------- + Validate that a data array contains the dimension 'time'. No specific + coordinates are required. + + >>> validate_dims_coords(data, {"time": []}) + + Validate that a data array contains the dimensions 'time' and 'space', + and that the 'space' dimension contains the coordinates 'x' and 'y'. + + >>> validate_dims_coords(data, {"time": [], "space": ["x", "y"]}) + + Raises + ------ + ValueError + If the input data does not contain the required dimension(s) + and/or the required coordinate(s). + + """ + missing_dims = [dim for dim in required_dim_coords if dim not in data.dims] + error_message = "" + if missing_dims: + error_message += ( + f"Input data must contain {missing_dims} as dimensions.\n" + ) + missing_coords = [] + for dim, coords in required_dim_coords.items(): + missing_coords = [ + coord for coord in coords if coord not in data.coords.get(dim, []) + ] + if missing_coords: + error_message += ( + "Input data must contain " + f"{missing_coords} in the '{dim}' coordinates." + ) + if error_message: + raise log_error(ValueError, error_message) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 7641aeeb..a1b933e0 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -181,4 +181,4 @@ def test_approximate_derivative_with_invalid_order(order): data = np.arange(10) expected_exception = ValueError if isinstance(order, int) else TypeError with pytest.raises(expected_exception): - kinematics._compute_approximate_time_derivative(data, order=order) + kinematics.compute_time_derivative(data, order=order) diff --git a/tests/test_unit/test_validators/test_array_validators.py b/tests/test_unit/test_validators/test_array_validators.py new file mode 100644 index 00000000..a1a4412c --- /dev/null +++ b/tests/test_unit/test_validators/test_array_validators.py @@ -0,0 +1,56 @@ +import re +from contextlib import nullcontext as does_not_raise + +import pytest + +from movement.validators.arrays import validate_dims_coords + + +def expect_value_error_with_message(error_msg): + """Expect a ValueError with the specified error message.""" + return pytest.raises(ValueError, match=re.escape(error_msg)) + + +valid_cases = [ + ({"time": []}, does_not_raise()), + ({"time": [0, 1]}, does_not_raise()), + ({"space": ["x", "y"]}, does_not_raise()), + ({"time": [], "space": []}, does_not_raise()), + ({"time": [], "space": ["x", "y"]}, does_not_raise()), +] # Valid cases (no error) + +invalid_cases = [ + ( + {"spacetime": []}, + expect_value_error_with_message( + "Input data must contain ['spacetime'] as dimensions." + ), + ), + ( + {"time": [0, 100], "space": ["x", "y"]}, + expect_value_error_with_message( + "Input data must contain [100] in the 'time' coordinates." + ), + ), + ( + {"space": ["x", "y", "z"]}, + expect_value_error_with_message( + "Input data must contain ['z'] in the 'space' coordinates." + ), + ), +] # Invalid cases (raise ValueError) + + +@pytest.mark.parametrize( + "required_dims_coords, expected_exception", + valid_cases + invalid_cases, +) +def test_validate_dims_coords( + valid_poses_dataset_uniform_linear_motion, # fixture from conftest.py + required_dims_coords, + expected_exception, +): + """Test validate_dims_coords for both valid and invalid inputs.""" + position_array = valid_poses_dataset_uniform_linear_motion["position"] + with expected_exception: + validate_dims_coords(position_array, required_dims_coords) From a6dc15ea46d72ab59935bf1c2979b31524e65b1b Mon Sep 17 00:00:00 2001 From: Brandon Peri <77279592+b-peri@users.noreply.github.com> Date: Fri, 4 Oct 2024 17:00:59 +0100 Subject: [PATCH 48/65] Compute Forward Vector (#276) * Basic implementation of `compute_head_direction_vector()` * Minor fixes docstring * Added unit test for `compute_head_direction_vector()` * Bug fixes for `test_compute_head_direction_vector()` * Added validator (and test) to ensure input is 2D * Refactored `navigation.py` and implemented PR review feedback * Extended testing and added `front_keypoint` argument to `compute_head_direction_vector()` * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Implemented PR feedback and bugfixes for `compute_2d_head_direction_vector()` * Removed uppercase letters from function names * Tweaked `compute_polar_coordinates.py` to use new function * Implemented feedback from Zulip discussion and created `compute_head_direction_vector()` alias function * Fixed typo in docstring * Tweaked `compute_forward_vector()` to use new validator * More fixes for `test_kinematics.py` * Bugfix for `compute_forward_vector()` and expanded `test_compute_forward_vector` to cover both `camera_view` options * Added test coverage for `compute_head_direction_vector()` alias * Reversed changes to `compute_polar_coordinates.py` and implemented final feedback --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- movement/analysis/kinematics.py | 178 +++++++++++++++++++++++++++++ tests/test_unit/test_kinematics.py | 167 +++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index b2bbbf9b..b1bce6ac 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -1,8 +1,12 @@ """Compute kinematic variables like velocity and acceleration.""" +from typing import Literal + +import numpy as np import xarray as xr from movement.utils.logging import log_error +from movement.utils.vector import compute_norm from movement.validators.arrays import validate_dims_coords @@ -165,3 +169,177 @@ def compute_time_derivative(data: xr.DataArray, order: int) -> xr.DataArray: for _ in range(order): result = result.differentiate("time") return result + + +def compute_forward_vector( + data: xr.DataArray, + left_keypoint: str, + right_keypoint: str, + camera_view: Literal["top_down", "bottom_up"] = "top_down", +): + """Compute a 2D forward vector given two left-right symmetric keypoints. + + The forward vector is computed as a vector perpendicular to the + line connecting two symmetrical keypoints on either side of the body + (i.e., symmetrical relative to the mid-sagittal plane), and pointing + forwards (in the rostral direction). A top-down or bottom-up view of the + animal is assumed (see Notes). + + Parameters + ---------- + data : xarray.DataArray + The input data representing position. This must contain + the two symmetrical keypoints located on the left and + right sides of the body, respectively. + left_keypoint : str + Name of the left keypoint, e.g., "left_ear" + right_keypoint : str + Name of the right keypoint, e.g., "right_ear" + camera_view : Literal["top_down", "bottom_up"], optional + The camera viewing angle, used to determine the upwards + direction of the animal. Can be either ``"top_down"`` (where the + upwards direction is [0, 0, -1]), or ``"bottom_up"`` (where the + upwards direction is [0, 0, 1]). If left unspecified, the camera + view is assumed to be ``"top_down"``. + + Returns + ------- + xarray.DataArray + An xarray DataArray representing the forward vector, with + dimensions matching the input data array, but without the + ``keypoints`` dimension. + + Notes + ----- + To determine the forward direction of the animal, we need to specify + (1) the right-to-left direction of the animal and (2) its upward direction. + We determine the right-to-left direction via the input left and right + keypoints. The upwards direction, in turn, can be determined by passing the + ``camera_view`` argument with either ``"top_down"`` or ``"bottom_up"``. If + the camera view is specified as being ``"top_down"``, or if no additional + information is provided, we assume that the upwards direction matches that + of the vector ``[0, 0, -1]``. If the camera view is ``"bottom_up"``, the + upwards direction is assumed to be given by ``[0, 0, 1]``. For both cases, + we assume that position values are expressed in the image coordinate + system (where the positive X-axis is oriented to the right, the positive + Y-axis faces downwards, and positive Z-axis faces away from the person + viewing the screen). + + If one of the required pieces of information is missing for a frame (e.g., + the left keypoint is not visible), then the computed head direction vector + is set to NaN. + + """ + # Validate input data + _validate_type_data_array(data) + validate_dims_coords( + data, + { + "time": [], + "keypoints": [left_keypoint, right_keypoint], + "space": [], + }, + ) + if len(data.space) != 2: + raise log_error( + ValueError, + "Input data must have exactly 2 spatial dimensions, but " + f"currently has {len(data.space)}.", + ) + + # Validate input keypoints + if left_keypoint == right_keypoint: + raise log_error( + ValueError, "The left and right keypoints may not be identical." + ) + + # Define right-to-left vector + right_to_left_vector = data.sel( + keypoints=left_keypoint, drop=True + ) - data.sel(keypoints=right_keypoint, drop=True) + + # Define upward vector + # default: negative z direction in the image coordinate system + if camera_view == "top_down": + upward_vector = np.array([0, 0, -1]) + else: + upward_vector = np.array([0, 0, 1]) + + upward_vector = xr.DataArray( + np.tile(upward_vector.reshape(1, -1), [len(data.time), 1]), + dims=["time", "space"], + ) + + # Compute forward direction as the cross product + # (right-to-left) cross (forward) = up + forward_vector = xr.cross( + right_to_left_vector, upward_vector, dim="space" + )[:, :, :-1] # keep only the first 2 dimensions of the result + + # Return unit vector + + return forward_vector / compute_norm(forward_vector) + + +def compute_head_direction_vector( + data: xr.DataArray, + left_keypoint: str, + right_keypoint: str, + camera_view: Literal["top_down", "bottom_up"] = "top_down", +): + """Compute the 2D head direction vector given two keypoints on the head. + + This function is an alias for :func:`compute_forward_vector()\ + `. For more + detailed information on how the head direction vector is computed, + please refer to the documentation for that function. + + Parameters + ---------- + data : xarray.DataArray + The input data representing position. This must contain + the two chosen keypoints corresponding to the left and + right of the head. + left_keypoint : str + Name of the left keypoint, e.g., "left_ear" + right_keypoint : str + Name of the right keypoint, e.g., "right_ear" + camera_view : Literal["top_down", "bottom_up"], optional + The camera viewing angle, used to determine the upwards + direction of the animal. Can be either ``"top_down"`` (where the + upwards direction is [0, 0, -1]), or ``"bottom_up"`` (where the + upwards direction is [0, 0, 1]). If left unspecified, the camera + view is assumed to be ``"top_down"``. + + Returns + ------- + xarray.DataArray + An xarray DataArray representing the head direction vector, with + dimensions matching the input data array, but without the + ``keypoints`` dimension. + + """ + return compute_forward_vector( + data, left_keypoint, right_keypoint, camera_view=camera_view + ) + + +def _validate_type_data_array(data: xr.DataArray) -> None: + """Validate the input data is an xarray DataArray. + + Parameters + ---------- + data : xarray.DataArray + The input data to validate. + + Raises + ------ + ValueError + If the input data is not an xarray DataArray. + + """ + if not isinstance(data, xr.DataArray): + raise log_error( + TypeError, + f"Input data must be an xarray.DataArray, but got {type(data)}.", + ) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index a1b933e0..a54d199c 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -1,3 +1,5 @@ +import re + import numpy as np import pytest import xarray as xr @@ -182,3 +184,168 @@ def test_approximate_derivative_with_invalid_order(order): expected_exception = ValueError if isinstance(order, int) else TypeError with pytest.raises(expected_exception): kinematics.compute_time_derivative(data, order=order) + + +@pytest.fixture +def valid_data_array_for_forward_vector(): + """Return a position data array for an individual with 3 keypoints + (left ear, right ear and nose), tracked for 4 frames, in x-y space. + """ + time = [0, 1, 2, 3] + individuals = ["individual_0"] + keypoints = ["left_ear", "right_ear", "nose"] + space = ["x", "y"] + + ds = xr.DataArray( + [ + [[[1, 0], [-1, 0], [0, -1]]], # time 0 + [[[0, 1], [0, -1], [1, 0]]], # time 1 + [[[-1, 0], [1, 0], [0, 1]]], # time 2 + [[[0, -1], [0, 1], [-1, 0]]], # time 3 + ], + dims=["time", "individuals", "keypoints", "space"], + coords={ + "time": time, + "individuals": individuals, + "keypoints": keypoints, + "space": space, + }, + ) + return ds + + +@pytest.fixture +def invalid_input_type_for_forward_vector(valid_data_array_for_forward_vector): + """Return a numpy array of position values by individual, per keypoint, + over time. + """ + return valid_data_array_for_forward_vector.values + + +@pytest.fixture +def invalid_dimensions_for_forward_vector(valid_data_array_for_forward_vector): + """Return a position DataArray in which the ``keypoints`` dimension has + been dropped. + """ + return valid_data_array_for_forward_vector.sel(keypoints="nose", drop=True) + + +@pytest.fixture +def invalid_spatial_dimensions_for_forward_vector( + valid_data_array_for_forward_vector, +): + """Return a position DataArray containing three spatial dimensions.""" + dataarray_3d = valid_data_array_for_forward_vector.pad( + space=(0, 1), constant_values=0 + ) + return dataarray_3d.assign_coords(space=["x", "y", "z"]) + + +@pytest.fixture +def valid_data_array_for_forward_vector_with_nans( + valid_data_array_for_forward_vector, +): + """Return a position DataArray where position values are NaN for the + ``left_ear`` keypoint at time ``1``. + """ + nan_dataarray = valid_data_array_for_forward_vector.where( + (valid_data_array_for_forward_vector.time != 1) + | (valid_data_array_for_forward_vector.keypoints != "left_ear") + ) + return nan_dataarray + + +def test_compute_forward_vector(valid_data_array_for_forward_vector): + """Test that the correct output forward direction vectors + are computed from a valid mock dataset. + """ + forward_vector = kinematics.compute_forward_vector( + valid_data_array_for_forward_vector, + "left_ear", + "right_ear", + camera_view="bottom_up", + ) + forward_vector_flipped = kinematics.compute_forward_vector( + valid_data_array_for_forward_vector, + "left_ear", + "right_ear", + camera_view="top_down", + ) + head_vector = kinematics.compute_head_direction_vector( + valid_data_array_for_forward_vector, + "left_ear", + "right_ear", + camera_view="bottom_up", + ) + known_vectors = np.array([[[0, -1]], [[1, 0]], [[0, 1]], [[-1, 0]]]) + + assert ( + isinstance(forward_vector, xr.DataArray) + and ("space" in forward_vector.dims) + and ("keypoints" not in forward_vector.dims) + ) + assert np.equal(forward_vector.values, known_vectors).all() + assert np.equal(forward_vector_flipped.values, known_vectors * -1).all() + assert head_vector.equals(forward_vector) + + +@pytest.mark.parametrize( + "input_data, expected_error, expected_match_str, keypoints", + [ + ( + "invalid_input_type_for_forward_vector", + TypeError, + "must be an xarray.DataArray", + ["left_ear", "right_ear"], + ), + ( + "invalid_dimensions_for_forward_vector", + ValueError, + "Input data must contain ['keypoints']", + ["left_ear", "right_ear"], + ), + ( + "invalid_spatial_dimensions_for_forward_vector", + ValueError, + "must have exactly 2 spatial dimensions", + ["left_ear", "right_ear"], + ), + ( + "valid_data_array_for_forward_vector", + ValueError, + "keypoints may not be identical", + ["left_ear", "left_ear"], + ), + ], +) +def test_compute_forward_vector_with_invalid_input( + input_data, keypoints, expected_error, expected_match_str, request +): + """Test that ``compute_forward_vector`` catches errors + correctly when passed invalid inputs. + """ + # Get fixture + input_data = request.getfixturevalue(input_data) + + # Catch error + with pytest.raises(expected_error, match=re.escape(expected_match_str)): + kinematics.compute_forward_vector( + input_data, keypoints[0], keypoints[1] + ) + + +def test_nan_behavior_forward_vector( + valid_data_array_for_forward_vector_with_nans, +): + """Test that ``compute_forward_vector()`` generates the + expected output for a valid input DataArray containing ``NaN`` + position values at a single time (``1``) and keypoint + (``left_ear``). + """ + forward_vector = kinematics.compute_forward_vector( + valid_data_array_for_forward_vector_with_nans, "left_ear", "right_ear" + ) + assert ( + np.isnan(forward_vector.values[1, 0, :]).all() + and not np.isnan(forward_vector.values[[0, 2, 3], 0, :]).any() + ) From bfb20d2aedd854d84913dbe79db4e605f6ae7bb1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:37:23 +0100 Subject: [PATCH 49/65] [pre-commit.ci] pre-commit autoupdate (#318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0) - [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7443f398..f5d6d261 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: autoupdate_schedule: monthly repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-docstring-first @@ -29,7 +29,7 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.3 + rev: v0.6.9 hooks: - id: ruff - id: ruff-format From a42838d2f8f299845849c343bed67ca92db6a905 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Thu, 17 Oct 2024 13:47:26 +0200 Subject: [PATCH 50/65] Update integration tests (#295) * Add filter with nan under threshold and varying window * Get kinematics tests * Adapt integration tests for kinematics+polar * Update integration tests for filtering * Fix factor 2 difference * Update conftest * Remove redundant comment in conftest * Apply feedback from kinematic tests * Cosmetic changes * Spoof user-agent to avoid 403 error * Check different URL * Ignore link to license temporarily * Try fake-useragent * Revert "Try fake-useragent" This reverts commit d67de0edc0bb0baea7a2d6fc23bf94237988a5df. --- docs/source/conf.py | 1 + tests/test_integration/test_filtering.py | 71 +++++------- .../test_kinematics_vector_transform.py | 106 ++++++++++++++---- tests/test_unit/test_filtering.py | 47 ++++++++ 4 files changed, 161 insertions(+), 64 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 9b051fb0..fda3e86f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -174,6 +174,7 @@ # A list of regular expressions that match URIs that should not be checked linkcheck_ignore = [ "https://pubs.acs.org/doi/*", # Checking dois is forbidden here + "https://opensource.org/license/bsd-3-clause/", # to avoid odd 403 error ] myst_url_schemes = { diff --git a/tests/test_integration/test_filtering.py b/tests/test_integration/test_filtering.py index 90efce6c..cba430f0 100644 --- a/tests/test_integration/test_filtering.py +++ b/tests/test_integration/test_filtering.py @@ -9,70 +9,56 @@ @pytest.fixture def sample_dataset(): - """Return a single-animal sample dataset, with time unit in frames. - This allows us to better control the expected number of NaNs in the tests. - """ + """Return a single-animal sample dataset, with time unit in frames.""" ds_path = fetch_dataset_paths("DLC_single-mouse_EPM.predictions.h5")[ "poses" ] ds = load_poses.from_dlc_file(ds_path) - ds["velocity"] = ds.move.compute_velocity() + return ds @pytest.mark.parametrize("window", [3, 5, 6, 13]) def test_nan_propagation_through_filters(sample_dataset, window, helpers): - """Test NaN propagation when passing a DataArray through - multiple filters sequentially. For the ``median_filter`` - and ``savgol_filter``, the number of NaNs is expected to increase + """Test NaN propagation is as expected when passing a DataArray through + filter by confidence, Savgol filter and interpolation. + For the ``savgol_filter``, the number of NaNs is expected to increase at most by the filter's window length minus one (``window - 1``) multiplied by the number of consecutive NaNs in the input data. """ - # Introduce nans via filter_by_confidence + # Compute number of low confidence keypoints + n_low_confidence_kpts = (sample_dataset.confidence.data < 0.6).sum() + + # Check filter position by confidence creates correct number of NaNs sample_dataset.update( {"position": sample_dataset.move.filter_by_confidence()} ) - expected_n_nans = 13136 - n_nans_confilt = helpers.count_nans(sample_dataset.position) - assert n_nans_confilt == expected_n_nans, ( - f"Expected {expected_n_nans} NaNs in filtered data, " - f"got: {n_nans_confilt}" - ) - n_consecutive_nans = helpers.count_consecutive_nans( - sample_dataset.position - ) - # Apply median filter and check that - # it doesn't introduce too many or too few NaNs - sample_dataset.update( - {"position": sample_dataset.move.median_filter(window)} - ) - n_nans_medfilt = helpers.count_nans(sample_dataset.position) - max_nans_increase = (window - 1) * n_consecutive_nans - assert ( - n_nans_medfilt <= n_nans_confilt + max_nans_increase - ), "Median filter introduced more NaNs than expected." + n_total_nans_input = helpers.count_nans(sample_dataset.position) + assert ( - n_nans_medfilt >= n_nans_confilt - ), "Median filter mysteriously removed NaNs." - n_consecutive_nans = helpers.count_consecutive_nans( + n_total_nans_input + == n_low_confidence_kpts * sample_dataset.dims["space"] + ) + + # Compute maximum expected increase in NaNs due to filtering + n_consecutive_nans_input = helpers.count_consecutive_nans( sample_dataset.position ) + max_nans_increase = (window - 1) * n_consecutive_nans_input - # Apply savgol filter and check that - # it doesn't introduce too many or too few NaNs + # Apply savgol filter and check that number of NaNs is within threshold sample_dataset.update( {"position": sample_dataset.move.savgol_filter(window, polyorder=2)} ) - n_nans_savgol = helpers.count_nans(sample_dataset.position) - max_nans_increase = (window - 1) * n_consecutive_nans - assert ( - n_nans_savgol <= n_nans_medfilt + max_nans_increase - ), "Savgol filter introduced more NaNs than expected." - assert ( - n_nans_savgol >= n_nans_medfilt - ), "Savgol filter mysteriously removed NaNs." - # Interpolate data (without max_gap) to eliminate all NaNs + n_total_nans_savgol = helpers.count_nans(sample_dataset.position) + + # Check that filtering does not reduce number of nans + assert n_total_nans_savgol >= n_total_nans_input + # Check that the increase in nans is below the expected threshold + assert n_total_nans_savgol - n_total_nans_input <= max_nans_increase + + # Interpolate data (without max_gap) and check it eliminates all NaNs sample_dataset.update( {"position": sample_dataset.move.interpolate_over_time()} ) @@ -105,6 +91,9 @@ def test_accessor_filter_method( applied, if valid data variables are passed, otherwise raise an exception. """ + # Compute velocity + sample_dataset["velocity"] = sample_dataset.move.compute_velocity() + with expected_exception as expected_type: if method in ["median_filter", "savgol_filter"]: # supply required "window" argument diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index 65318a08..63ecc2e4 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -1,33 +1,93 @@ -from contextlib import nullcontext as does_not_raise +import math +import numpy as np import pytest import xarray as xr from movement.utils import vector -class TestKinematicsVectorTransform: - """Test the vector transformation functionality with - various kinematic properties. +@pytest.mark.parametrize( + "valid_dataset_uniform_linear_motion", + [ + "valid_poses_dataset_uniform_linear_motion", + "valid_bboxes_dataset", + ], +) +@pytest.mark.parametrize( + "kinematic_variable, expected_kinematics_polar", + [ + ( + "displacement", + [ + np.vstack( + [ + np.zeros((1, 2)), + np.tile([math.sqrt(2), math.atan(1)], (9, 1)), + ], + ), # Individual 0, rho=sqrt(2), phi=45deg + np.vstack( + [ + np.zeros((1, 2)), + np.tile([math.sqrt(2), -math.atan(1)], (9, 1)), + ] + ), # Individual 1, rho=sqrt(2), phi=-45deg + ], + ), + ( + "velocity", + [ + np.tile( + [math.sqrt(2), math.atan(1)], (10, 1) + ), # Individual O, rho, phi=45deg + np.tile( + [math.sqrt(2), -math.atan(1)], (10, 1) + ), # Individual 1, rho, phi=-45deg + ], + ), + ( + "acceleration", + [ + np.zeros((10, 2)), # Individual 0 + np.zeros((10, 2)), # Individual 1 + ], + ), + ], +) +def test_cart2pol_transform_on_kinematics( + valid_dataset_uniform_linear_motion, + kinematic_variable, + expected_kinematics_polar, + request, +): + """Test transformation between Cartesian and polar coordinates + with various kinematic properties. """ + ds = request.getfixturevalue(valid_dataset_uniform_linear_motion) + kinematic_array_cart = getattr(ds.move, f"compute_{kinematic_variable}")() + kinematic_array_pol = vector.cart2pol(kinematic_array_cart) - @pytest.mark.parametrize( - "ds, expected_exception", - [ - ("valid_poses_dataset", does_not_raise()), - ("valid_poses_dataset_with_nan", does_not_raise()), - ("missing_dim_poses_dataset", pytest.raises(RuntimeError)), - ], + # Build expected data array + expected_array_pol = xr.DataArray( + np.stack(expected_kinematics_polar, axis=1), + # Stack along the "individuals" axis + dims=["time", "individuals", "space"], + ) + if "keypoints" in ds.position.coords: + expected_array_pol = expected_array_pol.expand_dims( + {"keypoints": ds.position.coords["keypoints"].size} + ) + expected_array_pol = expected_array_pol.transpose( + "time", "individuals", "keypoints", "space" + ) + + # Compare the values of the kinematic_array against the expected_array + np.testing.assert_allclose( + kinematic_array_pol.values, expected_array_pol.values + ) + + # Check we can recover the original Cartesian array + kinematic_array_cart_recover = vector.pol2cart(kinematic_array_pol) + xr.testing.assert_allclose( + kinematic_array_cart, kinematic_array_cart_recover ) - def test_cart_and_pol_transform( - self, ds, expected_exception, kinematic_property, request - ): - """Test transformation between Cartesian and polar coordinates - with various kinematic properties. - """ - ds = request.getfixturevalue(ds) - with expected_exception: - data = getattr(ds.move, f"compute_{kinematic_property}")() - pol_data = vector.cart2pol(data) - cart_data = vector.pol2cart(pol_data) - xr.testing.assert_allclose(cart_data, data) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 4b400287..d51af1be 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -233,6 +233,53 @@ def _assert_n_nans_in_position_per_individual( ) +@pytest.mark.parametrize( + "valid_dataset_with_nan", + list_valid_datasets_with_nans, +) +@pytest.mark.parametrize( + "window", + [3, 5, 6, 10], # data is nframes = 10 +) +@pytest.mark.parametrize( + "filter_func", + [median_filter, savgol_filter], +) +def test_filter_with_nans_on_position_varying_window( + valid_dataset_with_nan, window, filter_func, helpers, request +): + """Test that the number of NaNs in the filtered position data + increases at most by the filter's window length minus one + multiplied by the number of consecutive NaNs in the input data. + """ + # Prepare kwargs per filter + kwargs = {"window": window} + if filter_func == savgol_filter: + kwargs["polyorder"] = 2 + + # Filter position + valid_input_dataset = request.getfixturevalue(valid_dataset_with_nan) + position_filtered = filter_func( + valid_input_dataset.position, + **kwargs, + ) + + # Count number of NaNs in the input and filtered position data + n_total_nans_initial = helpers.count_nans(valid_input_dataset.position) + n_consecutive_nans_initial = helpers.count_consecutive_nans( + valid_input_dataset.position + ) + + n_total_nans_filtered = helpers.count_nans(position_filtered) + + max_nans_increase = (window - 1) * n_consecutive_nans_initial + + # Check that filtering does not reduce number of nans + assert n_total_nans_filtered >= n_total_nans_initial + # Check that the increase in nans is below the expected threshold + assert n_total_nans_filtered - n_total_nans_initial <= max_nans_increase + + @pytest.mark.parametrize( "valid_dataset", list_all_valid_datasets, From b399ce0aeb585ff971dc82f4f9215c4d6a5d2a81 Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:28:57 +0200 Subject: [PATCH 51/65] Fix command for installation of docs dependencies in the guide. (#323) * Change command for installation of docs dependencies in the guide. * Small edits to phrasing * Move environment creation to the top of the section * Combine all dependencies installations --- CONTRIBUTING.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac925113..9f350fdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,11 +171,17 @@ The build job is triggered on each PR, ensuring that the documentation build is The deployment job is only triggered whenever a tag is pushed to the _main_ branch, ensuring that the documentation is published in sync with each PyPI release. + ### Editing the documentation -To edit the documentation, first clone the repository, and install movement in a +To edit the documentation, first clone the repository, and install `movement` in a [development environment](#creating-a-development-environment). +Then, install a few additional dependencies in your development environment to be able to build the documentation locally. To do this, run the following command from the root of the repository: +```sh +pip install -r ./docs/requirements.txt +``` + Now create a new branch, edit the documentation source files (`.md` or `.rst` in the `docs` folder), and commit your changes. Submit your documentation changes via a pull request, following the [same guidelines as for code changes](#pull-requests). @@ -273,19 +279,17 @@ For example, to reference the {meth}`xarray.Dataset.update` method, use: ::: :::: + ### Building the documentation locally -We recommend that you build and view the documentation website locally, before you push it. -To do so, first navigate to `docs/`. -All subsequent commands should be run from within this directory. +We recommend that you build and view the documentation website locally, before you push your proposed changes. + +First, ensure your development environment with the required dependencies is active (see [Editing the documentation](#editing-the-documentation) for details on how to create it). Then, navigate to the `docs/` directory: ```sh cd docs ``` -Install the requirements for building the documentation: -```sh -pip install -r requirements.txt -``` +All subsequent commands should be run from this directory. -Build the documentation: +To build the documentation, run: ::::{tab-set} :::{tab-item} Unix platforms with `make` @@ -303,9 +307,8 @@ The local build can be viewed by opening `docs/build/index.html` in a browser. ::: :::: -To refresh the documentation after making changes, remove all generated files in `docs/`, -including the auto-generated API index `source/api_index.rst`, and those in `build/`, `source/api/`, and `source/examples/`. -Then, re-run the above command to rebuild the documentation. +To re-build the documentation after making changes, run the command below. It will remove all generated files in `docs/`, +including the auto-generated API index `source/api_index.rst`, and those in `build/`, `source/api/`, and `source/examples/`, and then re-build the documentation. ::::{tab-set} :::{tab-item} Unix platforms with `make` From 6c326082f03e8d2ad1bb4d6cb26367e878138806 Mon Sep 17 00:00:00 2001 From: Lauraschwarz <104347948+Lauraschwarz@users.noreply.github.com> Date: Tue, 22 Oct 2024 20:36:22 +0100 Subject: [PATCH 52/65] Add example to convert file formats and changing keypoints (#304) * first draft of example to convert file formats and changing keypoints * second draft to convert and modify pose track files. I split the function into three separate operations. * formatted the code and gotrid of spelling mistakes for the docs * changed outline of code, new functions now handle ds not fpaths and added additional example function to run on fpath * Apply suggestions from code review added Niko's suggestions, implementing comments Co-authored-by: Niko Sirmpilatze * pre-commit changes * corrected import error * pre-commit changes again * get rid of extra underlines * finishing touches * added default thumbnail for examples without plots --------- Co-authored-by: Niko Sirmpilatze --- docs/source/_static/data_icon.png | Bin 0 -> 41099 bytes docs/source/conf.py | 1 + examples/convert_file_formats.py | 231 ++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 docs/source/_static/data_icon.png create mode 100644 examples/convert_file_formats.py diff --git a/docs/source/_static/data_icon.png b/docs/source/_static/data_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..835121ffa412d4e1099eb7da7be593afef9efc43 GIT binary patch literal 41099 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 aVnd$)#a7%Eg&~3$2x(+_zS{8G-G2a_X4>fh literal 0 HcmV?d00001 diff --git a/docs/source/conf.py b/docs/source/conf.py index fda3e86f..42ac0668 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -109,6 +109,7 @@ "dependencies": ["environment.yml"], }, "reference_url": {"movement": None}, + "default_thumb_file": "source/_static/data_icon.png", # default thumbnail image "remove_config_comments": True, # do not render config params set as # sphinx_gallery_config [= value] } diff --git a/examples/convert_file_formats.py b/examples/convert_file_formats.py new file mode 100644 index 00000000..7ee1da6e --- /dev/null +++ b/examples/convert_file_formats.py @@ -0,0 +1,231 @@ +"""Convert pose tracks between file formats +=========================================== + +Load pose tracks from one file format, modify them, +and save them to another file format. +""" + +# %% +# Motivation +# ---------- +# When working with pose estimation data, it's often useful to convert +# between file formats. For example, you may need to +# use some downstream analysis tool that requires a specific file +# format, or expects the keypoints to be named in a certain way. +# +# In the following example, we will load a dataset from a +# SLEAP file, modify the keypoints (rename, delete, reorder), +# and save the modified dataset as a DeepLabCut file. +# +# We'll first walk through each step separately, and then +# combine them into a single function that can be applied +# to multiple files at once. + +# %% +# Imports +# ------- +import tempfile +from pathlib import Path + +from movement import sample_data +from movement.io import load_poses, save_poses + +# %% +# Load the dataset +# ---------------- +# We'll start with the path to a file output by one of +# our :ref:`supported pose estimation frameworks`. +# For example, the path could be something like: + +# uncomment and edit the following line to point to your own local file +# file_path = "/path/to/my/data.h5" + +# %% +# For the sake of this example, we will use the path to one of +# the sample datasets provided with ``movement``. + +file_path = sample_data.fetch_dataset_paths( + "SLEAP_single-mouse_EPM.analysis.h5" +)["poses"] +print(file_path) + +# %% +# Now let's load this file into a +# :ref:`movement poses dataset`, +# which we can then modify to our liking. + +ds = load_poses.from_sleap_file(file_path, fps=30) +print(ds, "\n") +print("Individuals:", ds.coords["individuals"].values) +print("Keypoints:", ds.coords["keypoints"].values) + + +# %% +# .. note:: +# If you're running this code in a Jupyter notebook, +# you can just type ``ds`` (instead of printing it) +# to explore the dataset interactively. + +# %% +# Rename keypoints +# ---------------- +# We start with a dictionary that maps old keypoint names to new ones. +# Next, we define a function that takes that dictionary and a dataset +# as inputs, and returns a modified dataset. Notice that under the hood +# this function calls :meth:`xarray.Dataset.assign_coords`. + +rename_dict = { + "snout": "nose", + "left_ear": "earL", + "right_ear": "earR", + "centre": "middle", + "tail_base": "tailbase", + "tail_end": "tailend", +} + + +def rename_keypoints(ds, rename_dict): + # get the current names of the keypoints + keypoint_names = ds.coords["keypoints"].values + + # rename the keypoints + if not rename_dict: + print("No keypoints to rename. Skipping renaming step.") + else: + new_keypoints = [rename_dict.get(kp, str(kp)) for kp in keypoint_names] + # Assign the modified values back to the Dataset + ds = ds.assign_coords(keypoints=new_keypoints) + return ds + + +# %% +# Let's apply the function to our dataset and see the results. +ds_renamed = rename_keypoints(ds, rename_dict) +print("Keypoints in modified dataset:", ds_renamed.coords["keypoints"].values) + + +# %% +# Delete keypoints +# ----------------- +# Let's create a list of keypoints to delete. +# In this case, we choose to get rid of the ``tailend`` keypoint, +# which is often hard to reliably track. +# We delete it using :meth:`xarray.Dataset.drop_sel`, +# wrapped in an appropriately named function. + +keypoints_to_delete = ["tailend"] + + +def delete_keypoints(ds, delete_keypoints): + if not delete_keypoints: + print("No keypoints to delete. Skipping deleting step.") + else: + # Delete the specified keypoints and their corresponding data + ds = ds.drop_sel(keypoints=delete_keypoints) + return ds + + +ds_deleted = delete_keypoints(ds_renamed, keypoints_to_delete) +print("Keypoints in modified dataset:", ds_deleted.coords["keypoints"].values) + + +# %% +# Reorder keypoints +# ------------------ +# We start with a list of keypoints in the desired order +# (in this case, we'll just swap the order of the left and right ears). +# We then use :meth:`xarray.Dataset.reindex`, wrapped in yet another function. + +ordered_keypoints = ["nose", "earR", "earL", "middle", "tailbase"] + + +def reorder_keypoints(ds, ordered_keypoints): + if not ordered_keypoints: + print("No keypoints to reorder. Skipping reordering step.") + else: + ds = ds.reindex(keypoints=ordered_keypoints) + return ds + + +ds_reordered = reorder_keypoints(ds_deleted, ordered_keypoints) +print( + "Keypoints in modified dataset:", ds_reordered.coords["keypoints"].values +) + +# %% +# Save the modified dataset +# --------------------------- +# Now that we have modified the dataset to our liking, +# let's save it to a .csv file in the DeepLabCut format. +# In this case, we save the file to a temporary +# directory, and we use the same file name +# as the original, but ending in ``_dlc.csv``. +# You will need to specify a different ``target_dir`` and edit +# the ``dest_path`` variable to your liking. + +target_dir = tempfile.mkdtemp() +dest_path = Path(target_dir) / f"{file_path.stem}_dlc.csv" + +save_poses.to_dlc_file(ds_reordered, dest_path, split_individuals=False) +print(f"Saved modified dataset to {dest_path}.") + +# %% +# .. note:: +# The ``split_individuals`` argument allows you to save +# a dataset with multiple individuals as separate files, +# with the individual ID appended to each file name. +# In this case, we set it to ``False`` because we only have +# one individual in the dataset, and we don't need its name +# appended to the file name. + + +# %% +# One function to rule them all +# ----------------------------- +# Since we know how to rename, delete, and reorder keypoints, +# let's put it all together in a single function +# and see how we could apply it to multiple files at once, +# as we might do in a real-world scenario. +# +# The following function will convert all files in a folder +# (that end with a specified suffix) from SLEAP to DeepLabCut format. +# Each file will be loaded, modified according to the +# ``rename_dict``, ``keypoints_to_delete``, and ``ordered_keypoints`` +# we've defined above, and saved to the target directory. + + +data_dir = "/path/to/your/data/" +target_dir = "/path/to/your/target/data/" + + +def convert_all(data_dir, target_dir, suffix=".slp"): + source_folder = Path(data_dir) + file_paths = list(source_folder.rglob(f"*{suffix}")) + + for file_path in file_paths: + file_path = Path(file_path) + + # this determines the file names for the modified files + dest_path = Path(target_dir) / f"{file_path.stem}_dlc.csv" + + if dest_path.exists(): + print(f"Skipping {file_path} as {dest_path} already exists.") + continue + + if file_path.exists(): + print(f"Processing: {file_path}") + # load the data from SLEAP file + ds = load_poses.from_sleap_file(file_path) + # modify the data + ds_renamed = rename_keypoints(ds, rename_dict) + ds_deleted = delete_keypoints(ds_renamed, keypoints_to_delete) + ds_reordered = reorder_keypoints(ds_deleted, ordered_keypoints) + # save modified data to a DeepLabCut file + save_poses.to_dlc_file( + ds_reordered, dest_path, split_individuals=False + ) + else: + raise ValueError( + f"File '{file_path}' does not exist. " + f"Please check the file path and try again." + ) From f7f3b48265f80b9a62059a5cbb55572b773e2ee7 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Thu, 24 Oct 2024 11:50:51 +0100 Subject: [PATCH 53/65] Drop the `.move` accessor (#322) * replace accessor with MovementDataset dataclass * moved pre-save validations inside save_poses module * deleted accessor code and associated tests * define dataset structure in modular classes * updated stale docstring for _validate_dataset() * remove mentions of the accessor from the getting started guide * dropped accessor use in examples * ignore linkcheck for opensource licenses * Revert "ignore linkcheck for opensource licenses" This reverts commit c8f3498f2c911ac79c08cc909746f2a7335a4699. * use ds.sizes instead of ds.dims to suppress warning * Add references * remove movement_dataset.py module --------- Co-authored-by: lochhh --- .../getting_started/movement_dataset.md | 58 ++-- examples/compute_kinematics.py | 45 ++- examples/filter_and_interpolate.py | 102 ++---- examples/smooth.py | 82 ++--- movement/__init__.py | 1 - movement/io/load_bboxes.py | 3 +- movement/io/load_poses.py | 3 +- movement/io/save_poses.py | 20 +- movement/move_accessor.py | 302 ------------------ movement/validators/datasets.py | 9 +- tests/conftest.py | 8 +- tests/test_integration/test_filtering.py | 76 ++--- .../test_kinematics_vector_transform.py | 5 +- tests/test_unit/test_load_bboxes.py | 4 +- tests/test_unit/test_load_poses.py | 4 +- tests/test_unit/test_move_accessor.py | 128 -------- tests/test_unit/test_save_poses.py | 6 +- .../test_datasets_validators.py | 2 +- 18 files changed, 168 insertions(+), 690 deletions(-) delete mode 100644 movement/move_accessor.py delete mode 100644 tests/test_unit/test_move_accessor.py diff --git a/docs/source/getting_started/movement_dataset.md b/docs/source/getting_started/movement_dataset.md index 6b2ef5e9..7e80a81c 100644 --- a/docs/source/getting_started/movement_dataset.md +++ b/docs/source/getting_started/movement_dataset.md @@ -195,7 +195,7 @@ For example, you can: [data aggregation and broadcasting](xarray:user-guide/computation.html), and - use `xarray`'s built-in [plotting methods](xarray:user-guide/plotting.html). -As an example, here's how you can use the `sel` method to select subsets of +As an example, here's how you can use {meth}`xarray.Dataset.sel` to select subsets of data: ```python @@ -223,44 +223,41 @@ position = ds.position.sel( ) # the output is a data array ``` -### Accessing movement-specific functionality +### Modifying movement datasets -`movement` extends `xarray`'s functionality with a number of convenience -methods that are specific to `movement` datasets. These `movement`-specific methods are accessed using the -`move` keyword. +Datasets can be modified by adding new **data variables** and **attributes**, +or updating existing ones. -For example, to compute the velocity and acceleration vectors for all individuals and keypoints across time, we provide the `move.compute_velocity` and `move.compute_acceleration` methods: +Let's imagine we want to compute the instantaneous velocity of all tracked +points and store the results within the same dataset, for convenience. ```python -velocity = ds.move.compute_velocity() -acceleration = ds.move.compute_acceleration() -``` +from movement.analysis.kinematics import compute_velocity -The `movement`-specific functionalities are implemented in the -{class}`movement.move_accessor.MovementDataset` class, which is an [accessor](https://docs.xarray.dev/en/stable/internals/extending-xarray.html) to the -underlying {class}`xarray.Dataset` object. Defining a custom accessor is convenient -to avoid conflicts with `xarray`'s built-in methods. +# compute velocity from position +velocity = compute_velocity(ds.position) +# add it to the dataset as a new data variable +ds["velocity"] = velocity -### Modifying movement datasets +# we could have also done both steps in a single line +ds["velocity"] = compute_velocity(ds.position) -The `velocity` and `acceleration` produced in the above example are {class}`xarray.DataArray` objects, with the same **dimensions** as the -original `position` **data variable**. +# we can now access velocity like any other data variable +ds.velocity +``` -In some cases, you may wish to -add these or other new **data variables** to the `movement` dataset for -convenience. This can be done by simply assigning them to the dataset -with an appropriate name: +The output of {func}`movement.analysis.kinematics.compute_velocity` is an {class}`xarray.DataArray` object, +with the same **dimensions** as the original `position` **data variable**, +so adding it to the existing `ds` makes sense and works seamlessly. -```python -ds["velocity"] = velocity -ds["acceleration"] = acceleration +We can also update existing **data variables** in-place, using {meth}`xarray.Dataset.update`. For example, if we wanted to update the `position` +and `velocity` arrays in our dataset, we could do: -# we can now access these using dot notation on the dataset -ds.velocity -ds.acceleration +```python +ds.update({"position": position_filtered, "velocity": velocity_filtered}) ``` -Custom **attributes** can also be added to the dataset: +Custom **attributes** can be added to the dataset with: ```python ds.attrs["my_custom_attribute"] = "my_custom_value" @@ -268,10 +265,3 @@ ds.attrs["my_custom_attribute"] = "my_custom_value" # we can now access this value using dot notation on the dataset ds.my_custom_attribute ``` - -We can also update existing **data variables** in-place, using the `update()` method. For example, if we wanted to update the `position` -and `velocity` arrays in our dataset, we could do: - -```python -ds.update({"position": position_filtered, "velocity": velocity_filtered}) -``` diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py index b3fefd4a..d9107696 100644 --- a/examples/compute_kinematics.py +++ b/examples/compute_kinematics.py @@ -117,27 +117,22 @@ # %% # Compute displacement # --------------------- +# The :mod:`movement.analysis.kinematics` module provides functions to compute +# various kinematic quantities, +# such as displacement, velocity, and acceleration. # We can start off by computing the distance travelled by the mice along -# their trajectories. -# For this, we can use the ``compute_displacement`` method of the -# ``move`` accessor. -displacement = ds.move.compute_displacement() +# their trajectories: -# %% -# This method will return a data array equivalent to the ``position`` one, -# but holding displacement data along the ``space`` axis, rather than -# position data. - -# %% -# Notice that we could also compute the displacement (and all the other -# kinematic variables) using the :mod:`movement.analysis.kinematics` module: - -# %% import movement.analysis.kinematics as kin -displacement_kin = kin.compute_displacement(position) +displacement = kin.compute_displacement(position) # %% +# The :func:`movement.analysis.kinematics.compute_displacement` +# function will return a data array equivalent to the ``position`` one, +# but holding displacement data along the ``space`` axis, rather than +# position data. +# # The ``displacement`` data array holds, for a given individual and keypoint # at timestep ``t``, the vector that goes from its previous position at time # ``t-1`` to its current position at time ``t``. @@ -271,13 +266,14 @@ # ---------------- # We can easily compute the velocity vectors for all individuals in our data # array: -velocity = ds.move.compute_velocity() +velocity = kin.compute_velocity(position) # %% -# The ``velocity`` method will return a data array equivalent to the -# ``position`` one, but holding velocity data along the ``space`` axis, rather -# than position data. Notice how ``xarray`` nicely deals with the different -# individuals and spatial dimensions for us! ✨ +# The :func:`movement.analysis.kinematics.compute_velocity` +# function will return a data array equivalent to +# the ``position`` one, but holding velocity data along the ``space`` axis, +# rather than position data. Notice how ``xarray`` nicely deals with the +# different individuals and spatial dimensions for us! ✨ # %% # We can plot the components of the velocity vector against time @@ -350,8 +346,9 @@ # %% # Compute acceleration # --------------------- -# We can compute the acceleration of the data with an equivalent method: -accel = ds.move.compute_acceleration() +# Let's now compute the acceleration for all individuals in our data +# array: +accel = kin.compute_acceleration(position) # %% # and plot of the components of the acceleration vector ``ax``, ``ay`` per @@ -375,8 +372,8 @@ fig.tight_layout() # %% -# The can also represent the magnitude (norm) of the acceleration vector -# for each individual: +# We can also compute and visualise the magnitude (norm) of the +# acceleration vector for each individual: fig, axes = plt.subplots(3, 1, sharex=True, sharey=True) for mouse_name, ax in zip(accel.individuals.values, axes, strict=False): # compute magnitude of the acceleration vector for one mouse diff --git a/examples/filter_and_interpolate.py b/examples/filter_and_interpolate.py index 71384ca7..fa18582b 100644 --- a/examples/filter_and_interpolate.py +++ b/examples/filter_and_interpolate.py @@ -9,6 +9,8 @@ # Imports # ------- from movement import sample_data +from movement.analysis.kinematics import compute_velocity +from movement.filtering import filter_by_confidence, interpolate_over_time # %% # Load a sample dataset @@ -73,35 +75,19 @@ # %% # Filter out points with low confidence # ------------------------------------- -# Using the -# :meth:`filter_by_confidence()\ -# ` -# method of the ``move`` accessor, -# we can filter out points with confidence scores below a certain threshold. -# The default ``threshold=0.6`` will be used when ``threshold`` is not -# provided. -# This method will also report the number of NaN values in the dataset before -# and after the filtering operation by default (``print_report=True``). +# Using the :func:`movement.filtering.filter_by_confidence` function from the +# :mod:`movement.filtering` module, we can filter out points with confidence +# scores below a certain threshold. This function takes ``position`` and +# ``confidence`` as required arguments, and accepts an optional ``threshold`` +# parameter, which defaults to ``threshold=0.6`` unless specified otherwise. +# The function will also report the number of NaN values in the dataset before +# and after the filtering operation by default, but you can disable this +# by passing ``print_report=False``. +# # We will use :meth:`xarray.Dataset.update` to update ``ds`` in-place # with the filtered ``position``. -ds.update({"position": ds.move.filter_by_confidence()}) - -# %% -# .. note:: -# The ``move`` accessor :meth:`filter_by_confidence()\ -# ` -# method is a convenience method that applies -# :func:`movement.filtering.filter_by_confidence`, -# which takes ``position`` and ``confidence`` as arguments. -# The equivalent function call using the -# :mod:`movement.filtering` module would be: -# -# .. code-block:: python -# -# from movement.filtering import filter_by_confidence -# -# ds.update({"position": filter_by_confidence(position, confidence)}) +ds.update({"position": filter_by_confidence(ds.position, ds.confidence)}) # %% # We can see that the filtering operation has introduced NaN values in the @@ -120,36 +106,16 @@ # %% # Interpolate over missing values # ------------------------------- -# Using the -# :meth:`interpolate_over_time()\ -# ` -# method of the ``move`` accessor, -# we can interpolate over the gaps we've introduced in the pose tracks. +# Using the :func:`movement.filtering.interpolate_over_time` function from the +# :mod:`movement.filtering` module, we can interpolate over gaps +# we've introduced in the pose tracks. # Here we use the default linear interpolation method (``method=linear``) # and interpolate over gaps of 40 frames or less (``max_gap=40``). # The default ``max_gap=None`` would interpolate over all gaps, regardless of # their length, but this should be used with caution as it can introduce # spurious data. The ``print_report`` argument acts as described above. -ds.update({"position": ds.move.interpolate_over_time(max_gap=40)}) - -# %% -# .. note:: -# The ``move`` accessor :meth:`interpolate_over_time()\ -# ` -# is also a convenience method that applies -# :func:`movement.filtering.interpolate_over_time` -# to the ``position`` data variable. -# The equivalent function call using the -# :mod:`movement.filtering` module would be: -# -# .. code-block:: python -# -# from movement.filtering import interpolate_over_time -# -# ds.update({"position": interpolate_over_time( -# position_filtered, max_gap=40 -# )}) +ds.update({"position": interpolate_over_time(ds.position, max_gap=40)}) # %% # We see that all NaN values have disappeared, meaning that all gaps were @@ -176,27 +142,25 @@ # %% # Filtering multiple data variables # --------------------------------- -# All :mod:`movement.filtering` functions are available via the -# ``move`` accessor. These ``move`` accessor methods operate on the -# ``position`` data variable in the dataset ``ds`` by default. -# There is also an additional argument ``data_vars`` that allows us to -# specify which data variables in ``ds`` to filter. -# When multiple data variable names are specified in ``data_vars``, -# the method will return a dictionary with the data variable names as keys -# and the filtered DataArrays as values, otherwise it will return a single -# DataArray that is the filtered data. -# This is useful when we want to apply the same filtering operation to +# We can also apply the same filtering operation to # multiple data variables in ``ds`` at the same time. # # For instance, to filter both ``position`` and ``velocity`` data variables -# in ``ds``, based on the confidence scores, we can specify -# ``data_vars=["position", "velocity"]`` in the method call. -# As the filtered data variables are returned as a dictionary, we can once -# again use :meth:`xarray.Dataset.update` to update ``ds`` in-place +# in ``ds``, based on the confidence scores, we can specify a dictionary +# with the data variable names as keys and the corresponding filtered +# DataArrays as values. Then we can once again use +# :meth:`xarray.Dataset.update` to update ``ds`` in-place # with the filtered data variables. -ds["velocity"] = ds.move.compute_velocity() -filtered_data_dict = ds.move.filter_by_confidence( - data_vars=["position", "velocity"] -) -ds.update(filtered_data_dict) +# Add velocity data variable to the dataset +ds["velocity"] = compute_velocity(ds.position) + +# Create a dictionary mapping data variable names to filtered DataArrays +# We disable report printing for brevity +update_dict = { + var: filter_by_confidence(ds[var], ds.confidence, print_report=False) + for var in ["position", "velocity"] +} + +# Use the dictionary to update the dataset in-place +ds.update(update_dict) diff --git a/examples/smooth.py b/examples/smooth.py index 316d9444..f87ac411 100644 --- a/examples/smooth.py +++ b/examples/smooth.py @@ -12,6 +12,11 @@ from scipy.signal import welch from movement import sample_data +from movement.filtering import ( + interpolate_over_time, + median_filter, + savgol_filter, +) # %% # Load a sample dataset @@ -33,8 +38,8 @@ # %% # Define a plotting function # -------------------------- -# Let's define a plotting function to help us visualise the effects smoothing -# both in the time and frequency domains. +# Let's define a plotting function to help us visualise the effects of +# smoothing both in the time and frequency domains. # The function takes as inputs two datasets containing raw and smooth data # respectively, and plots the position time series and power spectral density # (PSD) for a given individual and keypoint. The function also allows you to @@ -77,9 +82,8 @@ def plot_raw_and_smooth_timeseries_and_psd( ) # interpolate data to remove NaNs in the PSD calculation - pos_interp = ds.sel(**selection).move.interpolate_over_time( - print_report=False - ) + pos_interp = interpolate_over_time(pos, print_report=False) + # compute and plot the PSD freq, psd = welch(pos_interp, fs=ds.fps, nperseg=256) ax[1].semilogy( @@ -108,12 +112,9 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # Smoothing with a median filter # ------------------------------ -# Using the -# :meth:`median_filter()\ -# ` -# method of the ``move`` accessor, -# we apply a rolling window median filter over a 0.1-second window -# (4 frames) to the wasp dataset. +# Using the :func:`movement.filtering.median_filter` function on the +# ``position`` data variable, we can apply a rolling window median filter +# over a 0.1-second window (4 frames) to the wasp dataset. # As the ``window`` parameter is defined in *number of observations*, # we can simply multiply the desired time window by the frame rate # of the video. We will also create a copy of the dataset to avoid @@ -121,23 +122,7 @@ def plot_raw_and_smooth_timeseries_and_psd( window = int(0.1 * ds_wasp.fps) ds_wasp_smooth = ds_wasp.copy() -ds_wasp_smooth.update({"position": ds_wasp_smooth.move.median_filter(window)}) - -# %% -# .. note:: -# The ``move`` accessor :meth:`median_filter()\ -# ` -# method is a convenience method that applies -# :func:`movement.filtering.median_filter` -# to the ``position`` data variable. -# The equivalent function call using the -# :mod:`movement.filtering` module would be: -# -# .. code-block:: python -# -# from movement.filtering import median_filter -# -# ds_wasp_smooth.update({"position": median_filter(position, window)}) +ds_wasp_smooth.update({"position": median_filter(ds_wasp.position, window)}) # %% # We see from the printed report that the dataset has no missing values @@ -181,9 +166,7 @@ def plot_raw_and_smooth_timeseries_and_psd( window = int(0.1 * ds_mouse.fps) ds_mouse_smooth = ds_mouse.copy() -ds_mouse_smooth.update( - {"position": ds_mouse_smooth.move.median_filter(window)} -) +ds_mouse_smooth.update({"position": median_filter(ds_mouse.position, window)}) # %% # The report informs us that the raw data contains NaN values, most of which @@ -199,7 +182,7 @@ def plot_raw_and_smooth_timeseries_and_psd( # window are sufficient for the median to be calculated. Let's try this. ds_mouse_smooth.update( - {"position": ds_mouse.move.median_filter(window, min_periods=2)} + {"position": median_filter(ds_mouse.position, window, min_periods=2)} ) # %% @@ -222,7 +205,7 @@ def plot_raw_and_smooth_timeseries_and_psd( window = int(2 * ds_mouse.fps) ds_mouse_smooth.update( - {"position": ds_mouse.move.median_filter(window, min_periods=2)} + {"position": median_filter(ds_mouse.position, window, min_periods=2)} ) # %% @@ -248,13 +231,9 @@ def plot_raw_and_smooth_timeseries_and_psd( # %% # Smoothing with a Savitzky-Golay filter # -------------------------------------- -# Here we use the -# :meth:`savgol_filter()\ -# ` -# method of the ``move`` accessor, which is a convenience method that applies -# :func:`movement.filtering.savgol_filter` -# (a wrapper around :func:`scipy.signal.savgol_filter`), -# to the ``position`` data variable. +# Here we apply the :func:`movement.filtering.savgol_filter` function +# (a wrapper around :func:`scipy.signal.savgol_filter`), to the ``position`` +# data variable. # The Savitzky-Golay filter is a polynomial smoothing filter that can be # applied to time series data on a rolling window basis. # A polynomial with a degree specified by ``polyorder`` is applied to each @@ -268,7 +247,7 @@ def plot_raw_and_smooth_timeseries_and_psd( # to be used as the ``window`` size. window = int(0.2 * ds_mouse.fps) -ds_mouse_smooth.update({"position": ds_mouse.move.savgol_filter(window)}) +ds_mouse_smooth.update({"position": savgol_filter(ds_mouse.position, window)}) # %% # We see that the number of NaN values has increased after filtering. This is @@ -289,7 +268,7 @@ def plot_raw_and_smooth_timeseries_and_psd( # Now let's apply the same Savitzky-Golay filter to the wasp dataset. window = int(0.2 * ds_wasp.fps) -ds_wasp_smooth.update({"position": ds_wasp.move.savgol_filter(window)}) +ds_wasp_smooth.update({"position": savgol_filter(ds_wasp.position, window)}) # %% plot_raw_and_smooth_timeseries_and_psd( @@ -315,27 +294,24 @@ def plot_raw_and_smooth_timeseries_and_psd( # with a larger ``window`` to further smooth the data. # Between the two filters, we can interpolate over small gaps to avoid the # excessive proliferation of NaN values. Let's try this on the mouse dataset. -# First, we will apply the median filter. +# First, we will apply the median filter. window = int(0.1 * ds_mouse.fps) ds_mouse_smooth.update( - {"position": ds_mouse.move.median_filter(window, min_periods=2)} + {"position": median_filter(ds_mouse.position, window, min_periods=2)} ) -# %% -# Next, let's linearly interpolate over gaps smaller than 1 second (30 frames). - +# Next, let's linearly interpolate over gaps smaller +# than 1 second (30 frames). ds_mouse_smooth.update( - {"position": ds_mouse_smooth.move.interpolate_over_time(max_gap=30)} + {"position": interpolate_over_time(ds_mouse_smooth.position, max_gap=30)} ) -# %% -# Finally, let's apply the Savitzky-Golay filter over a 0.4-second window -# (12 frames). - +# Finally, let's apply the Savitzky-Golay filter +# over a 0.4-second window (12 frames). window = int(0.4 * ds_mouse.fps) ds_mouse_smooth.update( - {"position": ds_mouse_smooth.move.savgol_filter(window)} + {"position": savgol_filter(ds_mouse_smooth.position, window)} ) # %% diff --git a/movement/__init__.py b/movement/__init__.py index bc9115b1..bf5d4a2d 100644 --- a/movement/__init__.py +++ b/movement/__init__.py @@ -1,7 +1,6 @@ from importlib.metadata import PackageNotFoundError, version from movement.utils.logging import configure_logging -from movement.move_accessor import MovementDataset try: __version__ = version("movement") diff --git a/movement/io/load_bboxes.py b/movement/io/load_bboxes.py index 8550a2e8..3e1b0e0d 100644 --- a/movement/io/load_bboxes.py +++ b/movement/io/load_bboxes.py @@ -11,7 +11,6 @@ import pandas as pd import xarray as xr -from movement import MovementDataset from movement.utils.logging import log_error from movement.validators.datasets import ValidBboxesDataset from movement.validators.files import ValidFile, ValidVIATracksCSV @@ -631,7 +630,7 @@ def _ds_from_valid_data(data: ValidBboxesDataset) -> xr.Dataset: # Convert data to an xarray.Dataset # with dimensions ('time', 'individuals', 'space') - DIM_NAMES = MovementDataset.dim_names["bboxes"] + DIM_NAMES = ValidBboxesDataset.DIM_NAMES n_space = data.position_array.shape[-1] return xr.Dataset( data_vars={ diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index 2b1a25d8..f425d8a1 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -11,7 +11,6 @@ from sleap_io.io.slp import read_labels from sleap_io.model.labels import Labels -from movement import MovementDataset from movement.utils.logging import log_error, log_warning from movement.validators.datasets import ValidPosesDataset from movement.validators.files import ValidDeepLabCutCSV, ValidFile, ValidHDF5 @@ -654,7 +653,7 @@ def _ds_from_valid_data(data: ValidPosesDataset) -> xr.Dataset: time_coords = time_coords / data.fps time_unit = "seconds" - DIM_NAMES = MovementDataset.dim_names["poses"] + DIM_NAMES = ValidPosesDataset.DIM_NAMES # Convert data to an xarray.Dataset return xr.Dataset( data_vars={ diff --git a/movement/io/save_poses.py b/movement/io/save_poses.py index bc2c0e1c..c47d28f1 100644 --- a/movement/io/save_poses.py +++ b/movement/io/save_poses.py @@ -10,6 +10,7 @@ import xarray as xr from movement.utils.logging import log_error +from movement.validators.datasets import ValidPosesDataset from movement.validators.files import ValidFile logger = logging.getLogger(__name__) @@ -424,12 +425,25 @@ def _validate_dataset(ds: xr.Dataset) -> None: Raises ------ + TypeError + If the input is not an xarray Dataset. ValueError - If `ds` is not an a valid ``movement`` dataset. + If the dataset is missing required data variables or dimensions. """ if not isinstance(ds, xr.Dataset): raise log_error( - ValueError, f"Expected an xarray Dataset, but got {type(ds)}." + TypeError, f"Expected an xarray Dataset, but got {type(ds)}." ) - ds.move.validate() # validate the dataset + + missing_vars = set(ValidPosesDataset.VAR_NAMES) - set(ds.data_vars) + if missing_vars: + raise ValueError( + f"Missing required data variables: {sorted(missing_vars)}" + ) # sort for a reproducible error message + + missing_dims = set(ValidPosesDataset.DIM_NAMES) - set(ds.dims) + if missing_dims: + raise ValueError( + f"Missing required dimensions: {sorted(missing_dims)}" + ) # sort for a reproducible error message diff --git a/movement/move_accessor.py b/movement/move_accessor.py deleted file mode 100644 index 64b17651..00000000 --- a/movement/move_accessor.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Accessor for extending :class:`xarray.Dataset` objects.""" - -import logging -from typing import ClassVar - -import xarray as xr - -from movement import filtering -from movement.analysis import kinematics -from movement.utils.logging import log_error -from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset - -logger = logging.getLogger(__name__) - -# Preserve the attributes (metadata) of xarray objects after operations -xr.set_options(keep_attrs=True) - - -@xr.register_dataset_accessor("move") -class MovementDataset: - """An :class:`xarray.Dataset` accessor for ``movement`` data. - - A ``movement`` dataset is an :class:`xarray.Dataset` with a specific - structure to represent pose tracks or bounding boxes data, - associated confidence scores and relevant metadata. - - Methods/properties that extend the standard ``xarray`` functionality are - defined in this class. To avoid conflicts with ``xarray``'s namespace, - ``movement``-specific methods are accessed using the ``move`` keyword, - for example ``ds.move.validate()`` (see [1]_ for more details). - - Attributes - ---------- - dim_names : dict - A dictionary with the names of the expected dimensions in the dataset, - for each dataset type (``"poses"`` or ``"bboxes"``). - var_names : dict - A dictionary with the expected data variables in the dataset, for each - dataset type (``"poses"`` or ``"bboxes"``). - - References - ---------- - .. [1] https://docs.xarray.dev/en/stable/internals/extending-xarray.html - - """ - - # Set class attributes for expected dimensions and data variables - dim_names: ClassVar[dict] = { - "poses": ("time", "individuals", "keypoints", "space"), - "bboxes": ("time", "individuals", "space"), - } - var_names: ClassVar[dict] = { - "poses": ("position", "confidence"), - "bboxes": ("position", "shape", "confidence"), - } - - def __init__(self, ds: xr.Dataset): - """Initialize the MovementDataset.""" - self._obj = ds - # Set instance attributes based on dataset type - self.dim_names_instance = self.dim_names[self._obj.ds_type] - self.var_names_instance = self.var_names[self._obj.ds_type] - - def __getattr__(self, name: str) -> xr.DataArray: - """Forward requested but undefined attributes to relevant modules. - - This method currently only forwards kinematic property computation - and filtering operations to the respective functions in - :mod:`movement.analysis.kinematics` and - :mod:`movement.filtering`. - - Parameters - ---------- - name : str - The name of the attribute to get. - - Returns - ------- - xarray.DataArray - The computed attribute value. - - Raises - ------ - AttributeError - If the attribute does not exist. - - """ - - def method(*args, **kwargs): - if hasattr(kinematics, name): - return self.kinematics_wrapper(name, *args, **kwargs) - elif hasattr(filtering, name): - return self.filtering_wrapper(name, *args, **kwargs) - else: - error_msg = ( - f"'{self.__class__.__name__}' object has " - f"no attribute '{name}'" - ) - raise log_error(AttributeError, error_msg) - - return method - - def kinematics_wrapper( - self, fn_name: str, *args, **kwargs - ) -> xr.DataArray: - """Provide convenience method for computing kinematic properties. - - This method forwards kinematic property computation - to the respective functions in :mod:`movement.analysis.kinematics`. - - Parameters - ---------- - fn_name : str - The name of the kinematics function to call. - args : tuple - Positional arguments to pass to the function. - kwargs : dict - Keyword arguments to pass to the function. - - Returns - ------- - xarray.DataArray - The computed kinematics attribute value. - - Raises - ------ - RuntimeError - If the requested function fails to execute. - - Examples - -------- - Compute ``displacement`` based on the ``position`` data variable - in the Dataset ``ds`` and store the result in ``ds``. - - >>> ds["displacement"] = ds.move.compute_displacement() - - Compute ``velocity`` based on the ``position`` data variable in - the Dataset ``ds`` and store the result in ``ds``. - - >>> ds["velocity"] = ds.move.compute_velocity() - - Compute ``acceleration`` based on the ``position`` data variable - in the Dataset ``ds`` and store the result in ``ds``. - - >>> ds["acceleration"] = ds.move.compute_acceleration() - - """ - try: - return getattr(kinematics, fn_name)( - self._obj.position, *args, **kwargs - ) - except Exception as e: - error_msg = ( - f"Failed to evoke '{fn_name}' via 'move' accessor. {str(e)}" - ) - raise log_error(RuntimeError, error_msg) from e - - def filtering_wrapper( - self, fn_name: str, *args, data_vars: list[str] | None = None, **kwargs - ) -> xr.DataArray | dict[str, xr.DataArray]: - """Provide convenience method for filtering data variables. - - This method forwards filtering and/or smoothing to the respective - functions in :mod:`movement.filtering`. The data variables to - filter can be specified in ``data_vars``. If ``data_vars`` is not - specified, the ``position`` data variable is selected by default. - - Parameters - ---------- - fn_name : str - The name of the filtering function to call. - args : tuple - Positional arguments to pass to the function. - data_vars : list[str] | None - The data variables to apply filtering. If ``None``, the - ``position`` data variable will be passed by default. - kwargs : dict - Keyword arguments to pass to the function. - - Returns - ------- - xarray.DataArray | dict[str, xarray.DataArray] - The filtered data variable or a dictionary of filtered data - variables, if multiple data variables are specified. - - Raises - ------ - RuntimeError - If the requested function fails to execute. - - Examples - -------- - Filter the ``position`` data variable to drop points with - ``confidence`` below 0.7 and store the result back into the - Dataset ``ds``. - Since ``data_vars`` is not supplied, the filter will be applied to - the ``position`` data variable by default. - - >>> ds["position"] = ds.move.filter_by_confidence(threshold=0.7) - - Apply a median filter to the ``position`` data variable and - store this back into the Dataset ``ds``. - - >>> ds["position"] = ds.move.median_filter(window=3) - - Apply a Savitzky-Golay filter to both the ``position`` and - ``velocity`` data variables and store these back into the - Dataset ``ds``. ``filtered_data`` is a dictionary, where the keys - are the data variable names and the values are the filtered - DataArrays. - - >>> filtered_data = ds.move.savgol_filter( - ... window=3, data_vars=["position", "velocity"] - ... ) - >>> ds.update(filtered_data) - - """ - ds = self._obj - if data_vars is None: # Default to filter on position - data_vars = ["position"] - if fn_name == "filter_by_confidence": - # Add confidence to kwargs - kwargs["confidence"] = ds.confidence - try: - result = { - data_var: getattr(filtering, fn_name)( - ds[data_var], *args, **kwargs - ) - for data_var in data_vars - } - # Return DataArray if result only has one key - if len(result) == 1: - return result[list(result.keys())[0]] - return result - except Exception as e: - error_msg = ( - f"Failed to evoke '{fn_name}' via 'move' accessor. {str(e)}" - ) - raise log_error(RuntimeError, error_msg) from e - - def validate(self) -> None: - """Validate the dataset. - - This method checks if the dataset contains the expected dimensions, - data variables, and metadata attributes. It also ensures that the - dataset contains valid poses or bounding boxes data. - - Raises - ------ - ValueError - If the dataset is missing required dimensions, data variables, - or contains invalid poses or bounding boxes data. - - """ - fps = self._obj.attrs.get("fps", None) - source_software = self._obj.attrs.get("source_software", None) - try: - self._validate_dims() - self._validate_data_vars() - if self._obj.ds_type == "poses": - ValidPosesDataset( - position_array=self._obj["position"].values, - confidence_array=self._obj["confidence"].values, - individual_names=self._obj.coords["individuals"].values, - keypoint_names=self._obj.coords["keypoints"].values, - fps=fps, - source_software=source_software, - ) - elif self._obj.ds_type == "bboxes": - # Define frame_array. - # Recover from time axis in seconds if necessary. - frame_array = self._obj.coords["time"].values.reshape(-1, 1) - if self._obj.attrs["time_unit"] == "seconds": - frame_array *= fps - ValidBboxesDataset( - position_array=self._obj["position"].values, - shape_array=self._obj["shape"].values, - confidence_array=self._obj["confidence"].values, - individual_names=self._obj.coords["individuals"].values, - frame_array=frame_array, - fps=fps, - source_software=source_software, - ) - except Exception as e: - error_msg = ( - f"The dataset does not contain valid {self._obj.ds_type}. {e}" - ) - raise log_error(ValueError, error_msg) from e - - def _validate_dims(self) -> None: - missing_dims = set(self.dim_names_instance) - set(self._obj.dims) - if missing_dims: - raise ValueError( - f"Missing required dimensions: {sorted(missing_dims)}" - ) # sort for a reproducible error message - - def _validate_data_vars(self) -> None: - missing_vars = set(self.var_names_instance) - set(self._obj.data_vars) - if missing_vars: - raise ValueError( - f"Missing required data variables: {sorted(missing_vars)}" - ) # sort for a reproducible error message diff --git a/movement/validators/datasets.py b/movement/validators/datasets.py index fd31246d..99a68c10 100644 --- a/movement/validators/datasets.py +++ b/movement/validators/datasets.py @@ -1,7 +1,7 @@ """``attrs`` classes for validating data structures.""" from collections.abc import Iterable -from typing import Any +from typing import Any, ClassVar import attrs import numpy as np @@ -142,6 +142,10 @@ class ValidPosesDataset: validator=validators.optional(validators.instance_of(str)), ) + # Class variables + DIM_NAMES: ClassVar[tuple] = ("time", "individuals", "keypoints", "space") + VAR_NAMES: ClassVar[tuple] = ("position", "confidence") + # Add validators @position_array.validator def _validate_position_array(self, attribute, value): @@ -293,6 +297,9 @@ class ValidBboxesDataset: validator=validators.optional(validators.instance_of(str)), ) + DIM_NAMES: ClassVar[tuple] = ("time", "individuals", "space") + VAR_NAMES: ClassVar[tuple] = ("position", "shape", "confidence") + # Validators @position_array.validator @shape_array.validator diff --git a/tests/conftest.py b/tests/conftest.py index 272e5eaa..6da9a598 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,9 @@ import pytest import xarray as xr -from movement import MovementDataset from movement.sample_data import fetch_dataset_paths, list_datasets from movement.utils.logging import configure_logging +from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset def pytest_configure(): @@ -292,7 +292,7 @@ def valid_bboxes_dataset( """Return a valid bboxes dataset for two individuals moving in uniform linear motion, with 5 frames with low confidence values and time in frames. """ - dim_names = MovementDataset.dim_names["bboxes"] + dim_names = ValidBboxesDataset.DIM_NAMES position_array = valid_bboxes_arrays["position"] shape_array = valid_bboxes_arrays["shape"] @@ -376,7 +376,7 @@ def _valid_position_array(array_type): @pytest.fixture def valid_poses_dataset(valid_position_array, request): """Return a valid pose tracks dataset.""" - dim_names = MovementDataset.dim_names["poses"] + dim_names = ValidPosesDataset.DIM_NAMES # create a multi_individual_array by default unless overridden via param try: array_format = request.param @@ -490,7 +490,7 @@ def valid_poses_dataset_uniform_linear_motion( """Return a valid poses dataset for two individuals moving in uniform linear motion, with 5 frames with low confidence values and time in frames. """ - dim_names = MovementDataset.dim_names["poses"] + dim_names = ValidPosesDataset.DIM_NAMES position_array = valid_poses_array_uniform_linear_motion["position"] confidence_array = valid_poses_array_uniform_linear_motion["confidence"] diff --git a/tests/test_integration/test_filtering.py b/tests/test_integration/test_filtering.py index cba430f0..e3e87901 100644 --- a/tests/test_integration/test_filtering.py +++ b/tests/test_integration/test_filtering.py @@ -1,8 +1,10 @@ -from contextlib import nullcontext as does_not_raise - import pytest -import xarray as xr +from movement.filtering import ( + filter_by_confidence, + interpolate_over_time, + savgol_filter, +) from movement.io import load_poses from movement.sample_data import fetch_dataset_paths @@ -14,7 +16,6 @@ def sample_dataset(): "poses" ] ds = load_poses.from_dlc_file(ds_path) - return ds @@ -31,13 +32,18 @@ def test_nan_propagation_through_filters(sample_dataset, window, helpers): # Check filter position by confidence creates correct number of NaNs sample_dataset.update( - {"position": sample_dataset.move.filter_by_confidence()} + { + "position": filter_by_confidence( + sample_dataset.position, + sample_dataset.confidence, + ) + } ) n_total_nans_input = helpers.count_nans(sample_dataset.position) assert ( n_total_nans_input - == n_low_confidence_kpts * sample_dataset.dims["space"] + == n_low_confidence_kpts * sample_dataset.sizes["space"] ) # Compute maximum expected increase in NaNs due to filtering @@ -48,7 +54,11 @@ def test_nan_propagation_through_filters(sample_dataset, window, helpers): # Apply savgol filter and check that number of NaNs is within threshold sample_dataset.update( - {"position": sample_dataset.move.savgol_filter(window, polyorder=2)} + { + "position": savgol_filter( + sample_dataset.position, window, polyorder=2 + ) + } ) n_total_nans_savgol = helpers.count_nans(sample_dataset.position) @@ -60,56 +70,6 @@ def test_nan_propagation_through_filters(sample_dataset, window, helpers): # Interpolate data (without max_gap) and check it eliminates all NaNs sample_dataset.update( - {"position": sample_dataset.move.interpolate_over_time()} + {"position": interpolate_over_time(sample_dataset.position)} ) assert helpers.count_nans(sample_dataset.position) == 0 - - -@pytest.mark.parametrize( - "method", - [ - "filter_by_confidence", - "interpolate_over_time", - "median_filter", - "savgol_filter", - ], -) -@pytest.mark.parametrize( - "data_vars, expected_exception", - [ - (None, does_not_raise(xr.DataArray)), - (["position", "velocity"], does_not_raise(dict)), - (["vlocity"], pytest.raises(RuntimeError)), # Does not exist - ], -) -def test_accessor_filter_method( - sample_dataset, method, data_vars, expected_exception -): - """Test that filtering methods in the ``move`` accessor - return the expected data type and structure, and the - expected ``log`` attribute containing the filtering method - applied, if valid data variables are passed, otherwise - raise an exception. - """ - # Compute velocity - sample_dataset["velocity"] = sample_dataset.move.compute_velocity() - - with expected_exception as expected_type: - if method in ["median_filter", "savgol_filter"]: - # supply required "window" argument - result = getattr(sample_dataset.move, method)( - data_vars=data_vars, window=3 - ) - else: - result = getattr(sample_dataset.move, method)(data_vars=data_vars) - assert isinstance(result, expected_type) - if isinstance(result, xr.DataArray): - assert hasattr(result, "log") - assert result.log[0]["operation"] == method - elif isinstance(result, dict): - assert set(result.keys()) == set(data_vars) - assert all(hasattr(value, "log") for value in result.values()) - assert all( - value.log[0]["operation"] == method - for value in result.values() - ) diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index 63ecc2e4..5fa1b91c 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -4,6 +4,7 @@ import pytest import xarray as xr +import movement.analysis.kinematics as kin from movement.utils import vector @@ -64,7 +65,9 @@ def test_cart2pol_transform_on_kinematics( with various kinematic properties. """ ds = request.getfixturevalue(valid_dataset_uniform_linear_motion) - kinematic_array_cart = getattr(ds.move, f"compute_{kinematic_variable}")() + kinematic_array_cart = getattr(kin, f"compute_{kinematic_variable}")( + ds.position + ) kinematic_array_pol = vector.cart2pol(kinematic_array_cart) # Build expected data array diff --git a/tests/test_unit/test_load_bboxes.py b/tests/test_unit/test_load_bboxes.py index 474e6118..2f80459d 100644 --- a/tests/test_unit/test_load_bboxes.py +++ b/tests/test_unit/test_load_bboxes.py @@ -8,8 +8,8 @@ import pytest import xarray as xr -from movement import MovementDataset from movement.io import load_bboxes +from movement.validators.datasets import ValidBboxesDataset @pytest.fixture() @@ -127,7 +127,7 @@ def assert_dataset( assert dataset.confidence.shape == dataset.position.shape[:-1] # Check the dims and coords - DIM_NAMES = MovementDataset.dim_names["bboxes"] + DIM_NAMES = ValidBboxesDataset.DIM_NAMES assert all([i in dataset.dims for i in DIM_NAMES]) for d, dim in enumerate(DIM_NAMES[1:]): assert dataset.sizes[dim] == dataset.position.shape[d + 1] diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 8fedcdcb..77990a42 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -8,8 +8,8 @@ from sleap_io.io.slp import read_labels, write_labels from sleap_io.model.labels import LabeledFrame, Labels -from movement import MovementDataset from movement.io import load_poses +from movement.validators.datasets import ValidPosesDataset class TestLoadPoses: @@ -78,7 +78,7 @@ def assert_dataset( assert dataset.position.ndim == 4 assert dataset.confidence.shape == dataset.position.shape[:-1] # Check the dims and coords - DIM_NAMES = MovementDataset.dim_names["poses"] + DIM_NAMES = ValidPosesDataset.DIM_NAMES assert all([i in dataset.dims for i in DIM_NAMES]) for d, dim in enumerate(DIM_NAMES[1:]): assert dataset.sizes[dim] == dataset.position.shape[d + 1] diff --git a/tests/test_unit/test_move_accessor.py b/tests/test_unit/test_move_accessor.py deleted file mode 100644 index b87942e4..00000000 --- a/tests/test_unit/test_move_accessor.py +++ /dev/null @@ -1,128 +0,0 @@ -from contextlib import nullcontext as does_not_raise - -import pytest -import xarray as xr - - -@pytest.mark.parametrize( - "valid_dataset", ("valid_poses_dataset", "valid_bboxes_dataset") -) -def test_compute_kinematics_with_valid_dataset( - valid_dataset, kinematic_property, request -): - """Test that computing a kinematic property of a valid - poses or bounding boxes dataset via accessor methods returns - an instance of xr.DataArray. - """ - valid_input_dataset = request.getfixturevalue(valid_dataset) - - result = getattr( - valid_input_dataset.move, f"compute_{kinematic_property}" - )() - assert isinstance(result, xr.DataArray) - - -@pytest.mark.parametrize( - "invalid_dataset", - ( - "not_a_dataset", - "empty_dataset", - "missing_var_poses_dataset", - "missing_var_bboxes_dataset", - "missing_dim_poses_dataset", - "missing_dim_bboxes_dataset", - ), -) -def test_compute_kinematics_with_invalid_dataset( - invalid_dataset, kinematic_property, request -): - """Test that computing a kinematic property of an invalid - poses or bounding boxes dataset via accessor methods raises - the appropriate error. - """ - invalid_dataset = request.getfixturevalue(invalid_dataset) - expected_exception = ( - RuntimeError - if isinstance(invalid_dataset, xr.Dataset) - else AttributeError - ) - with pytest.raises(expected_exception): - getattr(invalid_dataset.move, f"compute_{kinematic_property}")() - - -@pytest.mark.parametrize( - "method", ["compute_invalid_property", "do_something"] -) -@pytest.mark.parametrize( - "valid_dataset", ("valid_poses_dataset", "valid_bboxes_dataset") -) -def test_invalid_move_method_call(valid_dataset, method, request): - """Test that invalid accessor method calls raise an AttributeError.""" - valid_input_dataset = request.getfixturevalue(valid_dataset) - with pytest.raises(AttributeError): - getattr(valid_input_dataset.move, method)() - - -@pytest.mark.parametrize( - "input_dataset, expected_exception, expected_patterns", - ( - ( - "valid_poses_dataset", - does_not_raise(), - [], - ), - ( - "valid_bboxes_dataset", - does_not_raise(), - [], - ), - ( - "valid_bboxes_dataset_in_seconds", - does_not_raise(), - [], - ), - ( - "missing_dim_poses_dataset", - pytest.raises(ValueError), - ["Missing required dimensions:", "['time']"], - ), - ( - "missing_dim_bboxes_dataset", - pytest.raises(ValueError), - ["Missing required dimensions:", "['time']"], - ), - ( - "missing_two_dims_bboxes_dataset", - pytest.raises(ValueError), - ["Missing required dimensions:", "['space', 'time']"], - ), - ( - "missing_var_poses_dataset", - pytest.raises(ValueError), - ["Missing required data variables:", "['position']"], - ), - ( - "missing_var_bboxes_dataset", - pytest.raises(ValueError), - ["Missing required data variables:", "['position']"], - ), - ( - "missing_two_vars_bboxes_dataset", - pytest.raises(ValueError), - ["Missing required data variables:", "['position', 'shape']"], - ), - ), -) -def test_move_validate( - input_dataset, expected_exception, expected_patterns, request -): - """Test the validate method returns the expected message.""" - input_dataset = request.getfixturevalue(input_dataset) - - with expected_exception as excinfo: - input_dataset.move.validate() - - if expected_patterns: - error_message = str(excinfo.value) - assert input_dataset.ds_type in error_message - assert all([pattern in error_message for pattern in expected_patterns]) diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index 0f606e31..592f0c9a 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -54,8 +54,8 @@ class TestSavePoses: ] invalid_poses_datasets_and_exceptions = [ - ("not_a_dataset", ValueError), - ("empty_dataset", RuntimeError), + ("not_a_dataset", TypeError), + ("empty_dataset", ValueError), ("missing_var_poses_dataset", ValueError), ("missing_dim_poses_dataset", ValueError), ] @@ -70,7 +70,7 @@ def output_file_params(self, request): @pytest.mark.parametrize( "ds, expected_exception", [ - (np.array([1, 2, 3]), pytest.raises(ValueError)), # incorrect type + (np.array([1, 2, 3]), pytest.raises(TypeError)), # incorrect type ( load_poses.from_dlc_file( DATA_PATHS.get("DLC_single-wasp.predictions.h5") diff --git a/tests/test_unit/test_validators/test_datasets_validators.py b/tests/test_unit/test_validators/test_datasets_validators.py index 493f1d46..e41331f7 100644 --- a/tests/test_unit/test_validators/test_datasets_validators.py +++ b/tests/test_unit/test_validators/test_datasets_validators.py @@ -352,7 +352,7 @@ def test_bboxes_dataset_validator_confidence_array( ( np.arange(10).reshape(-1, 2), pytest.raises(ValueError), - "Expected 'frame_array' to have shape (10, 1), " "but got (5, 2).", + "Expected 'frame_array' to have shape (10, 1), but got (5, 2).", ), # frame_array should be a column vector ( [1, 2, 3], From 1b6b44db03e850c5923c0507921acf07ef655cd6 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Thu, 24 Oct 2024 17:04:46 +0100 Subject: [PATCH 54/65] removed "analysis" level from namespace (#333) --- docs/source/getting_started/movement_dataset.md | 4 ++-- examples/compute_kinematics.py | 8 ++++---- examples/filter_and_interpolate.py | 2 +- movement/analysis/__init__.py | 0 movement/{analysis => }/kinematics.py | 2 +- .../test_integration/test_kinematics_vector_transform.py | 2 +- tests/test_unit/test_kinematics.py | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 movement/analysis/__init__.py rename movement/{analysis => }/kinematics.py (99%) diff --git a/docs/source/getting_started/movement_dataset.md b/docs/source/getting_started/movement_dataset.md index 7e80a81c..c13128d2 100644 --- a/docs/source/getting_started/movement_dataset.md +++ b/docs/source/getting_started/movement_dataset.md @@ -232,7 +232,7 @@ Let's imagine we want to compute the instantaneous velocity of all tracked points and store the results within the same dataset, for convenience. ```python -from movement.analysis.kinematics import compute_velocity +from movement.kinematics import compute_velocity # compute velocity from position velocity = compute_velocity(ds.position) @@ -246,7 +246,7 @@ ds["velocity"] = compute_velocity(ds.position) ds.velocity ``` -The output of {func}`movement.analysis.kinematics.compute_velocity` is an {class}`xarray.DataArray` object, +The output of {func}`movement.kinematics.compute_velocity` is an {class}`xarray.DataArray` object, with the same **dimensions** as the original `position` **data variable**, so adding it to the existing `ds` makes sense and works seamlessly. diff --git a/examples/compute_kinematics.py b/examples/compute_kinematics.py index d9107696..230131cc 100644 --- a/examples/compute_kinematics.py +++ b/examples/compute_kinematics.py @@ -117,18 +117,18 @@ # %% # Compute displacement # --------------------- -# The :mod:`movement.analysis.kinematics` module provides functions to compute +# The :mod:`movement.kinematics` module provides functions to compute # various kinematic quantities, # such as displacement, velocity, and acceleration. # We can start off by computing the distance travelled by the mice along # their trajectories: -import movement.analysis.kinematics as kin +import movement.kinematics as kin displacement = kin.compute_displacement(position) # %% -# The :func:`movement.analysis.kinematics.compute_displacement` +# The :func:`movement.kinematics.compute_displacement` # function will return a data array equivalent to the ``position`` one, # but holding displacement data along the ``space`` axis, rather than # position data. @@ -269,7 +269,7 @@ velocity = kin.compute_velocity(position) # %% -# The :func:`movement.analysis.kinematics.compute_velocity` +# The :func:`movement.kinematics.compute_velocity` # function will return a data array equivalent to # the ``position`` one, but holding velocity data along the ``space`` axis, # rather than position data. Notice how ``xarray`` nicely deals with the diff --git a/examples/filter_and_interpolate.py b/examples/filter_and_interpolate.py index fa18582b..baa17e99 100644 --- a/examples/filter_and_interpolate.py +++ b/examples/filter_and_interpolate.py @@ -9,8 +9,8 @@ # Imports # ------- from movement import sample_data -from movement.analysis.kinematics import compute_velocity from movement.filtering import filter_by_confidence, interpolate_over_time +from movement.kinematics import compute_velocity # %% # Load a sample dataset diff --git a/movement/analysis/__init__.py b/movement/analysis/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/movement/analysis/kinematics.py b/movement/kinematics.py similarity index 99% rename from movement/analysis/kinematics.py rename to movement/kinematics.py index b1bce6ac..17126d3e 100644 --- a/movement/analysis/kinematics.py +++ b/movement/kinematics.py @@ -290,7 +290,7 @@ def compute_head_direction_vector( """Compute the 2D head direction vector given two keypoints on the head. This function is an alias for :func:`compute_forward_vector()\ - `. For more + `. For more detailed information on how the head direction vector is computed, please refer to the documentation for that function. diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index 5fa1b91c..e91f4d64 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -4,7 +4,7 @@ import pytest import xarray as xr -import movement.analysis.kinematics as kin +import movement.kinematics as kin from movement.utils import vector diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index a54d199c..4c529f24 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -4,7 +4,7 @@ import pytest import xarray as xr -from movement.analysis import kinematics +from movement import kinematics @pytest.mark.parametrize( From 7813e7cd27dd8252a3651e1fe390c06ffd2bd4ee Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Fri, 25 Oct 2024 14:07:23 +0100 Subject: [PATCH 55/65] Refactor auto-generate API docs (#331) * Add `clean` action to Windows `make` file * Use `fail-on-warning` mode in Windows `make` file * Drop unused `--keep-going` flag in Makefile * Refactor auto-generate api docs * Update contributing docs * Remove Sphinx version constraint * Document make-mode only * Allow target chaining in Windows make --- CONTRIBUTING.md | 51 +++++++---------------- docs/Makefile | 3 +- docs/make.bat | 19 +++++++-- docs/make_api_index.py | 37 +++++++--------- docs/requirements.txt | 2 +- docs/source/_templates/api_index_head.rst | 3 ++ 6 files changed, 50 insertions(+), 65 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f350fdb..63dc93bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -291,55 +291,24 @@ All subsequent commands should be run from this directory. To build the documentation, run: -::::{tab-set} -:::{tab-item} Unix platforms with `make` ```sh make html ``` The local build can be viewed by opening `docs/build/html/index.html` in a browser. -::: - -:::{tab-item} All platforms -```sh -python make_api_index.py && sphinx-build source build -W --keep-going -``` -The local build can be viewed by opening `docs/build/index.html` in a browser. -::: -:::: -To re-build the documentation after making changes, run the command below. It will remove all generated files in `docs/`, -including the auto-generated API index `source/api_index.rst`, and those in `build/`, `source/api/`, and `source/examples/`, and then re-build the documentation. +To re-build the documentation after making changes, we recommend removing existing build files first. +The following command will remove all generated files in `docs/`, +including the auto-generated API index `source/api_index.rst`, and those in `build/`, `source/api/`, and `source/examples/`. It will then re-build the documentation: -::::{tab-set} -:::{tab-item} Unix platforms with `make` ```sh make clean html ``` -::: - -:::{tab-item} All platforms -```sh -rm -f source/api_index.rst && rm -rf build && rm -rf source/api && rm -rf source/examples -python make_api_index.py && sphinx-build source build -W --keep-going -``` -::: -:::: To check that external links are correctly resolved, run: -::::{tab-set} -:::{tab-item} Unix platforms with `make` ```sh make linkcheck ``` -::: - -:::{tab-item} All platforms -```sh -sphinx-build source build -b linkcheck -W --keep-going -``` -::: -:::: If the linkcheck step incorrectly marks links with valid anchors as broken, you can skip checking the anchors in specific links by adding the URLs to `linkcheck_anchors_ignore_for_url` in `docs/source/conf.py`, e.g.: @@ -352,6 +321,14 @@ linkcheck_anchors_ignore_for_url = [ ] ``` +:::{tip} +The `make` commands can be combined to run multiple tasks sequentially. +For example, to re-build the documentation and check the links, run: +```sh +make clean html linkcheck +``` +::: + ## Sample data We maintain some sample datasets to be used for testing, examples and tutorials on an @@ -399,9 +376,9 @@ To add a new file, you will need to: 6. Determine the sha256 checksum hash of each new file. You can do this in a terminal by running: ::::{tab-set} :::{tab-item} Ubuntu - ```bash - sha256sum - ``` + ```bash + sha256sum + ``` ::: :::{tab-item} MacOS diff --git a/docs/Makefile b/docs/Makefile index 529f6650..da95f613 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -4,8 +4,7 @@ # You can set these variables from the command line, and also # from the environment for the first two. # -W: if there are warnings, treat them as errors and exit with status 1. -# --keep-going: run sphinx-build to completion and exit with status 1 if errors. -SPHINXOPTS ?= -W --keep-going +SPHINXOPTS ?= -W SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build diff --git a/docs/make.bat b/docs/make.bat index 79a8b01a..b1e18dd6 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -9,6 +9,7 @@ if "%SPHINXBUILD%" == "" ( ) set SOURCEDIR=source set BUILDDIR=build +set SPHINXOPTS=-W %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( @@ -25,10 +26,22 @@ if errorlevel 9009 ( if "%1" == "" goto help -echo "Generating API index..." -python make_api_index.py +:process_targets +if "%1" == "clean" ( + @echo Removing auto-generated files under 'docs' and 'src'... + rmdir /S /Q %BUILDDIR% + del /Q %SOURCEDIR%\api_index.rst + rmdir /S /Q %SOURCEDIR%\api\ + rmdir /S /Q %SOURCEDIR%\examples\ +) else ( + @echo Generating API index... + python make_api_index.py + %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +) + +shift +if not "%1" == "" goto process_targets -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help diff --git a/docs/make_api_index.py b/docs/make_api_index.py index 37223112..c850022e 100644 --- a/docs/make_api_index.py +++ b/docs/make_api_index.py @@ -1,43 +1,36 @@ """Generate the API index page for all ``movement`` modules.""" import os +from pathlib import Path # Modules to exclude from the API index exclude_modules = ["cli_entrypoint"] # Set the current working directory to the directory of this script -script_dir = os.path.dirname(os.path.abspath(__file__)) +script_dir = Path(__file__).resolve().parent os.chdir(script_dir) def make_api_index(): """Create a doctree of all ``movement`` modules.""" doctree = "\n" - - for root, _, files in os.walk("../movement"): - # Remove leading "../" - root = root[3:] - for file in sorted(files): - if file.endswith(".py") and not file.startswith("_"): - # Convert file path to module name - module_name = os.path.join(root, file) - module_name = module_name[:-3].replace(os.sep, ".") - # Check if the module should be excluded - if not any( - file.startswith(exclude_module) - for exclude_module in exclude_modules - ): - doctree += f" {module_name}\n" - + api_path = Path("../movement") + for path in sorted(api_path.rglob("*.py")): + if path.name.startswith("_"): + continue + # Convert file path to module name + rel_path = path.relative_to(api_path.parent) + module_name = str(rel_path.with_suffix("")).replace(os.sep, ".") + if rel_path.stem not in exclude_modules: + doctree += f" {module_name}\n" # Get the header - with open("./source/_templates/api_index_head.rst") as f: - api_head = f.read() + api_head_path = Path("source") / "_templates" / "api_index_head.rst" + api_head = api_head_path.read_text() # Write api_index.rst with header + doctree - with open("./source/api_index.rst", "w") as f: - f.write("..\n This file is auto-generated.\n\n") + output_path = Path("source") / "api_index.rst" + with output_path.open("w") as f: f.write(api_head) f.write(doctree) - print(os.path.abspath("./source/api_index.rst")) if __name__ == "__main__": diff --git a/docs/requirements.txt b/docs/requirements.txt index 63615d66..0a950f2e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,7 +4,7 @@ myst-parser nbsphinx pydata-sphinx-theme setuptools-scm -sphinx>=7.0 +sphinx sphinx-autodoc-typehints sphinx-design sphinx-gallery diff --git a/docs/source/_templates/api_index_head.rst b/docs/source/_templates/api_index_head.rst index f1df7eb6..0c1e6b7e 100644 --- a/docs/source/_templates/api_index_head.rst +++ b/docs/source/_templates/api_index_head.rst @@ -1,3 +1,6 @@ +.. + This file is auto-generated. + .. _target-api: API Reference From b10896f00258852c2719c4e5bf493677fc6d6edd Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Tue, 29 Oct 2024 12:18:56 +0000 Subject: [PATCH 56/65] Encourage R users to check out `animovement` (#335) * added tip for R users * added script for converting admonitions to myst * refactored conversion script --- .gitignore | 1 + CONTRIBUTING.md | 8 ++- README.md | 11 +++- docs/Makefile | 8 ++- docs/convert_admonitions.py | 87 ++++++++++++++++++++++++++ docs/make.bat | 9 ++- docs/source/index.md | 2 +- docs/source/snippets/status-warning.md | 4 -- 8 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 docs/convert_admonitions.py delete mode 100644 docs/source/snippets/status-warning.md diff --git a/.gitignore b/.gitignore index c46b1d71..6611c73a 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ docs/build/ docs/source/examples/ docs/source/api/ docs/source/api_index.rst +docs/source/snippets/admonitions.md sg_execution_times.rst # MkDocs documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63dc93bf..88f5ecd0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -296,9 +296,13 @@ make html ``` The local build can be viewed by opening `docs/build/html/index.html` in a browser. -To re-build the documentation after making changes, we recommend removing existing build files first. +To re-build the documentation after making changes, +we recommend removing existing build files first. The following command will remove all generated files in `docs/`, -including the auto-generated API index `source/api_index.rst`, and those in `build/`, `source/api/`, and `source/examples/`. It will then re-build the documentation: +including the auto-generated files `source/api_index.rst` and +`source/snippets/admonitions.md`, as well as all files in + `build/`, `source/api/`, and `source/examples/`. + It will then re-build the documentation: ```sh make clean html diff --git a/README.md b/README.md index 5b1dec34..ab66a9b8 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,19 @@ We aim to support a range of pose estimation packages, along with 2D or 3D track Find out more on our [mission and scope](https://movement.neuroinformatics.dev/community/mission-scope.html) statement and our [roadmap](https://movement.neuroinformatics.dev/community/roadmaps.html). -## Status + + > [!Warning] > 🏗️ The package is currently in early development and the interface is subject to change. Feel free to play around and provide feedback. +> [!Tip] +> If you prefer analysing your data in R, we recommend checking out the +> [animovement](https://www.roald-arboel.com/animovement/) toolbox, which is similar in scope. +> We are working together with its developer +> to gradually converge on common data standards and workflows. + + + ## Join the movement Contributions to movement are absolutely encouraged, whether to fix a bug, develop a new feature, or improve the documentation. diff --git a/docs/Makefile b/docs/Makefile index da95f613..df622291 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -19,14 +19,20 @@ help: api_index.rst: python make_api_index.py +# Generate the snippets/admonitions.md file +# by converting the admonitions in the repo's README.md to MyST format +admonitions.md: + python convert_admonitions.py + # Remove all generated files clean: rm -rf ./build rm -f ./source/api_index.rst rm -rf ./source/api rm -rf ./source/examples + rm -rf ./source/snippets/admonitions.md # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile api_index.rst +%: Makefile api_index.rst admonitions.md @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/convert_admonitions.py b/docs/convert_admonitions.py new file mode 100644 index 00000000..dc3dc27c --- /dev/null +++ b/docs/convert_admonitions.py @@ -0,0 +1,87 @@ +"""Convert admonitions GitHub Flavored Markdown (GFM) to MyST Markdown.""" + +import re +from pathlib import Path + +# Valid admonition types supported by both GFM and MyST (case-insensitive) +VALID_TYPES = {"note", "tip", "important", "warning", "caution"} + + +def convert_gfm_admonitions_to_myst_md( + input_path: Path, output_path: Path, exclude: set[str] | None = None +): + """Convert admonitions from GitHub Flavored Markdown to MyST. + + Extracts GitHub Flavored Markdown admonitions from the input file and + writes them to the output file as MyST Markdown admonitions. + The original admonition type and order are preserved. + + Parameters + ---------- + input_path : Path + Path to the input file containing GitHub Flavored Markdown. + output_path : Path + Path to the output file to write the MyST Markdown admonitions. + exclude : set[str], optional + Set of admonition types to exclude from conversion (case-insensitive). + Default is None. + + """ + excluded_types = {s.lower() for s in (exclude or set())} + + # Read the input file + gfm_text = input_path.read_text(encoding="utf-8") + + # Regex pattern to match GFM admonitions + pattern = r"(^> \[!(\w+)\]\n(?:^> .*\n?)*)" + matches = re.finditer(pattern, gfm_text, re.MULTILINE) + + # Process matches and collect converted admonitions + admonitions = [] + for match in matches: + adm_myst = _process_match(match, excluded_types) + if adm_myst: + admonitions.append(adm_myst) + + if admonitions: + # Write all admonitions to a single file + output_path.write_text("\n".join(admonitions) + "\n", encoding="utf-8") + print(f"Admonitions written to {output_path}") + else: + print("No GitHub Markdown admonitions found.") + + +def _process_match(match: re.Match, excluded_types: set[str]) -> str | None: + """Process a regex match and return the converted admonition if valid.""" + # Extract the admonition type + adm_type = match.group(2).lower() + if adm_type not in VALID_TYPES or adm_type in excluded_types: + return None + + # Extract the content lines + full_block = match.group(0) + content = "\n".join( + line[2:].strip() + for line in full_block.split("\n") + if line.startswith("> ") and not line.startswith("> [!") + ).strip() + + # Return the converted admonition + return ":::{" + adm_type + "}\n" + content + "\n" + ":::\n" + + +if __name__ == "__main__": + # Path to the README.md file + # (1 level above the current script) + docs_dir = Path(__file__).resolve().parent + readme_path = docs_dir.parent / "README.md" + + # Path to the output file + # (inside the docs/source/snippets directory) + snippets_dir = docs_dir / "source" / "snippets" + target_path = snippets_dir / "admonitions.md" + + # Call the function + convert_gfm_admonitions_to_myst_md( + readme_path, target_path, exclude={"note"} + ) diff --git a/docs/make.bat b/docs/make.bat index b1e18dd6..1969d4b3 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -28,14 +28,19 @@ if "%1" == "" goto help :process_targets if "%1" == "clean" ( - @echo Removing auto-generated files under 'docs' and 'src'... + echo Removing auto-generated files... rmdir /S /Q %BUILDDIR% del /Q %SOURCEDIR%\api_index.rst rmdir /S /Q %SOURCEDIR%\api\ rmdir /S /Q %SOURCEDIR%\examples\ + del /Q %SOURCEDIR%\snippets\admonitions.md ) else ( - @echo Generating API index... + echo Generating API index... python make_api_index.py + + echo Converting admonitions... + python convert_admonitions.py + %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% ) diff --git a/docs/source/index.md b/docs/source/index.md index 629027b9..d37c1849 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -39,7 +39,7 @@ We aim to support a range of pose estimation packages, along with 2D or 3D track Find out more on our [mission and scope](target-mission) statement and our [roadmap](target-roadmaps). -```{include} /snippets/status-warning.md +```{include} /snippets/admonitions.md ``` ## Citation diff --git a/docs/source/snippets/status-warning.md b/docs/source/snippets/status-warning.md deleted file mode 100644 index a9ccbbb7..00000000 --- a/docs/source/snippets/status-warning.md +++ /dev/null @@ -1,4 +0,0 @@ -:::{admonition} Status -:class: warning -The package is currently in early development and the interface is subject to change. Feel free to play around and provide feedback. -::: From 536de0e010fb34bc014948367a6bd4e1e62d63e4 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Tue, 29 Oct 2024 13:48:21 +0000 Subject: [PATCH 57/65] Compute pairwise distances (#278) * Draft inter-individual distances * Return vector norm in `compute_interindividual_distances` * Add `compute_interkeypoint_distances` * Refactor pairwise distances tests * Use `scipy.spatial.distance.cdist` * Add examples to docstrings * Rename variables * Update test function args + fix indentation * Handle scalar and 1d dims * Handle missing `core_dim` * Refactor `cdist` and tests * Fix docstrings * Reorder functions + cleanup docs * Reduce pairwise distances functions * Mention examples of available distance metrics * Update docstrings * Require `pairs` in `compute_pairwise_distances` * Raise error if there are no pairs to compute distances for * Rename `core_dim` to `labels_dim` * Spell out expected pairs in test * Merge old `kinematics` file changes into new * Rename `core_dim` to `labels_dim` in tests * Validate dims in `compute_pairwise_distances` * Apply suggestions from code review Co-authored-by: Niko Sirmpilatze * Apply suggestions from code review Co-authored-by: Niko Sirmpilatze * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Niko Sirmpilatze Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- movement/kinematics.py | 332 +++++++++++++++++++++++++++++ tests/conftest.py | 5 +- tests/test_unit/test_kinematics.py | 206 ++++++++++++++++++ 3 files changed, 540 insertions(+), 3 deletions(-) diff --git a/movement/kinematics.py b/movement/kinematics.py index 17126d3e..a6e0b0b9 100644 --- a/movement/kinematics.py +++ b/movement/kinematics.py @@ -1,9 +1,11 @@ """Compute kinematic variables like velocity and acceleration.""" +import itertools from typing import Literal import numpy as np import xarray as xr +from scipy.spatial.distance import cdist from movement.utils.logging import log_error from movement.utils.vector import compute_norm @@ -324,6 +326,336 @@ def compute_head_direction_vector( ) +def _cdist( + a: xr.DataArray, + b: xr.DataArray, + dim: Literal["individuals", "keypoints"], + metric: str | None = "euclidean", + **kwargs, +) -> xr.DataArray: + """Compute distances between two position arrays across a given dimension. + + This function is a wrapper around :func:`scipy.spatial.distance.cdist` + and computes the pairwise distances between the two input position arrays + across the dimension specified by ``dim``. + The dimension can be either ``individuals`` or ``keypoints``. + The distances are computed using the specified ``metric``. + + Parameters + ---------- + a : xarray.DataArray + The first input data containing position information of a + single individual or keypoint, with ``time``, ``space`` + (in Cartesian coordinates), and ``individuals`` or ``keypoints`` + (as specified by ``dim``) as required dimensions. + b : xarray.DataArray + The second input data containing position information of a + single individual or keypoint, with ``time``, ``space`` + (in Cartesian coordinates), and ``individuals`` or ``keypoints`` + (as specified by ``dim``) as required dimensions. + dim : str + The dimension to compute the distances for. Must be either + ``'individuals'`` or ``'keypoints'``. + metric : str, optional + The distance metric to use. Must be one of the options supported + by :func:`scipy.spatial.distance.cdist`, e.g. ``'cityblock'``, + ``'euclidean'``, etc. + Defaults to ``'euclidean'``. + **kwargs : dict + Additional keyword arguments to pass to + :func:`scipy.spatial.distance.cdist`. + + Returns + ------- + xarray.DataArray + An xarray DataArray containing the computed distances between + each pair of inputs. + + Examples + -------- + Compute the Euclidean distance (default) between ``ind1`` and + ``ind2`` (i.e. interindividual distance for all keypoints) + using the ``position`` data variable in the Dataset ``ds``: + + >>> pos1 = ds.position.sel(individuals="ind1") + >>> pos2 = ds.position.sel(individuals="ind2") + >>> ind_dists = _cdist(pos1, pos2, dim="individuals") + + Compute the Euclidean distance (default) between ``key1`` and + ``key2`` (i.e. interkeypoint distance for all individuals) + using the ``position`` data variable in the Dataset ``ds``: + + >>> pos1 = ds.position.sel(keypoints="key1") + >>> pos2 = ds.position.sel(keypoints="key2") + >>> key_dists = _cdist(pos1, pos2, dim="keypoints") + + See Also + -------- + scipy.spatial.distance.cdist : The underlying function used. + compute_pairwise_distances : Compute pairwise distances between + ``individuals`` or ``keypoints`` + + """ + # The dimension from which ``dim`` labels are obtained + labels_dim = "individuals" if dim == "keypoints" else "keypoints" + elem1 = getattr(a, dim).item() + elem2 = getattr(b, dim).item() + a = _validate_labels_dimension(a, labels_dim) + b = _validate_labels_dimension(b, labels_dim) + result = xr.apply_ufunc( + cdist, + a, + b, + kwargs={"metric": metric, **kwargs}, + input_core_dims=[[labels_dim, "space"], [labels_dim, "space"]], + output_core_dims=[[elem1, elem2]], + vectorize=True, + ) + result = result.assign_coords( + { + elem1: getattr(a, labels_dim).values, + elem2: getattr(a, labels_dim).values, + } + ) + # Drop any squeezed coordinates + return result.squeeze(drop=True) + + +def compute_pairwise_distances( + data: xr.DataArray, + dim: Literal["individuals", "keypoints"], + pairs: dict[str, str | list[str]] | Literal["all"], + metric: str | None = "euclidean", + **kwargs, +) -> xr.DataArray | dict[str, xr.DataArray]: + """Compute pairwise distances between ``individuals`` or ``keypoints``. + + This function computes the distances between + pairs of ``individuals`` (i.e. interindividual distances) or + pairs of ``keypoints`` (i.e. interkeypoint distances), + as determined by ``dim``. + The distances are computed for the given ``pairs`` + using the specified ``metric``. + + Parameters + ---------- + data : xarray.DataArray + The input data containing position information, with ``time``, + ``space`` (in Cartesian coordinates), and + ``individuals`` or ``keypoints`` (as specified by ``dim``) + as required dimensions. + dim : Literal["individuals", "keypoints"] + The dimension to compute the distances for. Must be either + ``'individuals'`` or ``'keypoints'``. + pairs : dict[str, str | list[str]] or 'all' + Specifies the pairs of elements (either individuals or keypoints) + for which to compute distances, depending on the value of ``dim``. + + - If ``dim='individuals'``, ``pairs`` should be a dictionary where + each key is an individual name, and each value is also an individual + name or a list of such names to compute distances with. + - If ``dim='keypoints'``, ``pairs`` should be a dictionary where each + key is a keypoint name, and each value is also keypoint name or a + list of such names to compute distances with. + - Alternatively, use the special keyword ``'all'`` to compute distances + for all possible pairs of individuals or keypoints + (depending on ``dim``). + metric : str, optional + The distance metric to use. Must be one of the options supported + by :func:`scipy.spatial.distance.cdist`, e.g. ``'cityblock'``, + ``'euclidean'``, etc. + Defaults to ``'euclidean'``. + **kwargs : dict + Additional keyword arguments to pass to + :func:`scipy.spatial.distance.cdist`. + + Returns + ------- + xarray.DataArray or dict[str, xarray.DataArray] + The computed pairwise distances. If a single pair is specified in + ``pairs``, returns an :class:`xarray.DataArray`. If multiple pairs + are specified, returns a dictionary where each key is a string + representing the pair (e.g., ``'dist_ind1_ind2'`` or + ``'dist_key1_key2'``) and each value is an :class:`xarray.DataArray` + containing the computed distances for that pair. + + Raises + ------ + ValueError + If ``dim`` is not one of ``'individuals'`` or ``'keypoints'``; + if ``pairs`` is not a dictionary or ``'all'``; or + if there are no pairs in ``data`` to compute distances for. + + Examples + -------- + Compute the Euclidean distance (default) between ``ind1`` and ``ind2`` + (i.e. interindividual distance), for all possible pairs of keypoints. + + >>> position = xr.DataArray( + ... np.arange(36).reshape(2, 3, 3, 2), + ... coords={ + ... "time": np.arange(2), + ... "individuals": ["ind1", "ind2", "ind3"], + ... "keypoints": ["key1", "key2", "key3"], + ... "space": ["x", "y"], + ... }, + ... dims=["time", "individuals", "keypoints", "space"], + ... ) + >>> dist_ind1_ind2 = compute_pairwise_distances( + ... position, "individuals", {"ind1": "ind2"} + ... ) + >>> dist_ind1_ind2 + Size: 144B + 8.485 11.31 14.14 5.657 8.485 11.31 ... 5.657 8.485 11.31 2.828 5.657 8.485 + Coordinates: + * time (time) int64 16B 0 1 + * ind1 (ind1) >> dist_ind1_ind2.sel(ind1="key1", ind2="key2") + + Compute the Euclidean distance (default) between ``key1`` and ``key2`` + (i.e. interkeypoint distance), for all possible pairs of individuals. + + >>> dist_key1_key2 = compute_pairwise_distances( + ... position, "keypoints", {"key1": "key2"} + ... ) + >>> dist_key1_key2 + Size: 144B + 2.828 11.31 19.8 5.657 2.828 11.31 14.14 ... 2.828 11.31 14.14 5.657 2.828 + Coordinates: + * time (time) int64 16B 0 1 + * key1 (key1) >> dist_key1_key2.sel(key1="ind1", key2="ind1") + + To obtain the distances between ``key1`` of ``ind1`` and + ``key2`` of ``ind2``: + + >>> dist_key1_key2.sel(key1="ind1", key2="ind2") + + Compute the city block or Manhattan distance for multiple pairs of + keypoints using ``position``: + + >>> key_dists = compute_pairwise_distances( + ... position, + ... "keypoints", + ... {"key1": "key2", "key3": ["key1", "key2"]}, + ... metric="cityblock", + ... ) + >>> key_dists.keys() + dict_keys(['dist_key1_key2', 'dist_key3_key1', 'dist_key3_key2']) + + As multiple pairs of keypoints are specified, + the resulting ``key_dists`` is a dictionary containing the DataArrays + of computed distances for each pair of keypoints. + + Compute the city block or Manhattan distance for all possible pairs of + individuals using ``position``: + + >>> ind_dists = compute_pairwise_distances( + ... position, + ... "individuals", + ... "all", + ... metric="cityblock", + ... ) + >>> ind_dists.keys() + dict_keys(['dist_ind1_ind2', 'dist_ind1_ind3', 'dist_ind2_ind3']) + + See Also + -------- + scipy.spatial.distance.cdist : The underlying function used. + + """ + if dim not in ["individuals", "keypoints"]: + raise log_error( + ValueError, + "'dim' must be either 'individuals' or 'keypoints', " + f"but got {dim}.", + ) + if isinstance(pairs, str) and pairs != "all": + raise log_error( + ValueError, + f"'pairs' must be a dictionary or 'all', but got {pairs}.", + ) + validate_dims_coords(data, {"time": [], "space": ["x", "y"], dim: []}) + # Find all possible pair combinations if 'all' is specified + if pairs == "all": + paired_elements = list( + itertools.combinations(getattr(data, dim).values, 2) + ) + else: + paired_elements = [ + (elem1, elem2) + for elem1, elem2_list in pairs.items() + for elem2 in + ( + # Ensure elem2_list is a list + [elem2_list] if isinstance(elem2_list, str) else elem2_list + ) + ] + if not paired_elements: + raise log_error( + ValueError, "Could not find any pairs to compute distances for." + ) + pairwise_distances = { + f"dist_{elem1}_{elem2}": _cdist( + data.sel({dim: elem1}), + data.sel({dim: elem2}), + dim=dim, + metric=metric, + **kwargs, + ) + for elem1, elem2 in paired_elements + } + # Return DataArray if result only has one key + if len(pairwise_distances) == 1: + return next(iter(pairwise_distances.values())) + return pairwise_distances + + +def _validate_labels_dimension(data: xr.DataArray, dim: str) -> xr.DataArray: + """Validate the input data contains the ``dim`` for labelling dimensions. + + This function ensures the input data contains the ``dim`` + used as labels (coordinates) when applying + :func:`scipy.spatial.distance.cdist` to + the input data, by adding a temporary dimension if necessary. + + Parameters + ---------- + data : xarray.DataArray + The input data to validate. + dim : str + The dimension to validate. + + Returns + ------- + xarray.DataArray + The input data with the labels dimension validated. + + """ + if data.coords.get(dim) is None: + data = data.assign_coords({dim: "temp_dim"}) + if data.coords[dim].ndim == 0: + data = data.expand_dims(dim).transpose("time", "space", dim) + return data + + def _validate_type_data_array(data: xr.DataArray) -> None: """Validate the input data is an xarray DataArray. diff --git a/tests/conftest.py b/tests/conftest.py index 6da9a598..4de80c31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -567,6 +567,7 @@ def missing_two_dims_bboxes_dataset(valid_bboxes_dataset): return valid_bboxes_dataset.rename({"time": "tame", "space": "spice"}) +# --------------------------- Kinematics fixtures --------------------------- @pytest.fixture(params=["displacement", "velocity", "acceleration"]) def kinematic_property(request): """Return a kinematic property.""" @@ -820,6 +821,7 @@ def track_ids_not_unique_per_frame( return file_path +# ----------------- Helpers fixture ----------------- class Helpers: """Generic helper methods for ``movement`` test modules.""" @@ -834,9 +836,6 @@ def count_consecutive_nans(da): return (da.isnull().astype(int).diff("time") == 1).sum().item() -# ----------------- Helper fixture ----------------- - - @pytest.fixture def helpers(): """Return an instance of the ``Helpers`` class.""" diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 4c529f24..18cfa2db 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -349,3 +349,209 @@ def test_nan_behavior_forward_vector( np.isnan(forward_vector.values[1, 0, :]).all() and not np.isnan(forward_vector.values[[0, 2, 3], 0, :]).any() ) + + +@pytest.mark.parametrize( + "dim, expected_data", + [ + ( + "individuals", + np.array( + [ + [ + [0.0, 1.0, 1.0], + [1.0, np.sqrt(2), 0.0], + [1.0, 2.0, np.sqrt(2)], + ], + [ + [2.0, np.sqrt(5), 1.0], + [3.0, np.sqrt(10), 2.0], + [np.sqrt(5), np.sqrt(8), np.sqrt(2)], + ], + ] + ), + ), + ( + "keypoints", + np.array( + [[[1.0, 1.0], [1.0, 1.0]], [[1.0, np.sqrt(5)], [3.0, 1.0]]] + ), + ), + ], +) +def test_cdist_with_known_values( + dim, expected_data, valid_poses_dataset_uniform_linear_motion +): + """Test the computation of pairwise distances with known values.""" + labels_dim = "keypoints" if dim == "individuals" else "individuals" + input_dataarray = valid_poses_dataset_uniform_linear_motion.position.sel( + time=slice(0, 1) + ) # Use only the first two frames for simplicity + pairs = input_dataarray[dim].values[:2] + expected = xr.DataArray( + expected_data, + coords=[ + input_dataarray.time.values, + getattr(input_dataarray, labels_dim).values, + getattr(input_dataarray, labels_dim).values, + ], + dims=["time", pairs[0], pairs[1]], + ) + a = input_dataarray.sel({dim: pairs[0]}) + b = input_dataarray.sel({dim: pairs[1]}) + result = kinematics._cdist(a, b, dim) + xr.testing.assert_equal( + result, + expected, + ) + + +@pytest.mark.parametrize( + "valid_dataset", + [ + "valid_poses_dataset_uniform_linear_motion", + "valid_bboxes_dataset", + ], +) +@pytest.mark.parametrize( + "selection_fn", + [ + # individuals dim is scalar, + # poses: multiple keypoints + # bboxes: missing keypoints dim + # e.g. comparing 2 individuals from the same data array + lambda position: ( + position.isel(individuals=0), + position.isel(individuals=1), + ), + # individuals dim is 1D + # poses: multiple keypoints + # bboxes: missing keypoints dim + # e.g. comparing 2 single-individual data arrays + lambda position: ( + position.where( + position.individuals == position.individuals[0], drop=True + ).squeeze(), + position.where( + position.individuals == position.individuals[1], drop=True + ).squeeze(), + ), + # both individuals and keypoints dims are scalar (poses only) + # e.g. comparing 2 individuals from the same data array, + # at the same keypoint + lambda position: ( + position.isel(individuals=0, keypoints=0), + position.isel(individuals=1, keypoints=0), + ), + # individuals dim is scalar, keypoints dim is 1D (poses only) + # e.g. comparing 2 single-individual, single-keypoint data arrays + lambda position: ( + position.where( + position.keypoints == position.keypoints[0], drop=True + ).isel(individuals=0), + position.where( + position.keypoints == position.keypoints[0], drop=True + ).isel(individuals=1), + ), + ], + ids=[ + "dim_has_ndim_0", + "dim_has_ndim_1", + "labels_dim_has_ndim_0", + "labels_dim_has_ndim_1", + ], +) +def test_cdist_with_single_dim_inputs(valid_dataset, selection_fn, request): + """Test that the pairwise distances data array is successfully + returned regardless of whether the input DataArrays have + ``dim`` ("individuals") and ``labels_dim`` ("keypoints") + being either scalar (ndim=0) or 1D (ndim=1), + or if ``labels_dim`` is missing. + """ + if request.node.callspec.id not in [ + "labels_dim_has_ndim_0-valid_bboxes_dataset", + "labels_dim_has_ndim_1-valid_bboxes_dataset", + ]: # Skip tests with keypoints dim for bboxes + valid_dataset = request.getfixturevalue(valid_dataset) + position = valid_dataset.position + a, b = selection_fn(position) + assert isinstance(kinematics._cdist(a, b, "individuals"), xr.DataArray) + + +@pytest.mark.parametrize( + "dim, pairs, expected_data_vars", + [ + ("individuals", {"id_1": ["id_2"]}, None), # list input + ("individuals", {"id_1": "id_2"}, None), # string input + ( + "individuals", + {"id_1": ["id_2"], "id_2": "id_1"}, + [("id_1", "id_2"), ("id_2", "id_1")], + ), + ("individuals", "all", None), # all pairs + ("keypoints", {"centroid": ["left"]}, None), # list input + ("keypoints", {"centroid": "left"}, None), # string input + ( + "keypoints", + {"centroid": ["left"], "left": "right"}, + [("centroid", "left"), ("left", "right")], + ), + ( + "keypoints", + "all", + [("centroid", "left"), ("centroid", "right"), ("left", "right")], + ), # all pairs + ], +) +def test_compute_pairwise_distances_with_valid_pairs( + valid_poses_dataset_uniform_linear_motion, dim, pairs, expected_data_vars +): + """Test that the expected pairwise distances are computed + for valid ``pairs`` inputs. + """ + result = kinematics.compute_pairwise_distances( + valid_poses_dataset_uniform_linear_motion.position, dim, pairs + ) + if isinstance(result, dict): + expected_data_vars = [ + f"dist_{pair[0]}_{pair[1]}" for pair in expected_data_vars + ] + assert set(result.keys()) == set(expected_data_vars) + else: # expect single DataArray + assert isinstance(result, xr.DataArray) + + +@pytest.mark.parametrize( + "ds, dim, pairs", + [ + ( + "valid_poses_dataset_uniform_linear_motion", + "invalid_dim", + {"id_1": "id_2"}, + ), # invalid dim + ( + "valid_poses_dataset_uniform_linear_motion", + "keypoints", + "invalid_string", + ), # invalid pairs + ( + "valid_poses_dataset_uniform_linear_motion", + "individuals", + {}, + ), # empty pairs + ("missing_dim_poses_dataset", "keypoints", "all"), # invalid dataset + ( + "missing_dim_bboxes_dataset", + "individuals", + "all", + ), # invalid dataset + ], +) +def test_compute_pairwise_distances_with_invalid_input( + ds, dim, pairs, request +): + """Test that an error is raised for invalid inputs.""" + with pytest.raises(ValueError): + kinematics.compute_pairwise_distances( + request.getfixturevalue(ds).position, dim, pairs + ) From ca4daf2299141d5f69b1d2ab47c2ef58300cfc9c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:19:21 +0000 Subject: [PATCH 58/65] [pre-commit.ci] pre-commit autoupdate (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.7.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.7.2) - [github.com/pre-commit/mirrors-mypy: v1.11.2 → v1.13.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.2...v1.13.0) - [github.com/mgedmin/check-manifest: 0.49 → 0.50](https://github.com/mgedmin/check-manifest/compare/0.49...0.50) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5d6d261..6aafcddd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,12 +29,12 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.2 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.13.0 hooks: - id: mypy additional_dependencies: @@ -45,7 +45,7 @@ repos: - types-PyYAML - types-requests - repo: https://github.com/mgedmin/check-manifest - rev: "0.49" + rev: "0.50" hooks: - id: check-manifest args: [--no-build-isolation] From a3956c4cfd5c2b3cfc87c72ef720c5633b4af882 Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Tue, 5 Nov 2024 17:07:40 +0000 Subject: [PATCH 59/65] Implement `compute_speed` and `compute_path_length` (#280) * implement compute_speed and compute_path_length functions * added speed to existing kinematics unit test * rewrote compute_path_length with various nan policies * unit test compute_path_length across time ranges * fixed and refactor compute_path_length and its tests * fixed docstring for compute_path_length * Accept suggestion on docstring wording Co-authored-by: Chang Huan Lo * Remove print statement from test Co-authored-by: Chang Huan Lo * Ensure nan report is printed Co-authored-by: Chang Huan Lo * adapt warning message match in test * change 'any' to 'all' * uniform wording across path length docstrings * (mostly) leave time range validation to xarray slice * refactored parameters for test across time ranges * simplified test for path lenght with nans * replace drop policy with ffill * remove B905 ruff rule * make pre-commit happy --------- Co-authored-by: Chang Huan Lo --- movement/kinematics.py | 188 +++++++++++++++++++++++++- tests/conftest.py | 34 +++++ tests/test_unit/test_kinematics.py | 203 +++++++++++++++++++++++++++-- 3 files changed, 416 insertions(+), 9 deletions(-) diff --git a/movement/kinematics.py b/movement/kinematics.py index a6e0b0b9..12e1514f 100644 --- a/movement/kinematics.py +++ b/movement/kinematics.py @@ -7,7 +7,8 @@ import xarray as xr from scipy.spatial.distance import cdist -from movement.utils.logging import log_error +from movement.utils.logging import log_error, log_warning +from movement.utils.reports import report_nan_values from movement.utils.vector import compute_norm from movement.validators.arrays import validate_dims_coords @@ -173,6 +174,30 @@ def compute_time_derivative(data: xr.DataArray, order: int) -> xr.DataArray: return result +def compute_speed(data: xr.DataArray) -> xr.DataArray: + """Compute instantaneous speed at each time point. + + Speed is a scalar quantity computed as the Euclidean norm (magnitude) + of the velocity vector at each time point. + + + Parameters + ---------- + data : xarray.DataArray + The input data containing position information, with ``time`` + and ``space`` (in Cartesian coordinates) as required dimensions. + + Returns + ------- + xarray.DataArray + An xarray DataArray containing the computed speed, + with dimensions matching those of the input data, + except ``space`` is removed. + + """ + return compute_norm(compute_velocity(data)) + + def compute_forward_vector( data: xr.DataArray, left_keypoint: str, @@ -675,3 +700,164 @@ def _validate_type_data_array(data: xr.DataArray) -> None: TypeError, f"Input data must be an xarray.DataArray, but got {type(data)}.", ) + + +def compute_path_length( + data: xr.DataArray, + start: float | None = None, + stop: float | None = None, + nan_policy: Literal["ffill", "scale"] = "ffill", + nan_warn_threshold: float = 0.2, +) -> xr.DataArray: + """Compute the length of a path travelled between two time points. + + The path length is defined as the sum of the norms (magnitudes) of the + displacement vectors between two time points ``start`` and ``stop``, + which should be provided in the time units of the data array. + If not specified, the minimum and maximum time coordinates of the data + array are used as start and stop times, respectively. + + Parameters + ---------- + data : xarray.DataArray + The input data containing position information, with ``time`` + and ``space`` (in Cartesian coordinates) as required dimensions. + start : float, optional + The start time of the path. If None (default), + the minimum time coordinate in the data is used. + stop : float, optional + The end time of the path. If None (default), + the maximum time coordinate in the data is used. + nan_policy : Literal["ffill", "scale"], optional + Policy to handle NaN (missing) values. Can be one of the ``"ffill"`` + or ``"scale"``. Defaults to ``"ffill"`` (forward fill). + See Notes for more details on the two policies. + nan_warn_threshold : float, optional + If more than this proportion of values are missing in any point track, + a warning will be emitted. Defaults to 0.2 (20%). + + Returns + ------- + xarray.DataArray + An xarray DataArray containing the computed path length, + with dimensions matching those of the input data, + except ``time`` and ``space`` are removed. + + Notes + ----- + Choosing ``nan_policy="ffill"`` will use :meth:`xarray.DataArray.ffill` + to forward-fill missing segments (NaN values) across time. + This equates to assuming that a track remains stationary for + the duration of the missing segment and then instantaneously moves to + the next valid position, following a straight line. This approach tends + to underestimate the path length, and the error increases with the number + of missing values. + + Choosing ``nan_policy="scale"`` will adjust the path length based on the + the proportion of valid segments per point track. For example, if only + 80% of segments are present, the path length will be computed based on + these and the result will be divided by 0.8. This approach assumes + that motion dynamics are similar across observed and missing time + segments, which may not accurately reflect actual conditions. + + """ + validate_dims_coords(data, {"time": [], "space": []}) + data = data.sel(time=slice(start, stop)) + # Check that the data is not empty or too short + n_time = data.sizes["time"] + if n_time < 2: + raise log_error( + ValueError, + f"At least 2 time points are required to compute path length, " + f"but {n_time} were found. Double-check the start and stop times.", + ) + + _warn_about_nan_proportion(data, nan_warn_threshold) + + if nan_policy == "ffill": + return compute_norm( + compute_displacement(data.ffill(dim="time")).isel( + time=slice(1, None) + ) # skip first displacement (always 0) + ).sum(dim="time", min_count=1) # return NaN if no valid segment + + elif nan_policy == "scale": + return _compute_scaled_path_length(data) + else: + raise log_error( + ValueError, + f"Invalid value for nan_policy: {nan_policy}. " + "Must be one of 'ffill' or 'scale'.", + ) + + +def _warn_about_nan_proportion( + data: xr.DataArray, nan_warn_threshold: float +) -> None: + """Print a warning if the proportion of NaN values exceeds a threshold. + + The NaN proportion is evaluated per point track, and a given point is + considered NaN if any of its ``space`` coordinates are NaN. The warning + specifically lists the point tracks that exceed the threshold. + + Parameters + ---------- + data : xarray.DataArray + The input data array. + nan_warn_threshold : float + The threshold for the proportion of NaN values. Must be a number + between 0 and 1. + + """ + nan_warn_threshold = float(nan_warn_threshold) + if not 0 <= nan_warn_threshold <= 1: + raise log_error( + ValueError, + "nan_warn_threshold must be between 0 and 1.", + ) + + n_nans = data.isnull().any(dim="space").sum(dim="time") + data_to_warn_about = data.where( + n_nans > data.sizes["time"] * nan_warn_threshold, drop=True + ) + if len(data_to_warn_about) > 0: + log_warning( + "The result may be unreliable for point tracks with many " + "missing values. The following tracks have more than " + f"{nan_warn_threshold * 100:.3} % NaN values:", + ) + print(report_nan_values(data_to_warn_about)) + + +def _compute_scaled_path_length( + data: xr.DataArray, +) -> xr.DataArray: + """Compute scaled path length based on proportion of valid segments. + + Path length is first computed based on valid segments (non-NaN values + on both ends of the segment) and then scaled based on the proportion of + valid segments per point track - i.e. the result is divided by the + proportion of valid segments. + + Parameters + ---------- + data : xarray.DataArray + The input data containing position information, with ``time`` + and ``space`` (in Cartesian coordinates) as required dimensions. + + Returns + ------- + xarray.DataArray + An xarray DataArray containing the computed path length, + with dimensions matching those of the input data, + except ``time`` and ``space`` are removed. + + """ + # Skip first displacement segment (always 0) to not mess up the scaling + displacement = compute_displacement(data).isel(time=slice(1, None)) + # count number of valid displacement segments per point track + valid_segments = (~displacement.isnull()).all(dim="space").sum(dim="time") + # compute proportion of valid segments per point track + valid_proportion = valid_segments / (data.sizes["time"] - 1) + # return scaled path length + return compute_norm(displacement).sum(dim="time") / valid_proportion diff --git a/tests/conftest.py b/tests/conftest.py index 4de80c31..2a78e3a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -518,6 +518,40 @@ def valid_poses_dataset_uniform_linear_motion( ) +@pytest.fixture +def valid_poses_dataset_uniform_linear_motion_with_nans( + valid_poses_dataset_uniform_linear_motion, +): + """Return a valid poses dataset with NaN values in the position array. + + Specifically, we will introducde: + - 1 NaN value in the centroid keypoint of individual id_1 at time=0 + - 5 NaN values in the left keypoint of individual id_1 (frames 3-7) + - 10 NaN values in the right keypoint of individual id_1 (all frames) + """ + valid_poses_dataset_uniform_linear_motion.position.loc[ + { + "individuals": "id_1", + "keypoints": "centroid", + "time": 0, + } + ] = np.nan + valid_poses_dataset_uniform_linear_motion.position.loc[ + { + "individuals": "id_1", + "keypoints": "left", + "time": slice(3, 7), + } + ] = np.nan + valid_poses_dataset_uniform_linear_motion.position.loc[ + { + "individuals": "id_1", + "keypoints": "right", + } + ] = np.nan + return valid_poses_dataset_uniform_linear_motion + + # -------------------- Invalid datasets fixtures ------------------------------ @pytest.fixture def not_a_dataset(): diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 18cfa2db..4a68a23f 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -1,4 +1,5 @@ import re +from contextlib import nullcontext as does_not_raise import numpy as np import pytest @@ -43,6 +44,13 @@ np.zeros((10, 2)), # Individual 1 ], ), + ( + "speed", # magnitude of velocity + [ + np.ones(10) * np.sqrt(2), # Individual 0 + np.ones(10) * np.sqrt(2), # Individual 1 + ], + ), ], ) def test_kinematics_uniform_linear_motion( @@ -74,19 +82,24 @@ def test_kinematics_uniform_linear_motion( position ) + # Figure out which dimensions to expect in kinematic_array + # and in the final xarray.DataArray + expected_dims = ["time", "individuals"] + if kinematic_variable in ["displacement", "velocity", "acceleration"]: + expected_dims.append("space") + # Build expected data array from the expected numpy array expected_array = xr.DataArray( - np.stack(expected_kinematics, axis=1), # Stack along the "individuals" axis - dims=["time", "individuals", "space"], + np.stack(expected_kinematics, axis=1), + dims=expected_dims, ) if "keypoints" in position.coords: expected_array = expected_array.expand_dims( {"keypoints": position.coords["keypoints"].size} ) - expected_array = expected_array.transpose( - "time", "individuals", "keypoints", "space" - ) + expected_dims.insert(2, "keypoints") + expected_array = expected_array.transpose(*expected_dims) # Compare the values of the kinematic_array against the expected_array np.testing.assert_allclose(kinematic_array.values, expected_array.values) @@ -105,6 +118,7 @@ def test_kinematics_uniform_linear_motion( ("displacement", [5, 0]), # individual 0, individual 1 ("velocity", [6, 0]), ("acceleration", [7, 0]), + ("speed", [6, 0]), ], ) def test_kinematics_with_dataset_with_nans( @@ -134,10 +148,13 @@ def test_kinematics_with_dataset_with_nans( ] # expected nans per individual adjusted for space and keypoints dimensions + if "space" in kinematic_array.dims: + n_space_dims = position.sizes["space"] + else: + n_space_dims = 1 + expected_nans_adjusted = [ - n - * valid_dataset.sizes["space"] - * valid_dataset.sizes.get("keypoints", 1) + n * n_space_dims * valid_dataset.sizes.get("keypoints", 1) for n in expected_nans_per_individual ] # check number of nans per individual is as expected in kinematic array @@ -163,6 +180,7 @@ def test_kinematics_with_dataset_with_nans( "displacement", "velocity", "acceleration", + "speed", ], ) def test_kinematics_with_invalid_dataset( @@ -186,6 +204,175 @@ def test_approximate_derivative_with_invalid_order(order): kinematics.compute_time_derivative(data, order=order) +time_points_value_error = pytest.raises( + ValueError, + match="At least 2 time points are required to compute path length", +) + + +@pytest.mark.parametrize( + "start, stop, expected_exception", + [ + # full time ranges + (None, None, does_not_raise()), + (0, None, does_not_raise()), + (0, 9, does_not_raise()), + (0, 10, does_not_raise()), # xarray.sel will truncate to 0, 9 + (-1, 9, does_not_raise()), # xarray.sel will truncate to 0, 9 + # partial time ranges + (1, 8, does_not_raise()), + (1.5, 8.5, does_not_raise()), + (2, None, does_not_raise()), + # Empty time ranges + (9, 0, time_points_value_error), # start > stop + ("text", 9, time_points_value_error), # invalid start type + # Time range too short + (0, 0.5, time_points_value_error), + ], +) +def test_path_length_across_time_ranges( + valid_poses_dataset_uniform_linear_motion, + start, + stop, + expected_exception, +): + """Test path length computation for a uniform linear motion case, + across different time ranges. + + The test dataset ``valid_poses_dataset_uniform_linear_motion`` + contains 2 individuals ("id_0" and "id_1"), moving + along x=y and x=-y lines, respectively, at a constant velocity. + At each frame they cover a distance of sqrt(2) in x-y space, so in total + we expect a path length of sqrt(2) * num_segments, where num_segments is + the number of selected frames minus 1. + """ + position = valid_poses_dataset_uniform_linear_motion.position + with expected_exception: + path_length = kinematics.compute_path_length( + position, start=start, stop=stop + ) + + # Expected number of segments (displacements) in selected time range + num_segments = 9 # full time range: 10 frames - 1 + start = max(0, start) if start is not None else 0 + stop = min(9, stop) if stop is not None else 9 + if start is not None: + num_segments -= np.ceil(max(0, start)) + if stop is not None: + stop = min(9, stop) + num_segments -= 9 - np.floor(min(9, stop)) + + expected_path_length = xr.DataArray( + np.ones((2, 3)) * np.sqrt(2) * num_segments, + dims=["individuals", "keypoints"], + coords={ + "individuals": position.coords["individuals"], + "keypoints": position.coords["keypoints"], + }, + ) + xr.testing.assert_allclose(path_length, expected_path_length) + + +@pytest.mark.parametrize( + "nan_policy, expected_path_lengths_id_1, expected_exception", + [ + ( + "ffill", + np.array([np.sqrt(2) * 8, np.sqrt(2) * 9, np.nan]), + does_not_raise(), + ), + ( + "scale", + np.array([np.sqrt(2) * 9, np.sqrt(2) * 9, np.nan]), + does_not_raise(), + ), + ( + "invalid", # invalid value for nan_policy + np.zeros(3), + pytest.raises(ValueError, match="Invalid value for nan_policy"), + ), + ], +) +def test_path_length_with_nans( + valid_poses_dataset_uniform_linear_motion_with_nans, + nan_policy, + expected_path_lengths_id_1, + expected_exception, +): + """Test path length computation for a uniform linear motion case, + with varying number of missing values per individual and keypoint. + + The test dataset ``valid_poses_dataset_uniform_linear_motion_with_nans`` + contains 2 individuals ("id_0" and "id_1"), moving + along x=y and x=-y lines, respectively, at a constant velocity. + At each frame they cover a distance of sqrt(2) in x-y space. + + Individual "id_1" has some missing values per keypoint: + - "centroid" is missing a value on the very first frame + - "left" is missing 5 values in middle frames (not at the edges) + - "right" is missing values in all frames + + Individual "id_0" has no missing values. + + Because the underlying motion is uniform linear, the "scale" policy should + perfectly restore the path length for individual "id_1" to its true value. + The "ffill" policy should do likewise if frames are missing in the middle, + but will not "correct" for missing values at the edges. + """ + position = valid_poses_dataset_uniform_linear_motion_with_nans.position + with expected_exception: + path_length = kinematics.compute_path_length( + position, + nan_policy=nan_policy, + ) + # Get path_length for individual "id_1" as a numpy array + path_length_id_1 = path_length.sel(individuals="id_1").values + # Check them against the expected values + np.testing.assert_allclose( + path_length_id_1, expected_path_lengths_id_1 + ) + + +@pytest.mark.parametrize( + "nan_warn_threshold, expected_exception", + [ + (1, does_not_raise()), + (0.2, does_not_raise()), + (-1, pytest.raises(ValueError, match="between 0 and 1")), + ], +) +def test_path_length_warns_about_nans( + valid_poses_dataset_uniform_linear_motion_with_nans, + nan_warn_threshold, + expected_exception, + caplog, +): + """Test that a warning is raised when the number of missing values + exceeds a given threshold. + + See the docstring of ``test_path_length_with_nans`` for details + about what's in the dataset. + """ + position = valid_poses_dataset_uniform_linear_motion_with_nans.position + with expected_exception: + kinematics.compute_path_length( + position, nan_warn_threshold=nan_warn_threshold + ) + + if (nan_warn_threshold > 0.1) and (nan_warn_threshold < 0.5): + # Make sure that a warning was emitted + assert caplog.records[0].levelname == "WARNING" + assert "The result may be unreliable" in caplog.records[0].message + # Make sure that the NaN report only mentions + # the individual and keypoint that violate the threshold + assert caplog.records[1].levelname == "INFO" + assert "Individual: id_1" in caplog.records[1].message + assert "Individual: id_2" not in caplog.records[1].message + assert "left: 5/10 (50.0%)" in caplog.records[1].message + assert "right: 10/10 (100.0%)" in caplog.records[1].message + assert "centroid" not in caplog.records[1].message + + @pytest.fixture def valid_data_array_for_forward_vector(): """Return a position data array for an individual with 3 keypoints From 5626ef3a33f9d1d0b43bd4ebdcee8918421ca48f Mon Sep 17 00:00:00 2001 From: Luigi Petrucco Date: Tue, 12 Nov 2024 14:23:21 +0100 Subject: [PATCH 60/65] multiview dataset fixture --- tests/conftest.py | 8 ++++++++ tests/test_unit/test_kinematics.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 9acc418e..4e081c99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -276,6 +276,14 @@ def valid_poses_dataset(valid_position_array, request): ) +@pytest.fixture +def multi_view_dataset(): + view_names = ["view_0", "view_1"] + new_coord_views = xr.DataArray(view_names, dims="view") + dataset_list = [valid_poses_dataset() for _ in range(len(view_names))] + return xr.concat(dataset_list, dim=new_coord_views) + + @pytest.fixture def valid_poses_dataset_with_nan(valid_poses_dataset): """Return a valid pose tracks dataset with NaN values.""" diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 2d9096bc..fefd7924 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -110,3 +110,10 @@ def test_approximate_derivative_with_invalid_order(self, order): ) with pytest.raises(expected_exception): kinematics._compute_approximate_derivative(data, order=order) + + # @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) + # @pytest.fixture(mult) + # def test_multiview(self): + # ds = request.getfixturevalue("multi_view_dataset") + # pass + # result = kinematics.compute_displacement(multi_view_dataset.position) From 21fdd2a225a754efaed22385e8c21a97865426a9 Mon Sep 17 00:00:00 2001 From: Luigi Petrucco Date: Thu, 30 May 2024 11:19:44 +0200 Subject: [PATCH 61/65] First draft for function and tests --- docs/source/conf.py | 2 -- movement/io/load_poses.py | 35 ++++++++++++++++++++++++++++++ tests/test_unit/test_load_poses.py | 19 ++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 42ac0668..3039162e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -108,8 +108,6 @@ "binderhub_url": "https://mybinder.org", "dependencies": ["environment.yml"], }, - "reference_url": {"movement": None}, - "default_thumb_file": "source/_static/data_icon.png", # default thumbnail image "remove_config_comments": True, # do not render config params set as # sphinx_gallery_config [= value] } diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index f425d8a1..93bd57c5 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -351,6 +351,41 @@ def from_dlc_file( ) +def from_multi_view( + file_path_dict: dict[str, Union[Path, str]], + source_software: Literal["DeepLabCut", "SLEAP", "LightningPose"], + fps: Optional[float] = None, +) -> xr.Dataset: + """Load and merge pose tracking data from multiple views (cameras). + + Parameters + ---------- + file_path_dict : dict[str, Union[Path, str]] + A dict whose keys are the view names and values are the paths to load. + source_software : {'LightningPose', 'SLEAP', 'DeepLabCut'} + The source software of the file. + fps : float, optional + The number of frames per second in the video. If None (default), + the `time` coordinates will be in frame numbers. + + Returns + ------- + xarray.Dataset + Dataset containing the pose tracks, confidence scores, and metadata, + with an additional views dimension. + + """ + views_list = list(file_path_dict.keys()) + new_coord_views = xr.DataArray(views_list, dims="view") + + dataset_list = [ + from_file(f, source_software=source_software, fps=fps) + for f in file_path_dict.values() + ] + + return xr.concat(dataset_list, dim=new_coord_views) + + def _ds_from_lp_or_dlc_file( file_path: Path | str, source_software: Literal["LightningPose", "DeepLabCut"], diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 77990a42..7256644a 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -300,3 +300,22 @@ def test_from_numpy_valid( source_software=source_software, ) self.assert_dataset(ds, expected_source_software=source_software) + + + def test_from_multi_view(self): + """Test that the from_file() function delegates to the correct + loader function according to the source_software. + """ + view_names = ["view_0", "view_1"] + file_path_dict = { + view: POSE_DATA_PATHS.get("DLC_single-wasp.predictions.h5") + for view in view_names + } + + multi_view_ds = load_poses.from_multi_view( + file_path_dict, source_software="DeepLabCut" + ) + + assert isinstance(multi_view_ds, xr.Dataset) + assert "view" in multi_view_ds.dims + assert multi_view_ds.view.values.tolist() == view_names \ No newline at end of file From 087ba66b18c14ec2260734b064394d9264a3d356 Mon Sep 17 00:00:00 2001 From: Luigi Petrucco Date: Tue, 12 Nov 2024 14:47:38 +0100 Subject: [PATCH 62/65] merging --- movement/io/load_poses.py | 4 ++-- tests/conftest.py | 8 ++++++++ tests/test_unit/test_kinematics.py | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index 93bd57c5..83ec3d60 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -352,9 +352,9 @@ def from_dlc_file( def from_multi_view( - file_path_dict: dict[str, Union[Path, str]], + file_path_dict: dict[str, Path | str], source_software: Literal["DeepLabCut", "SLEAP", "LightningPose"], - fps: Optional[float] = None, + fps: float | None = None, ) -> xr.Dataset: """Load and merge pose tracking data from multiple views (cameras). diff --git a/tests/conftest.py b/tests/conftest.py index 2a78e3a7..e55b0c6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -411,6 +411,14 @@ def valid_poses_dataset(valid_position_array, request): ) +@pytest.fixture +def multi_view_dataset(): + view_names = ["view_0", "view_1"] + new_coord_views = xr.DataArray(view_names, dims="view") + dataset_list = [valid_poses_dataset() for _ in range(len(view_names))] + return xr.concat(dataset_list, dim=new_coord_views) + + @pytest.fixture def valid_poses_dataset_with_nan(valid_poses_dataset): """Return a valid pose tracks dataset with NaN values.""" diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 4a68a23f..c439bf54 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -742,3 +742,10 @@ def test_compute_pairwise_distances_with_invalid_input( kinematics.compute_pairwise_distances( request.getfixturevalue(ds).position, dim, pairs ) + + # @pytest.mark.parametrize("ds, expected_exception", kinematic_test_params) + # @pytest.fixture(mult) + # def test_multiview(self): + # ds = request.getfixturevalue("multi_view_dataset") + # pass + # result = kinematics.compute_displacement(multi_view_dataset.position) From ab622091838c8158ab7b29dd9394cc30d6c447bd Mon Sep 17 00:00:00 2001 From: Luigi Petrucco Date: Tue, 12 Nov 2024 14:52:27 +0100 Subject: [PATCH 63/65] still merging --- tests/test_unit/test_load_poses.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 7256644a..edc67e64 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -301,14 +301,13 @@ def test_from_numpy_valid( ) self.assert_dataset(ds, expected_source_software=source_software) - def test_from_multi_view(self): """Test that the from_file() function delegates to the correct loader function according to the source_software. """ view_names = ["view_0", "view_1"] file_path_dict = { - view: POSE_DATA_PATHS.get("DLC_single-wasp.predictions.h5") + view: DATA_PATHS.get("DLC_single-wasp.predictions.h5") for view in view_names } @@ -318,4 +317,4 @@ def test_from_multi_view(self): assert isinstance(multi_view_ds, xr.Dataset) assert "view" in multi_view_ds.dims - assert multi_view_ds.view.values.tolist() == view_names \ No newline at end of file + assert multi_view_ds.view.values.tolist() == view_names From 3d02d3e09f3b0ce8936b324f2a8d5f1f3b5b7514 Mon Sep 17 00:00:00 2001 From: Luigi Petrucco Date: Wed, 20 Nov 2024 08:55:09 +0100 Subject: [PATCH 64/65] renamed function --- movement/io/load_poses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/movement/io/load_poses.py b/movement/io/load_poses.py index 83ec3d60..de259aa7 100644 --- a/movement/io/load_poses.py +++ b/movement/io/load_poses.py @@ -351,7 +351,7 @@ def from_dlc_file( ) -def from_multi_view( +def from_multiview_files( file_path_dict: dict[str, Path | str], source_software: Literal["DeepLabCut", "SLEAP", "LightningPose"], fps: float | None = None, From 8c6b21357b67dd5be90864eac6e1f73852a73c97 Mon Sep 17 00:00:00 2001 From: Luigi Petrucco Date: Wed, 20 Nov 2024 11:10:49 +0100 Subject: [PATCH 65/65] fix tests --- tests/test_unit/test_load_poses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index edc67e64..148d247f 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -301,7 +301,7 @@ def test_from_numpy_valid( ) self.assert_dataset(ds, expected_source_software=source_software) - def test_from_multi_view(self): + def from_multiview_files(self): """Test that the from_file() function delegates to the correct loader function according to the source_software. """