
在空间转录组数据分析中,我们经常需要研究肿瘤细胞(Tumor)与正常组织(Normal)的交界区域。这个区域被称为浸润边缘(Invasive Margin),一般这个肿瘤边缘交界区可以使用10x的Xenium Browser(如果是VisiumHD细胞分割数据,则可以使用Loupe Browser)根据HE染色结果,手动圈选,但是细胞混杂,如何在密密麻麻的细胞中,精准地把那些在交界处的细胞挑出来比较考验耐心,就想着能不能基于细胞自动识别出肿瘤交界区,拟合一条边界线出来,便于后续研究,比如想看某些基因是否随着与交界处距离的变化它的表达也会发生变化等。
之前我们也有介绍过一篇23年的空间文章,研究的一个核心概念是肿瘤边缘的"侵袭区",文章中定义的是以肿瘤边缘为中心的500微米宽的区域,这里是肿瘤细胞侵袭和转移的活跃前沿,包含复杂的细胞成分及独特的分子特征,存在免疫抑制微环境、肿瘤细胞代谢重编程及受损肝细胞,对肿瘤进展和患者预后有关键影响。这个区域在肿瘤侵袭和转移中具有重要地位,文章后续都是围绕其位置、细胞组成、分子特征以及功能作用进行详细描述。
Cell Res | Stereo-seq揭示人类肝癌浸润区促进肝细胞-肿瘤细胞串扰、局部免疫抑制和肿瘤进展
主要就是找到肿瘤边缘,向肿瘤内部和外部扩展一定宽度,定义侵袭区

以下是这篇文章中关于肿瘤边缘的"侵袭区"的实现描述

文章方法描述中用到的是图像识别,这里我们介绍基于细胞空间位置实现的方法
利用K-近邻算法 (KNN) 的思想。如果一个细胞是“肿瘤细胞”,但是离它最近的N个邻居细胞中里出现了“正常”细胞(或者反过来),那么我们就可以确认这个细胞就处于交界区,我们可以给它打个boundary cell的标签。
如图所示,首先定义好肿瘤核心区域和正常区域

adata.obsm['spatial'] (空间坐标) 和 adata.obs['CN_category'] (细胞类型)。NearestNeighbors 对所有细胞的坐标构建索引,找出每个细胞最近的 n_neighbors 个邻居。Tumor,检查它的邻居里有没有 Normal。Normal,检查它的邻居里有没有 Tumor。True (是边界细胞)。adata.obs['is_boundary'] 中。n_neighbors=10**:这里黄色的细胞就是我们找到的处于边界上的细胞

plot_smooth_boundary_masked)这一步比较复杂,它不是简单地把黄点连起来(那样会很乱),而是采用“分类概率等高线”的方法。
将空间看作一个平面,训练一个分类器来预测平面上任意一点是 Tumor 的概率。在 Tumor 概率为 0.5 的地方,就是理论上的分界线。为了好看,增加了平滑处理;为了真实,增加了空白区域屏蔽。
is_boundary 细胞用红色高亮画出来。KNeighborsClassifier 训练。gaussian_filter)**:对概率图进行模糊处理。这会让 0 和 1 之间的过渡变得平滑,从而让画出来的线是圆润的曲线,而不是锯齿线。max_dist_threshold,将其概率设为 NaN。ax.contour 寻找概率为 0.5 的等高线。这就是 Tumor 和 Normal 分庭抗礼的分界线。min_vertex_count),只保留主边界。smooth_sigma=2 (或6)**:min_vertex_count=300**:max_dist_threshold=300**:黑色线即为你拟合出来的肿瘤细胞边界

既然已经拟合出了边界线,那么我们就可以计算每个细胞到这条线的最近距离,这个是最关键的,有了每个细胞到边界线的距离后,我们就很容易能够去自定义划定这个浸润带,比如上述说的定义一条50um宽的浸润带,那么我们只要去看,哪些细胞跟边界线的距离小于25um,这些细胞所在的区域就组成了这条50um宽的浸润带。接下来就可以去看这个区域中细胞的组成、与其他区域基因表达差异、功能差异、细胞间通讯等。

上面一步我们计算出了所有细胞到边界线的距离后,只需要根据一定的梯度距离,就可以将所有的细胞分到不同的浸润带内,只要给同一浸润带内的细胞打上标签,这样的话就能够画出多个连续的浸润带。

这个需求其实是可以投机取巧的,首先,我们还是第一步一样,计算每个细胞到边界线的距离,然后我们也知道每个细胞的类别,到底是肿瘤细胞还是正常细胞,同时也知道哪些细胞是在边界上的细胞,这样的话我们只要将除了边界上的细胞外,将正常细胞到边界线的距离值加上负号即可。

