简介:本资源面向病理图像处理方向的工程人员与科研用户,针对江丰生物扫描仪输出的kfb格式无法直接用于标注的痛点,提供一套将svs格式完整转换为tif格式的实用工具。由于ASAP等标注软件仅支持tif与svs,而官方kfb转tif工具常出现只显示左上角局部的问题,本软件通过先转svs再转tif的路径,帮助用户获得完整可用的tif文件,便于后续标注与算法训练。压缩包为rar格式,大小约21.58MB,文件总数与类型明细上游未提供,但核心为可执行的转换程序,体积轻便、部署简单。目前已有2765人学习下载,说明该方案在病理切片处理场景中具有实际参考价值。读者可获得一条绕开官方转换缺陷的可行思路,以及配套工具,直接用于kfb到tif的完整转换流程,减少因图像缺失导致的重复扫描与标注返工。
1. 病理切片从 SVS 到 TIF:为什么“完美转换”比想象中难
手里拿到一张几十 GB 的 SVS 全切片图像,想把它塞进自己的算法流水线,第一步往往就卡住了——SVS 是 Aperio 的私有格式,很多开源工具链读不了,而 TIF 通用、生态好、几乎所有图像库都认。于是“把 SVS 转成 TIF”成了数字病理落地绕不开的第一道坎。但真动手你会发现,直接改后缀名打不开,用普通图像库读出来只有缩略图,转完丢了金字塔层级,或者颜色整个偏掉。所谓“完美实现”,核心是三件事:保住多分辨率金字塔、保住原始像素和色彩、保住元数据里的物理分辨率(MPP)。这篇笔记就按一线实操的顺序,把选型、命令、参数和踩过的坑讲清楚,适合做病理 AI、医学图像处理,或者需要把 WSI 喂给下游标注和训练流程的工程师。
2. 先搞懂 SVS 里到底装了什么:金字塔、瓦片与元数据
2.1 SVS 不是一张图,是一摞图加一张说明书
很多人第一次用cv2.imread打开 SVS,得到一张几百像素的小图,就以为转换失败了。其实 SVS 内部是一个 TIFF 容器,里面按分辨率从高到低存了多个层级(level),level 0 是最高分辨率的全分辨率图,往后每层大约缩小 2 倍或 4 倍,形成图像金字塔。除此之外还有缩略图(thumbnail)、标签图(label)、宏观图(macro)等附属图像,以及一堆描述扫描仪、MPP、色彩配置的元数据。
理解这一点,转换的目标就明确了:不是把某一张图另存为 TIF,而是把整个金字塔结构连同元数据一起搬过去。如果只导出 level 0,文件会大到离谱且下游无法快速预览;如果只导出缩略图,精度全丢。常见的可靠做法是用 OpenSlide 或 tifffile 这类能识别 WSI 结构的库来读,而不是通用图像库。
2.2 为什么不能直接改后缀或用 PIL 另存
SVS 虽然底层是 TIFF,但它的瓦片组织、压缩方式(常见 JPEG 或 JPEG2000)和层级索引是 Aperio 私有的约定。PIL/Pillow 默认只读第一帧,遇到多页 TIFF 也只给你第一页,金字塔信息直接丢失。直接改后缀更糟,很多软件会按普通 TIFF 解析,读到损坏的目录结构就报错。
正确的路径是:用能理解 WSI 语义的读取器拿到每一层、每一块瓦片,再用支持 BigTIFF 和金字塔写入的库重新组织成标准 TIF。下面这张表是我在选型时对比的几个关键点:
| 维度 | OpenSlide | tifffile | pyvips |
|---|---|---|---|
| 读 SVS 金字塔 | 原生支持 | 需手动解析 | 原生支持 |
| 写金字塔 TIF | 不支持写 | 支持 | 支持 |
| 大文件内存占用 | 低(按需读块) | 中 | 低(流式) |
| 色彩保真 | 好 | 好 | 好 |
| 典型用途 | 读取+取块 | 读写底层 | 读写+重采样 |
我一般的组合是:OpenSlide 负责读,pyvips 或 tifffile 负责写。这样读取端稳定,写入端能控制金字塔和压缩。
2.3 转换前必须确认的三个参数
动手前先确认三件事,否则转完大概率要返工。第一是 MPP(每像素微米数),它决定了物理尺度,丢了它下游做面积、距离计算全错。第二是色彩空间,病理切片通常是 RGB,但有些扫描仪存的是 YCbCr 或带 ICC profile,转的时候要确保不被错误转换。第三是目标压缩方式,JPEG 体积小但有损,LZW 或 Deflate 无损但文件大,JPEG2000 介于两者之间。这三个参数在读取阶段就要从元数据里取出来,写的时候原样带上。
3. 用 OpenSlide + pyvips 跑通最小转换:从读元数据到写金字塔
3.1 环境准备与读取元数据
先把依赖装好。OpenSlide 需要系统库,pyvips 也需要 libvips,建议用 conda 装省去编译麻烦。
# 用 conda 装,避免手动编译 libvips 和 openslide conda install -c conda-forge openslide pyvips tifffile装完先读一张 SVS,把层级和元数据打印出来,确认结构再转。
import openslide slide = openslide.OpenSlide("sample.svs") # 打印所有层级尺寸,确认金字塔结构 print("level_count:", slide.level_count) for i in range(slide.level_count): print(f"level {i}: {slide.level_dimensions[i]}, downsample={slide.level_downsamples[i]}") # 取关键元数据:MPP 和扫描仪信息 props = slide.properties mpp_x = props.get("openslide.mpp-x") mpp_y = props.get("openslide.mpp-y") print("MPP:", mpp_x, mpp_y) print("vendor:", props.get("openslide.vendor"))这段代码先确认level_count是否大于 1,如果只有 1 层说明这张 SVS 本身没金字塔,转换时就得自己生成。level_downsamples告诉你每层相对 level 0 缩小了多少倍,写金字塔时要按这个比例重建。MPP 从openslide.mpp-x/y取,这是后面写元数据的关键。
3.2 逐层导出并重建金字塔 TIF
最稳的做法是逐层读取,用 pyvips 写成带金字塔的 TIF。pyvips 支持tiffsave时指定pyramid=True,但更可控的方式是我们自己按层写入,保证层级和原图一致。
import openslide import pyvips slide = openslide.OpenSlide("sample.svs") level = 0 w, h = slide.level_dimensions[level] # 从 OpenSlide 读 level 0 全分辨率,转成 pyvips 图像 # 注意:大图不要一次性读进内存,这里用 read_region 分块更稳 region = slide.read_region((0, 0), level, (w, h)).convert("RGB") # 转成 numpy 再交给 pyvips import numpy as np arr = np.array(region) vips_img = pyvips.Image.new_from_memory(arr.data, w, h, 3, "uchar") # 写金字塔 TIF,指定压缩和瓦片大小 vips_img.tiffsave( "output.tif", compression="jpeg", # 有损但体积小,无损用 lzw Q=90, # JPEG 质量 tile=True, tile_width=512, tile_height=512, pyramid=True, # 自动生成金字塔 bigtiff=True # 超过 4GB 必须开 )逻辑说明:read_region按区域读,避免一次性把几十 GB 读进内存;new_from_memory把 numpy 数组包成 pyvips 图像;tiffsave的pyramid=True让 libvips 自动按 2 倍下采样生成层级。参数上,compression决定体积和保真,Q只在 jpeg 时生效,tile_width/height影响下游随机读取效率,bigtiff在文件可能超 4GB 时必开。
3.3 把 MPP 和元数据写回去
光有像素不够,MPP 得写进 TIF 的元数据,否则下游工具不知道物理尺度。pyvips 支持通过image-properties或 XMP 写自定义标签,但更通用的做法是用 tifffile 补写分辨率标签。
import tifffile # 用 tifffile 打开刚写的文件,补写分辨率 # 注意:tifffile 的 resolution 单位是像素/单位,需换算 mpp = 0.25 # 假设从原 SVS 读到 0.25 微米/像素 # 转换为每厘米像素数:1 cm = 10000 微米 pixels_per_cm = 10000 / mpp with tifffile.TiffFile("output.tif") as tif: # 读取现有页面后重写,或直接在写入时指定 pass # 更简单:写入时就用 tifffile 指定 resolution tifffile.imwrite( "output_meta.tif", arr, resolution=(pixels_per_cm, pixels_per_cm), resolutionunit="CENTIMETER", photometric="rgb", tile=(512, 512), compression="jpeg", bigtiff=True )这里resolution和resolutionunit一起决定了物理尺度,很多查看器(包括部分 GIS 工具)靠这两个字段识别 MPP。如果原 SVS 的 MPP 是 0.25,换算成每厘米 40000 像素写进去,下游读出来就是 0.25 微米/像素。这一步不做,后面做测量就是错的。
4. 转换质量与性能的取舍:压缩、瓦片、色彩三个必调项
4.1 压缩方式怎么选:JPEG、LZW 还是 JPEG2000
压缩直接决定文件体积和是否丢信息。JPEG 有损但体积最小,适合只做预览或训练时能接受轻微损失的场景;LZW 和 Deflate 无损,体积大但像素完全一致,适合需要精确像素值的定量分析;JPEG2000 压缩率高且支持无损,但兼容性差,部分工具读不了。
我的经验是:如果下游是深度学习训练,JPEG Q90 基本看不出差异,体积能小一个数量级;如果要做染色定量、细胞计数这类对像素敏感的任务,老老实实上 LZW。切换只需改compression参数,但要注意 JPEG 不支持某些位深,RGB 8bit 没问题。
4.2 瓦片大小对随机读取的影响
WSI 下游最常见的操作是随机取块(patch),瓦片大小直接影响读取效率。太小(如 128)会导致目录项过多、寻道频繁;太大(如 1024)单次读取浪费带宽。512×512 是社区里比较通用的折中,和很多 WSI 工具默认值一致。如果下游框架有特定要求,按框架来,比如有些训练管线默认 256。
4.3 色彩保真:别让转换悄悄改了颜色
色彩翻车是最隐蔽的坑。OpenSlide 读出来默认是 RGB,但如果原图带 ICC profile 或存的是 YCbCr,转换时可能被自动转成 sRGB 导致偏色。验证方法是转完后取几个已知区域,和原图同区域做像素级对比,算一下平均绝对误差。如果误差集中在色偏方向,多半是色彩空间转换的问题。稳妥做法是读取时显式.convert("RGB"),写入时不要额外做色彩变换,保持通道顺序一致。
5. 避坑与排查:SVS 转 TIF 最常见的五个翻车现场
5.1 转完只有缩略图大小
现象:输出 TIF 打开只有几百像素。原因:用了 PIL 或 cv2 直接读 SVS,只拿到第一帧或缩略图。解决:改用 OpenSlide 的read_region指定 level 0 读取,确认level_dimensions[0]是全分辨率尺寸。
5.2 文件超过 4GB 写入失败
现象:写到一半报错或文件损坏。原因:没开 BigTIFF,标准 TIFF 上限 4GB。解决:写入时加bigtiff=True,pyvips 和 tifffile 都支持这个参数。
5.3 下游读不到金字塔层级
现象:转换后的 TIF 在查看器里只能看一层,缩放卡顿。原因:写入时没生成金字塔,或生成的金字塔层级和原图不一致。解决:pyvips 的pyramid=True会自动生成,若要精确匹配原图层级,手动按level_downsamples逐层写入。
5.4 MPP 丢失导致测量错误
现象:下游算出来的面积、距离差了几倍。原因:没写 resolution 元数据,或单位换算错了。解决:从原 SVS 读openslide.mpp-x,换算成每厘米像素数写入resolution,单位设CENTIMETER。
5.5 颜色整体偏绿或偏紫
现象:转换后颜色和原图明显不一致。原因:色彩空间被错误转换,或通道顺序 BGR/RGB 搞反。解决:读取时显式转 RGB,写入前用 numpy 对比原图和转换图的通道均值,确认没有异常偏移。
6. 进阶:批量转换、断点续跑与转换结果的自动校验
单张转换跑通后,实际项目里往往是成百上千张。批量时最容易出问题的是内存和中断。我的习惯是用一个带进度记录的脚本,每转完一张写一条日志,失败的重跑时跳过已完成的。下面是一个可复用的批量骨架:
import os import openslide import pyvips import numpy as np from pathlib import Path def convert_one(svs_path, out_path): slide = openslide.OpenSlide(svs_path) w, h = slide.level_dimensions[0] region = slide.read_region((0, 0), 0, (w, h)).convert("RGB") arr = np.array(region) img = pyvips.Image.new_from_memory(arr.data, w, h, 3, "uchar") img.tiffsave( out_path, compression="jpeg", Q=90, tile=True, tile_width=512, tile_height=512, pyramid=True, bigtiff=True ) slide.close() def batch(src_dir, dst_dir, done_log="done.txt"): done = set(Path(done_log).read_text().splitlines()) if os.path.exists(done_log) else set() for svs in Path(src_dir).glob("*.svs"): if svs.name in done: continue out = Path(dst_dir) / (svs.stem + ".tif") try: convert_one(str(svs), str(out)) with open(done_log, "a") as f: f.write(svs.name + "\n") except Exception as e: print(f"failed {svs.name}: {e}") batch("./svs", "./tif")这个脚本的关键点是done_log记录已完成文件,中断后重跑自动跳过,避免重复劳动。convert_one里读全分辨率再写,如果单张图特别大导致内存吃紧,可以改成按瓦片流式写入,但代码会复杂不少,一般几十 GB 以内用上面的写法够用。
转换完还得校验,不能默认成功。我一般抽几张做三件事:一是用 OpenSlide 重新打开输出 TIF,确认level_count和尺寸对得上;二是取中心区域和原图做像素对比,算平均绝对误差,JPEG 压缩下误差在个位数以内算正常;三是检查 resolution 标签是否写进去了。这三步能挡住绝大多数“看起来转完了其实不能用”的情况。
说个我自己的教训:早期图省事用 PIL 批量转,跑了一晚上,第二天发现全是缩略图,白干。后来养成习惯,任何批量转换先拿一张跑通、校验通过,再放开跑全量。转换这活儿没有后悔药,校验步骤省不得。希望帮到你。
本文还有配套的精品资源,点击获取