# 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). `pip install "skny[stlearn]"` adds stlearn, needed only to build the grid for workflow 1.
- 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
- 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: an AnnData from `stlearn.tl.cci.grid` (10 um bins, `uns["grid_xedges"]`, `uns["grid_yedges"]`).

```python
import skny as sk, stlearn as st
adata = st.ReadXenium(feature_cell_matrix_file=..., cell_summary_file=..., library_id="x", image_path=None, scale=1, spot_diameter_fullres=10)
n_col = int((adata.obs.imagecol.max() - adata.obs.imagecol.min()) / 10)
n_row = int((adata.obs.imagerow.max() - adata.obs.imagerow.min()) / 10)
grid = st.tl.cci.grid(adata, n_row=n_row, n_col=n_col, n_cpus=8, verbose=False)

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;
# 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.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}
```

`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