定义calculate_boundary函数实现
import os
import numpy as np
import pandas as pd
import scanpy as sc
import squidpy as sq
import seaborn as sns
import matplotlib.pyplot as plt
from scipy.spatial import cKDTree
from gene_set_enrichment import gsea
from scipy.ndimage import gaussian_filter
from sklearn.neighbors import NearestNeighbors, KNeighborsClassifier
import matplotlib.colors as mcolors
def calculate_boundary(adata, obs_col, cat_a, cat_b, n_neighbors, cat_focus, smooth_sigma=2, min_vertex_count=200, max_dist_threshold=300):
# 1. 获取坐标和标签
coords = adata.obsm['spatial']
labels = adata.obs[obs_col].values
# 2. 构建空间邻居图 (KNN)
nbrs = NearestNeighbors(n_neighbors=n_neighbors).fit(coords)
distances, indices = nbrs.kneighbors(coords)
boundary_mask = np.zeros(len(adata), dtype=bool)
# 3. 遍历每个细胞,检查其邻居
for i in range(len(adata)):
current_label = labels[i]
# 只关心 Tumor 或 Normal 细胞
if current_label not in [cat_a, cat_b]:
continue
# 获取邻居的标签
neighbor_indices = indices[i]
neighbor_labels = labels[neighbor_indices]
# 检查邻居中是否存在"对立"的类别
if current_label == cat_a and cat_b in neighbor_labels:
boundary_mask[i] = True
elif current_label == cat_b and cat_a in neighbor_labels:
boundary_mask[i] = True
# 将结果存回 adata
adata.obs['is_boundary'] = boundary_mask
print(f"识别出 {sum(boundary_mask)} 个边界细胞")
# 4. 训练KNN分类器
X = adata.obsm['spatial']
y = (adata.obs[obs_col] == cat_focus).astype(int).values
clf = KNeighborsClassifier(n_neighbors=n_neighbors)
clf.fit(X, y)
# 5. 创建网格
padding_percent = 0.1
x_min, x_max = X[:, 0].min(), X[:, 0].max()
y_min, y_max = X[:, 1].min(), X[:, 1].max()
x_pad = (x_max - x_min) * padding_percent
y_pad = (y_max - y_min) * padding_percent
grid_resolution = 500
xx, yy = np.meshgrid(np.linspace(x_min - x_pad, x_max + x_pad, grid_resolution),
np.linspace(y_min - y_pad, y_max + y_pad, grid_resolution))
grid_points = np.c_[xx.ravel(), yy.ravel()]
# 计算网格上每个点,距离最近的真实细胞有多远
nbrs_geometry = NearestNeighbors(n_neighbors=1).fit(X)
dists, _ = nbrs_geometry.kneighbors(grid_points)
dists = dists.reshape(xx.shape)
# 6. 预测概率 + 高斯模糊
Z = clf.predict_proba(grid_points)[:, 1]
Z = Z.reshape(xx.shape)
Z_smooth = gaussian_filter(Z, sigma=smooth_sigma)
# 如果网格点距离真实细胞太远,将其概率设为 NaN,这样 contour 就不会在那里画线了
Z_smooth[dists > max_dist_threshold] = np.nan
# 绘制背景散点
dot_size = 1
fig, ax = plt.subplots(figsize=(6, 6))
is_boundary = adata.obs['is_boundary'].astype(bool).values
mask_other_normal = (y == 0) & (~is_boundary)
ax.scatter(X[mask_other_normal, 0], X[mask_other_normal, 1], c='#F0A830', s=dot_size, alpha=0.8, linewidth=0)
mask_core_normal = (y == 1) & (~is_boundary)
ax.scatter(X[mask_core_normal, 0], X[mask_core_normal, 1], c='#7F1CD3', s=dot_size, alpha=0.8, linewidth=0)
mask_boundary = is_boundary
ax.scatter(X[mask_boundary, 0], X[mask_boundary, 1], c='red', s=dot_size, label='Identified Boundary Cells', alpha=0.8)
# 7. 提取轮廓, 这里的 contour 会自动处理 NaN 值(断开线条)
contours = ax.contour(xx, yy, Z_smooth, levels=[0.5], alpha=0)
# 8. 筛选并画线, 用于收集所有有效边界线上的坐标点
valid_boundary_coords = []
if len(contours.allsegs) > 0:
segments = contours.allsegs[0]
for coords in segments:
if len(coords) > min_vertex_count:
ax.plot(coords[:, 0], coords[:, 1], color='black', linewidth=2, linestyle='-', label='Fitted Curve')
valid_boundary_coords.append(coords)
ax.set_title(f"Boundary: Masked Extrapolation")
# 将拟合的线条坐标存入adata.uns, valid_boundary_coords 是一个列表,里面可能有几段线(如果是断开的)
adata.uns['fitted_boundary_coords'] = valid_boundary_coords
# Y轴坐标原点调整到左上角
ylim = ax.get_ylim()
if ylim[0] < ylim[1]:
ax.invert_yaxis()
# 计算每个细胞到拟合边界的距离并存入 adata.obs
col_name = 'is_fitted_boundary_dist'
if len(valid_boundary_coords) > 0:
# 1. 将所有有效线段的点拼接成一个大的坐标数组 (N_points, 2)
all_boundary_points = np.vstack(valid_boundary_coords)
# 2. 构建 KDTree 以便快速查找
tree = cKDTree(all_boundary_points)
# 3. 查询每个细胞(X)到最近边界点的距离
# k=1 表示找这1个最近的邻居
dist_values, _ = tree.query(X, k=1)
# 4. 存入 adata
adata.obs[col_name] = dist_values
print(f"Success: 已计算每个细胞到拟合线的距离,并存入 adata.obs['{col_name}']")
print(f" - 最小距离: {dist_values.min():.2f}")
print(f" - 最大距离: {dist_values.max():.2f}")
else:
# 如果没有找到符合条件的边界线,填充 NaN
print("Warning: 未检测到符合条件的边界线,距离列填充为 NaN。")
adata.obs[col_name] = np.nan
return adata