From 724d30643d9113f1aedebf7ee371ccd86ee863aa Mon Sep 17 00:00:00 2001 From: lochhh Date: Mon, 20 Jan 2025 16:20:53 +0000 Subject: [PATCH 01/37] Use consistent test ids (starting from `id_0`) --- tests/conftest.py | 14 +++++++------- tests/test_unit/test_kinematics.py | 30 +++++++++++++++--------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4a760514..ea0aca87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -569,7 +569,7 @@ def valid_poses_dataset_uniform_linear_motion( dim_names[0]: np.arange(n_frames), dim_names[1]: ["x", "y"], dim_names[2]: ["centroid", "left", "right"], - dim_names[3]: [f"id_{i}" for i in range(1, n_individuals + 1)], + dim_names[3]: [f"id_{i}" for i in range(n_individuals)], }, attrs={ "fps": None, @@ -588,27 +588,27 @@ def valid_poses_dataset_uniform_linear_motion_with_nans( """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) + - 1 NaN value in the centroid keypoint of individual id_0 at time=0 + - 5 NaN values in the left keypoint of individual id_0 (frames 3-7) + - 10 NaN values in the right keypoint of individual id_0 (all frames) """ valid_poses_dataset_uniform_linear_motion.position.loc[ { - "individuals": "id_1", + "individuals": "id_0", "keypoints": "centroid", "time": 0, } ] = np.nan valid_poses_dataset_uniform_linear_motion.position.loc[ { - "individuals": "id_1", + "individuals": "id_0", "keypoints": "left", "time": slice(3, 7), } ] = np.nan valid_poses_dataset_uniform_linear_motion.position.loc[ { - "individuals": "id_1", + "individuals": "id_0", "keypoints": "right", } ] = np.nan diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index ff3ffc22..76efeb80 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -271,7 +271,7 @@ def test_path_length_across_time_ranges( @pytest.mark.parametrize( - "nan_policy, expected_path_lengths_id_1, expected_exception", + "nan_policy, expected_path_lengths_id_0, expected_exception", [ ( "ffill", @@ -293,7 +293,7 @@ def test_path_length_across_time_ranges( def test_path_length_with_nans( valid_poses_dataset_uniform_linear_motion_with_nans, nan_policy, - expected_path_lengths_id_1, + expected_path_lengths_id_0, expected_exception, ): """Test path length computation for a uniform linear motion case, @@ -304,15 +304,15 @@ def test_path_length_with_nans( 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: + Individual "id_0" 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. + Individual "id_1" 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. + perfectly restore the path length for individual "id_0" 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. """ @@ -322,11 +322,11 @@ def test_path_length_with_nans( 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 + # Get path_length for individual "id_0" as a numpy array + path_length_id_0 = path_length.sel(individuals="id_0").values # Check them against the expected values np.testing.assert_allclose( - path_length_id_1, expected_path_lengths_id_1 + path_length_id_0, expected_path_lengths_id_0 ) @@ -363,8 +363,8 @@ def test_path_length_warns_about_nans( # 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 "Individual: id_0" in caplog.records[1].message + assert "Individual: id_1" 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 @@ -688,12 +688,12 @@ def test_cdist_with_single_dim_inputs(valid_dataset, selection_fn, request): @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_0": ["id_1"]}, None), # list input + ("individuals", {"id_0": "id_1"}, None), # string input ( "individuals", - {"id_1": ["id_2"], "id_2": "id_1"}, - [("id_1", "id_2"), ("id_2", "id_1")], + {"id_0": ["id_1"], "id_1": "id_0"}, + [("id_0", "id_1"), ("id_1", "id_0")], ), ("individuals", "all", None), # all pairs ("keypoints", {"centroid": ["left"]}, None), # list input @@ -734,7 +734,7 @@ def test_compute_pairwise_distances_with_valid_pairs( ( "valid_poses_dataset_uniform_linear_motion", "invalid_dim", - {"id_1": "id_2"}, + {"id_0": "id_1"}, ), # invalid dim ( "valid_poses_dataset_uniform_linear_motion", From 577c41c3dda4a0c667f0e97a2164cd452bf3825c Mon Sep 17 00:00:00 2001 From: lochhh Date: Mon, 20 Jan 2025 16:26:35 +0000 Subject: [PATCH 02/37] Use consistent names for fixtures `with_nan` --- tests/conftest.py | 2 +- tests/test_unit/test_kinematics.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ea0aca87..d1cf60b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -582,7 +582,7 @@ def valid_poses_dataset_uniform_linear_motion( @pytest.fixture -def valid_poses_dataset_uniform_linear_motion_with_nans( +def valid_poses_dataset_uniform_linear_motion_with_nan( valid_poses_dataset_uniform_linear_motion, ): """Return a valid poses dataset with NaN values in the position array. diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 76efeb80..fd073658 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -290,8 +290,8 @@ def test_path_length_across_time_ranges( ), ], ) -def test_path_length_with_nans( - valid_poses_dataset_uniform_linear_motion_with_nans, +def test_path_length_with_nan( + valid_poses_dataset_uniform_linear_motion_with_nan, nan_policy, expected_path_lengths_id_0, expected_exception, @@ -299,7 +299,7 @@ def test_path_length_with_nans( """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`` + The test dataset ``valid_poses_dataset_uniform_linear_motion_with_nan`` 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. @@ -316,7 +316,7 @@ def test_path_length_with_nans( 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 + position = valid_poses_dataset_uniform_linear_motion_with_nan.position with expected_exception: path_length = kinematics.compute_path_length( position, @@ -339,7 +339,7 @@ def test_path_length_with_nans( ], ) def test_path_length_warns_about_nans( - valid_poses_dataset_uniform_linear_motion_with_nans, + valid_poses_dataset_uniform_linear_motion_with_nan, nan_warn_threshold, expected_exception, caplog, @@ -350,7 +350,7 @@ def test_path_length_warns_about_nans( 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 + position = valid_poses_dataset_uniform_linear_motion_with_nan.position with expected_exception: kinematics.compute_path_length( position, nan_warn_threshold=nan_warn_threshold @@ -426,7 +426,7 @@ def invalid_spatial_dimensions_for_forward_vector( @pytest.fixture -def valid_data_array_for_forward_vector_with_nans( +def valid_data_array_for_forward_vector_with_nan( valid_data_array_for_forward_vector, ): """Return a position DataArray where position values are NaN for the @@ -522,7 +522,7 @@ def test_compute_forward_vector_with_invalid_input( def test_nan_behavior_forward_vector( - valid_data_array_for_forward_vector_with_nans: xr.DataArray, + valid_data_array_for_forward_vector_with_nan, ): """Test that ``compute_forward_vector()`` generates the expected output for a valid input DataArray containing ``NaN`` @@ -531,7 +531,7 @@ def test_nan_behavior_forward_vector( """ nan_time = 1 forward_vector = kinematics.compute_forward_vector( - valid_data_array_for_forward_vector_with_nans, "left_ear", "right_ear" + valid_data_array_for_forward_vector_with_nan, "left_ear", "right_ear" ) # Check coord preservation for preserved_coord in ["time", "space", "individuals"]: From 23e183f6b13c38450527c5218cba97d2384b5413 Mon Sep 17 00:00:00 2001 From: lochhh Date: Mon, 20 Jan 2025 19:28:27 +0000 Subject: [PATCH 03/37] Align uniform linear motion fixture with bboxes fixture --- tests/conftest.py | 32 ++++++++++++++++++++---------- tests/test_unit/test_kinematics.py | 18 +---------------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d1cf60b2..c9b0f2c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -488,7 +488,10 @@ 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. + It represents 2 individuals with 3 keypoints, + moving at constant velocity for 10 frames in 2D space. + At each frame they cover a distance of sqrt(2) in x-y space. + Specifically: - Individual 0 moves along the x=y line from the origin. - Individual 1 moves along the x=-y line line from the origin. @@ -548,8 +551,12 @@ def valid_poses_array_uniform_linear_motion(): 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. + """Return a valid poses dataset. + + The dataset represents 2 individuals ("id_0" and "id_1") + with 3 keypoints ("centroid", "left", "right") moving in uniform linear + motion for 10 frames in 2D space. + See the ``valid_poses_array_uniform_linear_motion`` fixture for details. """ dim_names = ValidPosesDataset.DIM_NAMES @@ -585,12 +592,15 @@ def valid_poses_dataset_uniform_linear_motion( def valid_poses_dataset_uniform_linear_motion_with_nan( 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_0 at time=0 - - 5 NaN values in the left keypoint of individual id_0 (frames 3-7) - - 10 NaN values in the right keypoint of individual id_0 (all frames) + """Return a valid poses dataset with NaNs introduced in the position array. + + Using ``valid_poses_dataset_uniform_linear_motion`` as the base dataset, + the following NaN values are introduced: + - Individual "id_0": + - 1 NaN value in the centroid keypoint of individual id_0 at time=0 + - 3 NaN values in the left keypoint of individual id_0 (frames 3, 7, 8) + - 10 NaN values in the right keypoint of individual id_0 (all frames) + - Individual "id_1" has no missing values. """ valid_poses_dataset_uniform_linear_motion.position.loc[ { @@ -603,7 +613,7 @@ def valid_poses_dataset_uniform_linear_motion_with_nan( { "individuals": "id_0", "keypoints": "left", - "time": slice(3, 7), + "time": [3, 7, 8], } ] = np.nan valid_poses_dataset_uniform_linear_motion.position.loc[ @@ -930,7 +940,7 @@ def count_nans(da): @staticmethod def count_consecutive_nans(da): """Count occurrences of consecutive NaNs in a DataArray.""" - return (da.isnull().astype(int).diff("time") == 1).sum().item() + return (da.isnull().astype(int).diff("time") != 0).sum().item() @pytest.fixture diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index fd073658..b127034c 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -298,19 +298,6 @@ def test_path_length_with_nan( ): """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_nan`` - 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_0" 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_1" has no missing values. - Because the underlying motion is uniform linear, the "scale" policy should perfectly restore the path length for individual "id_0" to its true value. The "ffill" policy should do likewise if frames are missing in the middle, @@ -346,9 +333,6 @@ def test_path_length_warns_about_nans( ): """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_nan.position with expected_exception: @@ -365,7 +349,7 @@ def test_path_length_warns_about_nans( assert caplog.records[1].levelname == "INFO" assert "Individual: id_0" in caplog.records[1].message assert "Individual: id_1" not in caplog.records[1].message - assert "left: 5/10 (50.0%)" in caplog.records[1].message + assert "left: 3/10 (30.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 From 44ff5fed66d6f91c07b4e017f5de44b2dfd64a31 Mon Sep 17 00:00:00 2001 From: lochhh Date: Mon, 20 Jan 2025 19:29:42 +0000 Subject: [PATCH 04/37] Replace poses fixture in test_filtering --- tests/test_unit/test_filtering.py | 105 ++++++++---------------------- 1 file changed, 26 insertions(+), 79 deletions(-) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index d51af1be..05674d7f 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -12,7 +12,7 @@ # Dataset fixtures list_valid_datasets_without_nans = [ - "valid_poses_dataset", + "valid_poses_dataset_uniform_linear_motion", "valid_bboxes_dataset", ] list_valid_datasets_with_nans = [ @@ -28,7 +28,8 @@ list_valid_datasets_with_nans, ) @pytest.mark.parametrize( - "max_gap, expected_n_nans_in_position", [(None, 0), (0, 3), (1, 2), (2, 0)] + "max_gap, expected_n_nans_in_position", + [(None, [20, 0]), (0, [26, 6]), (1, [24, 4]), (2, [20, 0])], ) def test_interpolate_over_time_on_position( valid_dataset_with_nan, @@ -42,7 +43,6 @@ def test_interpolate_over_time_on_position( for different values of ``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() @@ -53,9 +53,7 @@ def test_interpolate_over_time_on_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"]) + # Count number of NaNs n_nans_after_per_time_unit = {} for time_unit in ["frames", "seconds"]: # interpolate @@ -66,39 +64,24 @@ def test_interpolate_over_time_on_position( 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_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.sizes["space"] - * valid_dataset_in_frames.sizes.get("keypoints", 1) - # in bboxes dataset there is no keypoints dimension - * expected_n_nans_in_position - ) + n_nans_after = n_nans_after_per_time_unit["frames"] + dataset_index = list_valid_datasets_with_nans.index(valid_dataset_with_nan) + assert n_nans_after == expected_n_nans_in_position[dataset_index] @pytest.mark.parametrize( - "valid_dataset_no_nans, n_low_confidence_kpts", - [ - ("valid_poses_dataset", 20), - ("valid_bboxes_dataset", 5), - ], + "valid_dataset_no_nans", + list_valid_datasets_without_nans, ) def test_filter_by_confidence_on_position( - valid_dataset_no_nans, n_low_confidence_kpts, helpers, request + valid_dataset_no_nans, helpers, request ): """Test that points below the default 0.6 confidence threshold are converted to NaN. @@ -110,15 +93,14 @@ def test_filter_by_confidence_on_position( confidence=valid_input_dataset.confidence, threshold=0.6, ) - # Count number of NaNs in the full array n_nans = helpers.count_nans(position_filtered) - # 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 + n_low_confidence_kpts = 5 assert isinstance(position_filtered, xr.DataArray) assert n_nans == valid_input_dataset.sizes["space"] * n_low_confidence_kpts @@ -147,12 +129,9 @@ def test_filter_on_position( 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) @@ -163,12 +142,12 @@ def test_filter_on_position( ("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}), + "valid_poses_dataset_uniform_linear_motion", + [0, 0], # no nans in the input data + ), + ("valid_bboxes_dataset", [0, 0]), # no nans in the input data + ("valid_poses_dataset_uniform_linear_motion_with_nan", [38, 0]), + ("valid_bboxes_dataset_with_nan", [14, 0]), ], ) @pytest.mark.parametrize( @@ -189,49 +168,22 @@ def test_filter_with_nans_on_position( """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. """ - - def _assert_n_nans_in_position_per_individual( - valid_input_dataset, - position_filtered, - expected_nans_in_filt_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.sizes["individuals"]) - } - - # check number of nans per indiv is as expected - 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.sizes["space"] - * valid_input_dataset.sizes.get("keypoints", 1) - ) - # Filter position valid_input_dataset = request.getfixturevalue(valid_dataset) position_filtered = filter_func( valid_input_dataset.position, **filter_kwargs ) - - # check number of nans per indiv is as expected - _assert_n_nans_in_position_per_individual( - valid_input_dataset, - position_filtered, - expected_nans_in_filtered_position_per_indiv, + # Compute n nans in position after filtering per individual + n_nans_after_filtering_per_indiv = [ + helpers.count_nans(position_filtered.isel(individuals=i)) + for i in range(valid_input_dataset.sizes["individuals"]) + ] + # Check number of nans per indiv is as expected + assert ( + n_nans_after_filtering_per_indiv + == expected_nans_in_filtered_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_with_nan", @@ -256,24 +208,19 @@ def test_filter_with_nans_on_position_varying_window( 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 From 4f46c361a848010809cb59529f396ade52d3bc23 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 12:12:54 +0000 Subject: [PATCH 05/37] Group filtering tests by common dataset params --- tests/test_unit/test_filtering.py | 364 ++++++++++++++---------------- 1 file changed, 168 insertions(+), 196 deletions(-) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 05674d7f..3cf46159 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -24,56 +24,181 @@ @pytest.mark.parametrize( - "valid_dataset_with_nan", - list_valid_datasets_with_nans, + "valid_dataset", + list_all_valid_datasets, ) +class TestFilteringValidDataset: + """Test median and savgol filtering on valid datasets with/without NaNs.""" + + @pytest.mark.parametrize( + ("filter_func, filter_kwargs"), + [ + (median_filter, {"window": 3}), + (savgol_filter, {"window": 3, "polyorder": 2}), + ], + ) + def test_filter_with_nans_on_position( + self, + filter_func, + filter_kwargs, + valid_dataset, + helpers, + request, + ): + """Test NaN behaviour of the median and SG filters. + Both filters should set all values to NaN if one element of the + sliding window is NaN. + """ + # Expected number of nans in the position array per individual + expected_nans_in_filtered_position_per_indiv = { + "valid_poses_dataset_uniform_linear_motion": [ + 0, + 0, + ], # no nans in input + "valid_bboxes_dataset": [0, 0], # no nans in input + "valid_poses_dataset_uniform_linear_motion_with_nan": [38, 0], + "valid_bboxes_dataset_with_nan": [14, 0], + } + # Filter position + valid_input_dataset = request.getfixturevalue(valid_dataset) + position_filtered = filter_func( + valid_input_dataset.position, **filter_kwargs + ) + # Compute n nans in position after filtering per individual + n_nans_after_filtering_per_indiv = [ + helpers.count_nans(position_filtered.isel(individuals=i)) + for i in range(valid_input_dataset.sizes["individuals"]) + ] + # Check number of nans per indiv is as expected + assert ( + n_nans_after_filtering_per_indiv + == expected_nans_in_filtered_position_per_indiv[valid_dataset] + ) + + @pytest.mark.parametrize( + "override_kwargs", + [ + {"mode": "nearest"}, + {"axis": 1}, + {"mode": "nearest", "axis": 1}, + ], + ) + def test_savgol_filter_kwargs_override( + self, 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. + """ + expected_exception = ( + pytest.raises(ValueError) + if "axis" in override_kwargs + else does_not_raise() + ) + with expected_exception: + savgol_filter( + request.getfixturevalue(valid_dataset).position, + window=3, + **override_kwargs, + ) + + @pytest.mark.parametrize( - "max_gap, expected_n_nans_in_position", - [(None, [20, 0]), (0, [26, 6]), (1, [24, 4]), (2, [20, 0])], + "valid_dataset_with_nan", + list_valid_datasets_with_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 linearly interpolating - over time and that the resulting number of NaNs is as expected - for different values of ``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 +class TestFilteringValidDatasetWithNaNs: + """Test filtering functions on datasets with NaNs.""" + + @pytest.mark.parametrize( + "max_gap, expected_n_nans_in_position", + [(None, [20, 0]), (0, [26, 6]), (1, [24, 4]), (2, [20, 0])], + # expected total n nans: [poses, bboxes] ) - position = { - "frames": valid_dataset_in_frames.position, - "seconds": valid_dataset_in_seconds.position, - } - # Count number of NaNs - 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 + def test_interpolate_over_time_on_position( + self, + valid_dataset_with_nan, + max_gap, + expected_n_nans_in_position, + helpers, + request, + ): + """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``. + """ + 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 ) - # count nans - n_nans_after_per_time_unit[time_unit] = helpers.count_nans( - position_interp + position = { + "frames": valid_dataset_in_frames.position, + "seconds": valid_dataset_in_seconds.position, + } + # Count number of NaNs + 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_per_time_unit["frames"] + == n_nans_after_per_time_unit["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_per_time_unit["frames"] - == n_nans_after_per_time_unit["seconds"] + # The number of NaNs after interpolating should be as expected + n_nans_after = n_nans_after_per_time_unit["frames"] + dataset_index = list_valid_datasets_with_nans.index( + valid_dataset_with_nan + ) + assert n_nans_after == expected_n_nans_in_position[dataset_index] + + @pytest.mark.parametrize( + "window", + [3, 5, 6, 10], # data is nframes = 10 ) - # The number of NaNs after interpolating should be as expected - n_nans_after = n_nans_after_per_time_unit["frames"] - dataset_index = list_valid_datasets_with_nans.index(valid_dataset_with_nan) - assert n_nans_after == expected_n_nans_in_position[dataset_index] + @pytest.mark.parametrize("filter_func", [median_filter, savgol_filter]) + def test_filter_with_nans_on_position_varying_window( + self, 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( @@ -103,156 +228,3 @@ def test_filter_by_confidence_on_position( n_low_confidence_kpts = 5 assert isinstance(position_filtered, xr.DataArray) assert n_nans == valid_input_dataset.sizes["space"] * n_low_confidence_kpts - - -@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. - """ - # 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) - - -# Expected number of nans in the position array per -# individual, after applying a filter with window size 3 -@pytest.mark.parametrize( - ("valid_dataset, expected_nans_in_filtered_position_per_indiv"), - [ - ( - "valid_poses_dataset_uniform_linear_motion", - [0, 0], # no nans in the input data - ), - ("valid_bboxes_dataset", [0, 0]), # no nans in the input data - ("valid_poses_dataset_uniform_linear_motion_with_nan", [38, 0]), - ("valid_bboxes_dataset_with_nan", [14, 0]), - ], -) -@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_nans_in_filtered_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. - """ - # Filter position - valid_input_dataset = request.getfixturevalue(valid_dataset) - position_filtered = filter_func( - valid_input_dataset.position, **filter_kwargs - ) - # Compute n nans in position after filtering per individual - n_nans_after_filtering_per_indiv = [ - helpers.count_nans(position_filtered.isel(individuals=i)) - for i in range(valid_input_dataset.sizes["individuals"]) - ] - # Check number of nans per indiv is as expected - assert ( - n_nans_after_filtering_per_indiv - == expected_nans_in_filtered_position_per_indiv - ) - - -@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, -) -@pytest.mark.parametrize( - "override_kwargs", - [ - {"mode": "nearest"}, - {"axis": 1}, - {"mode": "nearest", "axis": 1}, - ], -) -def test_savgol_filter_kwargs_override( - 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. - """ - expected_exception = ( - pytest.raises(ValueError) - if "axis" in override_kwargs - else does_not_raise() - ) - with expected_exception: - savgol_filter( - request.getfixturevalue(valid_dataset).position, - window=3, - **override_kwargs, - ) From ce00f253854ea0a4a1ccf670c4a912fba26a93fb Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 12:28:30 +0000 Subject: [PATCH 06/37] Replace poses fixture in test_kinematics --- tests/test_unit/test_kinematics.py | 75 ++++++++++++++---------------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index b127034c..fd8bba2b 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -60,21 +60,7 @@ def test_kinematics_uniform_linear_motion( 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. - """ + """Test computed kinematics for a uniform linear motion case.""" # Compute kinematic array from input dataset position = request.getfixturevalue( valid_dataset_uniform_linear_motion @@ -82,13 +68,11 @@ def test_kinematics_uniform_linear_motion( kinematic_array = getattr(kinematics, f"compute_{kinematic_variable}")( 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.insert(1, "space") - # Build expected data array from the expected numpy array expected_array = xr.DataArray( # Stack along the "individuals" axis @@ -101,7 +85,6 @@ def test_kinematics_uniform_linear_motion( ) expected_dims.insert(-1, "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) @@ -109,17 +92,41 @@ def test_kinematics_uniform_linear_motion( @pytest.mark.parametrize( "valid_dataset_with_nan", [ - "valid_poses_dataset_with_nan", + "valid_poses_dataset_uniform_linear_motion_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]), - ("speed", [6, 0]), + ( + "displacement", + { + "valid_poses_dataset_uniform_linear_motion_with_nan": [30, 0], + "valid_bboxes_dataset_with_nan": [10, 0], + }, # [individual 0, individual 1] + ), + ( + "velocity", + { + "valid_poses_dataset_uniform_linear_motion_with_nan": [36, 0], + "valid_bboxes_dataset_with_nan": [12, 0], + }, + ), + ( + "acceleration", + { + "valid_poses_dataset_uniform_linear_motion_with_nan": [40, 0], + "valid_bboxes_dataset_with_nan": [14, 0], + }, + ), + ( + "speed", + { + "valid_poses_dataset_uniform_linear_motion_with_nan": [18, 0], + "valid_bboxes_dataset_with_nan": [6, 0], + }, + ), ], ) def test_kinematics_with_dataset_with_nans( @@ -129,12 +136,7 @@ def test_kinematics_with_dataset_with_nans( 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. - - """ + """Test kinematics computation for a dataset with nans.""" # compute kinematic array valid_dataset = request.getfixturevalue(valid_dataset_with_nan) position = valid_dataset.position @@ -146,17 +148,10 @@ def test_kinematics_with_dataset_with_nans( helpers.count_nans(kinematic_array.isel(individuals=i)) for i in range(valid_dataset.sizes["individuals"]) ] - # expected nans per individual adjusted for space and keypoints dimensions - n_space_dims = ( - position.sizes["space"] if "space" in kinematic_array.dims else 1 - ) - expected_nans_adjusted = [ - 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 - np.testing.assert_array_equal( - n_nans_kinematics_per_indiv, expected_nans_adjusted + # assert n nans in kinematic array per individual matches expected + assert ( + n_nans_kinematics_per_indiv + == expected_nans_per_individual[valid_dataset_with_nan] ) From 257a89492ff397e3e0a3f32820e94a8ec253098b Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 14:28:55 +0000 Subject: [PATCH 07/37] Replace poses fixture in test_save_poses --- tests/conftest.py | 144 +++++++++++++++++------------ tests/test_unit/test_save_poses.py | 73 ++++++++++----- 2 files changed, 138 insertions(+), 79 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c9b0f2c5..f4d1dd7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -488,83 +488,113 @@ 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, + Depending on the ``array_type`` requested (``multi_individual_array``, + ``single_keypoint_array``, or ``single_individual_array``), + the arrays can represent up to 2 individuals with up to 3 keypoints, moving at constant velocity for 10 frames in 2D space. - At each frame they cover a distance of sqrt(2) in x-y space. + Default is a ``multi_individual_array``. + At each frame the individuals cover a distance of sqrt(2) in x-y space. Specifically: - 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: + for the "centroid" (index=0) 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_space, n_keypoints, n_individuals = (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.zeros((n_frames, n_space, n_keypoints, n_individuals)) - position[:, 0, 0, :] = frames[:, None] # reshape to (n_frames, 1) - position[:, 1, 0, 0] = 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[:, 0, kpt, i] = ( - position[:, 0, 0, i] + offsets[i][kpt - 1][0] - ) - position[:, 1, kpt, i] = ( - position[:, 1, 0, i] + offsets[i][kpt - 1][1] - ) - # build an array of confidence values, all 0.9 - confidence = np.full((n_frames, n_keypoints, n_individuals), 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, 0, 1] = 0.1 + def _valid_poses_array(array_type): + """Return a dictionary of valid arrays for a ValidPosesDataset.""" + # Unless specified, default is a ``multi_individual_array`` with + # 10 frames, 3 keypoints, and 2 individuals in 2D space. + n_frames, n_space, n_keypoints, n_individuals = (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 (if applicable) + # They move one unit along x and y axes in each frame + frames = np.arange(n_frames) + position = np.zeros((n_frames, n_space, n_keypoints, n_individuals)) + position[:, 0, 0, :] = frames[:, None] # reshape to (n_frames, 1) + position[:, 1, 0, 0] = 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[:, 0, kpt, i] = ( + position[:, 0, 0, i] + offsets[i][kpt - 1][0] + ) + position[:, 1, kpt, i] = ( + position[:, 1, 0, i] + offsets[i][kpt - 1][1] + ) + + # build an array of confidence values, all 0.9 + confidence = np.full((n_frames, n_keypoints, n_individuals), 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, 0, 1] = 0.1 + + if array_type == "single_keypoint_array": + # return only the centroid keypoint + position = position[:, :, :1, :] + confidence = confidence[:, :1, :] + elif array_type == "single_individual_array": + # return only the first individual + position = position[:, :, :, :1] + confidence = confidence[:, :, :1] + return {"position": position, "confidence": confidence} - return {"position": position, "confidence": confidence} + return _valid_poses_array @pytest.fixture def valid_poses_dataset_uniform_linear_motion( - valid_poses_array_uniform_linear_motion, + valid_poses_array_uniform_linear_motion, request ): """Return a valid poses dataset. - The dataset represents 2 individuals ("id_0" and "id_1") - with 3 keypoints ("centroid", "left", "right") moving in uniform linear - motion for 10 frames in 2D space. + Depending on the ``array_type`` requested (``multi_individual_array``, + ``single_keypoint_array``, or ``single_individual_array``), + the dataset can represent up to 2 individuals ("id_0" and "id_1") + with up to 3 keypoints ("centroid", "left", "right") + moving in uniform linear motion for 10 frames in 2D space. + Default is a ``multi_individual_array``. See the ``valid_poses_array_uniform_linear_motion`` fixture for details. """ dim_names = ValidPosesDataset.DIM_NAMES - - 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 - + # create a multi_individual_array by default unless overridden via param + try: + array_format = request.param + except AttributeError: + array_format = "multi_individual_array" + poses_array = valid_poses_array_uniform_linear_motion(array_format) + position_array = poses_array["position"] + confidence_array = poses_array["confidence"] + n_frames, _, n_keypoints, n_individuals = position_array.shape return xr.Dataset( data_vars={ "position": xr.DataArray(position_array, dims=dim_names), @@ -575,7 +605,7 @@ def valid_poses_dataset_uniform_linear_motion( coords={ dim_names[0]: np.arange(n_frames), dim_names[1]: ["x", "y"], - dim_names[2]: ["centroid", "left", "right"], + dim_names[2]: ["centroid", "left", "right"][:n_keypoints], dim_names[3]: [f"id_{i}" for i in range(n_individuals)], }, attrs={ diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index 592f0c9a..3e7c19ca 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -122,7 +122,10 @@ def test_to_dlc_style_df(self, ds, expected_exception): ] def test_to_dlc_file_valid_dataset( - self, output_file_params, valid_poses_dataset, request + self, + output_file_params, + valid_poses_dataset_uniform_linear_motion, + request, ): """Test that saving a valid pose dataset to a valid/invalid DeepLabCut-style file returns the appropriate errors. @@ -131,7 +134,9 @@ def test_to_dlc_file_valid_dataset( file_fixture = output_file_params.get("file_fixture") val = request.getfixturevalue(file_fixture) file_path = val.get("file_path") if isinstance(val, dict) else val - save_poses.to_dlc_file(valid_poses_dataset, file_path) + save_poses.to_dlc_file( + valid_poses_dataset_uniform_linear_motion, file_path + ) @pytest.mark.parametrize( "invalid_poses_dataset, expected_exception", @@ -151,40 +156,48 @@ def test_to_dlc_file_invalid_dataset( ) @pytest.mark.parametrize( - "valid_poses_dataset, split_value", + "valid_poses_dataset_uniform_linear_motion, split_value", [("single_individual_array", True), ("multi_individual_array", False)], - indirect=["valid_poses_dataset"], + indirect=["valid_poses_dataset_uniform_linear_motion"], ) - def test_auto_split_individuals(self, valid_poses_dataset, split_value): + def test_auto_split_individuals( + self, valid_poses_dataset_uniform_linear_motion, split_value + ): """Test that setting 'split_individuals' to 'auto' yields True for single-individual datasets and False for multi-individual ones. """ assert ( - save_poses._auto_split_individuals(valid_poses_dataset) + save_poses._auto_split_individuals( + valid_poses_dataset_uniform_linear_motion + ) == split_value ) @pytest.mark.parametrize( - "valid_poses_dataset, split_individuals", + "valid_poses_dataset_uniform_linear_motion, split_individuals", [ ("single_individual_array", True), # single-individual, split ("multi_individual_array", False), # multi-individual, no split ("single_individual_array", False), # single-individual, no split ("multi_individual_array", True), # multi-individual, split ], - indirect=["valid_poses_dataset"], + indirect=["valid_poses_dataset_uniform_linear_motion"], ) def test_to_dlc_style_df_split_individuals( self, - valid_poses_dataset, + valid_poses_dataset_uniform_linear_motion, split_individuals, ): """Test that the `split_individuals` argument affects the behaviour of the `to_dlc_style_df` function as expected. """ - df = save_poses.to_dlc_style_df(valid_poses_dataset, split_individuals) + df = save_poses.to_dlc_style_df( + valid_poses_dataset_uniform_linear_motion, split_individuals + ) # Get the names of the individuals in the dataset - ind_names = valid_poses_dataset.individuals.values + ind_names = ( + valid_poses_dataset_uniform_linear_motion.individuals.values + ) if split_individuals is False: # this should produce a single df in multi-animal DLC format assert isinstance(df, pd.DataFrame) @@ -221,7 +234,7 @@ def test_to_dlc_style_df_split_individuals( ) def test_to_dlc_file_split_individuals( self, - valid_poses_dataset, + valid_poses_dataset_uniform_linear_motion, new_h5_file, split_individuals, expected_exception, @@ -231,12 +244,14 @@ def test_to_dlc_file_split_individuals( """ with expected_exception: save_poses.to_dlc_file( - valid_poses_dataset, + valid_poses_dataset_uniform_linear_motion, new_h5_file, split_individuals, ) # Get the names of the individuals in the dataset - ind_names = valid_poses_dataset.individuals.values + ind_names = ( + valid_poses_dataset_uniform_linear_motion.individuals.values + ) # "auto" becomes False, default valid dataset is multi-individual if split_individuals in [False, "auto"]: # this should save only one file @@ -252,7 +267,10 @@ def test_to_dlc_file_split_individuals( file_path_ind.unlink() def test_to_lp_file_valid_dataset( - self, output_file_params, valid_poses_dataset, request + self, + output_file_params, + valid_poses_dataset_uniform_linear_motion, + request, ): """Test that saving a valid pose dataset to a valid/invalid LightningPose-style file returns the appropriate errors. @@ -261,7 +279,9 @@ def test_to_lp_file_valid_dataset( file_fixture = output_file_params.get("file_fixture") val = request.getfixturevalue(file_fixture) file_path = val.get("file_path") if isinstance(val, dict) else val - save_poses.to_lp_file(valid_poses_dataset, file_path) + save_poses.to_lp_file( + valid_poses_dataset_uniform_linear_motion, file_path + ) @pytest.mark.parametrize( "invalid_poses_dataset, expected_exception", @@ -280,7 +300,10 @@ def test_to_lp_file_invalid_dataset( ) def test_to_sleap_analysis_file_valid_dataset( - self, output_file_params, valid_poses_dataset, request + self, + output_file_params, + valid_poses_dataset_uniform_linear_motion, + request, ): """Test that saving a valid pose dataset to a valid/invalid SLEAP-style file returns the appropriate errors. @@ -289,7 +312,9 @@ def test_to_sleap_analysis_file_valid_dataset( file_fixture = output_file_params.get("file_fixture") val = request.getfixturevalue(file_fixture) file_path = val.get("file_path") if isinstance(val, dict) else val - save_poses.to_sleap_analysis_file(valid_poses_dataset, file_path) + save_poses.to_sleap_analysis_file( + valid_poses_dataset_uniform_linear_motion, file_path + ) @pytest.mark.parametrize( "invalid_poses_dataset, expected_exception", @@ -307,12 +332,16 @@ def test_to_sleap_analysis_file_invalid_dataset( new_h5_file, ) - def test_remove_unoccupied_tracks(self, valid_poses_dataset): + def test_remove_unoccupied_tracks( + self, valid_poses_dataset_uniform_linear_motion + ): """Test that removing unoccupied tracks from a valid pose dataset returns the expected result. """ - new_individuals = [f"ind{i}" for i in range(1, 4)] + new_individuals = [f"id_{i}" for i in range(3)] # Add new individual with NaN data - ds = valid_poses_dataset.reindex(individuals=new_individuals) + ds = valid_poses_dataset_uniform_linear_motion.reindex( + individuals=new_individuals + ) ds = save_poses._remove_unoccupied_tracks(ds) - xr.testing.assert_equal(ds, valid_poses_dataset) + xr.testing.assert_equal(ds, valid_poses_dataset_uniform_linear_motion) From f78938339d352f94eb1e729756b6d01c0f6f7626 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 14:41:59 +0000 Subject: [PATCH 08/37] Replace poses fixture in datasets missing dim and var --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f4d1dd7c..4ab796d9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -669,9 +669,9 @@ def empty_dataset(): @pytest.fixture -def missing_var_poses_dataset(valid_poses_dataset): +def missing_var_poses_dataset(valid_poses_dataset_uniform_linear_motion): """Return a poses dataset missing position variable.""" - return valid_poses_dataset.drop_vars("position") + return valid_poses_dataset_uniform_linear_motion.drop_vars("position") @pytest.fixture @@ -687,9 +687,9 @@ def missing_two_vars_bboxes_dataset(valid_bboxes_dataset): @pytest.fixture -def missing_dim_poses_dataset(valid_poses_dataset): +def missing_dim_poses_dataset(valid_poses_dataset_uniform_linear_motion): """Return a poses dataset missing the time dimension.""" - return valid_poses_dataset.rename({"time": "tame"}) + return valid_poses_dataset_uniform_linear_motion.rename({"time": "tame"}) @pytest.fixture From 638dd8903c6b6b13b1cf6511ed30c76167b1532f Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 15:54:56 +0000 Subject: [PATCH 09/37] Replace poses fixture in test_reports --- tests/test_unit/test_reports.py | 100 ++++++++++++++------------------ 1 file changed, 42 insertions(+), 58 deletions(-) diff --git a/tests/test_unit/test_reports.py b/tests/test_unit/test_reports.py index 79c3bc89..059a8f8e 100644 --- a/tests/test_unit/test_reports.py +++ b/tests/test_unit/test_reports.py @@ -6,92 +6,86 @@ @pytest.mark.parametrize( "valid_dataset", [ - "valid_poses_dataset", + "valid_poses_dataset_uniform_linear_motion", "valid_bboxes_dataset", - "valid_poses_dataset_with_nan", + "valid_poses_dataset_uniform_linear_motion_with_nan", "valid_bboxes_dataset_with_nan", ], ) @pytest.mark.parametrize( - "data_selection, list_expected_individuals_indices", + "data_selection, 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 + ), # individual 0 only ], ) def test_report_nan_values_in_position_selecting_individual( valid_dataset, data_selection, - list_expected_individuals_indices, + expected_individuals_indices, request, ): """Test that the nan-value reporting function handles position data - with specific ``individuals`` , and that the data array name (position) + with specific ``individuals``, and that the data array name (position) and only the relevant individuals 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 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] + list_of_individuals = input_dataset["individuals"].values.tolist() + all_individuals = set(list_of_individuals) + expected_individuals = set( + list_of_individuals[i] for i in expected_individuals_indices ) + not_expected_individuals = all_individuals - expected_individuals + assert all(ind in report_str for ind in expected_individuals) and all( + ind not in report_str for ind in not_expected_individuals + ), "Report contains incorrect individuals." @pytest.mark.parametrize( "valid_dataset", [ - "valid_poses_dataset", - "valid_poses_dataset_with_nan", + "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset_uniform_linear_motion_with_nan", ], ) @pytest.mark.parametrize( - "data_selection, list_expected_keypoints, list_expected_individuals", + "data_selection, expected_keypoints, expected_individuals", [ ( lambda ds: ds.position, - ["key1", "key2"], - ["ind1", "ind2"], + {"centroid", "left", "right"}, + {"id_0", "id_1"}, ), # 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(keypoints=["centroid", "left"]), + {"centroid", "left"}, + {"id_0", "id_1"}, + ), # Report nans in position for 2 keypoints, for all individuals ( - 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 + lambda ds: ds.position.sel( + individuals="id_0", keypoints="centroid" + ), + set(), + {"id_0"}, + ), # Report nans in position for centroid of individual id_0 + # Note: if only 1 keypoint exists, its name is not explicitly reported ], ) def test_report_nan_values_in_position_selecting_keypoint( valid_dataset, data_selection, - list_expected_keypoints, - list_expected_individuals, + expected_keypoints, + expected_individuals, request, ): """Test that the nan-value reporting function handles position data @@ -101,29 +95,19 @@ def test_report_nan_values_in_position_selecting_keypoint( # 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]) - + all_keypoints = set(input_dataset["keypoints"].values.tolist()) + not_expected_keypoints = all_keypoints - expected_keypoints + assert all(kpt in report_str for kpt in expected_keypoints) and all( + kpt not in report_str for kpt in not_expected_keypoints + ), "Report contains incorrect 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] - ) + all_individuals = set(input_dataset["individuals"].values.tolist()) + not_expected_individuals = all_individuals - expected_individuals + assert all(ind in report_str for ind in expected_individuals) and all( + ind not in report_str for ind in not_expected_individuals + ), "Report contains incorrect individuals." From ce255cc4394a58d6f0b410b9e26c3a52776d37b1 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 16:03:43 +0000 Subject: [PATCH 10/37] Replace poses fixture in test_io --- tests/test_integration/test_io.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_integration/test_io.py b/tests/test_integration/test_io.py index 50f03933..0df1deea 100644 --- a/tests/test_integration/test_io.py +++ b/tests/test_integration/test_io.py @@ -24,16 +24,20 @@ def test_load_and_save_to_dlc_style_df(self, dlc_style_df): np.testing.assert_allclose(df.values, dlc_style_df.values) def test_save_and_load_dlc_file( - self, dlc_output_file, valid_poses_dataset + self, dlc_output_file, valid_poses_dataset_uniform_linear_motion ): """Test that saving pose tracks to DLC .h5 and .csv files and then loading them back in returns the same Dataset. """ save_poses.to_dlc_file( - valid_poses_dataset, dlc_output_file, split_individuals=False + valid_poses_dataset_uniform_linear_motion, + dlc_output_file, + split_individuals=False, ) ds = load_poses.from_dlc_file(dlc_output_file) - xr.testing.assert_allclose(ds, valid_poses_dataset) + xr.testing.assert_allclose( + ds, valid_poses_dataset_uniform_linear_motion + ) def test_convert_sleap_to_dlc_file(self, sleap_file, dlc_output_file): """Test that pose tracks loaded from SLEAP .slp and .h5 files, From 87288f7411173af133a5ffc9a6a8d219b7742b42 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 16:04:21 +0000 Subject: [PATCH 11/37] Replace poses fixture in test_logging --- tests/test_unit/test_logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unit/test_logging.py b/tests/test_unit/test_logging.py index 348a3687..bed1d50a 100644 --- a/tests/test_unit/test_logging.py +++ b/tests/test_unit/test_logging.py @@ -49,7 +49,7 @@ def test_log_warning(caplog): @pytest.mark.parametrize( "input_data", [ - "valid_poses_dataset", + "valid_poses_dataset_uniform_linear_motion", "valid_bboxes_dataset", ], ) From 831a26f0bb10abf3c5d0bd2efb916b75b1c57057 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 16:09:35 +0000 Subject: [PATCH 12/37] Remove valid_poses_dataset fixtures --- tests/conftest.py | 51 ----------------------------------------------- 1 file changed, 51 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4ab796d9..9ec52ed5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -432,57 +432,6 @@ def _valid_position_array(array_type): return _valid_position_array -@pytest.fixture -def valid_poses_dataset(valid_position_array, request): - """Return a valid pose tracks dataset.""" - dim_names = ValidPosesDataset.DIM_NAMES - # create a multi_individual_array by default unless overridden via param - try: - array_format = request.param - except AttributeError: - array_format = "multi_individual_array" - position_array = valid_position_array(array_format) - n_frames, n_keypoints, n_individuals = ( - position_array.shape[:1] + position_array.shape[2:] - ) - return xr.Dataset( - data_vars={ - "position": xr.DataArray(position_array, dims=dim_names), - "confidence": xr.DataArray( - np.repeat( - np.linspace(0.1, 1.0, n_frames), - n_keypoints * n_individuals, - ).reshape(position_array.shape[:1] + position_array.shape[2:]), - dims=dim_names[:1] + dim_names[2:], # exclude "space" - ), - }, - coords={ - "time": np.arange(n_frames), - "space": ["x", "y"], - "keypoints": [f"key{i}" for i in range(1, n_keypoints + 1)], - "individuals": [f"ind{i}" for i in range(1, n_individuals + 1)], - }, - attrs={ - "fps": None, - "time_unit": "frames", - "source_software": "SLEAP", - "source_file": "test.h5", - "ds_type": "poses", - }, - ) - - -@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 From ea93aa7e613be55606bc144ebfe788eba1d77a1f Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 16:13:21 +0000 Subject: [PATCH 13/37] Rename valid_poses_dataset_uniform_linear_motion fixtures --- tests/conftest.py | 26 ++++---- tests/test_integration/test_io.py | 8 +-- .../test_kinematics_vector_transform.py | 2 +- tests/test_unit/test_filtering.py | 6 +- tests/test_unit/test_kinematics.py | 44 +++++++------ tests/test_unit/test_logging.py | 2 +- tests/test_unit/test_reports.py | 8 +-- tests/test_unit/test_save_poses.py | 62 +++++++------------ .../test_validators/test_array_validators.py | 4 +- 9 files changed, 68 insertions(+), 94 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9ec52ed5..6088eb80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -521,9 +521,7 @@ def _valid_poses_array(array_type): @pytest.fixture -def valid_poses_dataset_uniform_linear_motion( - valid_poses_array_uniform_linear_motion, request -): +def valid_poses_dataset(valid_poses_array_uniform_linear_motion, request): """Return a valid poses dataset. Depending on the ``array_type`` requested (``multi_individual_array``, @@ -568,12 +566,12 @@ def valid_poses_dataset_uniform_linear_motion( @pytest.fixture -def valid_poses_dataset_uniform_linear_motion_with_nan( - valid_poses_dataset_uniform_linear_motion, +def valid_poses_dataset_with_nan( + valid_poses_dataset, ): """Return a valid poses dataset with NaNs introduced in the position array. - Using ``valid_poses_dataset_uniform_linear_motion`` as the base dataset, + Using ``valid_poses_dataset`` as the base dataset, the following NaN values are introduced: - Individual "id_0": - 1 NaN value in the centroid keypoint of individual id_0 at time=0 @@ -581,27 +579,27 @@ def valid_poses_dataset_uniform_linear_motion_with_nan( - 10 NaN values in the right keypoint of individual id_0 (all frames) - Individual "id_1" has no missing values. """ - valid_poses_dataset_uniform_linear_motion.position.loc[ + valid_poses_dataset.position.loc[ { "individuals": "id_0", "keypoints": "centroid", "time": 0, } ] = np.nan - valid_poses_dataset_uniform_linear_motion.position.loc[ + valid_poses_dataset.position.loc[ { "individuals": "id_0", "keypoints": "left", "time": [3, 7, 8], } ] = np.nan - valid_poses_dataset_uniform_linear_motion.position.loc[ + valid_poses_dataset.position.loc[ { "individuals": "id_0", "keypoints": "right", } ] = np.nan - return valid_poses_dataset_uniform_linear_motion + return valid_poses_dataset # -------------------- Invalid datasets fixtures ------------------------------ @@ -618,9 +616,9 @@ def empty_dataset(): @pytest.fixture -def missing_var_poses_dataset(valid_poses_dataset_uniform_linear_motion): +def missing_var_poses_dataset(valid_poses_dataset): """Return a poses dataset missing position variable.""" - return valid_poses_dataset_uniform_linear_motion.drop_vars("position") + return valid_poses_dataset.drop_vars("position") @pytest.fixture @@ -636,9 +634,9 @@ def missing_two_vars_bboxes_dataset(valid_bboxes_dataset): @pytest.fixture -def missing_dim_poses_dataset(valid_poses_dataset_uniform_linear_motion): +def missing_dim_poses_dataset(valid_poses_dataset): """Return a poses dataset missing the time dimension.""" - return valid_poses_dataset_uniform_linear_motion.rename({"time": "tame"}) + return valid_poses_dataset.rename({"time": "tame"}) @pytest.fixture diff --git a/tests/test_integration/test_io.py b/tests/test_integration/test_io.py index 0df1deea..d66b62b4 100644 --- a/tests/test_integration/test_io.py +++ b/tests/test_integration/test_io.py @@ -24,20 +24,18 @@ def test_load_and_save_to_dlc_style_df(self, dlc_style_df): np.testing.assert_allclose(df.values, dlc_style_df.values) def test_save_and_load_dlc_file( - self, dlc_output_file, valid_poses_dataset_uniform_linear_motion + self, dlc_output_file, valid_poses_dataset ): """Test that saving pose tracks to DLC .h5 and .csv files and then loading them back in returns the same Dataset. """ save_poses.to_dlc_file( - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, dlc_output_file, split_individuals=False, ) ds = load_poses.from_dlc_file(dlc_output_file) - xr.testing.assert_allclose( - ds, valid_poses_dataset_uniform_linear_motion - ) + xr.testing.assert_allclose(ds, valid_poses_dataset) def test_convert_sleap_to_dlc_file(self, sleap_file, dlc_output_file): """Test that pose tracks loaded from SLEAP .slp and .h5 files, diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index c1a3ce91..f4309347 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -11,7 +11,7 @@ @pytest.mark.parametrize( "valid_dataset_uniform_linear_motion", [ - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "valid_bboxes_dataset", ], ) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 3cf46159..581a629c 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -12,7 +12,7 @@ # Dataset fixtures list_valid_datasets_without_nans = [ - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "valid_bboxes_dataset", ] list_valid_datasets_with_nans = [ @@ -51,12 +51,12 @@ def test_filter_with_nans_on_position( """ # Expected number of nans in the position array per individual expected_nans_in_filtered_position_per_indiv = { - "valid_poses_dataset_uniform_linear_motion": [ + "valid_poses_dataset": [ 0, 0, ], # no nans in input "valid_bboxes_dataset": [0, 0], # no nans in input - "valid_poses_dataset_uniform_linear_motion_with_nan": [38, 0], + "valid_poses_dataset_with_nan": [38, 0], "valid_bboxes_dataset_with_nan": [14, 0], } # Filter position diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index fd8bba2b..49746637 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -12,7 +12,7 @@ @pytest.mark.parametrize( "valid_dataset_uniform_linear_motion", [ - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "valid_bboxes_dataset", ], ) @@ -92,7 +92,7 @@ def test_kinematics_uniform_linear_motion( @pytest.mark.parametrize( "valid_dataset_with_nan", [ - "valid_poses_dataset_uniform_linear_motion_with_nan", + "valid_poses_dataset_with_nan", "valid_bboxes_dataset_with_nan", ], ) @@ -102,28 +102,28 @@ def test_kinematics_uniform_linear_motion( ( "displacement", { - "valid_poses_dataset_uniform_linear_motion_with_nan": [30, 0], + "valid_poses_dataset_with_nan": [30, 0], "valid_bboxes_dataset_with_nan": [10, 0], }, # [individual 0, individual 1] ), ( "velocity", { - "valid_poses_dataset_uniform_linear_motion_with_nan": [36, 0], + "valid_poses_dataset_with_nan": [36, 0], "valid_bboxes_dataset_with_nan": [12, 0], }, ), ( "acceleration", { - "valid_poses_dataset_uniform_linear_motion_with_nan": [40, 0], + "valid_poses_dataset_with_nan": [40, 0], "valid_bboxes_dataset_with_nan": [14, 0], }, ), ( "speed", { - "valid_poses_dataset_uniform_linear_motion_with_nan": [18, 0], + "valid_poses_dataset_with_nan": [18, 0], "valid_bboxes_dataset_with_nan": [6, 0], }, ), @@ -223,7 +223,7 @@ def test_approximate_derivative_with_invalid_order(order): ], ) def test_path_length_across_time_ranges( - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, start, stop, expected_exception, @@ -231,14 +231,14 @@ def test_path_length_across_time_ranges( """Test path length computation for a uniform linear motion case, across different time ranges. - The test dataset ``valid_poses_dataset_uniform_linear_motion`` + The test dataset ``valid_poses_dataset`` 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 + position = valid_poses_dataset.position with expected_exception: path_length = kinematics.compute_path_length( position, start=start, stop=stop @@ -286,7 +286,7 @@ def test_path_length_across_time_ranges( ], ) def test_path_length_with_nan( - valid_poses_dataset_uniform_linear_motion_with_nan, + valid_poses_dataset_with_nan, nan_policy, expected_path_lengths_id_0, expected_exception, @@ -298,7 +298,7 @@ def test_path_length_with_nan( 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_nan.position + position = valid_poses_dataset_with_nan.position with expected_exception: path_length = kinematics.compute_path_length( position, @@ -321,7 +321,7 @@ def test_path_length_with_nan( ], ) def test_path_length_warns_about_nans( - valid_poses_dataset_uniform_linear_motion_with_nan, + valid_poses_dataset_with_nan, nan_warn_threshold, expected_exception, caplog, @@ -329,7 +329,7 @@ def test_path_length_warns_about_nans( """Test that a warning is raised when the number of missing values exceeds a given threshold. """ - position = valid_poses_dataset_uniform_linear_motion_with_nan.position + position = valid_poses_dataset_with_nan.position with expected_exception: kinematics.compute_path_length( position, nan_warn_threshold=nan_warn_threshold @@ -565,12 +565,10 @@ def test_nan_behavior_forward_vector( ), ], ) -def test_cdist_with_known_values( - dim, expected_data, valid_poses_dataset_uniform_linear_motion -): +def test_cdist_with_known_values(dim, expected_data, valid_poses_dataset): """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( + input_dataarray = valid_poses_dataset.position.sel( time=slice(0, 1) ) # Use only the first two frames for simplicity pairs = input_dataarray[dim].values[:2] @@ -595,7 +593,7 @@ def test_cdist_with_known_values( @pytest.mark.parametrize( "valid_dataset", [ - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "valid_bboxes_dataset", ], ) @@ -690,13 +688,13 @@ def test_cdist_with_single_dim_inputs(valid_dataset, selection_fn, request): ], ) def test_compute_pairwise_distances_with_valid_pairs( - valid_poses_dataset_uniform_linear_motion, dim, pairs, expected_data_vars + valid_poses_dataset, 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 + valid_poses_dataset.position, dim, pairs ) if isinstance(result, dict): expected_data_vars = [ @@ -711,17 +709,17 @@ def test_compute_pairwise_distances_with_valid_pairs( "ds, dim, pairs", [ ( - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "invalid_dim", {"id_0": "id_1"}, ), # invalid dim ( - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "keypoints", "invalid_string", ), # invalid pairs ( - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "individuals", {}, ), # empty pairs diff --git a/tests/test_unit/test_logging.py b/tests/test_unit/test_logging.py index bed1d50a..348a3687 100644 --- a/tests/test_unit/test_logging.py +++ b/tests/test_unit/test_logging.py @@ -49,7 +49,7 @@ def test_log_warning(caplog): @pytest.mark.parametrize( "input_data", [ - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "valid_bboxes_dataset", ], ) diff --git a/tests/test_unit/test_reports.py b/tests/test_unit/test_reports.py index 059a8f8e..e62791dd 100644 --- a/tests/test_unit/test_reports.py +++ b/tests/test_unit/test_reports.py @@ -6,9 +6,9 @@ @pytest.mark.parametrize( "valid_dataset", [ - "valid_poses_dataset_uniform_linear_motion", + "valid_poses_dataset", "valid_bboxes_dataset", - "valid_poses_dataset_uniform_linear_motion_with_nan", + "valid_poses_dataset_with_nan", "valid_bboxes_dataset_with_nan", ], ) @@ -54,8 +54,8 @@ def test_report_nan_values_in_position_selecting_individual( @pytest.mark.parametrize( "valid_dataset", [ - "valid_poses_dataset_uniform_linear_motion", - "valid_poses_dataset_uniform_linear_motion_with_nan", + "valid_poses_dataset", + "valid_poses_dataset_with_nan", ], ) @pytest.mark.parametrize( diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index 3e7c19ca..9e0d4768 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -124,7 +124,7 @@ def test_to_dlc_style_df(self, ds, expected_exception): def test_to_dlc_file_valid_dataset( self, output_file_params, - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, request, ): """Test that saving a valid pose dataset to a valid/invalid @@ -134,9 +134,7 @@ def test_to_dlc_file_valid_dataset( file_fixture = output_file_params.get("file_fixture") val = request.getfixturevalue(file_fixture) file_path = val.get("file_path") if isinstance(val, dict) else val - save_poses.to_dlc_file( - valid_poses_dataset_uniform_linear_motion, file_path - ) + save_poses.to_dlc_file(valid_poses_dataset, file_path) @pytest.mark.parametrize( "invalid_poses_dataset, expected_exception", @@ -156,48 +154,40 @@ def test_to_dlc_file_invalid_dataset( ) @pytest.mark.parametrize( - "valid_poses_dataset_uniform_linear_motion, split_value", + "valid_poses_dataset, split_value", [("single_individual_array", True), ("multi_individual_array", False)], - indirect=["valid_poses_dataset_uniform_linear_motion"], + indirect=["valid_poses_dataset"], ) - def test_auto_split_individuals( - self, valid_poses_dataset_uniform_linear_motion, split_value - ): + def test_auto_split_individuals(self, valid_poses_dataset, split_value): """Test that setting 'split_individuals' to 'auto' yields True for single-individual datasets and False for multi-individual ones. """ assert ( - save_poses._auto_split_individuals( - valid_poses_dataset_uniform_linear_motion - ) + save_poses._auto_split_individuals(valid_poses_dataset) == split_value ) @pytest.mark.parametrize( - "valid_poses_dataset_uniform_linear_motion, split_individuals", + "valid_poses_dataset, split_individuals", [ ("single_individual_array", True), # single-individual, split ("multi_individual_array", False), # multi-individual, no split ("single_individual_array", False), # single-individual, no split ("multi_individual_array", True), # multi-individual, split ], - indirect=["valid_poses_dataset_uniform_linear_motion"], + indirect=["valid_poses_dataset"], ) def test_to_dlc_style_df_split_individuals( self, - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, split_individuals, ): """Test that the `split_individuals` argument affects the behaviour of the `to_dlc_style_df` function as expected. """ - df = save_poses.to_dlc_style_df( - valid_poses_dataset_uniform_linear_motion, 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_uniform_linear_motion.individuals.values - ) + ind_names = valid_poses_dataset.individuals.values if split_individuals is False: # this should produce a single df in multi-animal DLC format assert isinstance(df, pd.DataFrame) @@ -234,7 +224,7 @@ def test_to_dlc_style_df_split_individuals( ) def test_to_dlc_file_split_individuals( self, - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, new_h5_file, split_individuals, expected_exception, @@ -244,14 +234,12 @@ def test_to_dlc_file_split_individuals( """ with expected_exception: save_poses.to_dlc_file( - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, new_h5_file, split_individuals, ) # Get the names of the individuals in the dataset - ind_names = ( - valid_poses_dataset_uniform_linear_motion.individuals.values - ) + ind_names = valid_poses_dataset.individuals.values # "auto" becomes False, default valid dataset is multi-individual if split_individuals in [False, "auto"]: # this should save only one file @@ -269,7 +257,7 @@ def test_to_dlc_file_split_individuals( def test_to_lp_file_valid_dataset( self, output_file_params, - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, request, ): """Test that saving a valid pose dataset to a valid/invalid @@ -279,9 +267,7 @@ def test_to_lp_file_valid_dataset( file_fixture = output_file_params.get("file_fixture") val = request.getfixturevalue(file_fixture) file_path = val.get("file_path") if isinstance(val, dict) else val - save_poses.to_lp_file( - valid_poses_dataset_uniform_linear_motion, file_path - ) + save_poses.to_lp_file(valid_poses_dataset, file_path) @pytest.mark.parametrize( "invalid_poses_dataset, expected_exception", @@ -302,7 +288,7 @@ def test_to_lp_file_invalid_dataset( def test_to_sleap_analysis_file_valid_dataset( self, output_file_params, - valid_poses_dataset_uniform_linear_motion, + valid_poses_dataset, request, ): """Test that saving a valid pose dataset to a valid/invalid @@ -312,9 +298,7 @@ def test_to_sleap_analysis_file_valid_dataset( file_fixture = output_file_params.get("file_fixture") val = request.getfixturevalue(file_fixture) file_path = val.get("file_path") if isinstance(val, dict) else val - save_poses.to_sleap_analysis_file( - valid_poses_dataset_uniform_linear_motion, file_path - ) + save_poses.to_sleap_analysis_file(valid_poses_dataset, file_path) @pytest.mark.parametrize( "invalid_poses_dataset, expected_exception", @@ -332,16 +316,12 @@ def test_to_sleap_analysis_file_invalid_dataset( new_h5_file, ) - def test_remove_unoccupied_tracks( - self, valid_poses_dataset_uniform_linear_motion - ): + def test_remove_unoccupied_tracks(self, valid_poses_dataset): """Test that removing unoccupied tracks from a valid pose dataset returns the expected result. """ new_individuals = [f"id_{i}" for i in range(3)] # Add new individual with NaN data - ds = valid_poses_dataset_uniform_linear_motion.reindex( - individuals=new_individuals - ) + ds = valid_poses_dataset.reindex(individuals=new_individuals) ds = save_poses._remove_unoccupied_tracks(ds) - xr.testing.assert_equal(ds, valid_poses_dataset_uniform_linear_motion) + xr.testing.assert_equal(ds, valid_poses_dataset) diff --git a/tests/test_unit/test_validators/test_array_validators.py b/tests/test_unit/test_validators/test_array_validators.py index 674d7b80..39305bc8 100644 --- a/tests/test_unit/test_validators/test_array_validators.py +++ b/tests/test_unit/test_validators/test_array_validators.py @@ -59,13 +59,13 @@ def expect_value_error_with_message(error_msg): valid_cases + invalid_cases, ) def test_validate_dims_coords( - valid_poses_dataset_uniform_linear_motion, # fixture from conftest.py + valid_poses_dataset, # fixture from conftest.py required_dims_coords, exact_coords, expected_exception, ): """Test validate_dims_coords for both valid and invalid inputs.""" - position_array = valid_poses_dataset_uniform_linear_motion["position"] + position_array = valid_poses_dataset["position"] with expected_exception: validate_dims_coords( position_array, required_dims_coords, exact_coords=exact_coords From 87b16334ca68b01511dcd00faa415acdbe9ef3d2 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 16:51:23 +0000 Subject: [PATCH 14/37] Replace valid_position_array fixtures in test_load_poses --- tests/test_unit/test_load_poses.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 8c07ae9d..6e3fbafa 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -282,19 +282,21 @@ def test_from_file_delegates_correctly(source_software, fps): @pytest.mark.parametrize("source_software", [None, "SLEAP"]) def test_from_numpy_valid( - valid_position_array, source_software, movement_dataset_asserts + valid_poses_array_uniform_linear_motion, + source_software, + movement_dataset_asserts, ): """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]) + poses_arrays = valid_poses_array_uniform_linear_motion( + "multi_individual_array" + ) ds = load_poses.from_numpy( - valid_position, - valid_confidence, - individual_names=["mouse1", "mouse2"], - keypoint_names=["snout", "tail"], + poses_arrays["position"], + poses_arrays["confidence"], + individual_names=["id_0", "id_1"], + keypoint_names=["centroid", "left", "right"], fps=None, source_software=source_software, ) From 55a14f84caf28767d2c74af44081d447c09364c7 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 16:53:14 +0000 Subject: [PATCH 15/37] Replace valid_position_array fixtures in test_datasets_validators --- .../test_datasets_validators.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/test_unit/test_validators/test_datasets_validators.py b/tests/test_unit/test_validators/test_datasets_validators.py index 17048334..0395c0da 100644 --- a/tests/test_unit/test_validators/test_datasets_validators.py +++ b/tests/test_unit/test_validators/test_datasets_validators.py @@ -120,9 +120,9 @@ def test_poses_dataset_validator_with_invalid_position_array( "confidence_array, expected_exception", [ ( - np.ones((10, 3, 2)), + np.ones((10, 2, 2)), pytest.raises(ValueError), - ), # will not match position_array shape + ), # will not match position_array shape (10, 2, 3, 2) ( [1, 2, 3], pytest.raises(ValueError), @@ -136,12 +136,14 @@ def test_poses_dataset_validator_with_invalid_position_array( def test_poses_dataset_validator_confidence_array( confidence_array, expected_exception, - valid_position_array, + valid_poses_array_uniform_linear_motion, ): """Test that invalid confidence arrays raise the appropriate errors.""" with expected_exception: poses = ValidPosesDataset( - position_array=valid_position_array("multi_individual_array"), + position_array=valid_poses_array_uniform_linear_motion( + "multi_individual_array" + )["position"], confidence_array=confidence_array, ) if confidence_array is None: @@ -149,28 +151,28 @@ def test_poses_dataset_validator_confidence_array( def test_poses_dataset_validator_keypoint_names( - position_array_params, valid_position_array + position_array_params, valid_poses_array_uniform_linear_motion ): """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=valid_poses_array_uniform_linear_motion( position_array_params.get("array_type") - ), + )["position"][:, :, :2, :], # select up to the first 2 keypoints 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 + position_array_params, valid_poses_array_uniform_linear_motion ): """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=valid_poses_array_uniform_linear_motion( position_array_params.get("array_type") - ), + )["position"], individual_names=position_array_params.get("names"), ) assert poses.individual_names == e @@ -188,14 +190,18 @@ def test_poses_dataset_validator_individual_names( ], ) def test_poses_dataset_validator_source_software( - valid_position_array, source_software, expected_exception + valid_poses_array_uniform_linear_motion, + 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"), + position_array=valid_poses_array_uniform_linear_motion( + "multi_individual_array" + )["position"], source_software=source_software, ) From a9781c707be8c3559d50717da33c717cf13b8004 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 16:54:42 +0000 Subject: [PATCH 16/37] Remove valid_position_array fixture --- tests/conftest.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6088eb80..23934d9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -404,34 +404,6 @@ def valid_bboxes_dataset_with_nan(valid_bboxes_dataset): # --------------------- Poses dataset fixtures ---------------------------- -@pytest.fixture -def valid_position_array(): - """Return a function that generates different kinds - of a valid position array. - """ - - def _valid_position_array(array_type): - """Return a valid position array.""" - # Unless specified, default is a multi_individual_array with - # 10 frames, 2 keypoints, and 2 individuals. - n_frames = 10 - n_keypoints = 2 - n_individuals = 2 - base = np.arange(n_frames, dtype=float)[ - :, np.newaxis, np.newaxis, np.newaxis - ] - if array_type == "single_keypoint_array": - n_keypoints = 1 - elif array_type == "single_individual_array": - n_individuals = 1 - x_points = np.repeat(base * base, n_keypoints * n_individuals) - y_points = np.repeat(base * 4, n_keypoints * n_individuals) - position_array = np.vstack((x_points, y_points)) - return position_array.reshape(n_frames, 2, n_keypoints, n_individuals) - - return _valid_position_array - - @pytest.fixture def valid_poses_array_uniform_linear_motion(): """Return a dictionary of valid arrays for a From 70f105ef59ac85a547b071406c06e16fa31269e7 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 17:01:28 +0000 Subject: [PATCH 17/37] Rename valid_poses_array_uniform_linear_motion --- tests/conftest.py | 20 ++++++------- tests/test_unit/test_load_poses.py | 8 ++---- .../test_datasets_validators.py | 28 ++++++++----------- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 23934d9d..74892e74 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -405,7 +405,7 @@ def valid_bboxes_dataset_with_nan(valid_bboxes_dataset): # --------------------- Poses dataset fixtures ---------------------------- @pytest.fixture -def valid_poses_array_uniform_linear_motion(): +def valid_poses_arrays(): """Return a dictionary of valid arrays for a ValidPosesDataset representing a uniform linear motion. @@ -426,7 +426,7 @@ def valid_poses_array_uniform_linear_motion(): - Individual 1 at frames 2, 3 """ - def _valid_poses_array(array_type): + def _valid_poses_arrays(array_type): """Return a dictionary of valid arrays for a ValidPosesDataset.""" # Unless specified, default is a ``multi_individual_array`` with # 10 frames, 3 keypoints, and 2 individuals in 2D space. @@ -489,11 +489,11 @@ def _valid_poses_array(array_type): confidence = confidence[:, :, :1] return {"position": position, "confidence": confidence} - return _valid_poses_array + return _valid_poses_arrays @pytest.fixture -def valid_poses_dataset(valid_poses_array_uniform_linear_motion, request): +def valid_poses_dataset(valid_poses_arrays, request): """Return a valid poses dataset. Depending on the ``array_type`` requested (``multi_individual_array``, @@ -502,15 +502,15 @@ def valid_poses_dataset(valid_poses_array_uniform_linear_motion, request): with up to 3 keypoints ("centroid", "left", "right") moving in uniform linear motion for 10 frames in 2D space. Default is a ``multi_individual_array``. - See the ``valid_poses_array_uniform_linear_motion`` fixture for details. + See the ``valid_poses_arrays`` fixture for details. """ dim_names = ValidPosesDataset.DIM_NAMES # create a multi_individual_array by default unless overridden via param try: - array_format = request.param + array_type = request.param except AttributeError: - array_format = "multi_individual_array" - poses_array = valid_poses_array_uniform_linear_motion(array_format) + array_type = "multi_individual_array" + poses_array = valid_poses_arrays(array_type) position_array = poses_array["position"] confidence_array = poses_array["confidence"] n_frames, _, n_keypoints, n_individuals = position_array.shape @@ -538,9 +538,7 @@ def valid_poses_dataset(valid_poses_array_uniform_linear_motion, request): @pytest.fixture -def valid_poses_dataset_with_nan( - valid_poses_dataset, -): +def valid_poses_dataset_with_nan(valid_poses_dataset): """Return a valid poses dataset with NaNs introduced in the position array. Using ``valid_poses_dataset`` as the base dataset, diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 6e3fbafa..67ccbf8f 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -282,16 +282,12 @@ def test_from_file_delegates_correctly(source_software, fps): @pytest.mark.parametrize("source_software", [None, "SLEAP"]) def test_from_numpy_valid( - valid_poses_array_uniform_linear_motion, - source_software, - movement_dataset_asserts, + valid_poses_arrays, source_software, movement_dataset_asserts ): """Test that loading pose tracks from a multi-animal numpy array with valid parameters returns a proper Dataset. """ - poses_arrays = valid_poses_array_uniform_linear_motion( - "multi_individual_array" - ) + poses_arrays = valid_poses_arrays("multi_individual_array") ds = load_poses.from_numpy( poses_arrays["position"], poses_arrays["confidence"], diff --git a/tests/test_unit/test_validators/test_datasets_validators.py b/tests/test_unit/test_validators/test_datasets_validators.py index 0395c0da..fcc63d14 100644 --- a/tests/test_unit/test_validators/test_datasets_validators.py +++ b/tests/test_unit/test_validators/test_datasets_validators.py @@ -134,16 +134,14 @@ def test_poses_dataset_validator_with_invalid_position_array( ], ) def test_poses_dataset_validator_confidence_array( - confidence_array, - expected_exception, - valid_poses_array_uniform_linear_motion, + confidence_array, expected_exception, valid_poses_arrays ): """Test that invalid confidence arrays raise the appropriate errors.""" with expected_exception: poses = ValidPosesDataset( - position_array=valid_poses_array_uniform_linear_motion( - "multi_individual_array" - )["position"], + position_array=valid_poses_arrays("multi_individual_array")[ + "position" + ], confidence_array=confidence_array, ) if confidence_array is None: @@ -151,12 +149,12 @@ def test_poses_dataset_validator_confidence_array( def test_poses_dataset_validator_keypoint_names( - position_array_params, valid_poses_array_uniform_linear_motion + position_array_params, valid_poses_arrays ): """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_poses_array_uniform_linear_motion( + position_array=valid_poses_arrays( position_array_params.get("array_type") )["position"][:, :, :2, :], # select up to the first 2 keypoints keypoint_names=position_array_params.get("names"), @@ -165,12 +163,12 @@ def test_poses_dataset_validator_keypoint_names( def test_poses_dataset_validator_individual_names( - position_array_params, valid_poses_array_uniform_linear_motion + position_array_params, valid_poses_arrays ): """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_poses_array_uniform_linear_motion( + position_array=valid_poses_arrays( position_array_params.get("array_type") )["position"], individual_names=position_array_params.get("names"), @@ -190,18 +188,16 @@ def test_poses_dataset_validator_individual_names( ], ) def test_poses_dataset_validator_source_software( - valid_poses_array_uniform_linear_motion, - source_software, - expected_exception, + valid_poses_arrays, 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_poses_array_uniform_linear_motion( - "multi_individual_array" - )["position"], + position_array=valid_poses_arrays("multi_individual_array")[ + "position" + ], source_software=source_software, ) From d6695ccbd8c11d4ffc5ccacdad25a337bc112188 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 21 Jan 2025 17:20:47 +0000 Subject: [PATCH 18/37] Fix newlines --- tests/test_integration/test_io.py | 4 +-- .../test_kinematics_vector_transform.py | 5 +--- tests/test_unit/test_filtering.py | 5 +--- tests/test_unit/test_kinematics.py | 26 ++++--------------- tests/test_unit/test_logging.py | 5 +--- tests/test_unit/test_reports.py | 5 +--- tests/test_unit/test_save_poses.py | 23 ++++------------ 7 files changed, 15 insertions(+), 58 deletions(-) diff --git a/tests/test_integration/test_io.py b/tests/test_integration/test_io.py index d66b62b4..50f03933 100644 --- a/tests/test_integration/test_io.py +++ b/tests/test_integration/test_io.py @@ -30,9 +30,7 @@ def test_save_and_load_dlc_file( loading them back in returns the same Dataset. """ save_poses.to_dlc_file( - valid_poses_dataset, - dlc_output_file, - split_individuals=False, + valid_poses_dataset, dlc_output_file, split_individuals=False ) ds = load_poses.from_dlc_file(dlc_output_file) xr.testing.assert_allclose(ds, valid_poses_dataset) diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index f4309347..38ab5f63 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -10,10 +10,7 @@ @pytest.mark.parametrize( "valid_dataset_uniform_linear_motion", - [ - "valid_poses_dataset", - "valid_bboxes_dataset", - ], + ["valid_poses_dataset", "valid_bboxes_dataset"], ) @pytest.mark.parametrize( "kinematic_variable, expected_kinematics_polar", diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 581a629c..d9eacff3 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -51,10 +51,7 @@ def test_filter_with_nans_on_position( """ # Expected number of nans in the position array per individual expected_nans_in_filtered_position_per_indiv = { - "valid_poses_dataset": [ - 0, - 0, - ], # no nans in input + "valid_poses_dataset": [0, 0], # no nans in input "valid_bboxes_dataset": [0, 0], # no nans in input "valid_poses_dataset_with_nan": [38, 0], "valid_bboxes_dataset_with_nan": [14, 0], diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 49746637..fe7520d7 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -11,10 +11,7 @@ @pytest.mark.parametrize( "valid_dataset_uniform_linear_motion", - [ - "valid_poses_dataset", - "valid_bboxes_dataset", - ], + ["valid_poses_dataset", "valid_bboxes_dataset"], ) @pytest.mark.parametrize( "kinematic_variable, expected_kinematics", @@ -91,10 +88,7 @@ def test_kinematics_uniform_linear_motion( @pytest.mark.parametrize( "valid_dataset_with_nan", - [ - "valid_poses_dataset_with_nan", - "valid_bboxes_dataset_with_nan", - ], + ["valid_poses_dataset_with_nan", "valid_bboxes_dataset_with_nan"], ) @pytest.mark.parametrize( "kinematic_variable, expected_nans_per_individual", @@ -223,10 +217,7 @@ def test_approximate_derivative_with_invalid_order(order): ], ) def test_path_length_across_time_ranges( - valid_poses_dataset, - start, - stop, - expected_exception, + valid_poses_dataset, start, stop, expected_exception ): """Test path length computation for a uniform linear motion case, across different time ranges. @@ -592,10 +583,7 @@ def test_cdist_with_known_values(dim, expected_data, valid_poses_dataset): @pytest.mark.parametrize( "valid_dataset", - [ - "valid_poses_dataset", - "valid_bboxes_dataset", - ], + ["valid_poses_dataset", "valid_bboxes_dataset"], ) @pytest.mark.parametrize( "selection_fn", @@ -718,11 +706,7 @@ def test_compute_pairwise_distances_with_valid_pairs( "keypoints", "invalid_string", ), # invalid pairs - ( - "valid_poses_dataset", - "individuals", - {}, - ), # empty pairs + ("valid_poses_dataset", "individuals", {}), # empty pairs ("missing_dim_poses_dataset", "keypoints", "all"), # invalid dataset ( "missing_dim_bboxes_dataset", diff --git a/tests/test_unit/test_logging.py b/tests/test_unit/test_logging.py index 348a3687..583fd79a 100644 --- a/tests/test_unit/test_logging.py +++ b/tests/test_unit/test_logging.py @@ -48,10 +48,7 @@ def test_log_warning(caplog): @pytest.mark.parametrize( "input_data", - [ - "valid_poses_dataset", - "valid_bboxes_dataset", - ], + ["valid_poses_dataset", "valid_bboxes_dataset"], ) @pytest.mark.parametrize( "selector_fn, expected_selector_type", diff --git a/tests/test_unit/test_reports.py b/tests/test_unit/test_reports.py index e62791dd..d7c896a4 100644 --- a/tests/test_unit/test_reports.py +++ b/tests/test_unit/test_reports.py @@ -53,10 +53,7 @@ def test_report_nan_values_in_position_selecting_individual( @pytest.mark.parametrize( "valid_dataset", - [ - "valid_poses_dataset", - "valid_poses_dataset_with_nan", - ], + ["valid_poses_dataset", "valid_poses_dataset_with_nan"], ) @pytest.mark.parametrize( "data_selection, expected_keypoints, expected_individuals", diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index 9e0d4768..ae812f89 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -122,10 +122,7 @@ def test_to_dlc_style_df(self, ds, expected_exception): ] def test_to_dlc_file_valid_dataset( - self, - output_file_params, - valid_poses_dataset, - request, + self, output_file_params, valid_poses_dataset, request ): """Test that saving a valid pose dataset to a valid/invalid DeepLabCut-style file returns the appropriate errors. @@ -178,9 +175,7 @@ def test_auto_split_individuals(self, valid_poses_dataset, split_value): indirect=["valid_poses_dataset"], ) def test_to_dlc_style_df_split_individuals( - self, - valid_poses_dataset, - split_individuals, + self, valid_poses_dataset, split_individuals ): """Test that the `split_individuals` argument affects the behaviour of the `to_dlc_style_df` function as expected. @@ -234,9 +229,7 @@ def test_to_dlc_file_split_individuals( """ with expected_exception: save_poses.to_dlc_file( - valid_poses_dataset, - new_h5_file, - split_individuals, + valid_poses_dataset, new_h5_file, split_individuals ) # Get the names of the individuals in the dataset ind_names = valid_poses_dataset.individuals.values @@ -255,10 +248,7 @@ def test_to_dlc_file_split_individuals( file_path_ind.unlink() def test_to_lp_file_valid_dataset( - self, - output_file_params, - valid_poses_dataset, - request, + self, output_file_params, valid_poses_dataset, request ): """Test that saving a valid pose dataset to a valid/invalid LightningPose-style file returns the appropriate errors. @@ -286,10 +276,7 @@ def test_to_lp_file_invalid_dataset( ) def test_to_sleap_analysis_file_valid_dataset( - self, - output_file_params, - valid_poses_dataset, - request, + self, output_file_params, valid_poses_dataset, request ): """Test that saving a valid pose dataset to a valid/invalid SLEAP-style file returns the appropriate errors. From 676f7a3aeda26854941e75fa25505bb37c81d671 Mon Sep 17 00:00:00 2001 From: lochhh Date: Wed, 22 Jan 2025 10:44:06 +0000 Subject: [PATCH 19/37] Fix up rebase merge error --- tests/test_unit/test_kinematics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index fe7520d7..17640501 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -507,7 +507,7 @@ def test_nan_behavior_forward_vector( for preserved_coord in ["time", "space", "individuals"]: assert np.all( forward_vector[preserved_coord] - == valid_data_array_for_forward_vector_with_nans[preserved_coord] + == valid_data_array_for_forward_vector_with_nan[preserved_coord] ) assert set(forward_vector["space"].values) == {"x", "y"} # Should have NaN values in the forward vector at time 1 and left_ear @@ -521,7 +521,7 @@ def test_nan_behavior_forward_vector( forward_vector.sel( time=[ t - for t in valid_data_array_for_forward_vector_with_nans.time + for t in valid_data_array_for_forward_vector_with_nan.time if t != nan_time ] ) From 11a155f00b7103c3e9fc79e984220c39c272d977 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 23 Jan 2025 18:58:31 +0000 Subject: [PATCH 20/37] Modularise fixtures --- .pre-commit-config.yaml | 1 + tests/conftest.py | 947 +------------------------------------ tests/fixtures/__init__.py | 0 tests/fixtures/datasets.py | 426 +++++++++++++++++ tests/fixtures/files.py | 486 +++++++++++++++++++ tests/fixtures/helpers.py | 23 + 6 files changed, 949 insertions(+), 934 deletions(-) create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/datasets.py create mode 100644 tests/fixtures/files.py create mode 100644 tests/fixtures/helpers.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54fb80ad..9258a3fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,7 @@ repos: args: [--fix=lf] - id: name-tests-test args: ["--pytest-test-first"] + exclude: ^tests/fixtures/ - id: requirements-txt-fixer - id: trailing-whitespace - repo: https://github.com/pre-commit/pygrep-hooks diff --git a/tests/conftest.py b/tests/conftest.py index 74892e74..1945b7d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,24 @@ """Fixtures and configurations applied to the entire test suite.""" import logging -import os -from pathlib import Path -from unittest.mock import mock_open, patch +from glob import glob -import h5py -import numpy as np -import pandas as pd import pytest -import xarray as xr 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 to_pytest_plugin_path(string: str) -> str: + """Convert a file path to a pytest-compatible plugin path.""" + return string.replace("/", ".").replace("\\", ".").replace(".py", "") + + +pytest_plugins = [ + to_pytest_plugin_path(fixture) + for fixture in glob("tests/fixtures/*.py") + if "__" not in fixture +] def pytest_configure(): @@ -37,929 +42,3 @@ def setup_logging(tmp_path): logger_name="movement", log_directory=(tmp_path / ".movement"), ) - - -# --------- File validator fixtures --------------------------------- -@pytest.fixture -def unreadable_file(tmp_path): - """Return a dictionary containing the file path and - expected permission for an unreadable .h5 file. - """ - file_path = tmp_path / "unreadable.h5" - file_mock = mock_open() - file_mock.return_value.read.side_effect = PermissionError - with ( - patch("builtins.open", side_effect=file_mock), - patch.object(Path, "exists", return_value=True), - ): - yield { - "file_path": file_path, - "expected_permission": "r", - } - - -@pytest.fixture -def unwriteable_file(tmp_path): - """Return a dictionary containing the file path and - expected permission for an unwriteable .h5 file. - """ - unwriteable_dir = tmp_path / "no_write" - unwriteable_dir.mkdir() - original_access = os.access - - def mock_access(path, mode): - if path == unwriteable_dir and mode == os.W_OK: - return False - # Ensure that the original access function is called - # for all other cases - return original_access(path, mode) - - with patch("os.access", side_effect=mock_access): - file_path = unwriteable_dir / "unwriteable.h5" - yield { - "file_path": file_path, - "expected_permission": "w", - } - - -@pytest.fixture -def wrong_ext_file(tmp_path): - """Return a dictionary containing the file path, - expected permission, and expected suffix for a file - with an incorrect extension. - """ - file_path = tmp_path / "wrong_extension.txt" - with open(file_path, "w") as f: - f.write("") - return { - "file_path": file_path, - "expected_permission": "r", - "expected_suffix": ["h5", "csv"], - } - - -@pytest.fixture -def nonexistent_file(tmp_path): - """Return a dictionary containing the file path and - expected permission for a nonexistent file. - """ - file_path = tmp_path / "nonexistent.h5" - return { - "file_path": file_path, - "expected_permission": "r", - } - - -@pytest.fixture -def directory(tmp_path): - """Return a dictionary containing the file path and - expected permission for a directory. - """ - file_path = tmp_path / "directory" - file_path.mkdir() - return { - "file_path": file_path, - "expected_permission": "r", - } - - -@pytest.fixture -def h5_file_no_dataframe(tmp_path): - """Return a dictionary containing the file path and - expected datasets for a .h5 file with no dataframe. - """ - file_path = tmp_path / "no_dataframe.h5" - with h5py.File(file_path, "w") as f: - f.create_dataset("data_in_list", data=[1, 2, 3]) - return { - "file_path": file_path, - "expected_datasets": ["dataframe"], - } - - -@pytest.fixture -def fake_h5_file(tmp_path): - """Return a dictionary containing the file path, - expected exception, and expected datasets for - a file with .h5 extension that is not in HDF5 format. - """ - file_path = tmp_path / "fake.h5" - with open(file_path, "w") as f: - f.write("") - return { - "file_path": file_path, - "expected_datasets": ["dataframe"], - "expected_permission": "w", - } - - -@pytest.fixture -def invalid_single_individual_csv_file(tmp_path): - """Return the file path for a fake single-individual .csv file.""" - file_path = tmp_path / "fake_single_individual.csv" - with open(file_path, "w") as f: - f.write("scorer,columns\nsome,columns\ncoords,columns\n") - f.write("1,2") - return file_path - - -@pytest.fixture -def invalid_multi_individual_csv_file(tmp_path): - """Return the file path for a fake multi-individual .csv file.""" - file_path = tmp_path / "fake_multi_individual.csv" - with open(file_path, "w") as f: - f.write( - "scorer,columns\nindividuals,columns\nbodyparts,columns\nsome,columns\n" - ) - f.write("1,2") - return file_path - - -@pytest.fixture -def new_file_wrong_ext(tmp_path): - """Return the file path for a new file with the wrong extension.""" - return tmp_path / "new_file_wrong_ext.txt" - - -@pytest.fixture -def new_h5_file(tmp_path): - """Return the file path for a new .h5 file.""" - return tmp_path / "new_file.h5" - - -@pytest.fixture -def new_csv_file(tmp_path): - """Return the file path for a new .csv file.""" - return tmp_path / "new_file.csv" - - -@pytest.fixture -def dlc_style_df(): - """Return a valid DLC-style DataFrame.""" - return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) - - -@pytest.fixture -def missing_keypoint_columns_anipose_csv_file(tmp_path): - """Return the file path for a fake single-individual .csv file.""" - file_path = tmp_path / "missing_keypoint_columns.csv" - columns = [ - "fnum", - "center_0", - "center_1", - "center_2", - "M_00", - "M_01", - "M_02", - "M_10", - "M_11", - "M_12", - "M_20", - "M_21", - "M_22", - ] - # Here we are missing kp0_z: - columns.extend(["kp0_x", "kp0_y", "kp0_score", "kp0_error", "kp0_ncams"]) - with open(file_path, "w") as f: - f.write(",".join(columns)) - f.write("\n") - f.write(",".join(["1"] * len(columns))) - return file_path - - -@pytest.fixture -def spurious_column_anipose_csv_file(tmp_path): - """Return the file path for a fake single-individual .csv file.""" - file_path = tmp_path / "spurious_column.csv" - columns = [ - "fnum", - "center_0", - "center_1", - "center_2", - "M_00", - "M_01", - "M_02", - "M_10", - "M_11", - "M_12", - "M_20", - "M_21", - "M_22", - ] - columns.extend(["funny_column"]) - with open(file_path, "w") as f: - f.write(",".join(columns)) - f.write("\n") - f.write(",".join(["1"] * len(columns))) - return file_path - - -@pytest.fixture( - params=[ - "SLEAP_single-mouse_EPM.analysis.h5", - "SLEAP_single-mouse_EPM.predictions.slp", - "SLEAP_three-mice_Aeon_proofread.analysis.h5", - "SLEAP_three-mice_Aeon_proofread.predictions.slp", - "SLEAP_three-mice_Aeon_mixed-labels.analysis.h5", - "SLEAP_three-mice_Aeon_mixed-labels.predictions.slp", - ] -) -def sleap_file(request): - """Return the file path for a SLEAP .h5 or .slp file.""" - 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_space, n_individuals = (10, 2, 2) - - # build a valid array for position or shape with all zeros - valid_bbox_array_all_zeros = np.zeros((n_frames, n_space, n_individuals)) - - # 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_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_space, n_individuals = (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.zeros((n_frames, n_space, n_individuals)) - for i in range(n_individuals): - position[:, 0, i] = np.arange(n_frames) - position[:, 1, i] = (-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)).transpose( - 0, 2, 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, - } - - -@pytest.fixture -def valid_bboxes_dataset( - valid_bboxes_arrays, -): - """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 = ValidBboxesDataset.DIM_NAMES - - 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 - - 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] + dim_names[2:] - ), - }, - coords={ - dim_names[0]: np.arange(n_frames), - dim_names[1]: ["x", "y"], - dim_names[2]: [f"id_{id}" for id in range(n_individuals)], - }, - 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 - - -# --------------------- Poses dataset fixtures ---------------------------- -@pytest.fixture -def valid_poses_arrays(): - """Return a dictionary of valid arrays for a - ValidPosesDataset representing a uniform linear motion. - - Depending on the ``array_type`` requested (``multi_individual_array``, - ``single_keypoint_array``, or ``single_individual_array``), - the arrays can represent up to 2 individuals with up to 3 keypoints, - moving at constant velocity for 10 frames in 2D space. - Default is a ``multi_individual_array``. - At each frame the individuals cover a distance of sqrt(2) in x-y space. - Specifically: - - 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 "centroid" (index=0) at the following frames, - which are set to 0.1: - - Individual 0 at frames 2, 3, 4 - - Individual 1 at frames 2, 3 - """ - - def _valid_poses_arrays(array_type): - """Return a dictionary of valid arrays for a ValidPosesDataset.""" - # Unless specified, default is a ``multi_individual_array`` with - # 10 frames, 3 keypoints, and 2 individuals in 2D space. - n_frames, n_space, n_keypoints, n_individuals = (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 (if applicable) - # They move one unit along x and y axes in each frame - frames = np.arange(n_frames) - position = np.zeros((n_frames, n_space, n_keypoints, n_individuals)) - position[:, 0, 0, :] = frames[:, None] # reshape to (n_frames, 1) - position[:, 1, 0, 0] = 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[:, 0, kpt, i] = ( - position[:, 0, 0, i] + offsets[i][kpt - 1][0] - ) - position[:, 1, kpt, i] = ( - position[:, 1, 0, i] + offsets[i][kpt - 1][1] - ) - - # build an array of confidence values, all 0.9 - confidence = np.full((n_frames, n_keypoints, n_individuals), 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, 0, 1] = 0.1 - - if array_type == "single_keypoint_array": - # return only the centroid keypoint - position = position[:, :, :1, :] - confidence = confidence[:, :1, :] - elif array_type == "single_individual_array": - # return only the first individual - position = position[:, :, :, :1] - confidence = confidence[:, :, :1] - return {"position": position, "confidence": confidence} - - return _valid_poses_arrays - - -@pytest.fixture -def valid_poses_dataset(valid_poses_arrays, request): - """Return a valid poses dataset. - - Depending on the ``array_type`` requested (``multi_individual_array``, - ``single_keypoint_array``, or ``single_individual_array``), - the dataset can represent up to 2 individuals ("id_0" and "id_1") - with up to 3 keypoints ("centroid", "left", "right") - moving in uniform linear motion for 10 frames in 2D space. - Default is a ``multi_individual_array``. - See the ``valid_poses_arrays`` fixture for details. - """ - dim_names = ValidPosesDataset.DIM_NAMES - # create a multi_individual_array by default unless overridden via param - try: - array_type = request.param - except AttributeError: - array_type = "multi_individual_array" - poses_array = valid_poses_arrays(array_type) - position_array = poses_array["position"] - confidence_array = poses_array["confidence"] - n_frames, _, n_keypoints, 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] + dim_names[2:] - ), - }, - coords={ - dim_names[0]: np.arange(n_frames), - dim_names[1]: ["x", "y"], - dim_names[2]: ["centroid", "left", "right"][:n_keypoints], - dim_names[3]: [f"id_{i}" for i in range(n_individuals)], - }, - attrs={ - "fps": None, - "time_unit": "frames", - "source_software": "test", - "source_file": "test_poses.h5", - "ds_type": "poses", - }, - ) - - -@pytest.fixture -def valid_poses_dataset_with_nan(valid_poses_dataset): - """Return a valid poses dataset with NaNs introduced in the position array. - - Using ``valid_poses_dataset`` as the base dataset, - the following NaN values are introduced: - - Individual "id_0": - - 1 NaN value in the centroid keypoint of individual id_0 at time=0 - - 3 NaN values in the left keypoint of individual id_0 (frames 3, 7, 8) - - 10 NaN values in the right keypoint of individual id_0 (all frames) - - Individual "id_1" has no missing values. - """ - valid_poses_dataset.position.loc[ - { - "individuals": "id_0", - "keypoints": "centroid", - "time": 0, - } - ] = np.nan - valid_poses_dataset.position.loc[ - { - "individuals": "id_0", - "keypoints": "left", - "time": [3, 7, 8], - } - ] = np.nan - valid_poses_dataset.position.loc[ - { - "individuals": "id_0", - "keypoints": "right", - } - ] = np.nan - return valid_poses_dataset - - -# -------------------- Invalid datasets fixtures ------------------------------ -@pytest.fixture -def not_a_dataset(): - """Return data that is not a pose tracks dataset.""" - return [1, 2, 3] - - -@pytest.fixture -def empty_dataset(): - """Return an empty pose tracks dataset.""" - return xr.Dataset() - - -@pytest.fixture -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_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 -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"}) - - -# --------------------------- Kinematics fixtures --------------------------- -@pytest.fixture(params=["displacement", "velocity", "acceleration"]) -def kinematic_property(request): - """Return a kinematic property.""" - return request.param - - -# ---------------- 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.""" - 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 - - -# ----------------- Helpers fixture ----------------- -class Helpers: - """Generic helper methods for ``movement`` test modules.""" - - @staticmethod - def count_nans(da): - """Count number of NaNs in a DataArray.""" - return da.isnull().sum().item() - - @staticmethod - def count_consecutive_nans(da): - """Count occurrences of consecutive NaNs in a DataArray.""" - return (da.isnull().astype(int).diff("time") != 0).sum().item() - - -@pytest.fixture -def helpers(): - """Return an instance of the ``Helpers`` class.""" - return Helpers - - -# --------- movement dataset assertion fixtures --------- -class MovementDatasetAsserts: - """Class for asserting valid ``movement`` poses or bboxes datasets.""" - - @staticmethod - def valid_dataset(dataset, expected_values): - """Assert the dataset is a proper ``movement`` Dataset. - - Parameters - ---------- - dataset : xr.Dataset - The dataset to validate. - expected_values : dict - A dictionary containing the expected values for the dataset. - It must contain the following keys: - - - dim_names: list of expected dimension names as defined in - movement.validators.datasets - - vars_dims: dictionary of data variable names and the - corresponding dimension sizes - - Optional keys include: - - - file_path: Path to the source file - - fps: int, frames per second - - source_software: str, name of the software used to generate - the dataset - - """ - expected_dim_names = expected_values.get("dim_names") - expected_file_path = expected_values.get("file_path") - assert isinstance(dataset, xr.Dataset) - # Expected variables are present and of right shape/type - for var, ndim in expected_values.get("vars_dims").items(): - data_var = dataset.get(var) - assert isinstance(data_var, xr.DataArray) - assert data_var.ndim == ndim - position_shape = dataset.position.shape - # Confidence has the same shape as position, except for the space dim - assert ( - dataset.confidence.shape == position_shape[:1] + position_shape[2:] - ) - # Check the dims and coords - expected_dim_length_dict = dict( - zip(expected_dim_names, position_shape, strict=True) - ) - assert expected_dim_length_dict == dataset.sizes - # Check the coords - for dim in expected_dim_names[1:]: - assert all(isinstance(s, str) for s in dataset.coords[dim].values) - assert all(coord in dataset.coords["space"] for coord in ["x", "y"]) - # Check the metadata attributes - assert dataset.source_file == ( - expected_file_path.as_posix() - if expected_file_path is not None - else None - ) - assert dataset.source_software == expected_values.get( - "source_software" - ) - assert dataset.fps == expected_values.get("fps") - - -@pytest.fixture -def movement_dataset_asserts(): - """Return an instance of the ``MovementDatasetAsserts`` class.""" - return MovementDatasetAsserts diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/datasets.py b/tests/fixtures/datasets.py new file mode 100644 index 00000000..207f590c --- /dev/null +++ b/tests/fixtures/datasets.py @@ -0,0 +1,426 @@ +"""Valid and invalid movement datasets and arrays fixtures.""" + +import numpy as np +import pytest +import xarray as xr + +from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset + + +# -------------------- Valid bboxes datasets and arrays -------------------- +@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_space, n_individuals = (10, 2, 2) + + # build a valid array for position or shape with all zeros + valid_bbox_array_all_zeros = np.zeros((n_frames, n_space, n_individuals)) + + # 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)], + } + + +@pytest.fixture +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_space, n_individuals = (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.zeros((n_frames, n_space, n_individuals)) + for i in range(n_individuals): + position[:, 0, i] = np.arange(n_frames) + position[:, 1, i] = (-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)).transpose( + 0, 2, 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, + } + + +@pytest.fixture +def valid_bboxes_dataset( + valid_bboxes_arrays, +): + """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 = ValidBboxesDataset.DIM_NAMES + + 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 + + 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] + dim_names[2:] + ), + }, + coords={ + dim_names[0]: np.arange(n_frames), + dim_names[1]: ["x", "y"], + dim_names[2]: [f"id_{id}" for id in range(n_individuals)], + }, + 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 + + +# -------------------- Valid poses datasets and arrays -------------------- +@pytest.fixture +def valid_poses_arrays(): + """Return a dictionary of valid arrays for a + ValidPosesDataset representing a uniform linear motion. + + Depending on the ``array_type`` requested (``multi_individual_array``, + ``single_keypoint_array``, or ``single_individual_array``), + the arrays can represent up to 2 individuals with up to 3 keypoints, + moving at constant velocity for 10 frames in 2D space. + Default is a ``multi_individual_array``. + At each frame the individuals cover a distance of sqrt(2) in x-y space. + Specifically: + - 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 "centroid" (index=0) at the following frames, + which are set to 0.1: + - Individual 0 at frames 2, 3, 4 + - Individual 1 at frames 2, 3 + """ + + def _valid_poses_arrays(array_type): + """Return a dictionary of valid arrays for a ValidPosesDataset.""" + # Unless specified, default is a ``multi_individual_array`` with + # 10 frames, 3 keypoints, and 2 individuals in 2D space. + n_frames, n_space, n_keypoints, n_individuals = (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 (if applicable) + # They move one unit along x and y axes in each frame + frames = np.arange(n_frames) + position = np.zeros((n_frames, n_space, n_keypoints, n_individuals)) + position[:, 0, 0, :] = frames[:, None] # reshape to (n_frames, 1) + position[:, 1, 0, 0] = 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[:, 0, kpt, i] = ( + position[:, 0, 0, i] + offsets[i][kpt - 1][0] + ) + position[:, 1, kpt, i] = ( + position[:, 1, 0, i] + offsets[i][kpt - 1][1] + ) + + # build an array of confidence values, all 0.9 + confidence = np.full((n_frames, n_keypoints, n_individuals), 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, 0, 1] = 0.1 + + if array_type == "single_keypoint_array": + # return only the centroid keypoint + position = position[:, :, :1, :] + confidence = confidence[:, :1, :] + elif array_type == "single_individual_array": + # return only the first individual + position = position[:, :, :, :1] + confidence = confidence[:, :, :1] + return {"position": position, "confidence": confidence} + + return _valid_poses_arrays + + +@pytest.fixture +def valid_poses_dataset(valid_poses_arrays, request): + """Return a valid poses dataset. + + Depending on the ``array_type`` requested (``multi_individual_array``, + ``single_keypoint_array``, or ``single_individual_array``), + the dataset can represent up to 2 individuals ("id_0" and "id_1") + with up to 3 keypoints ("centroid", "left", "right") + moving in uniform linear motion for 10 frames in 2D space. + Default is a ``multi_individual_array``. + See the ``valid_poses_arrays`` fixture for details. + """ + dim_names = ValidPosesDataset.DIM_NAMES + # create a multi_individual_array by default unless overridden via param + try: + array_type = request.param + except AttributeError: + array_type = "multi_individual_array" + poses_array = valid_poses_arrays(array_type) + position_array = poses_array["position"] + confidence_array = poses_array["confidence"] + n_frames, _, n_keypoints, 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] + dim_names[2:] + ), + }, + coords={ + dim_names[0]: np.arange(n_frames), + dim_names[1]: ["x", "y"], + dim_names[2]: ["centroid", "left", "right"][:n_keypoints], + dim_names[3]: [f"id_{i}" for i in range(n_individuals)], + }, + attrs={ + "fps": None, + "time_unit": "frames", + "source_software": "test", + "source_file": "test_poses.h5", + "ds_type": "poses", + }, + ) + + +@pytest.fixture +def valid_poses_dataset_with_nan(valid_poses_dataset): + """Return a valid poses dataset with NaNs introduced in the position array. + + Using ``valid_poses_dataset`` as the base dataset, + the following NaN values are introduced: + - Individual "id_0": + - 1 NaN value in the centroid keypoint of individual id_0 at time=0 + - 3 NaN values in the left keypoint of individual id_0 (frames 3, 7, 8) + - 10 NaN values in the right keypoint of individual id_0 (all frames) + - Individual "id_1" has no missing values. + """ + valid_poses_dataset.position.loc[ + { + "individuals": "id_0", + "keypoints": "centroid", + "time": 0, + } + ] = np.nan + valid_poses_dataset.position.loc[ + { + "individuals": "id_0", + "keypoints": "left", + "time": [3, 7, 8], + } + ] = np.nan + valid_poses_dataset.position.loc[ + { + "individuals": "id_0", + "keypoints": "right", + } + ] = np.nan + return valid_poses_dataset + + +# -------------------- Invalid bboxes datasets -------------------- +@pytest.fixture +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_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"}) + + +# -------------------- Invalid poses datasets -------------------- +@pytest.fixture +def not_a_dataset(): + """Return data that is not a pose tracks dataset.""" + return [1, 2, 3] + + +@pytest.fixture +def empty_dataset(): + """Return an empty pose tracks dataset.""" + return xr.Dataset() + + +@pytest.fixture +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_poses_dataset(valid_poses_dataset): + """Return a poses dataset missing the time dimension.""" + return valid_poses_dataset.rename({"time": "tame"}) + + +# --------- movement dataset assertion fixtures --------- +class MovementDatasetAsserts: + """Class for asserting valid ``movement`` poses or bboxes datasets.""" + + @staticmethod + def valid_dataset(dataset, expected_values): + """Assert the dataset is a proper ``movement`` Dataset. + + Parameters + ---------- + dataset : xr.Dataset + The dataset to validate. + expected_values : dict + A dictionary containing the expected values for the dataset. + It must contain the following keys: + + - dim_names: list of expected dimension names as defined in + movement.validators.datasets + - vars_dims: dictionary of data variable names and the + corresponding dimension sizes + + Optional keys include: + + - file_path: Path to the source file + - fps: int, frames per second + - source_software: str, name of the software used to generate + the dataset + + """ + expected_dim_names = expected_values.get("dim_names") + expected_file_path = expected_values.get("file_path") + assert isinstance(dataset, xr.Dataset) + # Expected variables are present and of right shape/type + for var, ndim in expected_values.get("vars_dims").items(): + data_var = dataset.get(var) + assert isinstance(data_var, xr.DataArray) + assert data_var.ndim == ndim + position_shape = dataset.position.shape + # Confidence has the same shape as position, except for the space dim + assert ( + dataset.confidence.shape == position_shape[:1] + position_shape[2:] + ) + # Check the dims and coords + expected_dim_length_dict = dict( + zip(expected_dim_names, position_shape, strict=True) + ) + assert expected_dim_length_dict == dataset.sizes + # Check the coords + for dim in expected_dim_names[1:]: + assert all(isinstance(s, str) for s in dataset.coords[dim].values) + assert all(coord in dataset.coords["space"] for coord in ["x", "y"]) + # Check the metadata attributes + assert dataset.source_file == ( + expected_file_path.as_posix() + if expected_file_path is not None + else None + ) + assert dataset.source_software == expected_values.get( + "source_software" + ) + assert dataset.fps == expected_values.get("fps") + + +@pytest.fixture +def movement_dataset_asserts(): + """Return an instance of the ``MovementDatasetAsserts`` class.""" + return MovementDatasetAsserts diff --git a/tests/fixtures/files.py b/tests/fixtures/files.py new file mode 100644 index 00000000..7806ddff --- /dev/null +++ b/tests/fixtures/files.py @@ -0,0 +1,486 @@ +"""Valid and invalid file fixtures.""" + +import os +from pathlib import Path +from unittest.mock import mock_open, patch + +import h5py +import pandas as pd +import pytest + + +# --------- File validator fixtures --------------------------------- +@pytest.fixture +def unreadable_file(tmp_path): + """Return a dictionary containing the file path and + expected permission for an unreadable .h5 file. + """ + file_path = tmp_path / "unreadable.h5" + file_mock = mock_open() + file_mock.return_value.read.side_effect = PermissionError + with ( + patch("builtins.open", side_effect=file_mock), + patch.object(Path, "exists", return_value=True), + ): + yield { + "file_path": file_path, + "expected_permission": "r", + } + + +@pytest.fixture +def unwriteable_file(tmp_path): + """Return a dictionary containing the file path and + expected permission for an unwriteable .h5 file. + """ + unwriteable_dir = tmp_path / "no_write" + unwriteable_dir.mkdir() + original_access = os.access + + def mock_access(path, mode): + if path == unwriteable_dir and mode == os.W_OK: + return False + # Ensure that the original access function is called + # for all other cases + return original_access(path, mode) + + with patch("os.access", side_effect=mock_access): + file_path = unwriteable_dir / "unwriteable.h5" + yield { + "file_path": file_path, + "expected_permission": "w", + } + + +@pytest.fixture +def wrong_ext_file(tmp_path): + """Return a dictionary containing the file path, + expected permission, and expected suffix for a file + with an incorrect extension. + """ + file_path = tmp_path / "wrong_extension.txt" + with open(file_path, "w") as f: + f.write("") + return { + "file_path": file_path, + "expected_permission": "r", + "expected_suffix": ["h5", "csv"], + } + + +@pytest.fixture +def nonexistent_file(tmp_path): + """Return a dictionary containing the file path and + expected permission for a nonexistent file. + """ + file_path = tmp_path / "nonexistent.h5" + return { + "file_path": file_path, + "expected_permission": "r", + } + + +@pytest.fixture +def directory(tmp_path): + """Return a dictionary containing the file path and + expected permission for a directory. + """ + file_path = tmp_path / "directory" + file_path.mkdir() + return { + "file_path": file_path, + "expected_permission": "r", + } + + +@pytest.fixture +def h5_file_no_dataframe(tmp_path): + """Return a dictionary containing the file path and + expected datasets for a .h5 file with no dataframe. + """ + file_path = tmp_path / "no_dataframe.h5" + with h5py.File(file_path, "w") as f: + f.create_dataset("data_in_list", data=[1, 2, 3]) + return { + "file_path": file_path, + "expected_datasets": ["dataframe"], + } + + +@pytest.fixture +def fake_h5_file(tmp_path): + """Return a dictionary containing the file path, + expected exception, and expected datasets for + a file with .h5 extension that is not in HDF5 format. + """ + file_path = tmp_path / "fake.h5" + with open(file_path, "w") as f: + f.write("") + return { + "file_path": file_path, + "expected_datasets": ["dataframe"], + "expected_permission": "w", + } + + +@pytest.fixture +def invalid_single_individual_csv_file(tmp_path): + """Return the file path for a fake single-individual .csv file.""" + file_path = tmp_path / "fake_single_individual.csv" + with open(file_path, "w") as f: + f.write("scorer,columns\nsome,columns\ncoords,columns\n") + f.write("1,2") + return file_path + + +@pytest.fixture +def invalid_multi_individual_csv_file(tmp_path): + """Return the file path for a fake multi-individual .csv file.""" + file_path = tmp_path / "fake_multi_individual.csv" + with open(file_path, "w") as f: + f.write( + "scorer,columns\nindividuals,columns\nbodyparts,columns\nsome,columns\n" + ) + f.write("1,2") + return file_path + + +@pytest.fixture +def new_file_wrong_ext(tmp_path): + """Return the file path for a new file with the wrong extension.""" + return tmp_path / "new_file_wrong_ext.txt" + + +@pytest.fixture +def new_h5_file(tmp_path): + """Return the file path for a new .h5 file.""" + return tmp_path / "new_file.h5" + + +@pytest.fixture +def new_csv_file(tmp_path): + """Return the file path for a new .csv file.""" + return tmp_path / "new_file.csv" + + +@pytest.fixture +def dlc_style_df(): + """Return a valid DLC-style DataFrame.""" + return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) + + +@pytest.fixture +def missing_keypoint_columns_anipose_csv_file(tmp_path): + """Return the file path for a fake single-individual .csv file.""" + file_path = tmp_path / "missing_keypoint_columns.csv" + columns = [ + "fnum", + "center_0", + "center_1", + "center_2", + "M_00", + "M_01", + "M_02", + "M_10", + "M_11", + "M_12", + "M_20", + "M_21", + "M_22", + ] + # Here we are missing kp0_z: + columns.extend(["kp0_x", "kp0_y", "kp0_score", "kp0_error", "kp0_ncams"]) + with open(file_path, "w") as f: + f.write(",".join(columns)) + f.write("\n") + f.write(",".join(["1"] * len(columns))) + return file_path + + +@pytest.fixture +def spurious_column_anipose_csv_file(tmp_path): + """Return the file path for a fake single-individual .csv file.""" + file_path = tmp_path / "spurious_column.csv" + columns = [ + "fnum", + "center_0", + "center_1", + "center_2", + "M_00", + "M_01", + "M_02", + "M_10", + "M_11", + "M_12", + "M_20", + "M_21", + "M_22", + ] + columns.extend(["funny_column"]) + with open(file_path, "w") as f: + f.write(",".join(columns)) + f.write("\n") + f.write(",".join(["1"] * len(columns))) + return file_path + + +@pytest.fixture( + params=[ + "SLEAP_single-mouse_EPM.analysis.h5", + "SLEAP_single-mouse_EPM.predictions.slp", + "SLEAP_three-mice_Aeon_proofread.analysis.h5", + "SLEAP_three-mice_Aeon_proofread.predictions.slp", + "SLEAP_three-mice_Aeon_mixed-labels.analysis.h5", + "SLEAP_three-mice_Aeon_mixed-labels.predictions.slp", + ] +) +def sleap_file(request): + """Return the file path for a SLEAP .h5 or .slp file.""" + return pytest.DATA_PATHS.get(request.param) + + +# ---------------- 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.""" + 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 diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py new file mode 100644 index 00000000..e1975508 --- /dev/null +++ b/tests/fixtures/helpers.py @@ -0,0 +1,23 @@ +"""Helpers fixture for ``movement`` test modules.""" + +import pytest + + +class Helpers: + """General helper methods for ``movement`` test modules.""" + + @staticmethod + def count_nans(da): + """Count number of NaNs in a DataArray.""" + return da.isnull().sum().item() + + @staticmethod + def count_consecutive_nans(da): + """Count occurrences of consecutive NaNs in a DataArray.""" + return (da.isnull().astype(int).diff("time") != 0).sum().item() + + +@pytest.fixture +def helpers(): + """Return an instance of the ``Helpers`` class.""" + return Helpers From 6e6948d48618516b1bc96c0142acb2242defb243 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 23 Jan 2025 19:16:23 +0000 Subject: [PATCH 21/37] Group kinematics tests by common params --- tests/test_unit/test_kinematics.py | 293 ++++++++++++----------------- 1 file changed, 123 insertions(+), 170 deletions(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 17640501..97cb1e55 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -10,175 +10,128 @@ @pytest.mark.parametrize( - "valid_dataset_uniform_linear_motion", - ["valid_poses_dataset", "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 - ], - ), - ( - "speed", # magnitude of velocity - [ - np.ones(10) * np.sqrt(2), # Individual 0 - np.ones(10) * np.sqrt(2), # Individual 1 - ], - ), - ], + "kinematic_variable", ["displacement", "velocity", "acceleration", "speed"] ) -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.""" - # Compute kinematic array from input dataset - position = request.getfixturevalue( - valid_dataset_uniform_linear_motion - ).position - kinematic_array = getattr(kinematics, f"compute_{kinematic_variable}")( - 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.insert(1, "space") - # Build expected data array from the expected numpy array - expected_array = xr.DataArray( - # Stack along the "individuals" axis - np.stack(expected_kinematics, axis=-1), - dims=expected_dims, +class TestComputeKinematics: + """Test ``compute_[kinematic_variable]`` with valid and invalid inputs.""" + + expected_kinematics = { + "displacement": [ + np.vstack([np.zeros((1, 2)), np.ones((9, 2))]), + np.multiply( + np.vstack([np.zeros((1, 2)), np.ones((9, 2))]), + np.array([1, -1]), + ), + ], # [Individual 0, Individual 1] + "velocity": [ + np.ones((10, 2)), + np.multiply(np.ones((10, 2)), np.array([1, -1])), + ], + "acceleration": [np.zeros((10, 2)), np.zeros((10, 2))], + "speed": [np.ones(10) * np.sqrt(2), np.ones(10) * np.sqrt(2)], + } # 2D: n_frames, n_space_dims + + @pytest.mark.parametrize( + "valid_dataset", ["valid_poses_dataset", "valid_bboxes_dataset"] ) - if "keypoints" in position.coords: - expected_array = expected_array.expand_dims( - {"keypoints": position.coords["keypoints"].size} + def test_kinematics(self, valid_dataset, kinematic_variable, request): + """Test computed kinematics for a uniform linear motion case.""" + # Compute kinematic array from input dataset + position = request.getfixturevalue(valid_dataset).position + kinematic_array = getattr(kinematics, f"compute_{kinematic_variable}")( + 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.insert(1, "space") + # Build expected data array from the expected numpy array + expected_array = xr.DataArray( + # Stack along the "individuals" axis + np.stack( + self.expected_kinematics.get(kinematic_variable), axis=-1 + ), + dims=expected_dims, + ) + if "keypoints" in position.coords: + expected_array = expected_array.expand_dims( + {"keypoints": position.coords["keypoints"].size} + ) + expected_dims.insert(-1, "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 ) - expected_dims.insert(-1, "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) - -@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", - { - "valid_poses_dataset_with_nan": [30, 0], - "valid_bboxes_dataset_with_nan": [10, 0], - }, # [individual 0, individual 1] - ), - ( - "velocity", - { - "valid_poses_dataset_with_nan": [36, 0], - "valid_bboxes_dataset_with_nan": [12, 0], - }, - ), - ( - "acceleration", - { - "valid_poses_dataset_with_nan": [40, 0], - "valid_bboxes_dataset_with_nan": [14, 0], - }, - ), - ( - "speed", - { - "valid_poses_dataset_with_nan": [18, 0], - "valid_bboxes_dataset_with_nan": [6, 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.""" - # 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.sizes["individuals"]) - ] - # assert n nans in kinematic array per individual matches expected - assert ( - n_nans_kinematics_per_indiv - == expected_nans_per_individual[valid_dataset_with_nan] + @pytest.mark.parametrize( + "valid_dataset_with_nan, expected_nans_per_individual", + [ + ( + "valid_poses_dataset_with_nan", + { + "displacement": [30, 0], + "velocity": [36, 0], + "acceleration": [40, 0], + "speed": [18, 0], + }, + ), + ( + "valid_bboxes_dataset_with_nan", + { + "displacement": [10, 0], + "velocity": [12, 0], + "acceleration": [14, 0], + "speed": [6, 0], + }, + ), + ], ) + def test_kinematics_with_dataset_with_nans( + self, + valid_dataset_with_nan, + expected_nans_per_individual, + kinematic_variable, + helpers, + request, + ): + """Test kinematics computation for a dataset with nans.""" + # 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.sizes["individuals"]) + ] + # assert n nans in kinematic array per individual matches expected + assert ( + n_nans_kinematics_per_indiv + == expected_nans_per_individual[kinematic_variable] + ) - -@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", - "speed", - ], -) -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( + "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)), + ], + ) + def test_kinematics_with_invalid_dataset( + self, 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"]) @@ -325,19 +278,19 @@ def test_path_length_warns_about_nans( kinematics.compute_path_length( position, nan_warn_threshold=nan_warn_threshold ) - - if (nan_warn_threshold > 0.1) and (nan_warn_threshold < 0.5): + if 0.1 < 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 + info_msg = caplog.records[1].message assert caplog.records[1].levelname == "INFO" - assert "Individual: id_0" in caplog.records[1].message - assert "Individual: id_1" not in caplog.records[1].message - assert "left: 3/10 (30.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 + assert "Individual: id_0" in info_msg + assert "Individual: id_1" not in info_msg + assert "left: 3/10 (30.0%)" in info_msg + assert "right: 10/10 (100.0%)" in info_msg + assert "centroid" not in info_msg @pytest.fixture From d4013fce1e1bd8067dcfa6f690848d3adde9a8a8 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 23 Jan 2025 19:19:06 +0000 Subject: [PATCH 22/37] Shorten arg name --- .../test_kinematics_vector_transform.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/test_integration/test_kinematics_vector_transform.py b/tests/test_integration/test_kinematics_vector_transform.py index 38ab5f63..feae39bb 100644 --- a/tests/test_integration/test_kinematics_vector_transform.py +++ b/tests/test_integration/test_kinematics_vector_transform.py @@ -9,8 +9,7 @@ @pytest.mark.parametrize( - "valid_dataset_uniform_linear_motion", - ["valid_poses_dataset", "valid_bboxes_dataset"], + "valid_dataset", ["valid_poses_dataset", "valid_bboxes_dataset"] ) @pytest.mark.parametrize( "kinematic_variable, expected_kinematics_polar", @@ -53,15 +52,12 @@ ], ) def test_cart2pol_transform_on_kinematics( - valid_dataset_uniform_linear_motion, - kinematic_variable, - expected_kinematics_polar, - request, + valid_dataset, 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) + ds = request.getfixturevalue(valid_dataset) kinematic_array_cart = getattr(kin, f"compute_{kinematic_variable}")( ds.position ) From 80c34130a766296120c98877124144287f1c5a1b Mon Sep 17 00:00:00 2001 From: lochhh Date: Fri, 24 Jan 2025 16:59:54 +0000 Subject: [PATCH 23/37] Refactor fixtures --- tests/conftest.py | 8 +- tests/fixtures/datasets.py | 27 +- tests/fixtures/files.py | 389 ++++++++---------- tests/test_unit/test_save_poses.py | 2 +- .../test_validators/test_files_validators.py | 26 +- 5 files changed, 199 insertions(+), 253 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1945b7d8..de9fc7ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,13 +9,13 @@ from movement.utils.logging import configure_logging -def to_pytest_plugin_path(string: str) -> str: - """Convert a file path to a pytest-compatible plugin path.""" - return string.replace("/", ".").replace("\\", ".").replace(".py", "") +def _to_module_string(path: str) -> str: + """Convert a file path to a module string.""" + return path.replace("/", ".").replace("\\", ".").replace(".py", "") pytest_plugins = [ - to_pytest_plugin_path(fixture) + _to_module_string(fixture) for fixture in glob("tests/fixtures/*.py") if "__" not in fixture ] diff --git a/tests/fixtures/datasets.py b/tests/fixtures/datasets.py index 207f590c..02d0d32e 100644 --- a/tests/fixtures/datasets.py +++ b/tests/fixtures/datasets.py @@ -1,12 +1,20 @@ -"""Valid and invalid movement datasets and arrays fixtures.""" +"""Valid and invalid data fixtures.""" import numpy as np +import pandas as pd import pytest import xarray as xr from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset +# -------------------- Valid DLC-style DataFrame -------------------- +@pytest.fixture +def dlc_style_df(): + """Return a valid DLC-style DataFrame.""" + return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) + + # -------------------- Valid bboxes datasets and arrays -------------------- @pytest.fixture def valid_bboxes_arrays_all_zeros(): @@ -285,24 +293,13 @@ def valid_poses_dataset_with_nan(valid_poses_dataset): - Individual "id_1" has no missing values. """ valid_poses_dataset.position.loc[ - { - "individuals": "id_0", - "keypoints": "centroid", - "time": 0, - } + {"individuals": "id_0", "keypoints": "centroid", "time": 0} ] = np.nan valid_poses_dataset.position.loc[ - { - "individuals": "id_0", - "keypoints": "left", - "time": [3, 7, 8], - } + {"individuals": "id_0", "keypoints": "left", "time": [3, 7, 8]} ] = np.nan valid_poses_dataset.position.loc[ - { - "individuals": "id_0", - "keypoints": "right", - } + {"individuals": "id_0", "keypoints": "right"} ] = np.nan return valid_poses_dataset diff --git a/tests/fixtures/files.py b/tests/fixtures/files.py index 7806ddff..8c04e80a 100644 --- a/tests/fixtures/files.py +++ b/tests/fixtures/files.py @@ -5,11 +5,10 @@ from unittest.mock import mock_open, patch import h5py -import pandas as pd import pytest -# --------- File validator fixtures --------------------------------- +# ------------------ Generic file fixtures ---------------------- @pytest.fixture def unreadable_file(tmp_path): """Return a dictionary containing the file path and @@ -81,20 +80,7 @@ def nonexistent_file(tmp_path): @pytest.fixture -def directory(tmp_path): - """Return a dictionary containing the file path and - expected permission for a directory. - """ - file_path = tmp_path / "directory" - file_path.mkdir() - return { - "file_path": file_path, - "expected_permission": "r", - } - - -@pytest.fixture -def h5_file_no_dataframe(tmp_path): +def no_dataframe_h5_file(tmp_path): """Return a dictionary containing the file path and expected datasets for a .h5 file with no dataframe. """ @@ -110,7 +96,7 @@ def h5_file_no_dataframe(tmp_path): @pytest.fixture def fake_h5_file(tmp_path): """Return a dictionary containing the file path, - expected exception, and expected datasets for + expected permission, and expected datasets for a file with .h5 extension that is not in HDF5 format. """ file_path = tmp_path / "fake.h5" @@ -146,9 +132,22 @@ def invalid_multi_individual_csv_file(tmp_path): @pytest.fixture -def new_file_wrong_ext(tmp_path): +def wrong_ext_new_file(tmp_path): """Return the file path for a new file with the wrong extension.""" - return tmp_path / "new_file_wrong_ext.txt" + return tmp_path / "wrong_ext_new_file.txt" + + +@pytest.fixture +def directory(tmp_path): + """Return a dictionary containing the file path and + expected permission for a directory. + """ + file_path = tmp_path / "directory" + file_path.mkdir() + return { + "file_path": file_path, + "expected_permission": "r", + } @pytest.fixture @@ -163,12 +162,7 @@ def new_csv_file(tmp_path): return tmp_path / "new_file.csv" -@pytest.fixture -def dlc_style_df(): - """Return a valid DLC-style DataFrame.""" - return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) - - +# ---------------- Anipose file fixtures ---------------------------- @pytest.fixture def missing_keypoint_columns_anipose_csv_file(tmp_path): """Return the file path for a fake single-individual .csv file.""" @@ -224,6 +218,7 @@ def spurious_column_anipose_csv_file(tmp_path): return file_path +# ---------------- SLEAP file fixtures ---------------------------- @pytest.fixture( params=[ "SLEAP_single-mouse_EPM.analysis.h5", @@ -240,247 +235,201 @@ def sleap_file(request): # ---------------- VIA tracks CSV file fixtures ---------------------------- +via_tracks_csv_file_valid_header = ( + "filename,file_size,file_attributes,region_count," + "region_id,region_shape_attributes,region_attributes\n" +) + + @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 +def invalid_via_tracks_csv_file(tmp_path, request): + """Return the file path for an invalid VIA tracks .csv file.""" + + def _invalid_via_tracks_csv_file(invalid_content): + file_path = tmp_path / "invalid_via_tracks.csv" + with open(file_path, "w") as f: + f.write(request.getfixturevalue(invalid_content)) + return file_path + + return _invalid_via_tracks_csv_file @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 +def via_invalid_header(): + """Return the content of a VIA tracks .csv file with invalid header.""" + return "filename,file_size,file_attributes\n1,2,3" @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 +def via_frame_number_in_file_attribute_not_integer(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + + "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""}"' + ) @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 +def via_frame_number_in_filename_wrong_pattern(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + + "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""}"' + ) @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 +def via_more_frame_numbers_than_filenames(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + "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""}"' + "\n" + "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""}"' + ) @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 +def via_less_frame_numbers_than_filenames(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + "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""}"' + "\n" + "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""}"' + ) @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 +def via_region_shape_attribute_not_rect(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + + "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 @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 +def via_region_shape_attribute_missing_x(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + + "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 @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 +def via_region_attribute_missing_track(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + + "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"" + ) @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 +def via_track_id_not_castable_as_int(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + + "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 + ) @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 +def via_track_ids_not_unique_per_frame(): + """Return the content of 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 + return ( + via_tracks_csv_file_valid_header + + "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""}"' + "\n" + "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 + ) diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index ae812f89..ca313a32 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -31,7 +31,7 @@ class TestSavePoses: # invalid file path }, { - "file_fixture": "new_file_wrong_ext", + "file_fixture": "wrong_ext_new_file", "to_dlc_file_expected_exception": pytest.raises(ValueError), "to_sleap_file_expected_exception": pytest.raises(ValueError), "to_lp_file_expected_exception": pytest.raises(ValueError), diff --git a/tests/test_unit/test_validators/test_files_validators.py b/tests/test_unit/test_validators/test_files_validators.py index b3149d64..86c868df 100644 --- a/tests/test_unit/test_validators/test_files_validators.py +++ b/tests/test_unit/test_validators/test_files_validators.py @@ -36,7 +36,7 @@ def test_file_validator_with_invalid_input( @pytest.mark.parametrize( "invalid_input, expected_exception", [ - ("h5_file_no_dataframe", pytest.raises(ValueError)), + ("no_dataframe_h5_file", pytest.raises(ValueError)), ("fake_h5_file", pytest.raises(ValueError)), ], ) @@ -72,7 +72,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( "invalid_input, error_type, log_message", [ ( - "via_tracks_csv_with_invalid_header", + "via_invalid_header", ValueError, ".csv header row does not match the known format for " "VIA tracks .csv files. " @@ -83,7 +83,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( "but got ['filename', 'file_size', 'file_attributes'].", ), ( - "frame_number_in_file_attribute_not_integer", + "via_frame_number_in_file_attribute_not_integer", ValueError, "04.09.2023-04-Right_RE_test_frame_A.png (row 0): " "'frame' file attribute cannot be cast as an integer. " @@ -91,7 +91,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( "{'clip': 123, 'frame': 'FOO'}.", ), ( - "frame_number_in_filename_wrong_pattern", + "via_frame_number_in_filename_wrong_pattern", AttributeError, "04.09.2023-04-Right_RE_test_frame_1.png (row 0): " "The provided frame regexp ((0\d*)\.\w+$) did not return " @@ -100,28 +100,28 @@ def test_deeplabcut_csv_validator_with_invalid_input( "filename.", ), ( - "more_frame_numbers_than_filenames", + "via_more_frame_numbers_than_filenames", 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. ", ), ( - "less_frame_numbers_than_filenames", + "via_less_frame_numbers_than_filenames", 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. ", ), ( - "region_shape_attribute_not_rect", + "via_region_shape_attribute_not_rect", ValueError, "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", + "via_region_shape_attribute_missing_x", ValueError, "04.09.2023-04-Right_RE_test_frame_01.png (row 0): " "at least one bounding box shape parameter is missing. " @@ -130,7 +130,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( "'['name', 'y', 'width', 'height']'.", ), ( - "region_attribute_missing_track", + "via_region_attribute_missing_track", ValueError, "04.09.2023-04-Right_RE_test_frame_01.png (row 0): " "bounding box does not have a 'track' attribute defined " @@ -138,7 +138,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( "Please review the VIA tracks .csv file.", ), ( - "track_id_not_castable_as_int", + "via_track_id_not_castable_as_int", ValueError, "04.09.2023-04-Right_RE_test_frame_01.png (row 0): " "the track ID for the bounding box cannot be cast " @@ -146,7 +146,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( "Please review the VIA tracks .csv file.", ), ( - "track_ids_not_unique_per_frame", + "via_track_ids_not_unique_per_frame", ValueError, "04.09.2023-04-Right_RE_test_frame_01.png: " "multiple bounding boxes in this file have the same track ID. " @@ -155,7 +155,7 @@ def test_deeplabcut_csv_validator_with_invalid_input( ], ) def test_via_tracks_csv_validator_with_invalid_input( - invalid_input, error_type, log_message, request + invalid_via_tracks_csv_file, invalid_input, error_type, log_message ): """Test that invalid VIA tracks .csv files raise the appropriate errors. @@ -171,7 +171,7 @@ def test_via_tracks_csv_validator_with_invalid_input( (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) + file_path = invalid_via_tracks_csv_file(invalid_input) with pytest.raises(error_type) as excinfo: ValidVIATracksCSV(file_path) From 10c6df54528815fec926ac6c13bd6afb243a62ca Mon Sep 17 00:00:00 2001 From: lochhh Date: Fri, 24 Jan 2025 17:24:54 +0000 Subject: [PATCH 24/37] Rename datasets.py to data.py --- tests/fixtures/{datasets.py => data.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/fixtures/{datasets.py => data.py} (100%) diff --git a/tests/fixtures/datasets.py b/tests/fixtures/data.py similarity index 100% rename from tests/fixtures/datasets.py rename to tests/fixtures/data.py From 551f0ad947a2b9095e7292c5cdf8468916162118 Mon Sep 17 00:00:00 2001 From: lochhh Date: Fri, 24 Jan 2025 17:51:14 +0000 Subject: [PATCH 25/37] Swap poses "centroid" and "left" keypoint NaNs --- tests/fixtures/data.py | 10 +++++----- tests/test_unit/test_kinematics.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/fixtures/data.py b/tests/fixtures/data.py index 02d0d32e..624f8826 100644 --- a/tests/fixtures/data.py +++ b/tests/fixtures/data.py @@ -287,16 +287,16 @@ def valid_poses_dataset_with_nan(valid_poses_dataset): Using ``valid_poses_dataset`` as the base dataset, the following NaN values are introduced: - Individual "id_0": - - 1 NaN value in the centroid keypoint of individual id_0 at time=0 - - 3 NaN values in the left keypoint of individual id_0 (frames 3, 7, 8) - - 10 NaN values in the right keypoint of individual id_0 (all frames) + - 3 NaNs in the centroid keypoint of individual id_0 (frames 3, 7, 8) + - 1 NaN in the left keypoint of individual id_0 at time=0 + - 10 NaNs in the right keypoint of individual id_0 (all frames) - Individual "id_1" has no missing values. """ valid_poses_dataset.position.loc[ - {"individuals": "id_0", "keypoints": "centroid", "time": 0} + {"individuals": "id_0", "keypoints": "centroid", "time": [3, 7, 8]} ] = np.nan valid_poses_dataset.position.loc[ - {"individuals": "id_0", "keypoints": "left", "time": [3, 7, 8]} + {"individuals": "id_0", "keypoints": "left", "time": 0} ] = np.nan valid_poses_dataset.position.loc[ {"individuals": "id_0", "keypoints": "right"} diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 97cb1e55..4d5c72be 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -214,7 +214,7 @@ def test_path_length_across_time_ranges( [ ( "ffill", - np.array([np.sqrt(2) * 8, np.sqrt(2) * 9, np.nan]), + np.array([np.sqrt(2) * 9, np.sqrt(2) * 8, np.nan]), does_not_raise(), ), ( @@ -288,9 +288,9 @@ def test_path_length_warns_about_nans( assert caplog.records[1].levelname == "INFO" assert "Individual: id_0" in info_msg assert "Individual: id_1" not in info_msg - assert "left: 3/10 (30.0%)" in info_msg + assert "centroid: 3/10 (30.0%)" in info_msg assert "right: 10/10 (100.0%)" in info_msg - assert "centroid" not in info_msg + assert "left" not in info_msg @pytest.fixture @@ -299,7 +299,7 @@ def valid_data_array_for_forward_vector(): (left ear, right ear and nose), tracked for 4 frames, in x-y space. """ time = [0, 1, 2, 3] - individuals = ["individual_0"] + individuals = ["id_0"] keypoints = ["left_ear", "right_ear", "nose"] space = ["x", "y"] From ac067134006b320c72bbabb7201334193a752439 Mon Sep 17 00:00:00 2001 From: lochhh Date: Mon, 27 Jan 2025 14:06:58 +0000 Subject: [PATCH 26/37] Fix up filtering test expectations after rebase --- tests/test_unit/test_filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index d9eacff3..82be7990 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -109,7 +109,7 @@ class TestFilteringValidDatasetWithNaNs: @pytest.mark.parametrize( "max_gap, expected_n_nans_in_position", - [(None, [20, 0]), (0, [26, 6]), (1, [24, 4]), (2, [20, 0])], + [(None, [22, 0]), (0, [28, 6]), (1, [26, 4]), (2, [22, 0])], # expected total n nans: [poses, bboxes] ) def test_interpolate_over_time_on_position( From a3f258ac32d92ba180d8ab5f82886190980d68ec Mon Sep 17 00:00:00 2001 From: sfmig <33267254+sfmig@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:53:50 +0000 Subject: [PATCH 27/37] Suggestion to move dataset validator fixture under helpers --- tests/fixtures/data.py | 69 --------------------------- tests/fixtures/helpers.py | 74 +++++++++++++++++++++++++++++ tests/test_unit/test_load_bboxes.py | 8 ++-- tests/test_unit/test_load_poses.py | 24 ++++------ 4 files changed, 88 insertions(+), 87 deletions(-) diff --git a/tests/fixtures/data.py b/tests/fixtures/data.py index 624f8826..84209fef 100644 --- a/tests/fixtures/data.py +++ b/tests/fixtures/data.py @@ -352,72 +352,3 @@ def missing_var_poses_dataset(valid_poses_dataset): def missing_dim_poses_dataset(valid_poses_dataset): """Return a poses dataset missing the time dimension.""" return valid_poses_dataset.rename({"time": "tame"}) - - -# --------- movement dataset assertion fixtures --------- -class MovementDatasetAsserts: - """Class for asserting valid ``movement`` poses or bboxes datasets.""" - - @staticmethod - def valid_dataset(dataset, expected_values): - """Assert the dataset is a proper ``movement`` Dataset. - - Parameters - ---------- - dataset : xr.Dataset - The dataset to validate. - expected_values : dict - A dictionary containing the expected values for the dataset. - It must contain the following keys: - - - dim_names: list of expected dimension names as defined in - movement.validators.datasets - - vars_dims: dictionary of data variable names and the - corresponding dimension sizes - - Optional keys include: - - - file_path: Path to the source file - - fps: int, frames per second - - source_software: str, name of the software used to generate - the dataset - - """ - expected_dim_names = expected_values.get("dim_names") - expected_file_path = expected_values.get("file_path") - assert isinstance(dataset, xr.Dataset) - # Expected variables are present and of right shape/type - for var, ndim in expected_values.get("vars_dims").items(): - data_var = dataset.get(var) - assert isinstance(data_var, xr.DataArray) - assert data_var.ndim == ndim - position_shape = dataset.position.shape - # Confidence has the same shape as position, except for the space dim - assert ( - dataset.confidence.shape == position_shape[:1] + position_shape[2:] - ) - # Check the dims and coords - expected_dim_length_dict = dict( - zip(expected_dim_names, position_shape, strict=True) - ) - assert expected_dim_length_dict == dataset.sizes - # Check the coords - for dim in expected_dim_names[1:]: - assert all(isinstance(s, str) for s in dataset.coords[dim].values) - assert all(coord in dataset.coords["space"] for coord in ["x", "y"]) - # Check the metadata attributes - assert dataset.source_file == ( - expected_file_path.as_posix() - if expected_file_path is not None - else None - ) - assert dataset.source_software == expected_values.get( - "source_software" - ) - assert dataset.fps == expected_values.get("fps") - - -@pytest.fixture -def movement_dataset_asserts(): - """Return an instance of the ``MovementDatasetAsserts`` class.""" - return MovementDatasetAsserts diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py index e1975508..32b5ee1d 100644 --- a/tests/fixtures/helpers.py +++ b/tests/fixtures/helpers.py @@ -1,11 +1,85 @@ """Helpers fixture for ``movement`` test modules.""" import pytest +import xarray as xr class Helpers: """General helper methods for ``movement`` test modules.""" + @staticmethod + def assert_valid_dataset(dataset, expected_values): + """Assert the dataset is a valid ``movement`` Dataset. + + The validation includes: + - checking the dataset is an xarray Dataset + - checking the expected variables are present and are of the right + shape and type + - checking the confidence array shape matches the position array + - checking the dimensions and coordinates against the expected values + - checking the coordinates' names and size + - checking the metadata attributes + + Parameters + ---------- + dataset : xr.Dataset + The dataset to validate. + expected_values : dict + A dictionary containing the expected values for the dataset. + It must contain the following keys: + + - dim_names: list of expected dimension names as defined in + movement.validators.datasets + - vars_dims: dictionary of data variable names and the + corresponding dimension sizes + + Optional keys include: + + - file_path: Path to the source file + - fps: int, frames per second + - source_software: str, name of the software used to generate + the dataset + + """ + # Check dataset is an xarray Dataset + assert isinstance(dataset, xr.Dataset) + + # Expected variables are present and of right shape/type + for var, ndim in expected_values.get("vars_dims").items(): + data_var = dataset.get(var) + assert isinstance(data_var, xr.DataArray) + assert data_var.ndim == ndim + position_shape = dataset.position.shape + + # Confidence has the same shape as position, except for the space dim + assert ( + dataset.confidence.shape == position_shape[:1] + position_shape[2:] + ) + + # Check the dims and coords + expected_dim_names = expected_values.get("dim_names") + expected_dim_length_dict = dict( + zip(expected_dim_names, position_shape, strict=True) + ) + assert expected_dim_length_dict == dataset.sizes + + # Check the coords + for dim in expected_dim_names[1:]: + assert all(isinstance(s, str) for s in dataset.coords[dim].values) + assert all(coord in dataset.coords["space"] for coord in ["x", "y"]) + + # Check the metadata attributes + expected_file_path = expected_values.get("file_path") + assert dataset.source_file == ( + expected_file_path.as_posix() + if expected_file_path is not None + else None + ) + assert dataset.source_software == expected_values.get( + "source_software" + ) + assert dataset.fps == expected_values.get("fps") + @staticmethod def count_nans(da): """Count number of NaNs in a DataArray.""" diff --git a/tests/test_unit/test_load_bboxes.py b/tests/test_unit/test_load_bboxes.py index a15e2644..0ba121e1 100644 --- a/tests/test_unit/test_load_bboxes.py +++ b/tests/test_unit/test_load_bboxes.py @@ -265,7 +265,7 @@ def test_from_via_tracks_file( fps, use_frame_numbers_from_file, frame_regexp, - movement_dataset_asserts, + helpers, ): """Test that loading tracked bounding box data from a valid VIA tracks .csv file returns a proper Dataset. @@ -283,7 +283,7 @@ def test_from_via_tracks_file( "fps": fps, "file_path": via_file_path, } - movement_dataset_asserts.valid_dataset(ds, expected_values) + helpers.assert_valid_dataset(ds, expected_values) @pytest.mark.parametrize( @@ -342,7 +342,7 @@ def test_from_numpy( with_frame_array, fps, source_software, - movement_dataset_asserts, + helpers, ): """Test that loading bounding boxes trajectories from the input numpy arrays returns a proper Dataset. @@ -360,7 +360,7 @@ def test_from_numpy( "source_software": source_software, "fps": fps, } - movement_dataset_asserts.valid_dataset(ds, expected_values) + helpers.assert_valid_dataset(ds, expected_values) # check time coordinates are as expected start_frame = ( from_numpy_inputs["frame_array"][0, 0] diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 67ccbf8f..1df5e9d3 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -74,7 +74,7 @@ def sleap_file_without_tracks(request): } -def test_load_from_sleap_file(sleap_file, movement_dataset_asserts): +def test_load_from_sleap_file(sleap_file, helpers): """Test that loading pose tracks from valid SLEAP files returns a proper Dataset. """ @@ -84,7 +84,7 @@ def test_load_from_sleap_file(sleap_file, movement_dataset_asserts): "source_software": "SLEAP", "file_path": sleap_file, } - movement_dataset_asserts.valid_dataset(ds, expected_values) + helpers.assert_valid_dataset(ds, expected_values) def test_load_from_sleap_file_without_tracks(sleap_file_without_tracks): @@ -142,7 +142,7 @@ def test_load_from_sleap_slp_file_or_h5_file_returns_same(slp_file, h5_file): "DLC_two-mice.predictions.csv", ], ) -def test_load_from_dlc_file(file_name, movement_dataset_asserts): +def test_load_from_dlc_file(file_name, helpers): """Test that loading pose tracks from valid DLC files returns a proper Dataset. """ @@ -153,15 +153,13 @@ def test_load_from_dlc_file(file_name, movement_dataset_asserts): "source_software": "DeepLabCut", "file_path": file_path, } - movement_dataset_asserts.valid_dataset(ds, expected_values) + helpers.assert_valid_dataset(ds, expected_values) @pytest.mark.parametrize( "source_software", ["DeepLabCut", "LightningPose", None] ) -def test_load_from_dlc_style_df( - dlc_style_df, source_software, movement_dataset_asserts -): +def test_load_from_dlc_style_df(dlc_style_df, source_software, helpers): """Test that loading pose tracks from a valid DLC-style DataFrame returns a proper Dataset. """ @@ -172,7 +170,7 @@ def test_load_from_dlc_style_df( **expected_values_poses, "source_software": source_software, } - movement_dataset_asserts.valid_dataset(ds, expected_values) + helpers.assert_valid_dataset(ds, expected_values) def test_load_from_dlc_file_csv_or_h5_file_returns_same(): @@ -220,7 +218,7 @@ def test_fps_and_time_coords(fps, expected_fps, expected_time_unit): "LP_mouse-twoview_AIND.predictions.csv", ], ) -def test_load_from_lp_file(file_name, movement_dataset_asserts): +def test_load_from_lp_file(file_name, helpers): """Test that loading pose tracks from valid LightningPose (LP) files returns a proper Dataset. """ @@ -231,7 +229,7 @@ def test_load_from_lp_file(file_name, movement_dataset_asserts): "source_software": "LightningPose", "file_path": file_path, } - movement_dataset_asserts.valid_dataset(ds, expected_values) + helpers.assert_valid_dataset(ds, expected_values) def test_load_from_lp_or_dlc_file_returns_same(): @@ -281,9 +279,7 @@ def test_from_file_delegates_correctly(source_software, fps): @pytest.mark.parametrize("source_software", [None, "SLEAP"]) -def test_from_numpy_valid( - valid_poses_arrays, source_software, movement_dataset_asserts -): +def test_from_numpy_valid(valid_poses_arrays, source_software, helpers): """Test that loading pose tracks from a multi-animal numpy array with valid parameters returns a proper Dataset. """ @@ -300,7 +296,7 @@ def test_from_numpy_valid( **expected_values_poses, "source_software": source_software, } - movement_dataset_asserts.valid_dataset(ds, expected_values) + helpers.assert_valid_dataset(ds, expected_values) def test_from_multiview_files(): From 0ecb40baeaa9cb8008396f54df60bc32d9df2960 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Thu, 30 Jan 2025 11:37:25 +0000 Subject: [PATCH 28/37] Apply suggestions from code review Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- tests/fixtures/data.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/data.py b/tests/fixtures/data.py index 84209fef..0a329e2e 100644 --- a/tests/fixtures/data.py +++ b/tests/fixtures/data.py @@ -10,7 +10,7 @@ # -------------------- Valid DLC-style DataFrame -------------------- @pytest.fixture -def dlc_style_df(): +def valid_dlc_style_df(): """Return a valid DLC-style DataFrame.""" return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) @@ -152,11 +152,11 @@ def valid_poses_arrays(): """Return a dictionary of valid arrays for a ValidPosesDataset representing a uniform linear motion. - Depending on the ``array_type`` requested (``multi_individual_array``, + This fixture is a factory of fixtures. Depending on the ``array_type`` requested (``multi_individual_array``, ``single_keypoint_array``, or ``single_individual_array``), - the arrays can represent up to 2 individuals with up to 3 keypoints, + the returned array can represent up to 2 individuals with up to 3 keypoints, moving at constant velocity for 10 frames in 2D space. - Default is a ``multi_individual_array``. + Default is a ``multi_individual_array`` (2 individuals, 3 keypoints each). At each frame the individuals cover a distance of sqrt(2) in x-y space. Specifically: - Individual 0 moves along the x=y line from the origin. @@ -244,7 +244,7 @@ def valid_poses_dataset(valid_poses_arrays, request): the dataset can represent up to 2 individuals ("id_0" and "id_1") with up to 3 keypoints ("centroid", "left", "right") moving in uniform linear motion for 10 frames in 2D space. - Default is a ``multi_individual_array``. + Default is a ``multi_individual_array`` (2 individuals, 3 keypoints each). See the ``valid_poses_arrays`` fixture for details. """ dim_names = ValidPosesDataset.DIM_NAMES From d96146db4620439906458a968518d3419212c92b Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 30 Jan 2025 12:10:49 +0000 Subject: [PATCH 29/37] Fix up `valid_dlc_poses_df` rename and E501 --- tests/fixtures/data.py | 20 ++++++++++---------- tests/test_integration/test_io.py | 6 +++--- tests/test_unit/test_load_poses.py | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/fixtures/data.py b/tests/fixtures/data.py index 0a329e2e..15e5c9a0 100644 --- a/tests/fixtures/data.py +++ b/tests/fixtures/data.py @@ -8,13 +8,6 @@ from movement.validators.datasets import ValidBboxesDataset, ValidPosesDataset -# -------------------- Valid DLC-style DataFrame -------------------- -@pytest.fixture -def valid_dlc_style_df(): - """Return a valid DLC-style DataFrame.""" - return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) - - # -------------------- Valid bboxes datasets and arrays -------------------- @pytest.fixture def valid_bboxes_arrays_all_zeros(): @@ -152,10 +145,11 @@ def valid_poses_arrays(): """Return a dictionary of valid arrays for a ValidPosesDataset representing a uniform linear motion. - This fixture is a factory of fixtures. Depending on the ``array_type`` requested (``multi_individual_array``, + This fixture is a factory of fixtures. + Depending on the ``array_type`` requested (``multi_individual_array``, ``single_keypoint_array``, or ``single_individual_array``), - the returned array can represent up to 2 individuals with up to 3 keypoints, - moving at constant velocity for 10 frames in 2D space. + the returned array can represent up to 2 individuals with + up to 3 keypoints, moving at constant velocity for 10 frames in 2D space. Default is a ``multi_individual_array`` (2 individuals, 3 keypoints each). At each frame the individuals cover a distance of sqrt(2) in x-y space. Specifically: @@ -304,6 +298,12 @@ def valid_poses_dataset_with_nan(valid_poses_dataset): return valid_poses_dataset +@pytest.fixture +def valid_dlc_poses_df(): + """Return a valid DLC-style poses DataFrame.""" + return pd.read_hdf(pytest.DATA_PATHS.get("DLC_single-wasp.predictions.h5")) + + # -------------------- Invalid bboxes datasets -------------------- @pytest.fixture def missing_var_bboxes_dataset(valid_bboxes_dataset): diff --git a/tests/test_integration/test_io.py b/tests/test_integration/test_io.py index 50f03933..2e1bf36a 100644 --- a/tests/test_integration/test_io.py +++ b/tests/test_integration/test_io.py @@ -15,13 +15,13 @@ 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_style_df(self, dlc_style_df): + def test_load_and_save_to_dlc_style_df(self, valid_dlc_poses_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_style_df(dlc_style_df) + ds = load_poses.from_dlc_style_df(valid_dlc_poses_df) df = save_poses.to_dlc_style_df(ds, split_individuals=False) - np.testing.assert_allclose(df.values, dlc_style_df.values) + np.testing.assert_allclose(df.values, valid_dlc_poses_df.values) def test_save_and_load_dlc_file( self, dlc_output_file, valid_poses_dataset diff --git a/tests/test_unit/test_load_poses.py b/tests/test_unit/test_load_poses.py index 1df5e9d3..90126f15 100644 --- a/tests/test_unit/test_load_poses.py +++ b/tests/test_unit/test_load_poses.py @@ -159,12 +159,12 @@ def test_load_from_dlc_file(file_name, helpers): @pytest.mark.parametrize( "source_software", ["DeepLabCut", "LightningPose", None] ) -def test_load_from_dlc_style_df(dlc_style_df, source_software, helpers): +def test_load_from_dlc_style_df(valid_dlc_poses_df, source_software, helpers): """Test that loading pose tracks from a valid DLC-style DataFrame returns a proper Dataset. """ ds = load_poses.from_dlc_style_df( - dlc_style_df, source_software=source_software + valid_dlc_poses_df, source_software=source_software ) expected_values = { **expected_values_poses, From 174b644f5fe02a6b3f60152b4c1ed103148b21a4 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 30 Jan 2025 12:15:29 +0000 Subject: [PATCH 30/37] Rename dataset fixtures module --- tests/fixtures/{data.py => datasets.py} | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) rename tests/fixtures/{data.py => datasets.py} (99%) diff --git a/tests/fixtures/data.py b/tests/fixtures/datasets.py similarity index 99% rename from tests/fixtures/data.py rename to tests/fixtures/datasets.py index 15e5c9a0..927a750e 100644 --- a/tests/fixtures/data.py +++ b/tests/fixtures/datasets.py @@ -79,9 +79,7 @@ def valid_bboxes_arrays(): @pytest.fixture -def valid_bboxes_dataset( - valid_bboxes_arrays, -): +def valid_bboxes_dataset(valid_bboxes_arrays): """Return a valid bboxes dataset for two individuals moving in uniform linear motion, with 5 frames with low confidence values and time in frames. """ From 6e045252d9ef038ac8d04b5039e6349d61c7f378 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 30 Jan 2025 12:16:15 +0000 Subject: [PATCH 31/37] Fix newlines --- tests/test_unit/test_filtering.py | 7 +------ tests/test_unit/test_load_bboxes.py | 6 +----- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index 82be7990..b9607267 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -38,12 +38,7 @@ class TestFilteringValidDataset: ], ) def test_filter_with_nans_on_position( - self, - filter_func, - filter_kwargs, - valid_dataset, - helpers, - request, + self, filter_func, filter_kwargs, valid_dataset, helpers, request ): """Test NaN behaviour of the median and SG filters. Both filters should set all values to NaN if one element of the diff --git a/tests/test_unit/test_load_bboxes.py b/tests/test_unit/test_load_bboxes.py index 0ba121e1..5922d498 100644 --- a/tests/test_unit/test_load_bboxes.py +++ b/tests/test_unit/test_load_bboxes.py @@ -261,11 +261,7 @@ def test_from_file( @pytest.mark.parametrize("use_frame_numbers_from_file", [True, False]) @pytest.mark.parametrize("frame_regexp", [None, r"(00\d*)\.\w+$"]) def test_from_via_tracks_file( - via_file_path, - fps, - use_frame_numbers_from_file, - frame_regexp, - helpers, + via_file_path, fps, use_frame_numbers_from_file, frame_regexp, helpers ): """Test that loading tracked bounding box data from a valid VIA tracks .csv file returns a proper Dataset. From 658da8d213301f4bfc2c16b73f2c51e7ef1191bc Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 30 Jan 2025 17:21:33 +0000 Subject: [PATCH 32/37] Update fixture descriptions --- tests/conftest.py | 2 +- tests/fixtures/datasets.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index de9fc7ff..29e823ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures and configurations applied to the entire test suite.""" +"""Fixtures and configurations shared by the entire test suite.""" import logging from glob import glob diff --git a/tests/fixtures/datasets.py b/tests/fixtures/datasets.py index 927a750e..3ee48a14 100644 --- a/tests/fixtures/datasets.py +++ b/tests/fixtures/datasets.py @@ -305,25 +305,29 @@ def valid_dlc_poses_df(): # -------------------- Invalid bboxes datasets -------------------- @pytest.fixture def missing_var_bboxes_dataset(valid_bboxes_dataset): - """Return a bboxes dataset missing position variable.""" + """Return a bboxes dataset missing the required 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 a bboxes dataset missing the required position + and shape variables. + """ return valid_bboxes_dataset.drop_vars(["position", "shape"]) @pytest.fixture def missing_dim_bboxes_dataset(valid_bboxes_dataset): - """Return a bboxes dataset missing the time dimension.""" + """Return a bboxes dataset missing the required 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 a bboxes dataset missing the required time + and space dimensions. + """ return valid_bboxes_dataset.rename({"time": "tame", "space": "spice"}) @@ -342,11 +346,11 @@ def empty_dataset(): @pytest.fixture def missing_var_poses_dataset(valid_poses_dataset): - """Return a poses dataset missing position variable.""" + """Return a poses dataset missing the required position variable.""" return valid_poses_dataset.drop_vars("position") @pytest.fixture def missing_dim_poses_dataset(valid_poses_dataset): - """Return a poses dataset missing the time dimension.""" + """Return a poses dataset missing the required time dimension.""" return valid_poses_dataset.rename({"time": "tame"}) From 13803673015ddc0f6fa20b204335ffc21da1b314 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Thu, 30 Jan 2025 17:27:46 +0000 Subject: [PATCH 33/37] Apply suggestions from code review Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- tests/test_unit/test_filtering.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/test_unit/test_filtering.py b/tests/test_unit/test_filtering.py index b9607267..d97dcc8e 100644 --- a/tests/test_unit/test_filtering.py +++ b/tests/test_unit/test_filtering.py @@ -68,25 +68,20 @@ def test_filter_with_nans_on_position( ) @pytest.mark.parametrize( - "override_kwargs", + "override_kwargs, expected_exception", [ - {"mode": "nearest"}, - {"axis": 1}, - {"mode": "nearest", "axis": 1}, + ({"mode": "nearest"}, does_not_raise()), + ({"axis": 1}, pytest.raises(ValueError)), + ({"mode": "nearest", "axis": 1}, pytest.raises(ValueError)), ], ) def test_savgol_filter_kwargs_override( - self, valid_dataset, override_kwargs, request + self, valid_dataset, override_kwargs, expected_exception, request ): """Test that overriding keyword arguments in the Savitzky-Golay filter works, except for the ``axis`` argument, which should raise a ValueError. """ - expected_exception = ( - pytest.raises(ValueError) - if "axis" in override_kwargs - else does_not_raise() - ) with expected_exception: savgol_filter( request.getfixturevalue(valid_dataset).position, @@ -158,7 +153,7 @@ def test_interpolate_over_time_on_position( @pytest.mark.parametrize( "window", - [3, 5, 6, 10], # data is nframes = 10 + [3, 5, 6, 10], # input data has 10 frames ) @pytest.mark.parametrize("filter_func", [median_filter, savgol_filter]) def test_filter_with_nans_on_position_varying_window( From 86f8cc303f8ff4cdcfcfe2694853cd6f5af876b0 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Thu, 30 Jan 2025 17:45:29 +0000 Subject: [PATCH 34/37] Update anipose fixture descriptions Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> --- tests/fixtures/files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/files.py b/tests/fixtures/files.py index 8c04e80a..eca2b6fd 100644 --- a/tests/fixtures/files.py +++ b/tests/fixtures/files.py @@ -165,7 +165,7 @@ def new_csv_file(tmp_path): # ---------------- Anipose file fixtures ---------------------------- @pytest.fixture def missing_keypoint_columns_anipose_csv_file(tmp_path): - """Return the file path for a fake single-individual .csv file.""" + """Return the file path for a single-individual anipose .csv file. with the z-coordinate of keypoint kp0 missing""" file_path = tmp_path / "missing_keypoint_columns.csv" columns = [ "fnum", @@ -193,7 +193,7 @@ def missing_keypoint_columns_anipose_csv_file(tmp_path): @pytest.fixture def spurious_column_anipose_csv_file(tmp_path): - """Return the file path for a fake single-individual .csv file.""" + """Return the file path for a single-individual anipose .csv file with an additional unexpected column.""" file_path = tmp_path / "spurious_column.csv" columns = [ "fnum", From 57874bf1deba2c284cafe3cb98aba926af0475cd Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 30 Jan 2025 18:01:51 +0000 Subject: [PATCH 35/37] Rename `wrong_extension`-related fixtures --- tests/fixtures/files.py | 21 ++++++++++++------- tests/test_unit/test_save_poses.py | 2 +- .../test_validators/test_files_validators.py | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/fixtures/files.py b/tests/fixtures/files.py index eca2b6fd..0ad892ea 100644 --- a/tests/fixtures/files.py +++ b/tests/fixtures/files.py @@ -52,10 +52,10 @@ def mock_access(path, mode): @pytest.fixture -def wrong_ext_file(tmp_path): +def wrong_extension_file(tmp_path): """Return a dictionary containing the file path, expected permission, and expected suffix for a file - with an incorrect extension. + with unsupported extension. """ file_path = tmp_path / "wrong_extension.txt" with open(file_path, "w") as f: @@ -82,7 +82,8 @@ def nonexistent_file(tmp_path): @pytest.fixture def no_dataframe_h5_file(tmp_path): """Return a dictionary containing the file path and - expected datasets for a .h5 file with no dataframe. + expected datasets for a .h5 file that lacks the + dataset "dataframe". """ file_path = tmp_path / "no_dataframe.h5" with h5py.File(file_path, "w") as f: @@ -132,9 +133,9 @@ def invalid_multi_individual_csv_file(tmp_path): @pytest.fixture -def wrong_ext_new_file(tmp_path): - """Return the file path for a new file with the wrong extension.""" - return tmp_path / "wrong_ext_new_file.txt" +def wrong_extension_new_file(tmp_path): + """Return the file path for a new file with unsupported extension.""" + return tmp_path / "wrong_extension_new_file.txt" @pytest.fixture @@ -165,7 +166,9 @@ def new_csv_file(tmp_path): # ---------------- Anipose file fixtures ---------------------------- @pytest.fixture def missing_keypoint_columns_anipose_csv_file(tmp_path): - """Return the file path for a single-individual anipose .csv file. with the z-coordinate of keypoint kp0 missing""" + """Return the file path for a single-individual anipose .csv file + missing the z-coordinate of keypoint kp0 "kp0_z". + """ file_path = tmp_path / "missing_keypoint_columns.csv" columns = [ "fnum", @@ -193,7 +196,9 @@ def missing_keypoint_columns_anipose_csv_file(tmp_path): @pytest.fixture def spurious_column_anipose_csv_file(tmp_path): - """Return the file path for a single-individual anipose .csv file with an additional unexpected column.""" + """Return the file path for a single-individual anipose .csv file + with an unexpected column. + """ file_path = tmp_path / "spurious_column.csv" columns = [ "fnum", diff --git a/tests/test_unit/test_save_poses.py b/tests/test_unit/test_save_poses.py index ca313a32..a495e790 100644 --- a/tests/test_unit/test_save_poses.py +++ b/tests/test_unit/test_save_poses.py @@ -31,7 +31,7 @@ class TestSavePoses: # invalid file path }, { - "file_fixture": "wrong_ext_new_file", + "file_fixture": "wrong_extension_new_file", "to_dlc_file_expected_exception": pytest.raises(ValueError), "to_sleap_file_expected_exception": pytest.raises(ValueError), "to_lp_file_expected_exception": pytest.raises(ValueError), diff --git a/tests/test_unit/test_validators/test_files_validators.py b/tests/test_unit/test_validators/test_files_validators.py index 86c868df..4b5288cf 100644 --- a/tests/test_unit/test_validators/test_files_validators.py +++ b/tests/test_unit/test_validators/test_files_validators.py @@ -15,7 +15,7 @@ ("unreadable_file", pytest.raises(PermissionError)), ("unwriteable_file", pytest.raises(PermissionError)), ("fake_h5_file", pytest.raises(FileExistsError)), - ("wrong_ext_file", pytest.raises(ValueError)), + ("wrong_extension_file", pytest.raises(ValueError)), ("nonexistent_file", pytest.raises(FileNotFoundError)), ("directory", pytest.raises(IsADirectoryError)), ], From 37cecae0d76c6cce10aebb43f7f88fcd4893e52a Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 30 Jan 2025 18:10:52 +0000 Subject: [PATCH 36/37] Refer to fixtures in test descriptions --- tests/test_unit/test_kinematics.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 4d5c72be..9bc7cee1 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -35,7 +35,10 @@ class TestComputeKinematics: "valid_dataset", ["valid_poses_dataset", "valid_bboxes_dataset"] ) def test_kinematics(self, valid_dataset, kinematic_variable, request): - """Test computed kinematics for a uniform linear motion case.""" + """Test computed kinematics for a uniform linear motion case. + See the ``valid_poses_dataset`` and ``valid_bboxes_dataset`` fixtures + for details. + """ # Compute kinematic array from input dataset position = request.getfixturevalue(valid_dataset).position kinematic_array = getattr(kinematics, f"compute_{kinematic_variable}")( @@ -96,7 +99,10 @@ def test_kinematics_with_dataset_with_nans( helpers, request, ): - """Test kinematics computation for a dataset with nans.""" + """Test kinematics computation for a dataset with nans. + See the ``valid_poses_dataset_with_nan`` and + ``valid_bboxes_dataset_with_nan`` fixtures for details. + """ # compute kinematic array valid_dataset = request.getfixturevalue(valid_dataset_with_nan) position = valid_dataset.position From 8bd6ef13261589ac4dbbc0e604911df02c83678f Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 30 Jan 2025 18:15:05 +0000 Subject: [PATCH 37/37] Rename `compute_time_derivative` test + parametrise expectations --- tests/test_unit/test_kinematics.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 9bc7cee1..b8a2c9c3 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -140,12 +140,19 @@ def test_kinematics_with_invalid_dataset( getattr(kinematics, f"compute_{kinematic_variable}")(position) -@pytest.mark.parametrize("order", [0, -1, 1.0, "1"]) -def test_approximate_derivative_with_invalid_order(order): +@pytest.mark.parametrize( + "order, expected_exception", + [ + (0, pytest.raises(ValueError)), + (-1, pytest.raises(ValueError)), + (1.0, pytest.raises(TypeError)), + ("1", pytest.raises(TypeError)), + ], +) +def test_time_derivative_with_invalid_order(order, expected_exception): """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): + with expected_exception: kinematics.compute_time_derivative(data, order=order)