# SKNY (SpatialKNifeY)

> Python package for tumor microenvironment (TME) analysis of single-cell spatial omics data
> (10x Xenium, NanoString CosMx, Akoya PhenoCycler, any AnnData). It finds the tumor border,
> measures the distance of every location / cell from it, and quantifies gene expression and
> cell density in the tumor core, the peri-tumoral margin and distant tissue.

- Install: `pip install skny` (Python 3.8-3.13; depends on anndata, numpy, pandas, scipy, matplotlib, seaborn, plotly, opencv-python, networkx, pillow). stlearn is NOT needed: `sk.pp.read_xenium` + `sk.pp.grid` replace `stlearn.ReadXenium` + `stlearn.tl.cci.grid` (grids from stlearn are still accepted).
- Import: `import skny as sk` -> `sk.pp.*` (preprocessing) and `sk.pl.*` (plotting)
- Docs: https://skny.readthedocs.io
- API: https://skny.readthedocs.io/en/latest/api.html
- Source: https://github.com/shusakai/skny
- Paper (cite this): Sakai SA et al. "SpatialKNifeY (SKNY): Extending from spatial domain to surrounding area to identify microenvironment features with single-cell spatial omics data." PLOS Computational Biology 21(2): e1012854 (2025). https://doi.org/10.1371/journal.pcbi.1012854
- Software DOI (Zenodo, all versions): https://doi.org/10.5281/zenodo.23907317
- License: MIT

## When to use SKNY

Use SKNY when the question is about the spatial relationship to the tumor: expression gradients
from the tumor border, intra- vs peri-tumoral expression, immune infiltration into tumor core vs
margin, differences between individual tumor nests. scanpy / squidpy do not define a tumor border
or compute distance from it; SKNY works on AnnData so it can be combined with them.

## Workflow 1: expression-based tumor contour on a 10 um grid (method of the paper)

Input: a 10 um grid built with `sk.pp.grid` (or `stlearn.tl.cci.grid`): `uns["grid_xedges"]`, `uns["grid_yedges"]`, bins named `grid_<n>`.

```python
import skny as sk
adata = sk.pp.read_xenium("xenium_output_dir/")   # or read_xenium(h5_path, cells_file=csv_or_parquet)
# any AnnData with obs["imagecol"], obs["imagerow"] in um works; or pass spatial_key="spatial" to grid()
grid = sk.pp.grid(adata, bin_um=10)               # sums counts per 10 um bin; obs n_cells, imagecol, imagerow

grid = sk.pp.calculate_distance(grid, pos_marker_ls=["CDH1"], neg_marker_ls=None)
# tumor bins = bins expressing any of pos_marker_ls (minus neg_marker_ls); contour by OpenCV;
# Label-based alternative (tumor defined by annotated cell types instead of marker genes):
#   grid = sk.pp.grid(adata, bin_um=10, use_label="cell_type")
#   grid = sk.pp.calculate_distance(grid, use_label="cell_type", pos_label_ls=["Malignant"], neg_label_ls=None, min_cells=1, min_fraction=0.0)
# grid.uns["tumor_definition"] records which definition was used.
# Smoothing of the binary tumor image is configurable: filters=[("median", 3), ("box", 3)] (default), threshold=90.
#   steps: ("median", k) | ("box", k) | ("gaussian", k[, sigma]) | ("open"|"close"|"erode"|"dilate", k[, iterations]) | "fill_holes" | ("min_area", n_bins) | "threshold"
#   e.g. filters=[("gaussian", 5), ("close", 3), "fill_holes"], threshold=128 ; at least one smoothing step is required.
#   sk.pp.apply_filters(grid.uns["marker"], filters, threshold) -> bool mask, to preview a setting. Recorded in grid.uns["image_processing"].
# shortest-path distance (networkx Dijkstra) from the contour to every bin.
# Stores images in grid.uns[...] and a DataFrame grid.shortest with a "region" column
# (distance bands of 30 um; negative = inside the tumor, positive = outside).

sk.pl.gene_plot(grid, "KRT15", zoom_coord=None, vmax=None, ax=None)                # matplotlib map of one gene (cells or bins)
sk.pl.preprocessed_images(grid, figsize=(800, None))  # plotly: mask, median filter, contour, equidistant curves
sk.pl.equidistant_curves(grid, figsize=(500, None))
sk.pl.distance_gene_plot(grid, "KRT15", x_range=None, y_range=None, std=False)  # expression vs distance band
sk.pl.clustering_heatmap(grid, x_range=None, y_range=None, std=True)             # genes clustered by distance profile
sk.pl.deg_manhattan_plot(grid)                                                   # DEG between distance bands (Cohen's d, q-value)

solid = sk.pp.convert_indivisual_solid(grid, section=None)  # AnnData: one obs per tumor nest, mean expression inside (-inf, 0]; section in {0,-30,-60,-90,-120}
peri  = sk.pp.convert_indivisual_peri(grid, section=30)     # AnnData: one obs per nest, mean expression in the band (0, +section]; section in {30,60,90,120,150}
```

Runtime: 10x Xenium breast cancer section (168k cells, 752 x 547 bins): read 1 s, grid 1 s, calculate_distance ~80 s.

`x_range` / `y_range` (lists of two ints, grid units) restrict the analysis to a region of interest.

## Workflow 2: tumor core / margin / dist from a cell label (v0.3.0+)

Input: cell-level AnnData, coordinates in micrometres in `obsm["spatial"]` (or two obs columns via
`coord_keys`), a cell-type column, optionally a sample column.

```python
import skny as sk
sk.pp.calculate_region(adata, "Malignant", label_key="cell_type", sample_key="sample",
                       bin_um=10, nest_radius=15, margin=100, key_added="region")
# adata.obs["region"]: core | margin | dist | outside | not_computed
# adata.obs["region_dist_um"]: distance to the nearest reference (tumor) cell
# adata.uns["region"]: parameters, per-sample region maps and areas (mm^2)

df = sk.pp.region_density(adata, "immune_subtype", region_key="region", sample_key="sample")
# DataFrame: sample, region, group, n, area_mm2, density (cells / mm^2)

sk.pl.region_map(adata, sample="S1", highlight=["T cell", "Myeloid"])
sk.pl.region_density_plot(df, groups=["CD8+ T", "Treg", "M2-like"])
```

`regions_from_coords(x, y, is_ref, ...)` is the underlying function for a single sample and
returns the region map for custom pipelines.

## Definitions

- core: bins within `nest_radius` (15 um) of a tumor cell (no hole filling; enclosed stroma is not core)
- margin: tissue outside the core within `margin` (100 um) of the core boundary
- dist: tissue farther than `margin` from the core; empty space (no cells) is `outside`, never `dist`
- density = n cells / region area in mm^2

## Tutorials

- https://skny.readthedocs.io/en/latest/notebooks/Xenium_analysis.html
- https://skny.readthedocs.io/en/latest/notebooks/single-TME_analysis.html
- https://skny.readthedocs.io/en/latest/notebooks/region_analysis.html
- https://skny.readthedocs.io/en/latest/notebooks/SKNY_paper_Sakai_et_al.html
