编者按
本期更新的是Xenium格式的全流程Python分析,我们将其整个流程都完整封装进了OmicVerse,希望对相关研究者有所帮助。
需要注意的是,本文里的可视化函数不支持通过squidpy,spatialdata方式读取的nanostring数据。
steorra
10x Genomics Xenium 是这两年用得越来越多的一类高分辨率空间转录组平台。它和传统 spot 级技术最大的区别,不是“分辨率更高”这句空话,而是数据结构真的变了。你拿到手的不只是一个表达矩阵,还会同时拿到细胞质心坐标、polygon 边界,以及形态学图像。
所以 Xenium 教程真正难的地方,从来都不只是“把数据读进来”。更现实的问题是:坐标有没有对上,分割边界靠不靠谱,最后算出来的聚类能不能放回组织里解释。
本教程以 10x 提供的公开数据集Xenium FFPE Human Breast Cancer Replicate 1为例,演示 OmicVerse 里一条比较顺手、也比较实用的 Xenium 工作流:
ov.io.read_xenium()读入数据,检查坐标、metadata 和 polygon。你会发现,Xenium 和 CosMx、Visium HD 的分析思路有不少相通的地方,但它对“局部区域放大”和“图像配准检查”的依赖会更强。原因很简单:它天生就是带分割边界的单细胞空间数据。
这一部分主要完成三件事:导入omicverse、设置统一绘图风格,以及启用自动重载。
ov.style(font_path='Arial'):保证后续图像在不同机器上导出时风格尽量一致。%autoreload 2:如果你在本地调试 OmicVerse 源码,重新运行单元时会自动加载修改后的模块。Path和numpy:后面分别用于路径组织和基础数值操作。from pathlib import Path
import numpy as np
import omicverse as ov
ov.style(font_path='Arial')
%load_ext autoreload
%autoreload 2
🔬 Starting plot initialization...
Using already downloaded Arial font from: /tmp/omicverse_arial.ttf
Registered as: Arial
🧬 Detecting GPU devices…
✅ NVIDIA CUDA GPUs detected: 1
• [CUDA 0] NVIDIA H100 80GB HBM3
Memory: 79.1 GB | Compute: 9.0
____ _ _ __
/ __ \____ ___ (_)___| | / /__ _____________
/ / / / __ `__ \/ / ___/ | / / _ \/ ___/ ___/ _ \
/ /_/ / / / / / / / /__ | |/ / __/ / (__ ) __/
\____/_/ /_/ /_/_/\___/ |___/\___/_/ /____/\___/
🔖 Version: 2.1.2rc1 📚 Tutorials: https://omicverse.readthedocs.io/
✅ plot_set complete.
和前面几篇教程一样,这里也会初始化 OmicVerse 的 CPU/GPU 混合运行模式。对 Xenium 这种十几万细胞量级的数据来说,后面的 PCA、邻接图和 Leiden 会更受益。
ov.settings.cpu_gpu_mixed_init()
CPU-GPU mixed mode activated
Available GPU accelerators: CUDA
这里使用的是 10x 官方公开的Xenium FFPE Human Breast Cancer Replicate 1。数据量不算小,大约 16.7 万个细胞、313 个基因,但正好适合拿来走一遍完整流程。
Xenium 的原始outs/目录通常挺大。除了计数矩阵和细胞 metadata,它还会附带边界文件和多通道形态学 OME-TIFF。真要动手分析时,你不用一上来就把所有文件都背下来。先记住最关键的几样就够了:
cell_feature_matrix.h5:细胞 × 基因计数矩阵。cells.csv.gz:细胞级 metadata,包括质心坐标。cell_boundaries.parquet:每个细胞的 polygon 边界。experiment.xenium:实验 metadata。如果你还想把聚类结果叠加到 DAPI / morphology 背景上,则需要额外下载morphology_focus.ome.tif。
# !mkdir -p data/xenium_breast_rep1
# BASE='https://cf.10xgenomics.com/samples/xenium/1.0.1/Xenium_FFPE_Human_Breast_Cancer_Rep1'
# !wget -O data/xenium_breast_rep1/cell_feature_matrix.h5 $BASE/Xenium_FFPE_Human_Breast_Cancer_Rep1_cell_feature_matrix.h5
# !wget -O data/xenium_breast_rep1/cells.csv.gz $BASE/Xenium_FFPE_Human_Breast_Cancer_Rep1_cells.csv.gz
# !wget -O data/xenium_breast_rep1/cell_boundaries.parquet $BASE/Xenium_FFPE_Human_Breast_Cancer_Rep1_cell_boundaries.parquet
# !wget -O data/xenium_breast_rep1/experiment.xenium $BASE/Xenium_FFPE_Human_Breast_Cancer_Rep1_experiment.xenium
# 可选项:只有需要叠加形态学背景时才下载
# !wget -O data/xenium_breast_rep1/morphology_focus.ome.tif $BASE/Xenium_FFPE_Human_Breast_Cancer_Rep1_morphology_focus.ome.tif
正式读入前,先把目录树打印出来,这一步很值。Xenium 这种多文件拼起来的数据,很多问题其实都是路径问题。早点看清楚,省得后面报错了再倒回来找。
sample_dir = Path('data') / 'xenium_breast_rep1'
ov.utils.print_tree(sample_dir)
xenium_breast_rep1/
├── cell_boundaries.parquet
├── cell_feature_matrix.h5
├── cells.csv.gz
├── experiment.xenium
└── morphology_focus.ome.tif
这里的核心入口是ov.io.read_xenium()。它做的事不只是“读矩阵”,而是把 Xenium 最关键的几类空间信息一起装进同一个AnnData:
cell_feature_matrix.h5;cells.csv.gz合并到obs;obsm['spatial'];cell_boundaries.parquet被转换成 WKTPOLYGON字符串,存到obs['geometry'];experiment.xenium则进入uns['spatial'][library_id]['metadata']。这一步对 Xenium 特别重要。因为只要这些对象都被规整进同一个adata,后面很多事就会自然很多:整切片看全局,局部区域看 polygon,叠加形态学图像做检查,全都能接得上。
adata = ov.io.read_xenium(sample_dir, load_image=False)
adata
[Xenium] Reading Xenium data from: /scratch/users/steorra/xenium_test/breast_rep1
[Xenium] Loaded cell polygons (geometry WKT) for 167780/167780 cells
[Xenium] Done (n_obs=167780, n_vars=313, library_id=Replicate 1)
AnnData object with n_obs × n_vars = 167780 × 313
obs: 'transcript_counts', 'control_probe_counts', 'control_codeword_counts', 'total_counts', 'cell_area', 'nucleus_area', 'geometry'
var: 'gene_ids', 'feature_types', 'genome'
uns: 'spatial', 'omicverse_io'
obsm: 'spatial'
读完以后别急着往下跑。先打印几个关键量,确认三件事:
library_id;library_id = next(iter(adata.uns['spatial']))
sf = adata.uns['spatial'][library_id]['scalefactors']
print('library_id :', library_id)
print('spatial range (µm) :',
adata.obsm['spatial'].min(axis=0).tolist(), '->',
adata.obsm['spatial'].max(axis=0).tolist())
print('pixel size (µm/px) :', 1 / sf['tissue_hires_scalef'])
print('mean cell diameter :', round(sf['spot_diameter_fullres'] / sf['tissue_hires_scalef'], 2), 'µm')
print('cells with geometry:', (adata.obs['geometry'] != '').sum())
library_id : Replicate 1
spatial range (µm) : [2.1325161457061768, 3.642256736755371] -> [7523.0869140625, 5474.37939453125]
pixel size (µm/px) : 0.2125
mean cell diameter : 15.7 µm
cells with geometry: 164000
在进入过滤和聚类之前,先别急。先对整张切片做一个整体预览。这里看两个最常用的量:
total_counts:每个细胞捕获到的总转录本数;cell_area:细胞面积。原因也很直接。对于肿瘤样本,计数通常会在肿瘤上皮区域更集中;而细胞面积往往能帮你很快区分更小的免疫细胞和更大的上皮细胞。如果这里看上去就不对劲,后面的聚类大概率也解释不通。
import matplotlib.pyplot as plt
fig, axs = plt.subplots(1, 2, figsize=(12, 5))
ov.pl.embedding(
adata, basis='spatial', color='total_counts',
vmax='p99', cmap='Reds', ax=axs[0], show=False, title='total_counts',
)
axs[0].invert_yaxis()
ov.pl.embedding(
adata, basis='spatial', color='cell_area',
vmax='p99', cmap='viridis', ax=axs[1], show=False, title='cell_area (µm²)',
)
axs[1].invert_yaxis()
plt.tight_layout()

total_counts与cell_area的空间分布。接下来做一个最基础的过滤:去掉转录本数太低的细胞。对 Xenium 来说,这类对象通常是分割伪影、边缘细胞,或者离背景太近的位置。
这里用10个 counts 作为一个比较温和的阈值。目的不是一步到位把 QC 做到极致,而是先把最明显异常的对象拿掉。真到项目里,你还是应该结合total_counts分布再细调。
import scipy.sparse as sp
counts = np.asarray(adata.X.sum(axis=1)).ravel() if sp.issparse(adata.X) else adata.X.sum(axis=1)
print(f'cells pre-QC : {adata.n_obs}')
adata = adata[counts >= 10].copy()
print(f'cells post-QC: {adata.n_obs} (>= 10 transcripts/cell)')
cells pre-QC : 167780
cells post-QC: 164000 (>= 10 transcripts/cell)
Xenium 这份 panel 一共只有 313 个基因,所以这里不再做 HVG 选择,而是直接把全部基因送进 PCA。整体预处理流程沿用单细胞里最常见的一套:
normalize_totallog1pscale这里没什么花哨技巧,核心就是把数据整理到适合降维和图构建的状态。
ov.pp.normalize_total(adata, target_sum=1e4)
ov.pp.log1p(adata)
ov.pp.scale(adata)
🔍 Count Normalization:
Target sum: 10000.0
Exclude highly expressed: False
✅ Count Normalization Completed Successfully!
✓ Processed: 164,000 cells × 313 genes
✓ Runtime: 0.04s
╭─ SUMMARY: scale ───────────────────────────────────────────────────╮
│ Duration: 0.7115s │
│ Shape: 164,000 x 313 (Unchanged) │
│ │
│ CHANGES DETECTED │
│ ──────────────── │
│ ● UNS │ ✚ REFERENCE_MANU │
│ │ ✚ status │
│ │ ✚ status_args │
│ │
│ ● LAYERS │ ✚ scaled (array, 164000x313) │
│ │
╰────────────────────────────────────────────────────────────────────╯
这一段就是标准下游分析主线:先 PCA,再建邻接图,最后跑 Leiden。
对这个大小的 Xenium 切片来说,resolution=0.5一般会得到一个中等粒度的聚类结果。先拿它看组织 compartment 和主要细胞状态很合适。如果你想拆得更细,就继续调高;如果只想保留更粗的结构,就往下调。
ov.pp.pca(adata, layer='scaled', n_pcs=50)
ov.pp.neighbors(
adata, n_neighbors=15,
use_rep='scaled|original|X_pca', n_pcs=50,
)
ov.pp.leiden(adata, resolution=0.5)
print(f"leiden: {adata.obs['leiden'].nunique()} clusters")
🚀 Using GPU to calculate PCA...
NVIDIA CUDA GPUs detected:
📊 [CUDA 0] NVIDIA H100 80GB HBM3
------------------------------ 5/81559 MiB (0.0%)
computing PCA🔍
with n_comps=50
Using CUDA device: NVIDIA H100 80GB HBM3
✅ Using built-in torch_pca for GPU-accelerated PCA
🚀 Using torch_pca PCA for CUDA GPU acceleration
🚀 torch_pca PCA backend: CUDA GPU acceleration (supports sparse matrices)
📊 PCA input data type: ndarray, shape: (164000, 313), dtype: float64
🔧 solver_used_in_uns (planned): covariance_eigh
🔧 PCA solver used: covariance_eigh
finished✅ (0.56s)
╭─ SUMMARY: pca ─────────────────────────────────────────────────────╮
│ Duration: 0.5841s │
│ Shape: 164,000 x 313 (Unchanged) │
│ │
│ CHANGES DETECTED │
│ ──────────────── │
│ ● UNS │ ✚ pca │
│ │ └─ params: {'zero_center': True, 'use_highly_variable': Fa...│
│ │ ✚ scaled|original|cum_sum_eigenvalues │
│ │ ✚ scaled|original|pca_var_ratios │
│ │
│ ● OBSM │ ✚ X_pca (array, 164000x50) │
│ │ ✚ scaled|original|X_pca (array, 164000x50) │
│ │
╰────────────────────────────────────────────────────────────────────╯
🚀 Using torch CPU/GPU mixed mode to calculate neighbors...
NVIDIA CUDA GPUs detected:
📊 [CUDA 0] NVIDIA H100 80GB HBM3
------------------------------ 1487/81559 MiB (1.8%)
🚀 Mixed mode default transformer: pyg
🔍 K-Nearest Neighbors Graph Construction:
Mode: cpu-gpu-mixed
Neighbors: 15
Method: torch
Metric: euclidean
Transformer: pyg
Representation: scaled|original|X_pca
PCs used: 50
🔍 Computing neighbor distances...
💡 Using PyTorch Geometric KNN on cuda
🔍 Computing connectivity matrix...
💡 Using UMAP-style connectivity
✓ Graph is fully connected
✅ KNN Graph Construction Completed Successfully!
✓ Processed: 164,000 cells with 15 neighbors each
✓ Results added to AnnData object:
• 'neighbors': Neighbors metadata (adata.uns)
• 'distances': Distance matrix (adata.obsp)
• 'connectivities': Connectivity matrix (adata.obsp)
╭─ SUMMARY: neighbors ───────────────────────────────────────────────╮
│ Duration: 12.3926s │
│ Shape: 164,000 x 313 (Unchanged) │
│ │
│ CHANGES DETECTED │
│ ──────────────── │
│ ● UNS │ ✚ neighbors │
│ │ └─ params: {'n_neighbors': 15, 'method': 'torch', 'random_...│
│ │
│ ● OBSP │ ✚ connectivities (sparse matrix, 164000x164000) │
│ │ ✚ distances (sparse matrix, 164000x164000) │
│ │
╰────────────────────────────────────────────────────────────────────╯
⚙️ Using torch CPU/GPU mixed mode to calculate Leiden...
NVIDIA CUDA GPUs detected:
📊 [CUDA 0] NVIDIA H100 80GB HBM3
|----------------------------- 2943/81559 MiB (3.6%)
Using batch size `n_batches` calculated from sqrt(n_obs): 404
Leiden: 0%| | 0/10 [00:00<?, ?it/s]
L0 Batched(404): 0%| | 0/100 [00:00<?, ?it/s]
L1 Batched(404): 0%| | 0/100 [00:00<?, ?it/s]
L2 Batched(404): 0%| | 0/100 [00:00<?, ?it/s]
L3 Batched(404): 0%| | 0/100 [00:00<?, ?it/s]
╭─ SUMMARY: leiden ──────────────────────────────────────────────────╮
│ Duration: 158.7248s │
│ Shape: 164,000 x 313 (Unchanged) │
│ │
│ CHANGES DETECTED │
│ ──────────────── │
│ ● OBS │ ✚ leiden (category) │
│ │
│ ● UNS │ ✚ leiden │
│ │ └─ params: {'resolution': 0.5, 'random_state': 0, 'local_i...│
│ │
╰────────────────────────────────────────────────────────────────────╯
leiden: 20 clusters
完成 Leiden 之后,先不要急着看局部 polygon。先回到整张切片上确认整体格局。这里同时画:
leiden:看大尺度空间 compartment;这一步更像“导航图”。先把大方向看明白,后面你才知道该去放大哪一块。
marker = next((g for g in ['KRT7', 'EPCAM', 'ERBB2', 'ESR1', 'KRT14']
if g in adata.var_names), adata.var_names[0])
fig, axs = plt.subplots(1, 2, figsize=(13, 6))
ov.pl.embedding(
adata, basis='spatial', color='leiden',
palette=ov.pl.palette_112,
legend_fontsize=8, ax=axs[0], show=False, title='Leiden clusters',
)
axs[0].invert_yaxis()
ov.pl.embedding(
adata, basis='spatial', color=marker,
vmax='p99.2', cmap='Reds', ax=axs[1], show=False, title=marker,
)
axs[1].invert_yaxis()
plt.tight_layout()

和 Visium HD 的 bin 视图不一样,Xenium 一个很值钱的地方,就是它天然带着细胞级 polygon。走到这一步,就不该只停留在质心散点图了,而要切到ov.pl.spatialseg()看真实分割边界。
不过这里有个很现实的限制:整张 16 万细胞切片的 polygon 如果一次性全画出来,图会又慢又挤,单个细胞几乎看不清。所以更合理的做法是:
ov.pl.embedding()看全局;ov.pl.spatialseg()看真实细胞边界。ov.pl.spatialseg(
adata, color='leiden',
library_id=library_id,
edges_color='white', edges_width=0.3,
alpha=1.0, legend_fontsize=8,
palette=ov.pl.palette_112,
crop_coord=(2000, 3200, 2500, 3700),
figsize=(7, 6),
)

ov.pl.spatialseg更适合观察真实细胞 polygon。如果把同一块区域切换成 marker 着色,你就能更直观地判断:当前聚类边界和基因表达模式到底是不是一回事。很多时候,这一步比单独看表格更有说服力。
import numpy as np
expr = adata[:, marker].X.toarray().ravel() if hasattr(adata[:, marker].X, 'toarray') else np.asarray(adata[:, marker].X).ravel()
vmax_marker = float(np.percentile(expr[expr > 0], 99)) if (expr > 0).any() else1.0
ov.pl.spatialseg(
adata, color=marker,
library_id=library_id,
edges_color='white', edges_width=0.3,
alpha=1.0, legend_fontsize=8,
cmap='Reds', vmax=vmax_marker,
crop_coord=(2000, 3200, 2500, 3700),
figsize=(7, 6),
)

如果你已经下载了morphology_focus.ome.tif,下一步就可以把 Leiden 标签直接放回形态学背景里看。这里重新调用read_xenium(load_image=True),读取带图像版本的对象,再把已经算好的 Leiden 标签带过去。
这样做的好处很直接:不用为了加一张底图,就在带图像版本上重跑整条分析流程。
adata_img = ov.io.read_xenium(sample_dir, load_image=True)
adata_img.obs['leiden'] = adata.obs['leiden'] # carry over labels
ov.pl.spatialseg(
adata_img, color='leiden', library_id=library_id,
alpha_img=0.5, alpha=0.8,
palette=ov.pl.palette_112,
crop_coord=(2000, 3200, 2500, 3700),
)
对于 Xenium 这类大图像样本,一个经验非常重要:无论你是看聚类还是 marker,都优先用crop_coord放大局部区域,而不是直接在整张背景图上硬画全部 polygon。
分析完成后,把当前AnnData直接写盘保存。下次再打开时,就可以跳过前面的读入、QC 和聚类步骤,直接进入可视化或下游分析。
adata.write('data/xenium_breast_rep1_processed.h5ad')
# adata = ov.read('data/xenium_breast_rep1_processed.h5ad')
Xenium 还有一个很适合做工程优化的点:第一次读取时,read_xenium()不只是读矩阵,还要把cells.csv.gz、cell_boundaries.parquet和实验 metadata 一起组装进AnnData,其中 polygon 的 WKT 构建会花掉一部分时间。
所以,如果你会反复重开 notebook 或来回调图,最好直接用cache_file=写一个缓存版 h5ad。后面再读同一个缓存文件时,速度通常会快很多。
import time, os
cache_path = 'data/xenium_breast_rep1_cache.h5ad'
if os.path.exists(cache_path):
os.remove(cache_path) # 为了展示时间差异,这里先删除旧缓存
t0 = time.time()
_ = ov.io.read_xenium(sample_dir, load_image=False, cache_file=cache_path)
t_cold = time.time() - t0
t0 = time.time()
_ = ov.io.read_xenium(sample_dir, cache_file=cache_path)
t_warm = time.time() - t0
print(f'cold (raw parse + cache write): {t_cold:.2f} s')
print(f'warm (cache read) : {t_warm:.2f} s')
print(f'speedup : {t_cold / t_warm:.1f}x')
[Xenium] Reading Xenium data from: /scratch/users/steorra/xenium_test/breast_rep1
[Xenium] Loaded cell polygons (geometry WKT) for 167780/167780 cells
[Xenium] Wrote cache AnnData to: /scratch/users/steorra/analysis/omicverse_dev/omicverse/omicverse_guide/docs/Tutorials-space/data/xenium_breast_rep1_cache.h5ad
[Xenium] Done (n_obs=167780, n_vars=313, library_id=Replicate 1)
[Xenium] Reading cached AnnData from: /scratch/users/steorra/analysis/omicverse_dev/omicverse/omicverse_guide/docs/Tutorials-space/data/xenium_breast_rep1_cache.h5ad
cold (raw parse + cache write): 9.70 s
warm (cache read) : 0.27 s
speedup : 35.9x
前面的load_image=True只是说明 Xenium 可以带底图读入。这里再往前走一步,真正把聚类和 marker 叠加回形态学图像里,看空间结果是不是和组织结构对得上。
对 Xenium 来说,这一步往往比单纯看 UMAP 或数值表格更重要。因为你最后真正想回答的,通常不是“分成了几个群”,而是“这些群在组织里到底在哪儿,边界合不合理,和形态学有没有对应关系”。
# 一次性调用:读取图像、组装完整 AnnData,并把结果写入缓存
adata_img = ov.io.read_xenium(
sample_dir,
load_image=True,
image_max_dim=4096,
cache_file='data/xenium_breast_rep1_with_image_cache.h5ad',
)
# 把已经处理好的 adata 中的 Leiden 标签带过来,
# 这样无需在带图像版本上重新跑整条分析流程。
adata_img = adata_img[adata.obs_names].copy()
adata_img.obs['leiden'] = adata.obs['leiden'].values
img = adata_img.uns['spatial'][library_id]['images']['hires']
sf = adata_img.uns['spatial'][library_id]['scalefactors']
print('image shape :', img.shape, 'dtype:', img.dtype)
print('hires_scalef :', round(sf['tissue_hires_scalef'], 4),
'(micron -> image-pixel)')
print('fullres diameter:', round(sf['spot_diameter_fullres'], 2), 'px')
[Xenium] Reading cached AnnData from: /scratch/users/steorra/analysis/omicverse_dev/omicverse/omicverse_guide/docs/Tutorials-space/data/xenium_breast_rep1_with_image_cache.h5ad
image shape : (1611, 2213) dtype: uint16
hires_scalef : 0.2941 (micron -> image-pixel)
fullres diameter: 4.62 px
下面这几张图的读法其实很简单:
ov.pl.spatialseg(
adata_img, color='leiden',
library_id=library_id,
edges_color='white', edges_width=0.4,
alpha=0.45, alpha_img=1.0,
legend_fontsize=8,
palette=ov.pl.palette_112,
crop_coord=(2000, 3200, 2500, 3700),
figsize=(7, 6),
)

ov.pl.spatialseg(
adata_img, color=marker,
library_id=library_id,
edges_color='white', edges_width=0.4,
alpha=0.65, alpha_img=1.0,
legend_fontsize=8,
cmap='Reds', vmax=vmax_marker,
crop_coord=(2000, 3200, 2500, 3700),
figsize=(7, 6),
)

ov.pl.spatialseg(
adata_img, color='KRT7',
library_id=library_id,
edges_color='white', edges_width=0.4,
alpha=0.65, alpha_img=1.0,
legend_fontsize=8,
cmap=ov.pl.create_custom_colormap('#a51616'), vmax=10,
crop_coord=(2000, 3200, 2500, 3700),
figsize=(7, 6),
)

有时为了更容易看清单细胞轮廓,可以进一步增大seg_contourpx。这种调整不改变表达值,只是让边界显示得更明显。
ov.pl.spatialseg(
adata_img, color='KRT7',
library_id=library_id,
edges_color='white', edges_width=0.4,
alpha=0.65, alpha_img=1.0,
legend_fontsize=8,
cmap=ov.pl.create_custom_colormap('#a51616'), vmax=10,
seg_contourpx=1.5,
crop_coord=(2000, 3200, 2500, 3700),
figsize=(7, 6),
)

seg_contourpx后,polygon 边界会更加清晰。这是 Xenium 教程里一个很值得保留的小检查。很多时候,聚类本身看起来没什么问题,但如果 polygon 和 DAPI 没对齐,后面的空间解释还是会偏。
所以最后做一个很直接的 sanity check:只保留 polygon 轮廓线,不做填充,然后叠加到一个更小的 DAPI 局部区域上。理想情况下,每个 polygon 里面都应该能看到对应的亮核。
# 直接手工渲染 polygon,这样可以完整控制 facecolor='none',
# 避免填充颜色把 DAPI 背景完全遮住(`ov.pl.spatialseg(alpha=0, ...)`
# 在 alpha 为 0 时会直接跳过整个 collection)。
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon as _MplPoly
from matplotlib.collections import PatchCollection
from shapely import wkt as _wkt
x0u, x1u, y0u, y1u = 2400, 2800, 2900, 3300# 更紧的局部裁剪(微米)
sf = adata_img.uns['spatial'][library_id]['scalefactors']['tissue_hires_scalef']
xy = adata_img.obsm['spatial']
mask = (xy[:,0] > x0u) & (xy[:,0] < x1u) & (xy[:,1] > y0u) & (xy[:,1] < y1u)
fig, ax = plt.subplots(figsize=(7, 6))
img = adata_img.uns['spatial'][library_id]['images']['hires']
ax.imshow(img, origin='upper', cmap='gray',
vmax=float(np.percentile(img, 99.5)))
patches = []
for i in np.where(mask)[0]:
w = adata_img.obs['geometry'].iloc[i]
ifnot w: continue
geom = _wkt.loads(w)
ifnot hasattr(geom, 'exterior'): continue
xs, ys = geom.exterior.xy
pts = np.column_stack((np.array(xs)*sf, np.array(ys)*sf))
patches.append(_MplPoly(pts, closed=True))
ax.add_collection(PatchCollection(
patches, facecolor='none', edgecolor='yellow', linewidth=0.5, alpha=0.9,
))
ax.set_xlim(x0u * sf, x1u * sf)
ax.set_ylim(y1u * sf, y0u * sf) # 反转 y 轴以匹配图像坐标习惯
ax.set_title(f'alignment check — {len(patches)} cells (yellow) on DAPI')
ax.set_aspect('equal')
plt.show()
