diff --git a/audinterface/core/feature.py b/audinterface/core/feature.py index a6cb34f..8d98235 100644 --- a/audinterface/core/feature.py +++ b/audinterface/core/feature.py @@ -545,7 +545,7 @@ def process_index( if cache_root is not None: cache_root = audeer.mkdir(cache_root) - hash = audformat.utils.hash(index) + hash = audformat.utils.hash(index, strict=True) cache_path = os.path.join(cache_root, f"{hash}.pkl") if cache_path and os.path.exists(cache_path): @@ -731,7 +731,7 @@ def _reshape_3d(self, features: np.ndarray | pd.Series): n_frames = features.shape[1] else: raise RuntimeError( - f"Cannot determine feature shape from " f"{features.shape}, ", + f"Cannot determine feature shape from {features.shape}, ", f"when expected shape is " f"({self.num_channels, self.num_features, -1}).", ) @@ -743,17 +743,11 @@ def _reshape_3d(self, features: np.ndarray | pd.Series): # assert channels and features have expected length if n_channels != self.num_channels: raise RuntimeError( - f"Number of channels must be" - f" {self.num_channels}, " - f"not " - f"{n_channels}." + f"Number of channels must be {self.num_channels}, not {n_channels}." ) if n_features != self.num_features: raise RuntimeError( - f"Number of features must be " - f"{self.num_features}, " - f"not " - f"{n_features}." + f"Number of features must be {self.num_features}, not {n_features}." ) # reshape features to (channels, features, frames) @@ -857,9 +851,7 @@ def _values_to_frame( features = features.reshape(new_shape).T if n_frames > 1 and self.win_dur is None: - raise RuntimeError( - f"Got " f"{n_frames} " f"frames, but 'win_dur' is not set." - ) + raise RuntimeError(f"Got {n_frames} frames, but 'win_dur' is not set.") return features diff --git a/audinterface/core/process.py b/audinterface/core/process.py index f8e9789..f42d921 100644 --- a/audinterface/core/process.py +++ b/audinterface/core/process.py @@ -603,7 +603,7 @@ def process_index( if cache_root is not None: cache_root = audeer.mkdir(cache_root) - hash = audformat.utils.hash(index) + hash = audformat.utils.hash(index, strict=True) cache_path = os.path.join(cache_root, f"{hash}.pkl") if cache_path and os.path.exists(cache_path): diff --git a/tests/test_feature.py b/tests/test_feature.py index e529c8f..4db9595 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -30,6 +30,20 @@ def segment(signal, sampling_rate): SEGMENT = audinterface.Segment(process_func=segment) +def index_durations(index: pd.Index, root: str = None) -> pd.Series: + r"""Get series with duration of all index elements.""" + if len(index) == 0: + return pd.Series(index=index) + segmented_index = audformat.utils.to_segmented_index( + index, allow_nat=False, root=root + ) + starts = segmented_index.get_level_values(audformat.define.IndexField.START) + ends = segmented_index.get_level_values(audformat.define.IndexField.END) + durations = (ends - starts).to_series() + durations.index = index + return durations + + def feature_extractor(signal, _): return np.ones((NUM_CHANNELS, NUM_FEATURES)) @@ -531,6 +545,78 @@ def test_process_index(tmpdir, preserve_index): pd.testing.assert_frame_equal(df, df_cached) +@pytest.mark.parametrize( + "index,durations", + [ + (audformat.filewise_index([f"f{i}.wav" for i in range(10)]), [1.0] * 10), + ( + audformat.segmented_index( + files=[f"f{i}.wav" for i in range(5)], + starts=[0, 0, 1, 1, 2], + ends=[1, 1, 2, 2, 3], + ), + [1.0, 2.0, 3.0, 3.0, 3.0], + ), + ], +) +@pytest.mark.parametrize("preserve_index", [False, True]) +def test_process_index_order(tmpdir, index, durations, preserve_index): + # Ensure the returned index is as expected + # when the same index but different order have already been cached + # https://github.com/audeering/audinterface/issues/203 + cache_root = os.path.join(tmpdir, "cache") + feature = audinterface.Feature( + feature_names="mean", + process_func=mean, + ) + # Create signals for the index + sampling_rate = 8000 + signals = [ + np.random.uniform(-1.0, 1.0, (1, int(duration * sampling_rate))) + for duration in durations + ] + root = str(tmpdir.mkdir("wav")) + files = [] + is_segmented_index = audformat.is_segmented_index(index) + for i, signal in enumerate(signals): + if is_segmented_index: + file, _, _ = index[i] + else: + file = index[i] + path = os.path.join(root, file) + af.write(path, signal, sampling_rate) + files.append(file) + segment_durations = index_durations(index, root=root) + + # Run process once with caching + df = feature.process_index(index, root=root, cache_root=cache_root) + + # Run process again but on reverse index + reverse_index = index[::-1] + reverse_durations = segment_durations[::-1] + df_reverse = feature.process_index( + reverse_index, root=root, cache_root=cache_root, preserve_index=preserve_index + ) + # Make sure the resulting reverse features are as expected + if preserve_index or is_segmented_index: + expected_index = reverse_index + else: + expected_index = audformat.segmented_index( + files=files[::-1], + starts=[0] * len(reverse_index), + ends=reverse_durations.values, + ) + expected_df = pd.DataFrame( + index=expected_index, data={col: df[col].values[::-1] for col in df.columns} + ) + pd.testing.assert_frame_equal(df_reverse, expected_df) + + # Make sure there is one cache file for the original index + # and one file for the reversed index + files_in_cache = audeer.list_file_names(cache_root) + assert len(files_in_cache) == 2 + + @pytest.mark.parametrize( "process_func, applies_sliding_window, num_feat, signal, start, end, " "is_mono, expected", diff --git a/tests/test_process.py b/tests/test_process.py index cd0f764..32ddf7d 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -49,6 +49,20 @@ def signal_modification(signal, sampling_rate, subtract=False): return signal +def index_durations(index: pd.Index, root: str = None) -> pd.Series: + r"""Get series with duration of all index elements.""" + if len(index) == 0: + return pd.Series(index=index) + segmented_index = audformat.utils.to_segmented_index( + index, allow_nat=False, root=root + ) + starts = segmented_index.get_level_values(audformat.define.IndexField.START) + ends = segmented_index.get_level_values(audformat.define.IndexField.END) + durations = (ends - starts).to_series() + durations.index = index + return durations + + @pytest.mark.parametrize( "process_func, segment, signal, sampling_rate, start, end, keep_nat, " "channels, mixdown, expected_output", @@ -390,7 +404,7 @@ def test_process_file( @pytest.mark.parametrize( - "process_func, num_files, signal, sampling_rate, starts, ends, " "expected_output", + "process_func, num_files, signal, sampling_rate, starts, ends, expected_output", [ ( signal_duration, @@ -710,6 +724,88 @@ def test_process_index(tmpdir, num_workers, multiprocessing, preserve_index): pd.testing.assert_series_equal(y, y_cached) +@pytest.mark.parametrize( + "index,durations", + [ + (audformat.filewise_index([f"f{i}.wav" for i in range(10)]), [1.0] * 10), + ( + audformat.segmented_index( + files=[f"f{i}.wav" for i in range(5)], + starts=[0, 0, 1, 1, 2], + ends=[1, 1, 2, 2, 3], + ), + [1.0, 2.0, 3.0, 3.0, 3.0], + ), + ], +) +@pytest.mark.parametrize("preserve_index", [False, True]) +def test_process_index_order(tmpdir, index, durations, preserve_index): + # Ensure the returned index is as expected + # when the same index but different order have already been cached + # https://github.com/audeering/audinterface/issues/203 + cache_root = os.path.join(tmpdir, "cache") + process = audinterface.Process( + process_func=None, + sampling_rate=None, + resample=False, + verbose=False, + ) + # Create signals for the index + sampling_rate = 8000 + signals = [ + np.random.uniform(-1.0, 1.0, (1, int(duration * sampling_rate))) + for duration in durations + ] + root = str(tmpdir.mkdir("wav")) + files = [] + is_segmented_index = audformat.is_segmented_index(index) + for i, signal in enumerate(signals): + if is_segmented_index: + file, _, _ = index[i] + else: + file = index[i] + path = os.path.join(root, file) + af.write(path, signal, sampling_rate) + files.append(file) + segment_durations = index_durations(index, root=root) + + # Run process once with caching + process.process_index(index, root=root, cache_root=cache_root) + + # Run process again but on reverse index + reverse_index = index[::-1] + reverse_durations = segment_durations[::-1] + y_reverse = process.process_index( + reverse_index, root=root, cache_root=cache_root, preserve_index=preserve_index + ) + # Make sure the index is as expected + if preserve_index or is_segmented_index: + expected_index = reverse_index + else: + expected_index = audformat.segmented_index( + files=files[::-1], + starts=[0] * len(reverse_index), + ends=reverse_durations.values, + ) + pd.testing.assert_index_equal(y_reverse.index, expected_index) + # Make sure the files contain the expected signals + for idx, value in y_reverse.items(): + if not preserve_index or is_segmented_index: + path, start, end = idx + else: + path = idx + start = end = None + signal, sampling_rate = audinterface.utils.read_audio( + path, start=start, end=end, root=root + ) + np.testing.assert_equal(signal, value) + + # Make sure there is one cache file for the original index + # and one file for the reversed index + files_in_cache = audeer.list_file_names(cache_root) + assert len(files_in_cache) == 2 + + def test_process_index_filewise_end_times(tmpdir): # Ensure the resulting segmented index # returned by audinterface.process_index() @@ -1138,7 +1234,7 @@ def test_process_signal_from_index( @pytest.mark.parametrize( - "process_func, signal, sampling_rate, min_signal_dur, " "max_signal_dur, expected", + "process_func, signal, sampling_rate, min_signal_dur, max_signal_dur, expected", [ ( None,