2. 数据格式与预处理:Visium输出文件、质量控制与过滤
做空间转录组的第一步,就是搞清楚Visium平台到底给了我们什么。我最初接触这个平台时,对着三个文件看了半天,心想:这仨东西怎么拼一起?后来在项目里一遍遍折腾,才摸清它们的脾气。说白了,一套标准的Visium输出就三样:H5、TIFF、JSON。缺一不可,少一个你就没法往下走。
2.1 Visium输出的三大文件
下表把这三种文件的角色说清楚了。建议你打印出来贴在工位上,或者至少记住核心区别。
| 文件 | 后缀 | 核心内容 | 用途 |
|---|---|---|---|
| H5 | .h5 | 基因表达矩阵(UMI计数) | 定量每个spot的基因表达 |
| TIFF | .tiff | 组织切片的高分辨率染色图像 | 图像配准、组织识别、spot坐标映射 |
| JSON | .json | 空间坐标、组织检测结果、元数据 | 定义每个spot的位置和组织归属 |
这三者的关系我画了一张图,看完你就能明白整个预处理链路。
你看,数据从三个源头进来,先做对齐整合,再做质量控制,最后过滤掉那些“污染”或“low quality”的spot。这张图的流程我建议你用自己的数据跑一遍,体会会更深。
2.2 H5文件:表达矩阵的存储格式
H5是HDF5格式,说白了就是一个多层文件夹结构的二进制文件。你可以把它想象成一个压缩包,里面包含:基因名列表、barcode列表、以及一个稀疏的计数矩阵。
我用Python的h5py库打开过一个实际数据,结构大致是这样:
import h5py
with h5py.File("sample_filtered_feature_bc_matrix.h5", "r") as f:
print(f.keys())
# 输出: <KeysViewHDF5 ['matrix', 'features', 'barcodes']>
print(f['matrix'].keys())
# 输出: <KeysViewHDF5 ['barcodes', 'data', 'indices', 'indptr', 'shape']>
print(f['matrix/shape'][:])
# 输出: [36601 4775] # 36601个基因,4775个spot
我记得第一次看这个结构时,被“indices”、“indptr”搞晕了。后来才明白,这就是标准的CSR稀疏矩阵存储。你不需要自己写解析代码,因为现成的工具(比如Scanpy的sc.read_10x_h5())已经帮你处理好了。但懂一点底层总没坏处。
2.3 TIFF图像:组织切片的高分辨率图像
TIFF文件里存的是组织切片的染色图——通常是H&E染色后的高分辨率扫描。这张图有多重要呢?它决定了你每个spot对应的组织位置是否准确。Space Runner在跑的时候,已经用图像做了alignment,把spot坐标和像素坐标对应起来了。
但你拿到TIFF后,可能还需要做两件事:一是检查图像方向,二是手动确认组织区域是否覆盖了所有spot。我遇到过好几次:在项目初期,我们怀疑某个spot的组织注释不对,后来发现是TIFF在读取时被自动旋转了90度。嗯,这里要注意——很多看图软件和Python的图像库对TIFF Orientation的解读不一样。
# 用tifffile读取,保持原始方向
import tifffile as tif
img = tif.imread("tissue_hires_image.tiff")
print(img.shape) # (2048, 2049, 3) - 高度x宽度x通道
空间坐标和图像之间的转换,靠的是JSON里提供的“tissue_positions_list”和“scalefactors_json.json”。这些坐标是相对于图像像素的,但实际分析时会归一化到hm距离,所以别搞混单位。
2.4 JSON文件:空间坐标与元数据
JSON文件像是一个说明书。我随便打开一个样本的“spatial/tissue_positions_list.json”,内容是这样的:
{
"barcode": "ACGGCTGAGTCTCGCT-1",
"in_tissue": 1,
"array_row": 0,
"array_col": 0,
"pxl_row_in_fullres": 2130,
"pxl_col_in_fullres": 1500
}
这里in_tissue字段决定了一个spot是否落在组织区域内。有些spot可能离组织很远,根本没有捕获到细胞,它们理论上就不应该参与后续分析。不过,有时候组织边缘的spot也可能标记为1,但实际上只包含少量碎片——这就是需要QC的原因。
另一个关键文件是scalefactors_json.json,它提供了将像素坐标转换为um坐标的缩放因子:
{
"spot_diameter_fullres": 89.56,
"fullres": "hires",
"tissue_hires_scalef": 0.251289,
"fiducial_diameter_fullres": 190.0
}
我建议你先检查tissue_hires_scalef——如果它特别小,说明你的hires图像分辨率太低,可能看不清细节。那也没关系,低分辨率图像不影响表达矩阵计算。
2.5 数据质量控制(QC)
为什么需要QC?你想想看,一个spot的直径大约55微米,可以捕获1到10个细胞。但如果这个spot刚好沾上了坏死的组织碎片、或者被气泡污染,它的UMI计数和基因数就会异常。不剔除这些异常点,后面的聚类和差异分析结果就会飘。
常见的QC指标有三个:
- 总UMI计数:过低的往往表示没有捕获到细胞,过高的可能是多重捕获。
- 检测到的基因数:通常和总UMI计数正相关,但如果基因数少而UMI多,说明可能是光学噪声或非特异结合。
- 线粒体基因占比:超过15%-20%通常表示细胞膜破损或组织坏死。
我记得在分析一个肿瘤样本时,发现一批spot的线粒体比例高达40%——后来跟病理确认,那个区域正好是坏死区。所以高线粒体比例不只是技术质量问题,也可能提示真实的生物学特征。但作为预处理,我们还是先把它剔除掉,再单独分析。
2.6 过滤低质量点
拿到QC指标后,就该动手筛掉那些糟糕的spot了。以Scanpy为例,做法是这样的:
import scanpy as sc
import pandas as pd
# 读取数据
adata = sc.read_10x_h5("filtered_feature_bc_matrix.h5")
# 计算QC指标
adata.var["mt"] = adata.var_names.str.startswith("MT-") # 人
sc.pp.calculate_qc_metrics(adata, qc_vars=["mt"], percent_top=None, log1p=False)
# 过滤条件
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_cells(adata, max_genes=6000)
adata = adata[adata.obs.pct_counts_mt < 20, :]
这些阈值(min_genes=200, max_genes=6000, mt<20%)是比较常规的。但实际项目里我建议你画个violin plot看看分布再定:
sc.pl.violin(adata, ['n_genes_by_counts', 'total_counts', 'pct_counts_mt'],
multi_panel=True)
目测一下异常峰的边界在哪里。比如有些样本总UMI计数普遍偏低,用200基因的阈值可能会把好多好的也都扔掉。反过来,如果基因数普遍高,6000上限也可能太紧。
2.7 常见避坑指南
- 千万别把过滤后的数据覆盖原始文件 – 我建议保留一个raw层,或者至少留一个未过滤的备份。因为后续分析可能需要回溯。
- 检查JSON里的“in_tissue”变量 – 如果这个标志为0,表示该spot不在组织上,但在某些数据集里它也可能包含了背景信号。我一般只保留in_tissue==1的spot,再进行QC。
- 注意TIFF和H5的barcode顺序是否匹配 – 虽然Space Ranger保证它们是对齐的,但如果你自己手动修改过文件,就可能错位。保险做法是用JSON里的barcode列表去重排H5的列序。
- 线粒体占比的阈值不要死套20% – 对于某些代谢旺盛的组织(如肝脏),正常细胞线粒体基因占比就可能超过20%。此时需要结合图像看组织学形态来定。
做完这些,你手里就有了一份干净的空间表达数据:每个spot有可靠的UMI计数和明确的组织位置。接下来可以放心做降维、聚类和美哭了的花花图了——不过那是后面章节的事了。