From 26bf957c001a10e5f4187ce539cb35902679834a Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Fri, 23 Jan 2026 14:59:26 +0100 Subject: [PATCH 01/16] added two window calculations --- src/squidpy/tl/_sliding_window.py | 237 ++++++++++++++++++++++++++---- 1 file changed, 207 insertions(+), 30 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index a3a5840e0..a01cdd9d1 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -1,6 +1,7 @@ from __future__ import annotations from itertools import product +from typing import Literal import numpy as np import pandas as pd @@ -23,7 +24,8 @@ def sliding_window( coord_columns: tuple[str, str] = ("globalX", "globalY"), sliding_window_key: str = "sliding_window_assignment", spatial_key: str = "spatial", - drop_partial_windows: bool = False, + partial_windows: Literal["adaptive", "drop", "split"] | None = None, + max_nr_cells: int | None = None, copy: bool = False, ) -> pd.DataFrame | None: """ @@ -42,8 +44,14 @@ def sliding_window( overlap: int Overlap size between consecutive windows. (0 = no overlap) %(spatial_key)s - drop_partial_windows: bool - If True, drop windows that are smaller than the window size at the borders. + partial_windows: Literal["adaptive", "drop", "split"] | None + If None, possibly small windows at the edges are kept. + If 'adaptive', all windows might be shrunken a bit to avoid small windows at the edges. + If 'drop', possibly small windows at the edges are removed. + If 'split', windows are split into subwindows until not exceeding `max_nr_cells` + max_nr_cells: int | None + The maximum number of cells allowed after merging two windows. + Required if `partial_windows = split` copy: bool If True, return the result, otherwise save it to the adata object. @@ -54,6 +62,17 @@ def sliding_window( """ if overlap < 0: raise ValueError("Overlap must be non-negative.") + if overlap >= window_size: + raise ValueError("Overlap must be less than the window size.") + if overlap >= window_size // 2 and window_size == "adaptive": + raise ValueError("Overlap must be less than window_size // 2 when using 'adaptive'") + + if partial_windows == "split" and max_nr_cells is None: + raise ValueError("max_nr_cells must be set when partial_windows is 'split'.") + if partial_windows != "split" and max_nr_cells is not None: + logg.warning("Ignoring max_nr_cells as partial_windows is not 'split'.") + if partial_windows == "split" and overlap != 0: + logg.warning("Ignoring overlap as it cannot be used with 'split'.") if isinstance(adata, SpatialData): adata = adata.table @@ -119,7 +138,11 @@ def sliding_window( max_y=max_y, window_size=window_size, overlap=overlap, - drop_partial_windows=drop_partial_windows, + partial_windows=partial_windows, + lib_coords=lib_coords, + x_col=x_col, + y_col=y_col, + max_nr_cells=max_nr_cells, ) lib_key = f"{lib}_" if lib is not None else "" @@ -131,22 +154,18 @@ def sliding_window( y_start = window["y_start"] y_end = window["y_end"] - mask = ( - (lib_coords[x_col] >= x_start) - & (lib_coords[x_col] <= x_end) - & (lib_coords[y_col] >= y_start) - & (lib_coords[y_col] <= y_end) + mask = _get_window_mask( + x_col=x_col, + y_col=y_col, + lib_coords=lib_coords, + x_start=x_start, + x_end=x_end, + y_start=y_start, + y_end=y_end, ) obs_indices = lib_coords.index[mask] if overlap == 0: - mask = ( - (lib_coords[x_col] >= x_start) - & (lib_coords[x_col] <= x_end) - & (lib_coords[y_col] >= y_start) - & (lib_coords[y_col] <= y_end) - ) - obs_indices = lib_coords.index[mask] sliding_window_df.loc[obs_indices, sliding_window_key] = f"{lib_key}window_{idx}" else: @@ -174,6 +193,51 @@ def sliding_window( _save_data(adata, attr="obs", key=col_name, data=col_data) +def _get_window_mask( + x_col: str, + y_col: str, + lib_coords: pd.DataFrame, + x_start: int, + x_end: int, + y_start: int, + y_end: int, +) -> pd.Series: + """ + Compute a boolean mask selecting coordinates that fall within a given window. + + Parameters + ---------- + x_col: str + Column name in `lib_coords` containing x-coordinates. + y_col: str + Column name in `lib_coords` containing y-coordinates. + lib_coords: pd.DataFrame + DataFrame containing spatial coordinates (e.g. `adata.obs` subset for one library). + Coordinate values are expected to be integers. + x_start: int + Lower bound of the window in x-direction (inclusive). + x_end: int + Upper bound of the window in x-direction (inclusive). + y_start: int + Lower bound of the window in y-direction (inclusive). + y_end: int + Upper bound of the window in y-direction (inclusive). + + Returns + ------- + pd.Series + Boolean mask indicating which rows in `lib_coords` fall inside the specified window. + """ + mask = ( + (lib_coords[x_col] >= x_start) + & (lib_coords[x_col] <= x_end) + & (lib_coords[y_col] >= y_start) + & (lib_coords[y_col] <= y_end) + ) + + return mask + + def _calculate_window_corners( min_x: int, max_x: int, @@ -181,7 +245,11 @@ def _calculate_window_corners( max_y: int, window_size: int, overlap: int = 0, - drop_partial_windows: bool = False, + partial_windows: Literal["adaptive", "drop", "split"] | None = None, + lib_coords: pd.DataFrame | None = None, + x_col: str | None = None, + y_col: str | None = None, + max_nr_cells: int | None = None, ) -> pd.DataFrame: """ Calculate the corner points of all windows covering the area from min_x to max_x and min_y to max_y, @@ -199,23 +267,38 @@ def _calculate_window_corners( maximum Y coordinate window_size: float size of each window + lib_coords: pd.DataFrame | None + coordinates of all samples for one library + x_col: str | None + the column in `lib_coords` corresponding to the x coordinates + y_col: str | None + the column in `lib_coords` corresponding to the y coordinates overlap: float overlap between consecutive windows (must be less than window_size) - drop_partial_windows: bool - if True, drop border windows that are smaller than window_size; - if False, create smaller windows at the borders to cover the remaining space. + partial_windows: Literal["adaptive", "drop", "split"] | None + If None, possibly small windows at the edges are kept. + If 'adaptive', all windows might be shrunken a bit to avoid small windows at the edges. + If 'drop', possibly small windows at the edges are removed. + If 'split', windows are split into subwindows until not exceeding `max_nr_cells` Returns ------- windows: pandas DataFrame with columns ['x_start', 'x_end', 'y_start', 'y_end'] """ - if overlap < 0: - raise ValueError("Overlap must be non-negative.") - if overlap >= window_size: - raise ValueError("Overlap must be less than the window size.") + # adjust x and y window size if 'adaptive' + if partial_windows == "adaptive": + number_x_windows = np.ceil((max_x - min_x) / window_size) + number_y_windows = np.ceil((max_y - min_y) / window_size) - x_step = window_size - overlap - y_step = window_size - overlap + x_window_size = (max_x - min_x) / number_x_windows + y_window_size = (max_y - min_y) / number_y_windows + else: + x_window_size = window_size + y_window_size = window_size + + # create the step sizes for each window + x_step = x_window_size - overlap + y_step = y_window_size - overlap # Generate starting points x_starts = np.arange(min_x, max_x, x_step) @@ -224,16 +307,110 @@ def _calculate_window_corners( # Create all combinations of x and y starting points starts = list(product(x_starts, y_starts)) windows = pd.DataFrame(starts, columns=["x_start", "y_start"]) - windows["x_end"] = windows["x_start"] + window_size - windows["y_end"] = windows["y_start"] + window_size + windows["x_end"] = windows["x_start"] + x_window_size + windows["y_end"] = windows["y_start"] + y_window_size # Adjust windows that extend beyond the bounds - if not drop_partial_windows: + if partial_windows is None: windows["x_end"] = windows["x_end"].clip(upper=max_x) windows["y_end"] = windows["y_end"].clip(upper=max_y) - else: + elif partial_windows == "adaptive": + pass + elif partial_windows == "drop": valid_windows = (windows["x_end"] <= max_x) & (windows["y_end"] <= max_y) windows = windows[valid_windows] + elif partial_windows == "split": + # split the slide recursively into windows with at most max_nr_cells + coord_x_sorted = lib_coords.sort_values(by=[x_col]) + coord_y_sorted = lib_coords.sort_values(by=[y_col]) + + windows = _split_window( + max_nr_cells, x_col, y_col, coord_x_sorted, coord_y_sorted, min_x, max_x, min_y, max_y + ).sort_values(["x_start", "x_end", "y_start", "y_end"]) + else: + raise ValueError(f"{partial_windows} is not a valid partial_windows argument.") windows = windows.reset_index(drop=True) return windows[["x_start", "x_end", "y_start", "y_end"]] + + +def _split_window( + max_cells: int, + x_col: str, + y_col: str, + coord_x_sorted: pd.DataFrame, + coord_y_sorted: pd.DataFrame, + x_start: int, + x_end: int, + y_start: int, + y_end: int, +) -> pd.DataFrame: + """ + Recursively split a rectangular window into subwindows such that each subwindow + contains at most `max_cells` cells and at least `max_cells` // 2 cells. + + Parameters + ---------- + max_cells : int + Maximum number of cells allowed per window. + x_col : str + Name of the column in `coord_x_sorted` and `coord_y_sorted` corresponding to + x coordinates. + y_col : str + Name of the column in `coord_x_sorted` and `coord_y_sorted` corresponding to + y coordinates. + coord_x_sorted : pandas.DataFrame + DataFrame containing cell coordinates, sorted by `x_col`. + coord_y_sorted : pandas.DataFrame + DataFrame containing cell coordinates, sorted by `y_col`. + x_start : int + Left (minimum) x coordinate of the current window. + x_end : int + Right (maximum) x coordinate of the current window. + y_start : int + Bottom (minimum) y coordinate of the current window. + y_end : int + Top (maximum) y coordinate of the current window. + + Returns + ------- + windows: pandas DataFrame with columns ['x_start', 'x_end', 'y_start', 'y_end'] + """ + # return current window if it contains less cells than max_cells + n_cells = _get_window_mask(x_col, y_col, coord_x_sorted, x_start, x_end, y_start, y_end).sum() + + if n_cells <= max_cells: + return pd.DataFrame({"x_start": [x_start], "x_end": [x_end], "y_start": [y_start], "y_end": [y_end]}) + + # define start and stop indices of subsetted windows + sub_coord_x_sorted = coord_x_sorted[ + _get_window_mask(x_col, y_col, coord_x_sorted, x_start, x_end, y_start, y_end) + ].reset_index(drop=True) + + sub_coord_y_sorted = coord_y_sorted[ + _get_window_mask(x_col, y_col, coord_y_sorted, x_start, x_end, y_start, y_end) + ].reset_index(drop=True) + + middle_pos = len(sub_coord_x_sorted) // 2 + + if (x_end - x_start) > (y_end - y_start): + # vertical split + x_middle = sub_coord_x_sorted[x_col].iloc[middle_pos] + + indices = ((x_start, x_middle, y_start, y_end), (x_middle, x_end, y_start, y_end)) + else: + # horizontal split + y_middle = sub_coord_y_sorted.loc[middle_pos, y_col] + + indices = ((x_start, x_end, y_start, y_middle), (x_start, x_end, y_middle, y_end)) + + # recursively continue with either left&right or upper&lower windows pairs + windows = [] + for x_start, x_end, y_start, y_end in indices: + windows.append( + _split_window( + max_cells, x_col, y_col, sub_coord_x_sorted, sub_coord_y_sorted, x_start, x_end, y_start, y_end + ) + ) + + return pd.concat(windows) From f86074e8f3381395f5498bae3aaf747dbef784b3 Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Thu, 5 Feb 2026 14:25:28 +0100 Subject: [PATCH 02/16] bugfix partial_windows --- src/squidpy/tl/_sliding_window.py | 46 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index a01cdd9d1..bd7333a0c 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -46,9 +46,9 @@ def sliding_window( %(spatial_key)s partial_windows: Literal["adaptive", "drop", "split"] | None If None, possibly small windows at the edges are kept. - If 'adaptive', all windows might be shrunken a bit to avoid small windows at the edges. - If 'drop', possibly small windows at the edges are removed. - If 'split', windows are split into subwindows until not exceeding `max_nr_cells` + If `adaptive`, all windows might be shrunken a bit to avoid small windows at the edges. + If `drop`, possibly small windows at the edges are removed. + If `split`, windows are split into subwindows until not exceeding `max_nr_cells` max_nr_cells: int | None The maximum number of cells allowed after merging two windows. Required if `partial_windows = split` @@ -60,19 +60,18 @@ def sliding_window( If ``copy = True``, returns the sliding window annotation(s) as pandas dataframe Otherwise, stores the sliding window annotation(s) in .obs. """ - if overlap < 0: - raise ValueError("Overlap must be non-negative.") - if overlap >= window_size: - raise ValueError("Overlap must be less than the window size.") - if overlap >= window_size // 2 and window_size == "adaptive": - raise ValueError("Overlap must be less than window_size // 2 when using 'adaptive'") - - if partial_windows == "split" and max_nr_cells is None: - raise ValueError("max_nr_cells must be set when partial_windows is 'split'.") - if partial_windows != "split" and max_nr_cells is not None: - logg.warning("Ignoring max_nr_cells as partial_windows is not 'split'.") - if partial_windows == "split" and overlap != 0: - logg.warning("Ignoring overlap as it cannot be used with 'split'.") + if partial_windows == "split": + if max_nr_cells is None: + raise ValueError("`max_nr_cells` must be set when `partial_windows == split`.") + if window_size is not None: + logg.warning(f"Ingoring `window_size` when using `{partial_windows}`.") + if overlap != 0: + logg.warning("Ignoring `overlap` as it cannot be used with `split`.") + else: + if max_nr_cells is not None: + logg.warning("Ignoring `max_nr_cells` as `partial_windows != split`.") + if overlap < 0: + raise ValueError("Overlap must be non-negative.") if isinstance(adata, SpatialData): adata = adata.table @@ -105,8 +104,13 @@ def sliding_window( # mostly arbitrary choice, except that full integers usually generate windows with 1-2 cells at the borders window_size = max(int(np.floor(coord_range // 3.95)), 1) - if window_size <= 0: - raise ValueError("Window size must be larger than 0.") + if partial_windows != "split": + if window_size <= 0: + raise ValueError("Window size must be larger than 0.") + if overlap >= window_size: + raise ValueError("Overlap must be less than the window size.") + if overlap >= window_size // 2 and window_size == "adaptive": + raise ValueError("Overlap must be less than `window_size` // 2 when using `adaptive`.") if library_key is not None and library_key not in adata.obs: raise ValueError(f"Library key '{library_key}' not found in adata.obs") @@ -265,7 +269,7 @@ def _calculate_window_corners( minimum Y coordinate max_y: float maximum Y coordinate - window_size: float + window_size: int size of each window lib_coords: pd.DataFrame | None coordinates of all samples for one library @@ -290,8 +294,8 @@ def _calculate_window_corners( number_x_windows = np.ceil((max_x - min_x) / window_size) number_y_windows = np.ceil((max_y - min_y) / window_size) - x_window_size = (max_x - min_x) / number_x_windows - y_window_size = (max_y - min_y) / number_y_windows + x_window_size = np.ceil((max_x - min_x) / number_x_windows) + y_window_size = np.ceil((max_y - min_y) / number_y_windows) else: x_window_size = window_size y_window_size = window_size From 248bae76f7f4dff25a8c5cc683ff2d65a5cd038b Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Thu, 5 Feb 2026 17:55:54 +0100 Subject: [PATCH 03/16] style improvement --- src/squidpy/tl/_sliding_window.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index bd7333a0c..c0ecb9e6f 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -64,12 +64,12 @@ def sliding_window( if max_nr_cells is None: raise ValueError("`max_nr_cells` must be set when `partial_windows == split`.") if window_size is not None: - logg.warning(f"Ingoring `window_size` when using `{partial_windows}`.") + logg.warning(f"Ingoring `window_size` when using `{partial_windows}`") if overlap != 0: - logg.warning("Ignoring `overlap` as it cannot be used with `split`.") + logg.warning("Ignoring `overlap` as it cannot be used with `split`") else: if max_nr_cells is not None: - logg.warning("Ignoring `max_nr_cells` as `partial_windows != split`.") + logg.warning("Ignoring `max_nr_cells` as `partial_windows != split`") if overlap < 0: raise ValueError("Overlap must be non-negative.") @@ -121,7 +121,7 @@ def sliding_window( sliding_window_df = pd.DataFrame(index=adata.obs.index) if sliding_window_key in adata.obs: - logg.warning(f"Overwriting existing column '{sliding_window_key}' in adata.obs.") + logg.warning(f"Overwriting existing column '{sliding_window_key}' in adata.obs") for lib in libraries: if lib is not None: From 80b464f37a02da01513e4862a4ead9b0f0992157 Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Thu, 5 Feb 2026 17:57:59 +0100 Subject: [PATCH 04/16] updated tests --- tests/tools/test_sliding_window.py | 225 +++++++++++++++++------------ 1 file changed, 136 insertions(+), 89 deletions(-) diff --git a/tests/tools/test_sliding_window.py b/tests/tools/test_sliding_window.py index 3dd670b00..57528752c 100644 --- a/tests/tools/test_sliding_window.py +++ b/tests/tools/test_sliding_window.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pandas as pd import pytest from anndata import AnnData @@ -8,29 +9,30 @@ class TestSlidingWindow: @pytest.mark.parametrize( - "windowsize_overlap_drop", + "window_size, overlap, partial_windows", [ - (300, 0, False), - (300, 50, False), - (300, 50, True), + (300, 0, None), + (300, 50, None), + (300, 50, "drop"), ], ) def test_sliding_window_several_slices( self, adata_mibitof: AnnData, - windowsize_overlap_drop: tuple[int, int, bool], + window_size: int, + overlap: int, + partial_windows: str | None, sliding_window_key: str = "sliding_window_key", library_key: str = "library_id", ): - def _count_total_assignments(): - total_cells = 0 + def count_total_assignments(df: pd.DataFrame) -> int: + total = 0 for lib_key in ["point8", "point16", "point23"]: - cols_in_lib = df.columns[df.columns.str.contains(lib_key)] - for col in cols_in_lib: - total_cells += df[col].sum() - return total_cells + cols = df.columns[df.columns.str.contains(lib_key)] + for col in cols: + total += df[col].sum() + return total - window_size, overlap, drop_partial_windows = windowsize_overlap_drop df = sliding_window( adata_mibitof, library_key=library_key, @@ -38,24 +40,25 @@ def _count_total_assignments(): overlap=overlap, coord_columns=("globalX", "globalY"), sliding_window_key=sliding_window_key, + partial_windows=partial_windows, copy=True, - drop_partial_windows=drop_partial_windows, ) + assert len(df) == adata_mibitof.n_obs + if overlap == 0: - sliding_window_columns = [col for col in df.columns if sliding_window_key in col] - assert len(sliding_window_columns) == 1 # only one sliding window - assert df[sliding_window_key].isnull().sum() == 0 # no unassigned cells - assert len(df) == adata_mibitof.n_obs # correct amount of rows + # single categorical assignment + assert sliding_window_key in df.columns + assert df[sliding_window_key].notnull().all() else: - sliding_window_cols = df.columns[df.columns.str.contains("sliding_window")] + sliding_window_cols = df.columns[df.columns.str.contains(sliding_window_key)] - if drop_partial_windows: + if partial_windows == "drop": assert len(sliding_window_cols) == 27 - assert _count_total_assignments() == 2536 + assert count_total_assignments(df) == 2536 else: assert len(sliding_window_cols) == 70 - assert _count_total_assignments() == 4569 + assert count_total_assignments(df) == 4569 @pytest.mark.parametrize("overlap", [0, 2]) def test_sliding_window_square_grid( @@ -71,107 +74,151 @@ def test_sliding_window_square_grid( overlap=overlap, coord_columns=("globalX", "globalY"), sliding_window_key=sliding_window_key, + partial_windows=None, copy=True, ) - assert len(df) == adata_squaregrid.n_obs # correct amount of rows + assert len(df) == adata_squaregrid.n_obs if overlap == 0: - sliding_window_columns = [col for col in df.columns if sliding_window_key in col] - assert len(sliding_window_columns) == 1 # only one sliding window - assert df[sliding_window_key].isnull().sum() == 0 # no unassigned cells + assert sliding_window_key in df.columns + assert df[sliding_window_key].notnull().all() else: - for i in range(9): # we expect 9 windows - assert ( - f"{sliding_window_key}_window_{i}" in df.columns - ) # correct number of columns; multiple sliding windows + for i in range(9): # 3x3 grid + assert f"{sliding_window_key}_window_{i}" in df.columns - def test_sliding_window_invalid_window_size( - self, - adata_squaregrid: AnnData, - ): - with pytest.raises(ValueError, match="Window size must be larger than 0."): + def test_sliding_window_invalid_arguments(self, adata_squaregrid: AnnData): + with pytest.raises(ValueError, match="Window size must be larger than 0"): sliding_window( adata_squaregrid, - window_size=-10, + window_size=-1, overlap=0, coord_columns=("globalX", "globalY"), - sliding_window_key="sliding_window", copy=True, ) - with pytest.raises(ValueError, match="Overlap must be non-negative."): + with pytest.raises(ValueError, match="Overlap must be non-negative"): sliding_window( adata_squaregrid, window_size=10, - overlap=-10, + overlap=-1, coord_columns=("globalX", "globalY"), - sliding_window_key="sliding_window", copy=True, ) - def test_calculate_window_corners_overlap(self): - min_x = 0 - max_x = 200 - min_y = 0 - max_y = 200 - window_size = 100 - overlap = 20 + with pytest.raises(ValueError, match="max_nr_cells"): + sliding_window( + adata_squaregrid, + window_size=None, + overlap=0, + partial_windows="split", + coord_columns=("globalX", "globalY"), + copy=True, + ) + + def test_sliding_window_adaptive_assigns_all_cells( + self, + adata_squaregrid: AnnData, + sliding_window_key: str = "sliding_window_key", + ): + df = sliding_window( + adata_squaregrid, + window_size=5, + overlap=0, + coord_columns=("globalX", "globalY"), + sliding_window_key=sliding_window_key, + partial_windows="adaptive", + copy=True, + ) + + assert sliding_window_key in df.columns + assert df[sliding_window_key].notnull().all() + assert len(df) == adata_squaregrid.n_obs + + def test_sliding_window_split_respects_max_nr_cells( + self, + adata_mibitof: AnnData, + sliding_window_key: str = "sliding_window_key", + library_key: str = "library_id", + ): + max_nr_cells = 100 + + df = sliding_window( + adata_mibitof, + library_key=library_key, + window_size=None, + overlap=0, + coord_columns=("globalX", "globalY"), + sliding_window_key=sliding_window_key, + partial_windows="split", + max_nr_cells=max_nr_cells, + copy=True, + ) + + assert sliding_window_key in df.columns + assert df[sliding_window_key].notnull().all() + + counts = df[sliding_window_key].value_counts() + assert counts.max() <= max_nr_cells + assert counts.shape[0] > 1 # more than one window + +class TestCalculateWindowCorners: + def test_overlap(self): windows = _calculate_window_corners( - min_x=min_x, - max_x=max_x, - min_y=min_y, - max_y=max_y, - window_size=window_size, - overlap=overlap, - drop_partial_windows=False, + min_x=0, + max_x=200, + min_y=0, + max_y=200, + window_size=100, + overlap=20, + partial_windows=None, ) assert windows.shape == (9, 4) - assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] - assert windows.iloc[-1].values.tolist() == [160, 200, 160, 200] - - def test_calculate_window_corners_no_overlap(self): - min_x = 0 - max_x = 200 - min_y = 0 - max_y = 200 - window_size = 100 - overlap = 0 + assert windows.iloc[0].tolist() == [0, 100, 0, 100] + assert windows.iloc[-1].tolist() == [160, 200, 160, 200] + def test_no_overlap(self): windows = _calculate_window_corners( - min_x=min_x, - max_x=max_x, - min_y=min_y, - max_y=max_y, - window_size=window_size, - overlap=overlap, - drop_partial_windows=False, + min_x=0, + max_x=200, + min_y=0, + max_y=200, + window_size=100, + overlap=0, + partial_windows=None, ) assert windows.shape == (4, 4) - assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] - assert windows.iloc[-1].values.tolist() == [100, 200, 100, 200] - - def test_calculate_window_corners_drop_partial_windows(self): - min_x = 0 - max_x = 200 - min_y = 0 - max_y = 200 - window_size = 100 - overlap = 20 + assert windows.iloc[-1].tolist() == [100, 200, 100, 200] + def test_drop_partial_windows(self): windows = _calculate_window_corners( - min_x=min_x, - max_x=max_x, - min_y=min_y, - max_y=max_y, - window_size=window_size, - overlap=overlap, - drop_partial_windows=True, + min_x=0, + max_x=200, + min_y=0, + max_y=200, + window_size=100, + overlap=20, + partial_windows="drop", ) assert windows.shape == (4, 4) - assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] - assert windows.iloc[-1].values.tolist() == [80, 180, 80, 180] + assert windows.iloc[-1].tolist() == [80, 180, 80, 180] + + def test_adaptive_windows_cover_extent(self): + windows = _calculate_window_corners( + min_x=0, + max_x=200, + min_y=0, + max_y=200, + window_size=90, + overlap=0, + partial_windows="adaptive", + ) + + assert windows["x_start"].min() == 0 + assert windows["y_start"].min() == 0 + assert windows["x_end"].max() >= 200 + assert windows["y_end"].max() >= 200 From 82407bb093b755ed158bc87abebcc5dc7c4eb8fc Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Fri, 6 Feb 2026 16:24:26 +0100 Subject: [PATCH 05/16] improved partial_window split --- src/squidpy/tl/_sliding_window.py | 5 ++++- tests/tools/test_sliding_window.py | 29 +++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index c0ecb9e6f..4f259adff 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -294,6 +294,7 @@ def _calculate_window_corners( number_x_windows = np.ceil((max_x - min_x) / window_size) number_y_windows = np.ceil((max_y - min_y) / window_size) + # use np.ceil to avoid float errors x_window_size = np.ceil((max_x - min_x) / number_x_windows) y_window_size = np.ceil((max_y - min_y) / number_y_windows) else: @@ -319,7 +320,9 @@ def _calculate_window_corners( windows["x_end"] = windows["x_end"].clip(upper=max_x) windows["y_end"] = windows["y_end"].clip(upper=max_y) elif partial_windows == "adaptive": - pass + # as window_size is an integer to avoid float errors, it can exceed max_x and max_y -> clip + windows["x_end"] = windows["x_end"].clip(upper=max_x) + windows["y_end"] = windows["y_end"].clip(upper=max_y) elif partial_windows == "drop": valid_windows = (windows["x_end"] <= max_x) & (windows["y_end"] <= max_y) windows = windows[valid_windows] diff --git a/tests/tools/test_sliding_window.py b/tests/tools/test_sliding_window.py index 57528752c..25004ac96 100644 --- a/tests/tools/test_sliding_window.py +++ b/tests/tools/test_sliding_window.py @@ -135,18 +135,24 @@ def test_sliding_window_adaptive_assigns_all_cells( assert df[sliding_window_key].notnull().all() assert len(df) == adata_squaregrid.n_obs - def test_sliding_window_split_respects_max_nr_cells( + def test_sliding_window_split_nr_cells( self, adata_mibitof: AnnData, sliding_window_key: str = "sliding_window_key", library_key: str = "library_id", ): + """ + Test that when using 'split', each window contains at most max_nr_cells + and at least max_nr_cells // 2 cells, + unless the total number of cells is smaller than max_nr_cells // 2. + """ max_nr_cells = 100 + total_cells = adata_mibitof.n_obs df = sliding_window( adata_mibitof, library_key=library_key, - window_size=None, + window_size=None, # ignored in split mode overlap=0, coord_columns=("globalX", "globalY"), sliding_window_key=sliding_window_key, @@ -155,12 +161,19 @@ def test_sliding_window_split_respects_max_nr_cells( copy=True, ) - assert sliding_window_key in df.columns - assert df[sliding_window_key].notnull().all() - counts = df[sliding_window_key].value_counts() + + # all windows respect the upper bound assert counts.max() <= max_nr_cells - assert counts.shape[0] > 1 # more than one window + + # determine strict lower bound + lower_bound = max_nr_cells // 2 + if total_cells < lower_bound: + # if total cells are too few, just one window is allowed smaller + assert counts.max() == total_cells + else: + # otherwise, every window must satisfy the lower bound + assert (counts >= lower_bound).all() class TestCalculateWindowCorners: @@ -220,5 +233,5 @@ def test_adaptive_windows_cover_extent(self): assert windows["x_start"].min() == 0 assert windows["y_start"].min() == 0 - assert windows["x_end"].max() >= 200 - assert windows["y_end"].max() >= 200 + assert windows["x_end"].max() == 200 + assert windows["y_end"].max() == 200 From 66c566a259a97ec395d1f1157e0042ca16485a0d Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Thu, 12 Feb 2026 18:39:13 +0100 Subject: [PATCH 06/16] bugfix partial_windows == "split" when overlap is set --- src/squidpy/tl/_sliding_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index 98718f8ac..65c68aec7 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -169,7 +169,7 @@ def sliding_window( ) obs_indices = lib_coords.index[mask] - if overlap == 0: + if overlap == 0 or partial_windows == "split": sliding_window_df.loc[obs_indices, sliding_window_key] = f"{lib_key}window_{idx}" else: From f515f93c50679e0a76a635eb40acb3005a1be500 Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Thu, 12 Feb 2026 19:04:36 +0100 Subject: [PATCH 07/16] simplify code --- src/squidpy/tl/_sliding_window.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index 65c68aec7..bc08f613b 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -128,7 +128,6 @@ def sliding_window( lib_mask = adata.obs[library_key] == lib lib_coords = coords.loc[lib_mask] else: - lib_mask = np.ones(len(adata), dtype=bool) lib_coords = coords min_x, max_x = lib_coords[x_col].min(), lib_coords[x_col].max() @@ -174,9 +173,7 @@ def sliding_window( else: col_name = f"{sliding_window_key}_{lib_key}window_{idx}" - sliding_window_df.loc[obs_indices, col_name] = True - # Avoid chained assignment for pandas CoW compatibility - sliding_window_df[col_name] = sliding_window_df[col_name].fillna(False) + sliding_window_df[col_name] = mask if overlap == 0: # create categorical variable for ordered windows From 6c7e7f59b91652c0c78aa5c7d39c8852718dd88e Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Fri, 13 Feb 2026 10:58:36 +0100 Subject: [PATCH 08/16] bugfix adaptive with overlap --- src/squidpy/tl/_sliding_window.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index bc08f613b..6f0bccbb9 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -109,7 +109,7 @@ def sliding_window( raise ValueError("Window size must be larger than 0.") if overlap >= window_size: raise ValueError("Overlap must be less than the window size.") - if overlap >= window_size // 2 and window_size == "adaptive": + if overlap >= window_size // 2 and partial_windows == "adaptive": raise ValueError("Overlap must be less than `window_size` // 2 when using `adaptive`.") if library_key is not None and library_key not in adata.obs: @@ -289,12 +289,20 @@ def _calculate_window_corners( """ # adjust x and y window size if 'adaptive' if partial_windows == "adaptive": - number_x_windows = np.ceil((max_x - min_x) / window_size) - number_y_windows = np.ceil((max_y - min_y) / window_size) + total_width = max_x - min_x + total_height = max_y - min_y - # use np.ceil to avoid float errors - x_window_size = np.ceil((max_x - min_x) / number_x_windows) - y_window_size = np.ceil((max_y - min_y) / number_y_windows) + # number of windows in x and y direction + number_x_windows = np.ceil((total_width - overlap) / (window_size - overlap)) + number_y_windows = np.ceil((total_height - overlap) / (window_size - overlap)) + + # window size in x and y direction + x_window_size = (total_width + (number_x_windows - 1) * overlap) / number_x_windows + y_window_size = (total_height + (number_y_windows - 1) * overlap) / number_y_windows + + # avoid float errors + x_window_size = np.ceil(x_window_size) + y_window_size = np.ceil(y_window_size) else: x_window_size = window_size y_window_size = window_size @@ -321,6 +329,12 @@ def _calculate_window_corners( # as window_size is an integer to avoid float errors, it can exceed max_x and max_y -> clip windows["x_end"] = windows["x_end"].clip(upper=max_x) windows["y_end"] = windows["y_end"].clip(upper=max_y) + + # remove redundant windows in the corners + redundant_windows = ((windows["x_end"] - windows["x_start"]) <= overlap) | ( + (windows["y_end"] - windows["y_start"]) <= overlap + ) + windows = windows[~redundant_windows] elif partial_windows == "drop": valid_windows = (windows["x_end"] <= max_x) & (windows["y_end"] <= max_y) windows = windows[valid_windows] From c1fa430b0c88e9fbb5a56080122a3c262cfcd211 Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Fri, 13 Feb 2026 10:59:34 +0100 Subject: [PATCH 09/16] Update notebooks submodule pointer --- docs/notebooks | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/notebooks b/docs/notebooks index 1cbaf62a3..17d368281 160000 --- a/docs/notebooks +++ b/docs/notebooks @@ -1 +1 @@ -Subproject commit 1cbaf62a32f65b950552229d210b9884757ce116 +Subproject commit 17d368281ea7b11f7e1174436bbc2429191a0245 From 01e2e3bfd5b99704f316e7cbd4010290faae1006 Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Sun, 8 Mar 2026 18:34:46 +0100 Subject: [PATCH 10/16] reduce changes to sliding window --- src/squidpy/tl/_sliding_window.py | 66 +++++------ tests/tools/test_sliding_window.py | 179 +++++++++++++---------------- 2 files changed, 117 insertions(+), 128 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index 6f0bccbb9..a9a493b8c 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -121,13 +121,14 @@ def sliding_window( sliding_window_df = pd.DataFrame(index=adata.obs.index) if sliding_window_key in adata.obs: - logg.warning(f"Overwriting existing column '{sliding_window_key}' in adata.obs") + logg.warning(f"Overwriting existing column '{sliding_window_key}' in adata.obs.") for lib in libraries: if lib is not None: lib_mask = adata.obs[library_key] == lib lib_coords = coords.loc[lib_mask] else: + lib_mask = np.ones(len(adata), dtype=bool) lib_coords = coords min_x, max_x = lib_coords[x_col].min(), lib_coords[x_col].max() @@ -143,8 +144,7 @@ def sliding_window( overlap=overlap, partial_windows=partial_windows, lib_coords=lib_coords, - x_col=x_col, - y_col=y_col, + coord_columns=(x_col, y_col), max_nr_cells=max_nr_cells, ) @@ -158,8 +158,7 @@ def sliding_window( y_end = window["y_end"] mask = _get_window_mask( - x_col=x_col, - y_col=y_col, + coord_columns=(x_col, y_col), lib_coords=lib_coords, x_start=x_start, x_end=x_end, @@ -169,11 +168,19 @@ def sliding_window( obs_indices = lib_coords.index[mask] if overlap == 0 or partial_windows == "split": + mask = ( + (lib_coords[x_col] >= x_start) + & (lib_coords[x_col] <= x_end) + & (lib_coords[y_col] >= y_start) + & (lib_coords[y_col] <= y_end) + ) + obs_indices = lib_coords.index[mask] sliding_window_df.loc[obs_indices, sliding_window_key] = f"{lib_key}window_{idx}" else: col_name = f"{sliding_window_key}_{lib_key}window_{idx}" - sliding_window_df[col_name] = mask + sliding_window_df.loc[obs_indices, col_name] = True + sliding_window_df.loc[:, col_name].fillna(False, inplace=True) if overlap == 0: # create categorical variable for ordered windows @@ -196,8 +203,7 @@ def sliding_window( def _get_window_mask( - x_col: str, - y_col: str, + coord_columns: tuple[str, str], lib_coords: pd.DataFrame, x_start: int, x_end: int, @@ -209,10 +215,8 @@ def _get_window_mask( Parameters ---------- - x_col: str - Column name in `lib_coords` containing x-coordinates. - y_col: str - Column name in `lib_coords` containing y-coordinates. + coord_columns: Tuple[str, str] + Tuple of column names in `adata.obs` that specify the coordinates (x, y), i.e. ('globalX', 'globalY') lib_coords: pd.DataFrame DataFrame containing spatial coordinates (e.g. `adata.obs` subset for one library). Coordinate values are expected to be integers. @@ -230,6 +234,8 @@ def _get_window_mask( pd.Series Boolean mask indicating which rows in `lib_coords` fall inside the specified window. """ + x_col, y_col = coord_columns + mask = ( (lib_coords[x_col] >= x_start) & (lib_coords[x_col] <= x_end) @@ -249,8 +255,7 @@ def _calculate_window_corners( overlap: int = 0, partial_windows: Literal["adaptive", "drop", "split"] | None = None, lib_coords: pd.DataFrame | None = None, - x_col: str | None = None, - y_col: str | None = None, + coord_columns: tuple[str, str] | None = None, max_nr_cells: int | None = None, ) -> pd.DataFrame: """ @@ -267,14 +272,12 @@ def _calculate_window_corners( minimum Y coordinate max_y: float maximum Y coordinate - window_size: int + window_size: float size of each window lib_coords: pd.DataFrame | None coordinates of all samples for one library - x_col: str | None - the column in `lib_coords` corresponding to the x coordinates - y_col: str | None - the column in `lib_coords` corresponding to the y coordinates + coord_columns: Tuple[str, str] + Tuple of column names in `adata.obs` that specify the coordinates (x, y), i.e. ('globalX', 'globalY') overlap: float overlap between consecutive windows (must be less than window_size) partial_windows: Literal["adaptive", "drop", "split"] | None @@ -340,11 +343,13 @@ def _calculate_window_corners( windows = windows[valid_windows] elif partial_windows == "split": # split the slide recursively into windows with at most max_nr_cells + x_col, y_col = coord_columns + coord_x_sorted = lib_coords.sort_values(by=[x_col]) coord_y_sorted = lib_coords.sort_values(by=[y_col]) windows = _split_window( - max_nr_cells, x_col, y_col, coord_x_sorted, coord_y_sorted, min_x, max_x, min_y, max_y + max_nr_cells, (x_col, y_col), coord_x_sorted, coord_y_sorted, min_x, max_x, min_y, max_y ).sort_values(["x_start", "x_end", "y_start", "y_end"]) else: raise ValueError(f"{partial_windows} is not a valid partial_windows argument.") @@ -355,8 +360,7 @@ def _calculate_window_corners( def _split_window( max_cells: int, - x_col: str, - y_col: str, + coord_columns: tuple[str, str], coord_x_sorted: pd.DataFrame, coord_y_sorted: pd.DataFrame, x_start: int, @@ -372,12 +376,8 @@ def _split_window( ---------- max_cells : int Maximum number of cells allowed per window. - x_col : str - Name of the column in `coord_x_sorted` and `coord_y_sorted` corresponding to - x coordinates. - y_col : str - Name of the column in `coord_x_sorted` and `coord_y_sorted` corresponding to - y coordinates. + coord_columns: Tuple[str, str] + Tuple of column names in `adata.obs` that specify the coordinates (x, y), i.e. ('globalX', 'globalY') coord_x_sorted : pandas.DataFrame DataFrame containing cell coordinates, sorted by `x_col`. coord_y_sorted : pandas.DataFrame @@ -395,19 +395,21 @@ def _split_window( ------- windows: pandas DataFrame with columns ['x_start', 'x_end', 'y_start', 'y_end'] """ + x_col, y_col = coord_columns + # return current window if it contains less cells than max_cells - n_cells = _get_window_mask(x_col, y_col, coord_x_sorted, x_start, x_end, y_start, y_end).sum() + n_cells = _get_window_mask(coord_columns, coord_x_sorted, x_start, x_end, y_start, y_end).sum() if n_cells <= max_cells: return pd.DataFrame({"x_start": [x_start], "x_end": [x_end], "y_start": [y_start], "y_end": [y_end]}) # define start and stop indices of subsetted windows sub_coord_x_sorted = coord_x_sorted[ - _get_window_mask(x_col, y_col, coord_x_sorted, x_start, x_end, y_start, y_end) + _get_window_mask(coord_columns, coord_x_sorted, x_start, x_end, y_start, y_end) ].reset_index(drop=True) sub_coord_y_sorted = coord_y_sorted[ - _get_window_mask(x_col, y_col, coord_y_sorted, x_start, x_end, y_start, y_end) + _get_window_mask(coord_columns, coord_y_sorted, x_start, x_end, y_start, y_end) ].reset_index(drop=True) middle_pos = len(sub_coord_x_sorted) // 2 @@ -428,7 +430,7 @@ def _split_window( for x_start, x_end, y_start, y_end in indices: windows.append( _split_window( - max_cells, x_col, y_col, sub_coord_x_sorted, sub_coord_y_sorted, x_start, x_end, y_start, y_end + max_cells, (x_col, y_col), sub_coord_x_sorted, sub_coord_y_sorted, x_start, x_end, y_start, y_end ) ) diff --git a/tests/tools/test_sliding_window.py b/tests/tools/test_sliding_window.py index 25004ac96..f094cf8e0 100644 --- a/tests/tools/test_sliding_window.py +++ b/tests/tools/test_sliding_window.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pandas as pd import pytest from anndata import AnnData @@ -9,7 +8,7 @@ class TestSlidingWindow: @pytest.mark.parametrize( - "window_size, overlap, partial_windows", + "windowsize_overlap_drop", [ (300, 0, None), (300, 50, None), @@ -19,20 +18,19 @@ class TestSlidingWindow: def test_sliding_window_several_slices( self, adata_mibitof: AnnData, - window_size: int, - overlap: int, - partial_windows: str | None, + windowsize_overlap_drop: tuple[int, int, str | None], sliding_window_key: str = "sliding_window_key", library_key: str = "library_id", ): - def count_total_assignments(df: pd.DataFrame) -> int: - total = 0 + def _count_total_assignments(): + total_cells = 0 for lib_key in ["point8", "point16", "point23"]: - cols = df.columns[df.columns.str.contains(lib_key)] - for col in cols: - total += df[col].sum() - return total + cols_in_lib = df.columns[df.columns.str.contains(lib_key)] + for col in cols_in_lib: + total_cells += df[col].sum() + return total_cells + window_size, overlap, partial_windows = windowsize_overlap_drop df = sliding_window( adata_mibitof, library_key=library_key, @@ -40,25 +38,24 @@ def count_total_assignments(df: pd.DataFrame) -> int: overlap=overlap, coord_columns=("globalX", "globalY"), sliding_window_key=sliding_window_key, - partial_windows=partial_windows, copy=True, + partial_windows=partial_windows, ) - assert len(df) == adata_mibitof.n_obs - if overlap == 0: - # single categorical assignment - assert sliding_window_key in df.columns - assert df[sliding_window_key].notnull().all() + sliding_window_columns = [col for col in df.columns if sliding_window_key in col] + assert len(sliding_window_columns) == 1 # only one sliding window + assert df[sliding_window_key].isnull().sum() == 0 # no unassigned cells + assert len(df) == adata_mibitof.n_obs # correct amount of rows else: - sliding_window_cols = df.columns[df.columns.str.contains(sliding_window_key)] + sliding_window_cols = df.columns[df.columns.str.contains("sliding_window")] if partial_windows == "drop": assert len(sliding_window_cols) == 27 - assert count_total_assignments(df) == 2536 + assert _count_total_assignments() == 2536 else: assert len(sliding_window_cols) == 70 - assert count_total_assignments(df) == 4569 + assert _count_total_assignments() == 4569 @pytest.mark.parametrize("overlap", [0, 2]) def test_sliding_window_square_grid( @@ -74,39 +71,46 @@ def test_sliding_window_square_grid( overlap=overlap, coord_columns=("globalX", "globalY"), sliding_window_key=sliding_window_key, - partial_windows=None, copy=True, ) - assert len(df) == adata_squaregrid.n_obs + assert len(df) == adata_squaregrid.n_obs # correct amount of rows if overlap == 0: - assert sliding_window_key in df.columns - assert df[sliding_window_key].notnull().all() + sliding_window_columns = [col for col in df.columns if sliding_window_key in col] + assert len(sliding_window_columns) == 1 # only one sliding window + assert df[sliding_window_key].isnull().sum() == 0 # no unassigned cells else: - for i in range(9): # 3x3 grid - assert f"{sliding_window_key}_window_{i}" in df.columns + for i in range(9): # we expect 9 windows + assert ( + f"{sliding_window_key}_window_{i}" in df.columns + ) # correct number of columns; multiple sliding windows - def test_sliding_window_invalid_arguments(self, adata_squaregrid: AnnData): - with pytest.raises(ValueError, match="Window size must be larger than 0"): + def test_sliding_window_invalid_window_size( + self, + adata_squaregrid: AnnData, + ): + with pytest.raises(ValueError, match="Window size must be larger than 0."): sliding_window( adata_squaregrid, - window_size=-1, + window_size=-10, overlap=0, coord_columns=("globalX", "globalY"), + sliding_window_key="sliding_window", copy=True, ) - with pytest.raises(ValueError, match="Overlap must be non-negative"): + with pytest.raises(ValueError, match="Overlap must be non-negative."): sliding_window( adata_squaregrid, window_size=10, - overlap=-1, + overlap=-10, coord_columns=("globalX", "globalY"), + sliding_window_key="sliding_window", copy=True, ) - with pytest.raises(ValueError, match="max_nr_cells"): + with pytest.raises(ValueError, match="`max_nr_cells` must be set when `partial_windows == split`."): sliding_window( adata_squaregrid, window_size=None, @@ -116,25 +120,6 @@ def test_sliding_window_invalid_arguments(self, adata_squaregrid: AnnData): copy=True, ) - def test_sliding_window_adaptive_assigns_all_cells( - self, - adata_squaregrid: AnnData, - sliding_window_key: str = "sliding_window_key", - ): - df = sliding_window( - adata_squaregrid, - window_size=5, - overlap=0, - coord_columns=("globalX", "globalY"), - sliding_window_key=sliding_window_key, - partial_windows="adaptive", - copy=True, - ) - - assert sliding_window_key in df.columns - assert df[sliding_window_key].notnull().all() - assert len(df) == adata_squaregrid.n_obs - def test_sliding_window_split_nr_cells( self, adata_mibitof: AnnData, @@ -152,9 +137,6 @@ def test_sliding_window_split_nr_cells( df = sliding_window( adata_mibitof, library_key=library_key, - window_size=None, # ignored in split mode - overlap=0, - coord_columns=("globalX", "globalY"), sliding_window_key=sliding_window_key, partial_windows="split", max_nr_cells=max_nr_cells, @@ -175,63 +157,68 @@ def test_sliding_window_split_nr_cells( # otherwise, every window must satisfy the lower bound assert (counts >= lower_bound).all() + def test_calculate_window_corners_overlap(self): + min_x = 0 + max_x = 200 + min_y = 0 + max_y = 200 + window_size = 100 + overlap = 20 -class TestCalculateWindowCorners: - def test_overlap(self): windows = _calculate_window_corners( - min_x=0, - max_x=200, - min_y=0, - max_y=200, - window_size=100, - overlap=20, + min_x=min_x, + max_x=max_x, + min_y=min_y, + max_y=max_y, + window_size=window_size, + overlap=overlap, partial_windows=None, ) assert windows.shape == (9, 4) - assert windows.iloc[0].tolist() == [0, 100, 0, 100] - assert windows.iloc[-1].tolist() == [160, 200, 160, 200] + assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] + assert windows.iloc[-1].values.tolist() == [160, 200, 160, 200] + + def test_calculate_window_corners_no_overlap(self): + min_x = 0 + max_x = 200 + min_y = 0 + max_y = 200 + window_size = 100 + overlap = 0 - def test_no_overlap(self): windows = _calculate_window_corners( - min_x=0, - max_x=200, - min_y=0, - max_y=200, - window_size=100, - overlap=0, + min_x=min_x, + max_x=max_x, + min_y=min_y, + max_y=max_y, + window_size=window_size, + overlap=overlap, partial_windows=None, ) assert windows.shape == (4, 4) - assert windows.iloc[-1].tolist() == [100, 200, 100, 200] + assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] + assert windows.iloc[-1].values.tolist() == [100, 200, 100, 200] + + def test_calculate_window_corners_drop_partial_windows(self): + min_x = 0 + max_x = 200 + min_y = 0 + max_y = 200 + window_size = 100 + overlap = 20 - def test_drop_partial_windows(self): windows = _calculate_window_corners( - min_x=0, - max_x=200, - min_y=0, - max_y=200, - window_size=100, - overlap=20, + min_x=min_x, + max_x=max_x, + min_y=min_y, + max_y=max_y, + window_size=window_size, + overlap=overlap, partial_windows="drop", ) assert windows.shape == (4, 4) - assert windows.iloc[-1].tolist() == [80, 180, 80, 180] - - def test_adaptive_windows_cover_extent(self): - windows = _calculate_window_corners( - min_x=0, - max_x=200, - min_y=0, - max_y=200, - window_size=90, - overlap=0, - partial_windows="adaptive", - ) - - assert windows["x_start"].min() == 0 - assert windows["y_start"].min() == 0 - assert windows["x_end"].max() == 200 - assert windows["y_end"].max() == 200 + assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] + assert windows.iloc[-1].values.tolist() == [80, 180, 80, 180] From a48f2bfe47aa4d90f2b8dd0108f2e4fbcddfed0e Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Sun, 8 Mar 2026 19:48:50 +0100 Subject: [PATCH 11/16] improved tests --- tests/tools/test_sliding_window.py | 33 +++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_sliding_window.py b/tests/tools/test_sliding_window.py index f094cf8e0..672290de9 100644 --- a/tests/tools/test_sliding_window.py +++ b/tests/tools/test_sliding_window.py @@ -8,17 +8,19 @@ class TestSlidingWindow: @pytest.mark.parametrize( - "windowsize_overlap_drop", + "windowsize_overlap_partial", [ (300, 0, None), (300, 50, None), (300, 50, "drop"), + (300, 0, "adaptive"), + (300, 50, "adaptive"), ], ) def test_sliding_window_several_slices( self, adata_mibitof: AnnData, - windowsize_overlap_drop: tuple[int, int, str | None], + windowsize_overlap_partial: tuple[int, int, str | None], sliding_window_key: str = "sliding_window_key", library_key: str = "library_id", ): @@ -30,7 +32,7 @@ def _count_total_assignments(): total_cells += df[col].sum() return total_cells - window_size, overlap, partial_windows = windowsize_overlap_drop + window_size, overlap, partial_windows = windowsize_overlap_partial df = sliding_window( adata_mibitof, library_key=library_key, @@ -53,6 +55,9 @@ def _count_total_assignments(): if partial_windows == "drop": assert len(sliding_window_cols) == 27 assert _count_total_assignments() == 2536 + elif partial_windows == "adaptive": + assert len(sliding_window_cols) == 48 + assert _count_total_assignments() == 4411 else: assert len(sliding_window_cols) == 70 assert _count_total_assignments() == 4569 @@ -222,3 +227,25 @@ def test_calculate_window_corners_drop_partial_windows(self): assert windows.shape == (4, 4) assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] assert windows.iloc[-1].values.tolist() == [80, 180, 80, 180] + + def test_calculate_window_corners_adaptive_partial_windows(self): + min_x = 0 + max_x = 200 + min_y = 0 + max_y = 200 + window_size = 100 + overlap = 20 + + windows = _calculate_window_corners( + min_x=min_x, + max_x=max_x, + min_y=min_y, + max_y=max_y, + window_size=window_size, + overlap=overlap, + partial_windows="adaptive", + ) + + assert windows.shape == (9, 4) + assert windows.iloc[0].values.tolist() == [0, 80, 0, 80] + assert windows.iloc[-1].values.tolist() == [120, 200, 120, 200] From 4c7d9b1db0cdd90732d37e53c9cfcb15e3d338e5 Mon Sep 17 00:00:00 2001 From: Felix Schroeder Date: Sun, 8 Mar 2026 20:24:34 +0100 Subject: [PATCH 12/16] revert minor change --- src/squidpy/tl/_sliding_window.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index a9a493b8c..634efc349 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -180,7 +180,8 @@ def sliding_window( else: col_name = f"{sliding_window_key}_{lib_key}window_{idx}" sliding_window_df.loc[obs_indices, col_name] = True - sliding_window_df.loc[:, col_name].fillna(False, inplace=True) + # Avoid chained assignment for pandas CoW compatibility + sliding_window_df[col_name] = sliding_window_df[col_name].fillna(False) if overlap == 0: # create categorical variable for ordered windows From 3cbf2ffd543801fece41e88951441fbba91e3db1 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 13 Aug 2026 00:48:46 +0200 Subject: [PATCH 13/16] refactor(sliding_window): split grid/split methods, fix crashes, deprecate drop_partial_windows Maintainer edit addressing the review of #1116. - API (Option A): add `method="grid"|"split"`; `partial_windows` is now the edge policy only ("keep"|"drop"|"adaptive"); `max_nr_cells` is split-only. `_calculate_window_corners` is pure geometry again (split dispatched before it). - Deprecate `drop_partial_windows` (kept at its released position; maps to `partial_windows="drop"` with a FutureWarning) instead of removing it. - Fix `drop`/`adaptive` + overlap==0 crash: cells outside every window get an explicit "unassigned" category instead of NaN, so the ordered sort no longer calls int() on a float NaN. - Rewrite split as a positional (count-based) median split: non-overlapping and strictly decreasing by construction, so it cannot recurse forever or double-assign boundary cells; validates max_nr_cells >= 1. - Fix adaptive on a library smaller than one window (clamp the per-library window count, guard the arange step, keep a sole thin window). - Build overlapping-window boolean columns once via concat (was quadratic frame fragmentation); stop leaking coord columns into .obs. Tests updated to the new API with regressions for each fix. Grid output verified byte-identical to the previous implementation for keep/drop/adaptive. --- src/squidpy/tl/_sliding_window.py | 486 +++++++++++++---------------- tests/tools/test_sliding_window.py | 206 ++++++------ 2 files changed, 310 insertions(+), 382 deletions(-) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index 9ab5115e8..8c39f1efa 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -1,6 +1,7 @@ from __future__ import annotations -from itertools import product +import warnings +from itertools import count, product from typing import Literal import numpy as np @@ -14,6 +15,10 @@ __all__ = ["sliding_window"] +# Label for cells that fall in no window (only possible for grid ``drop``/``adaptive``); kept as an +# explicit category instead of ``NaN`` so downstream code (and the ordered sort) never sees a float NaN. +UNASSIGNED = "unassigned" + @d.dedent def sliding_window( @@ -24,55 +29,87 @@ def sliding_window( coord_columns: tuple[str, str] = ("globalX", "globalY"), sliding_window_key: str = "sliding_window_assignment", spatial_key: str = "spatial", - partial_windows: Literal["adaptive", "drop", "split"] | None = None, - max_nr_cells: int | None = None, + drop_partial_windows: bool | None = None, copy: bool = False, *, + method: Literal["grid", "split"] = "grid", + partial_windows: Literal["keep", "drop", "adaptive"] = "keep", + max_nr_cells: int | None = None, table_key: str | None = None, ) -> pd.DataFrame | None: """ - Divide a tissue slice into regulary shaped spatially contiguous regions (windows). + Divide a tissue slice into spatially contiguous regions (windows). + + Two tiling strategies are available via ``method``: + + - ``"grid"`` (default) lays a regular grid of ``window_size`` windows (optionally overlapping). + ``partial_windows`` controls the windows at the tissue edge. + - ``"split"`` recursively splits the cells into windows of roughly equal cell count + (at most ``max_nr_cells`` each), ignoring ``window_size``/``overlap``. Parameters ---------- %(adata)s - %(table_key)s - window_size: int - Size of the sliding window. %(library_key)s - coord_columns: Tuple[str, str] - Tuple of column names in `adata.obs` that specify the coordinates (x, y), e.i. ('globalX', 'globalY') - sliding_window_key: str - Base name for sliding window columns. + window_size: int | None + Size of each grid window (``method="grid"``). Inferred from the extent when ``None``. overlap: int - Overlap size between consecutive windows. (0 = no overlap) + Overlap between consecutive grid windows (0 = no overlap). Only used for ``method="grid"``. + coord_columns: tuple[str, str] + Column names in ``adata.obs`` holding the ``(x, y)`` coordinates, e.g. ``('globalX', 'globalY')``. + sliding_window_key: str + Base name for the sliding-window column(s) written to ``.obs``. %(spatial_key)s - partial_windows: Literal["adaptive", "drop", "split"] | None - If None, possibly small windows at the edges are kept. - If `adaptive`, all windows might be shrunken a bit to avoid small windows at the edges. - If `drop`, possibly small windows at the edges are removed. - If `split`, windows are split into subwindows until not exceeding `max_nr_cells` - max_nr_cells: int | None - The maximum number of cells allowed after merging two windows. - Required if `partial_windows = split` + drop_partial_windows: bool | None + Deprecated. Use ``partial_windows`` instead. ``True`` maps to ``partial_windows="drop"``. copy: bool - If True, return the result, otherwise save it to the adata object. + If ``True``, return the result; otherwise store it in ``adata.obs``. + method: Literal["grid", "split"] + Tiling strategy. ``"grid"`` for a regular grid, ``"split"`` for equal-cell-count windows. + partial_windows: Literal["keep", "drop", "adaptive"] + Edge-window handling for ``method="grid"`` (ignored for ``"split"``). + ``"keep"`` clips edge windows to the tissue bounds; ``"drop"`` removes windows that would extend + past the bounds (their cells become ``"unassigned"``); ``"adaptive"`` shrinks all windows slightly + so they tile the extent evenly. + max_nr_cells: int | None + Maximum number of cells per window. Required for (and only used by) ``method="split"``. + %(table_key)s Returns ------- - If ``copy = True``, returns the sliding window annotation(s) as pandas dataframe - Otherwise, stores the sliding window annotation(s) in .obs. + If ``copy = True``, returns the sliding-window annotation(s) as a :class:`pandas.DataFrame`. + Otherwise, stores the annotation(s) in ``adata.obs`` and returns ``None``. """ - if partial_windows == "split": + # --- deprecation: drop_partial_windows -> partial_windows --- + if drop_partial_windows is not None: + warnings.warn( + "`drop_partial_windows` is deprecated and will be removed in a future release; " + "use `partial_windows='drop'` (or 'keep') instead.", + FutureWarning, + stacklevel=2, + ) + if partial_windows != "keep": + raise ValueError("Pass either `drop_partial_windows` (deprecated) or `partial_windows`, not both.") + partial_windows = "drop" if drop_partial_windows else "keep" + + # --- validate arguments --- + if method not in ("grid", "split"): + raise ValueError(f"`method` must be 'grid' or 'split', got {method!r}.") + if partial_windows not in ("keep", "drop", "adaptive"): + raise ValueError(f"`partial_windows` must be 'keep', 'drop' or 'adaptive', got {partial_windows!r}.") + + if method == "split": if max_nr_cells is None: - raise ValueError("`max_nr_cells` must be set when `partial_windows == split`.") - if window_size is not None: - logg.warning(f"Ingoring `window_size` when using `{partial_windows}`") - if overlap != 0: - logg.warning("Ignoring `overlap` as it cannot be used with `split`") - else: + raise ValueError("`max_nr_cells` must be set when method='split'.") + if max_nr_cells < 1: + raise ValueError("`max_nr_cells` must be >= 1.") + if window_size is not None or overlap != 0 or partial_windows != "keep": + raise ValueError( + "`window_size`, `overlap` and `partial_windows` are not used with method='split'; leave them unset." + ) + else: # grid if max_nr_cells is not None: - logg.warning("Ignoring `max_nr_cells` as `partial_windows != split`") + raise ValueError("`max_nr_cells` is only used with method='split'.") if overlap < 0: raise ValueError("Overlap must be non-negative.") @@ -82,7 +119,7 @@ def sliding_window( if copy: adata = adata.copy() - # extract coordinates of observations + # --- extract coordinates of observations --- x_col, y_col = coord_columns if x_col in adata.obs and y_col in adata.obs: coords = adata.obs[[x_col, y_col]].copy() @@ -97,46 +134,50 @@ def sliding_window( f"Coordinates not found. Provide `{coord_columns}` in `adata.obs` or specify a suitable `spatial_key` in `adata.obsm`." ) - # infer window size if not provided - if window_size is None: - coord_range = max( - coords[x_col].max() - coords[x_col].min(), - coords[y_col].max() - coords[y_col].min(), - ) - # mostly arbitrary choice, except that full integers usually generate windows with 1-2 cells at the borders - window_size = max(int(np.floor(coord_range // 3.95)), 1) - - if partial_windows != "split": + # --- grid: infer + validate window size --- + if method == "grid": + if window_size is None: + coord_range = max( + coords[x_col].max() - coords[x_col].min(), + coords[y_col].max() - coords[y_col].min(), + ) + # mostly arbitrary choice, except that full integers usually generate windows with 1-2 cells at the borders + window_size = max(int(np.floor(coord_range // 3.95)), 1) if window_size <= 0: raise ValueError("Window size must be larger than 0.") if overlap >= window_size: raise ValueError("Overlap must be less than the window size.") - if overlap >= window_size // 2 and partial_windows == "adaptive": - raise ValueError("Overlap must be less than `window_size` // 2 when using `adaptive`.") + if partial_windows == "adaptive" and overlap >= window_size // 2: + raise ValueError("Overlap must be less than `window_size` // 2 when partial_windows='adaptive'.") if library_key is not None and library_key not in adata.obs: raise ValueError(f"Library key '{library_key}' not found in adata.obs") libraries = [None] if library_key is None else adata.obs[library_key].unique() - # Create a DataFrame to store the sliding window assignments - sliding_window_df = pd.DataFrame(index=adata.obs.index) - if sliding_window_key in adata.obs: logg.warning(f"Overwriting existing column '{sliding_window_key}' in adata.obs.") + sliding_window_df = pd.DataFrame(index=adata.obs.index) + # For overlapping grids we emit one boolean column per window. Collect them all and concatenate once + # at the end: adding them one-by-one fragments the frame and is quadratic in the number of windows. + bool_columns: dict[str, pd.Series] = {} + for lib in libraries: - if lib is not None: - lib_mask = adata.obs[library_key] == lib - lib_coords = coords.loc[lib_mask] - else: - lib_mask = np.ones(len(adata), dtype=bool) - lib_coords = coords + lib_coords = coords.loc[adata.obs[library_key] == lib] if lib is not None else coords + lib_key = f"{lib}_" if lib is not None else "" + + if method == "split": + # each cell is assigned to exactly one window (non-overlapping by construction) + labels = _split_cells(lib_coords, coord_columns, max_nr_cells) + for label in np.unique(labels): + obs_indices = lib_coords.index[labels == label] + sliding_window_df.loc[obs_indices, sliding_window_key] = f"{lib_key}window_{label}" + continue min_x, max_x = lib_coords[x_col].min(), lib_coords[x_col].max() min_y, max_y = lib_coords[y_col].min(), lib_coords[y_col].max() - # precalculate windows windows = _calculate_window_corners( min_x=min_x, max_x=max_x, @@ -145,181 +186,170 @@ def sliding_window( window_size=window_size, overlap=overlap, partial_windows=partial_windows, - lib_coords=lib_coords, - coord_columns=(x_col, y_col), - max_nr_cells=max_nr_cells, ) - lib_key = f"{lib}_" if lib is not None else "" - - # assign observations to windows for idx, window in windows.iterrows(): - x_start = window["x_start"] - x_end = window["x_end"] - y_start = window["y_start"] - y_end = window["y_end"] - mask = _get_window_mask( - coord_columns=(x_col, y_col), + coord_columns=coord_columns, lib_coords=lib_coords, - x_start=x_start, - x_end=x_end, - y_start=y_start, - y_end=y_end, + x_start=window["x_start"], + x_end=window["x_end"], + y_start=window["y_start"], + y_end=window["y_end"], ) obs_indices = lib_coords.index[mask] - - if overlap == 0 or partial_windows == "split": - mask = ( - (lib_coords[x_col] >= x_start) - & (lib_coords[x_col] <= x_end) - & (lib_coords[y_col] >= y_start) - & (lib_coords[y_col] <= y_end) - ) - obs_indices = lib_coords.index[mask] + if overlap == 0: sliding_window_df.loc[obs_indices, sliding_window_key] = f"{lib_key}window_{idx}" - else: col_name = f"{sliding_window_key}_{lib_key}window_{idx}" - sliding_window_df.loc[obs_indices, col_name] = True - # Avoid chained assignment for pandas CoW compatibility - sliding_window_df[col_name] = sliding_window_df[col_name].fillna(False) - - if overlap == 0: - # create categorical variable for ordered windows - sliding_window_df[sliding_window_key] = pd.Categorical( - sliding_window_df[sliding_window_key], - ordered=True, - categories=sorted( - sliding_window_df[sliding_window_key].unique(), - key=lambda x: int(x.split("_")[-1]), - ), - ) + col = bool_columns.setdefault(col_name, pd.Series(False, index=sliding_window_df.index)) + col.loc[obs_indices] = True - sliding_window_df[x_col] = coords[x_col] - sliding_window_df[y_col] = coords[y_col] + if bool_columns: + sliding_window_df = pd.concat([sliding_window_df, pd.DataFrame(bool_columns)], axis=1) + + if method == "split" or overlap == 0: + # single categorical column: order windows by their trailing index, put unassigned cells last + sliding_window_df[sliding_window_key] = _ordered_window_categorical(sliding_window_df[sliding_window_key]) if copy: return sliding_window_df for col_name, col_data in sliding_window_df.items(): _save_data(adata, attr="obs", key=col_name, data=col_data) + return None + + +def _ordered_window_categorical(values: pd.Series) -> pd.Categorical: + """Ordered categorical of window labels sorted by trailing index; unassigned cells (``NaN``) go last. + + Cells outside every window (grid ``drop``/``adaptive``) arrive as ``NaN``; they become an explicit + ``"unassigned"`` category so the ordered sort never calls ``int(...)`` on a float ``NaN``. + """ + filled = values.fillna(UNASSIGNED) + present = list(pd.unique(filled)) + windows = sorted((c for c in present if c != UNASSIGNED), key=lambda s: int(str(s).split("_")[-1])) + categories = windows + ([UNASSIGNED] if UNASSIGNED in present else []) + return pd.Categorical(filled, ordered=True, categories=categories) def _get_window_mask( coord_columns: tuple[str, str], lib_coords: pd.DataFrame, - x_start: int, - x_end: int, - y_start: int, - y_end: int, + x_start: float, + x_end: float, + y_start: float, + y_end: float, ) -> pd.Series: - """ - Compute a boolean mask selecting coordinates that fall within a given window. + """Boolean mask selecting the rows of ``lib_coords`` inside the (inclusive) window.""" + x_col, y_col = coord_columns + return ( + (lib_coords[x_col] >= x_start) + & (lib_coords[x_col] <= x_end) + & (lib_coords[y_col] >= y_start) + & (lib_coords[y_col] <= y_end) + ) + + +def _split_cells(coords: pd.DataFrame, coord_columns: tuple[str, str], max_cells: int) -> np.ndarray: + """Assign each cell to a window by recursive count-based (median) splitting. + + Each window holds at most ``max_cells`` cells and, unless the whole input is smaller, at least + ``max_cells // 2``. The split is on cell *position* (the median index of the longer axis), so windows + are **non-overlapping by construction** — no cell can land in two windows, and every split strictly + shrinks both halves, so it always terminates (given ``max_cells >= 1``). Parameters ---------- - coord_columns: Tuple[str, str] - Tuple of column names in `adata.obs` that specify the coordinates (x, y), i.e. ('globalX', 'globalY') - lib_coords: pd.DataFrame - DataFrame containing spatial coordinates (e.g. `adata.obs` subset for one library). - Coordinate values are expected to be integers. - x_start: int - Lower bound of the window in x-direction (inclusive). - x_end: int - Upper bound of the window in x-direction (inclusive). - y_start: int - Lower bound of the window in y-direction (inclusive). - y_end: int - Upper bound of the window in y-direction (inclusive). + coords + Coordinates for one library (index-aligned to the cells). + coord_columns + ``(x_col, y_col)`` column names in ``coords``. + max_cells + Maximum number of cells per window. Returns ------- - pd.Series - Boolean mask indicating which rows in `lib_coords` fall inside the specified window. + Integer window label per row of ``coords`` (positional order). """ x_col, y_col = coord_columns + x = coords[x_col].to_numpy() + y = coords[y_col].to_numpy() + labels = np.empty(len(coords), dtype=int) + counter = count() + + def recurse(idx: np.ndarray) -> None: + if len(idx) <= max_cells: + labels[idx] = next(counter) + return + xi, yi = x[idx], y[idx] + # split along the axis with the larger spatial extent, at the median cell + if (xi.max() - xi.min()) >= (yi.max() - yi.min()): + order = idx[np.argsort(xi, kind="stable")] + else: + order = idx[np.argsort(yi, kind="stable")] + mid = len(order) // 2 + recurse(order[:mid]) + recurse(order[mid:]) - mask = ( - (lib_coords[x_col] >= x_start) - & (lib_coords[x_col] <= x_end) - & (lib_coords[y_col] >= y_start) - & (lib_coords[y_col] <= y_end) - ) - - return mask + recurse(np.arange(len(coords), dtype=int)) + return labels def _calculate_window_corners( - min_x: int, - max_x: int, - min_y: int, - max_y: int, + min_x: float, + max_x: float, + min_y: float, + max_y: float, window_size: int, overlap: int = 0, - partial_windows: Literal["adaptive", "drop", "split"] | None = None, - lib_coords: pd.DataFrame | None = None, - coord_columns: tuple[str, str] | None = None, - max_nr_cells: int | None = None, + partial_windows: Literal["keep", "drop", "adaptive"] = "keep", ) -> pd.DataFrame: """ - Calculate the corner points of all windows covering the area from min_x to max_x and min_y to max_y, - with specified window_size and overlap. + Corner points of a regular grid of windows covering ``[min_x, max_x] x [min_y, max_y]``. Parameters ---------- - min_x: float - minimum X coordinate - max_x: float - maximum X coordinate - min_y: float - minimum Y coordinate - max_y: float - maximum Y coordinate - window_size: float - size of each window - lib_coords: pd.DataFrame | None - coordinates of all samples for one library - coord_columns: Tuple[str, str] - Tuple of column names in `adata.obs` that specify the coordinates (x, y), i.e. ('globalX', 'globalY') - overlap: float - overlap between consecutive windows (must be less than window_size) - partial_windows: Literal["adaptive", "drop", "split"] | None - If None, possibly small windows at the edges are kept. - If 'adaptive', all windows might be shrunken a bit to avoid small windows at the edges. - If 'drop', possibly small windows at the edges are removed. - If 'split', windows are split into subwindows until not exceeding `max_nr_cells` + min_x, max_x, min_y, max_y + Extent to tile. + window_size + Size of each window. + overlap + Overlap between consecutive windows (must be less than ``window_size``). + partial_windows + Edge handling: ``"keep"`` clips edge windows to the bounds; ``"drop"`` removes windows that would + extend past the bounds; ``"adaptive"`` shrinks all windows slightly to tile the extent evenly. Returns ------- - windows: pandas DataFrame with columns ['x_start', 'x_end', 'y_start', 'y_end'] + DataFrame with columns ``['x_start', 'x_end', 'y_start', 'y_end']``. """ - # adjust x and y window size if 'adaptive' + if overlap < 0: + raise ValueError("Overlap must be non-negative.") + if overlap >= window_size: + raise ValueError("Overlap must be less than the window size.") + if partial_windows == "adaptive": total_width = max_x - min_x total_height = max_y - min_y - - # number of windows in x and y direction - number_x_windows = np.ceil((total_width - overlap) / (window_size - overlap)) - number_y_windows = np.ceil((total_height - overlap) / (window_size - overlap)) - - # window size in x and y direction - x_window_size = (total_width + (number_x_windows - 1) * overlap) / number_x_windows - y_window_size = (total_height + (number_y_windows - 1) * overlap) / number_y_windows - - # avoid float errors - x_window_size = np.ceil(x_window_size) - y_window_size = np.ceil(y_window_size) + # number of windows per axis; clamp to >= 1 so a library smaller than one window (e.g. span + # <= overlap, common when the global window_size is set from a larger library) yields a single + # window instead of dividing by zero. + number_x_windows = max(int(np.ceil((total_width - overlap) / (window_size - overlap))), 1) + number_y_windows = max(int(np.ceil((total_height - overlap) / (window_size - overlap))), 1) + # window size per axis (integer to avoid float drift) + x_window_size = np.ceil((total_width + (number_x_windows - 1) * overlap) / number_x_windows) + y_window_size = np.ceil((total_height + (number_y_windows - 1) * overlap) / number_y_windows) else: x_window_size = window_size y_window_size = window_size - # create the step sizes for each window x_step = x_window_size - overlap y_step = y_window_size - overlap - # Generate starting points - x_starts = np.arange(min_x, max_x, x_step) - y_starts = np.arange(min_y, max_y, y_step) + # Generate starting points. A non-positive step means one window already covers the whole span + # (span <= overlap) -> emit a single window at the minimum rather than an empty grid. + x_starts = np.arange(min_x, max_x, x_step) if x_step > 0 else np.array([min_x]) + y_starts = np.arange(min_y, max_y, y_step) if y_step > 0 else np.array([min_y]) # Create all combinations of x and y starting points starts = list(product(x_starts, y_starts)) @@ -327,114 +357,26 @@ def _calculate_window_corners( windows["x_end"] = windows["x_start"] + x_window_size windows["y_end"] = windows["y_start"] + y_window_size - # Adjust windows that extend beyond the bounds - if partial_windows is None: + if partial_windows == "keep": windows["x_end"] = windows["x_end"].clip(upper=max_x) windows["y_end"] = windows["y_end"].clip(upper=max_y) elif partial_windows == "adaptive": - # as window_size is an integer to avoid float errors, it can exceed max_x and max_y -> clip + # the integer window size can exceed max_x/max_y -> clip, then drop degenerate corner slivers. + # Only drop a thin window when its axis has neighbours (>1 window): a sole window covering a + # small library is thin but not redundant, and must be kept. windows["x_end"] = windows["x_end"].clip(upper=max_x) windows["y_end"] = windows["y_end"].clip(upper=max_y) - - # remove redundant windows in the corners - redundant_windows = ((windows["x_end"] - windows["x_start"]) <= overlap) | ( - (windows["y_end"] - windows["y_start"]) <= overlap - ) + thin_x = (windows["x_end"] - windows["x_start"]) <= overlap + thin_y = (windows["y_end"] - windows["y_start"]) <= overlap + # a thin window is a redundant sliver only if its axis actually has more than one window; + # a lone window covering a small library is thin but must be kept. + redundant_windows = (thin_x & (len(x_starts) > 1)) | (thin_y & (len(y_starts) > 1)) windows = windows[~redundant_windows] elif partial_windows == "drop": valid_windows = (windows["x_end"] <= max_x) & (windows["y_end"] <= max_y) windows = windows[valid_windows] - elif partial_windows == "split": - # split the slide recursively into windows with at most max_nr_cells - x_col, y_col = coord_columns - - coord_x_sorted = lib_coords.sort_values(by=[x_col]) - coord_y_sorted = lib_coords.sort_values(by=[y_col]) - - windows = _split_window( - max_nr_cells, (x_col, y_col), coord_x_sorted, coord_y_sorted, min_x, max_x, min_y, max_y - ).sort_values(["x_start", "x_end", "y_start", "y_end"]) else: - raise ValueError(f"{partial_windows} is not a valid partial_windows argument.") + raise ValueError(f"{partial_windows} is not a valid `partial_windows` argument.") windows = windows.reset_index(drop=True) return windows[["x_start", "x_end", "y_start", "y_end"]] - - -def _split_window( - max_cells: int, - coord_columns: tuple[str, str], - coord_x_sorted: pd.DataFrame, - coord_y_sorted: pd.DataFrame, - x_start: int, - x_end: int, - y_start: int, - y_end: int, -) -> pd.DataFrame: - """ - Recursively split a rectangular window into subwindows such that each subwindow - contains at most `max_cells` cells and at least `max_cells` // 2 cells. - - Parameters - ---------- - max_cells : int - Maximum number of cells allowed per window. - coord_columns: Tuple[str, str] - Tuple of column names in `adata.obs` that specify the coordinates (x, y), i.e. ('globalX', 'globalY') - coord_x_sorted : pandas.DataFrame - DataFrame containing cell coordinates, sorted by `x_col`. - coord_y_sorted : pandas.DataFrame - DataFrame containing cell coordinates, sorted by `y_col`. - x_start : int - Left (minimum) x coordinate of the current window. - x_end : int - Right (maximum) x coordinate of the current window. - y_start : int - Bottom (minimum) y coordinate of the current window. - y_end : int - Top (maximum) y coordinate of the current window. - - Returns - ------- - windows: pandas DataFrame with columns ['x_start', 'x_end', 'y_start', 'y_end'] - """ - x_col, y_col = coord_columns - - # return current window if it contains less cells than max_cells - n_cells = _get_window_mask(coord_columns, coord_x_sorted, x_start, x_end, y_start, y_end).sum() - - if n_cells <= max_cells: - return pd.DataFrame({"x_start": [x_start], "x_end": [x_end], "y_start": [y_start], "y_end": [y_end]}) - - # define start and stop indices of subsetted windows - sub_coord_x_sorted = coord_x_sorted[ - _get_window_mask(coord_columns, coord_x_sorted, x_start, x_end, y_start, y_end) - ].reset_index(drop=True) - - sub_coord_y_sorted = coord_y_sorted[ - _get_window_mask(coord_columns, coord_y_sorted, x_start, x_end, y_start, y_end) - ].reset_index(drop=True) - - middle_pos = len(sub_coord_x_sorted) // 2 - - if (x_end - x_start) > (y_end - y_start): - # vertical split - x_middle = sub_coord_x_sorted[x_col].iloc[middle_pos] - - indices = ((x_start, x_middle, y_start, y_end), (x_middle, x_end, y_start, y_end)) - else: - # horizontal split - y_middle = sub_coord_y_sorted.loc[middle_pos, y_col] - - indices = ((x_start, x_end, y_start, y_middle), (x_start, x_end, y_middle, y_end)) - - # recursively continue with either left&right or upper&lower windows pairs - windows = [] - for x_start, x_end, y_start, y_end in indices: - windows.append( - _split_window( - max_cells, (x_col, y_col), sub_coord_x_sorted, sub_coord_y_sorted, x_start, x_end, y_start, y_end - ) - ) - - return pd.concat(windows) diff --git a/tests/tools/test_sliding_window.py b/tests/tools/test_sliding_window.py index 672290de9..fd9382e09 100644 --- a/tests/tools/test_sliding_window.py +++ b/tests/tools/test_sliding_window.py @@ -1,17 +1,31 @@ from __future__ import annotations +import numpy as np +import pandas as pd import pytest from anndata import AnnData from squidpy.tl import _calculate_window_corners, sliding_window +from squidpy.tl._sliding_window import _split_cells + + +def _grid_adata(n_per_side: int = 30, extent: float = 300.0, seed: int = 0, library_key: str | None = None) -> AnnData: + """A uniform point cloud in an ``extent`` x ``extent`` square (optionally split across libraries).""" + rng = np.random.default_rng(seed) + n = n_per_side * n_per_side + xy = rng.uniform(0, extent, size=(n, 2)) + obs = pd.DataFrame({"globalX": xy[:, 0], "globalY": xy[:, 1]}, index=[f"c{i}" for i in range(n)]) + if library_key is not None: + obs[library_key] = rng.choice(["a", "b"], size=n) + return AnnData(X=np.zeros((n, 1), dtype=np.float32), obs=obs) class TestSlidingWindow: @pytest.mark.parametrize( "windowsize_overlap_partial", [ - (300, 0, None), - (300, 50, None), + (300, 0, "keep"), + (300, 50, "keep"), (300, 50, "drop"), (300, 0, "adaptive"), (300, 50, "adaptive"), @@ -20,7 +34,7 @@ class TestSlidingWindow: def test_sliding_window_several_slices( self, adata_mibitof: AnnData, - windowsize_overlap_partial: tuple[int, int, str | None], + windowsize_overlap_partial: tuple[int, int, str], sliding_window_key: str = "sliding_window_key", library_key: str = "library_id", ): @@ -47,7 +61,7 @@ def _count_total_assignments(): if overlap == 0: sliding_window_columns = [col for col in df.columns if sliding_window_key in col] assert len(sliding_window_columns) == 1 # only one sliding window - assert df[sliding_window_key].isnull().sum() == 0 # no unassigned cells + assert df[sliding_window_key].isnull().sum() == 0 # no NaN (unassigned is an explicit category) assert len(df) == adata_mibitof.n_obs # correct amount of rows else: sliding_window_cols = df.columns[df.columns.str.contains("sliding_window")] @@ -96,34 +110,63 @@ def test_sliding_window_invalid_window_size( adata_squaregrid: AnnData, ): with pytest.raises(ValueError, match="Window size must be larger than 0."): - sliding_window( - adata_squaregrid, - window_size=-10, - overlap=0, - coord_columns=("globalX", "globalY"), - sliding_window_key="sliding_window", - copy=True, - ) + sliding_window(adata_squaregrid, window_size=-10, overlap=0, coord_columns=("globalX", "globalY"), copy=True) with pytest.raises(ValueError, match="Overlap must be non-negative."): - sliding_window( - adata_squaregrid, - window_size=10, - overlap=-10, - coord_columns=("globalX", "globalY"), - sliding_window_key="sliding_window", - copy=True, - ) - - with pytest.raises(ValueError, match="`max_nr_cells` must be set when `partial_windows == split`."): - sliding_window( - adata_squaregrid, - window_size=None, - overlap=0, - partial_windows="split", - coord_columns=("globalX", "globalY"), - copy=True, - ) + sliding_window(adata_squaregrid, window_size=10, overlap=-10, coord_columns=("globalX", "globalY"), copy=True) + + with pytest.raises(ValueError, match="`max_nr_cells` must be set when method='split'."): + sliding_window(adata_squaregrid, method="split", coord_columns=("globalX", "globalY"), copy=True) + + def test_sliding_window_method_validation(self): + adata = _grid_adata() + with pytest.raises(ValueError, match="must be 'grid' or 'split'"): + sliding_window(adata, method="nope", copy=True) # type: ignore[arg-type] + with pytest.raises(ValueError, match=">= 1"): + sliding_window(adata, method="split", max_nr_cells=0, copy=True) + with pytest.raises(ValueError, match="only used with method='split'"): + sliding_window(adata, method="grid", max_nr_cells=100, copy=True) + with pytest.raises(ValueError, match="not used with method='split'"): + sliding_window(adata, method="split", max_nr_cells=100, overlap=5, copy=True) + with pytest.raises(ValueError, match="not used with method='split'"): + sliding_window(adata, method="split", max_nr_cells=100, window_size=50, copy=True) + + def test_sliding_window_drop_overlap0_no_crash(self): + """Regression: drop + overlap==0 used to raise in the categorical sort; unassigned cells are labelled.""" + adata = _grid_adata() + df = sliding_window(adata, window_size=100, overlap=0, partial_windows="drop", copy=True) + col = df["sliding_window_assignment"] + assert col.isnull().sum() == 0 # no NaN + assert "unassigned" in list(col.cat.categories) + assert col.cat.categories[-1] == "unassigned" # ordered last + assert (col == "unassigned").sum() > 0 # drop genuinely strands some edge cells here + + def test_sliding_window_deprecated_drop_partial_windows(self): + adata = _grid_adata() + with pytest.warns(FutureWarning, match="drop_partial_windows"): + deprecated = sliding_window(adata, window_size=100, overlap=0, drop_partial_windows=True, copy=True) + current = sliding_window(adata, window_size=100, overlap=0, partial_windows="drop", copy=True) + assert deprecated["sliding_window_assignment"].astype(str).equals(current["sliding_window_assignment"].astype(str)) + with pytest.raises(ValueError, match="not both"): + sliding_window(adata, drop_partial_windows=True, partial_windows="drop", copy=True) + + def test_sliding_window_adaptive_small_library(self): + """Regression (B5): a library smaller than one window must not divide-by-zero / drop its only window.""" + rng = np.random.default_rng(1) + big = rng.uniform(0, 1000, size=(500, 2)) + tiny = rng.uniform(0, 10, size=(20, 2)) # span (~10) <= overlap (50) + xy = np.vstack([big, tiny]) + obs = pd.DataFrame( + {"globalX": xy[:, 0], "globalY": xy[:, 1], "library_id": ["big"] * 500 + ["tiny"] * 20}, + index=[f"c{i}" for i in range(520)], + ) + adata = AnnData(X=np.zeros((520, 1), dtype=np.float32), obs=obs) + df = sliding_window( + adata, library_key="library_id", window_size=200, overlap=50, partial_windows="adaptive", copy=True + ) + tiny_cols = [c for c in df.columns if "tiny_" in c] + assert tiny_cols # the tiny library still gets a window + assert (df.iloc[500:][tiny_cols].sum(axis=1) > 0).all() # every tiny cell is covered def test_sliding_window_split_nr_cells( self, @@ -131,121 +174,64 @@ def test_sliding_window_split_nr_cells( sliding_window_key: str = "sliding_window_key", library_key: str = "library_id", ): - """ - Test that when using 'split', each window contains at most max_nr_cells - and at least max_nr_cells // 2 cells, - unless the total number of cells is smaller than max_nr_cells // 2. - """ + """Each window holds <= max_nr_cells and >= max_nr_cells // 2 cells (per library).""" max_nr_cells = 100 - total_cells = adata_mibitof.n_obs - df = sliding_window( adata_mibitof, library_key=library_key, sliding_window_key=sliding_window_key, - partial_windows="split", + method="split", max_nr_cells=max_nr_cells, copy=True, ) - counts = df[sliding_window_key].value_counts() - - # all windows respect the upper bound + assert df[sliding_window_key].isnull().sum() == 0 # split covers every cell assert counts.max() <= max_nr_cells - - # determine strict lower bound - lower_bound = max_nr_cells // 2 - if total_cells < lower_bound: - # if total cells are too few, just one window is allowed smaller - assert counts.max() == total_cells - else: - # otherwise, every window must satisfy the lower bound - assert (counts >= lower_bound).all() + assert (counts >= max_nr_cells // 2).all() + + def test_split_cells_partition_and_bounds(self): + """_split_cells partitions the cells (each in exactly one window) and respects the bounds, even with ties.""" + rng = np.random.default_rng(2) + coords = pd.DataFrame({"globalX": rng.uniform(0, 100, 1000), "globalY": rng.uniform(0, 100, 1000)}) + labels = _split_cells(coords, ("globalX", "globalY"), max_cells=50) + assert len(labels) == len(coords) # one label per cell -> a partition (non-overlapping, full cover) + counts = pd.Series(labels).value_counts() + assert counts.max() <= 50 + assert (counts >= 25).all() + + # degenerate: all cells share a coordinate -> must still terminate and stay bounded + same = pd.DataFrame({"globalX": np.zeros(200), "globalY": np.zeros(200)}) + labels = _split_cells(same, ("globalX", "globalY"), max_cells=50) + assert pd.Series(labels).value_counts().max() <= 50 def test_calculate_window_corners_overlap(self): - min_x = 0 - max_x = 200 - min_y = 0 - max_y = 200 - window_size = 100 - overlap = 20 - windows = _calculate_window_corners( - min_x=min_x, - max_x=max_x, - min_y=min_y, - max_y=max_y, - window_size=window_size, - overlap=overlap, - partial_windows=None, + min_x=0, max_x=200, min_y=0, max_y=200, window_size=100, overlap=20, partial_windows="keep" ) - assert windows.shape == (9, 4) assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] assert windows.iloc[-1].values.tolist() == [160, 200, 160, 200] def test_calculate_window_corners_no_overlap(self): - min_x = 0 - max_x = 200 - min_y = 0 - max_y = 200 - window_size = 100 - overlap = 0 - windows = _calculate_window_corners( - min_x=min_x, - max_x=max_x, - min_y=min_y, - max_y=max_y, - window_size=window_size, - overlap=overlap, - partial_windows=None, + min_x=0, max_x=200, min_y=0, max_y=200, window_size=100, overlap=0, partial_windows="keep" ) - assert windows.shape == (4, 4) assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] assert windows.iloc[-1].values.tolist() == [100, 200, 100, 200] def test_calculate_window_corners_drop_partial_windows(self): - min_x = 0 - max_x = 200 - min_y = 0 - max_y = 200 - window_size = 100 - overlap = 20 - windows = _calculate_window_corners( - min_x=min_x, - max_x=max_x, - min_y=min_y, - max_y=max_y, - window_size=window_size, - overlap=overlap, - partial_windows="drop", + min_x=0, max_x=200, min_y=0, max_y=200, window_size=100, overlap=20, partial_windows="drop" ) - assert windows.shape == (4, 4) assert windows.iloc[0].values.tolist() == [0, 100, 0, 100] assert windows.iloc[-1].values.tolist() == [80, 180, 80, 180] def test_calculate_window_corners_adaptive_partial_windows(self): - min_x = 0 - max_x = 200 - min_y = 0 - max_y = 200 - window_size = 100 - overlap = 20 - windows = _calculate_window_corners( - min_x=min_x, - max_x=max_x, - min_y=min_y, - max_y=max_y, - window_size=window_size, - overlap=overlap, - partial_windows="adaptive", + min_x=0, max_x=200, min_y=0, max_y=200, window_size=100, overlap=20, partial_windows="adaptive" ) - assert windows.shape == (9, 4) assert windows.iloc[0].values.tolist() == [0, 80, 0, 80] assert windows.iloc[-1].values.tolist() == [120, 200, 120, 200] From d6c411ed2ef0faa00a7a824248f054c5e81a32ae Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 13 Aug 2026 00:53:04 +0200 Subject: [PATCH 14/16] chore: drop docs/notebooks submodule bump (resolve conflict with main) The notebook change belongs in squidpy_notebooks (#156), as requested in review; reverting the submodule pointer to main also resolves the only merge conflict. --- docs/notebooks | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/notebooks b/docs/notebooks index 17d368281..4984ce95c 160000 --- a/docs/notebooks +++ b/docs/notebooks @@ -1 +1 @@ -Subproject commit 17d368281ea7b11f7e1174436bbc2429191a0245 +Subproject commit 4984ce95c7b9858ac8be8b662a32e86316d3870e From fc5e78b61cc4dd252c230dc82104e39a978bc1ef Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:53:15 +0000 Subject: [PATCH 15/16] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/tools/test_sliding_window.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_sliding_window.py b/tests/tools/test_sliding_window.py index fd9382e09..76ff0f02c 100644 --- a/tests/tools/test_sliding_window.py +++ b/tests/tools/test_sliding_window.py @@ -110,10 +110,14 @@ def test_sliding_window_invalid_window_size( adata_squaregrid: AnnData, ): with pytest.raises(ValueError, match="Window size must be larger than 0."): - sliding_window(adata_squaregrid, window_size=-10, overlap=0, coord_columns=("globalX", "globalY"), copy=True) + sliding_window( + adata_squaregrid, window_size=-10, overlap=0, coord_columns=("globalX", "globalY"), copy=True + ) with pytest.raises(ValueError, match="Overlap must be non-negative."): - sliding_window(adata_squaregrid, window_size=10, overlap=-10, coord_columns=("globalX", "globalY"), copy=True) + sliding_window( + adata_squaregrid, window_size=10, overlap=-10, coord_columns=("globalX", "globalY"), copy=True + ) with pytest.raises(ValueError, match="`max_nr_cells` must be set when method='split'."): sliding_window(adata_squaregrid, method="split", coord_columns=("globalX", "globalY"), copy=True) @@ -146,7 +150,9 @@ def test_sliding_window_deprecated_drop_partial_windows(self): with pytest.warns(FutureWarning, match="drop_partial_windows"): deprecated = sliding_window(adata, window_size=100, overlap=0, drop_partial_windows=True, copy=True) current = sliding_window(adata, window_size=100, overlap=0, partial_windows="drop", copy=True) - assert deprecated["sliding_window_assignment"].astype(str).equals(current["sliding_window_assignment"].astype(str)) + assert ( + deprecated["sliding_window_assignment"].astype(str).equals(current["sliding_window_assignment"].astype(str)) + ) with pytest.raises(ValueError, match="not both"): sliding_window(adata, drop_partial_windows=True, partial_windows="drop", copy=True) From 63fa324ba74cbdaa1b8c062c6b108b59cdca9293 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 13 Aug 2026 02:22:21 +0200 Subject: [PATCH 16/16] docs(sliding_window): note the overlap>0 one-column-per-window memory cost --- src/squidpy/tl/_sliding_window.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/squidpy/tl/_sliding_window.py b/src/squidpy/tl/_sliding_window.py index 8c39f1efa..710708b1d 100644 --- a/src/squidpy/tl/_sliding_window.py +++ b/src/squidpy/tl/_sliding_window.py @@ -55,6 +55,9 @@ def sliding_window( Size of each grid window (``method="grid"``). Inferred from the extent when ``None``. overlap: int Overlap between consecutive grid windows (0 = no overlap). Only used for ``method="grid"``. + A positive overlap produces one boolean column per window (an ``n_obs`` x ``n_windows`` table), + which can be memory-heavy for grids with many windows; ``overlap=0`` yields a single categorical + column instead. coord_columns: tuple[str, str] Column names in ``adata.obs`` holding the ``(x, y)`` coordinates, e.g. ``('globalX', 'globalY')``. sliding_window_key: str