简介:面向数字病理图像处理与标注场景的格式转换工具包,聚焦江丰生物扫描仪输出的kfb文件难以直接用于ASAP标注的问题。针对kfb转tif后仅显示左上角局部、无法完整呈现切片内容的常见困扰,提供以svs为中转再输出完整tif的可行方案,适合需要将私有格式整理为tiff等通用格式的病理图像工程人员与标注用户。压缩包为rar格式,体积约21.58MB,文件总数与类型明细上游暂未提供,目录与内容以实际下载为准。已有2780人学习,说明该转换需求在数字病理实践中具有一定普遍性。其价值在于帮助读者绕开kfb直接转tif的裁切缺陷,获得可被常规标注软件读取的完整tif文件,并为后续切片标注、数据整理和流程复现提供可参考的工具与思路。
1. 别急着改扩展名:svs 转 tif 塌掉的往往是层级和比例尺
上周有人丢过来一个文件夹,里面躺着几十个切片,说要先做 svs 转 tif,再喂给训练脚本。他试的第一招是把后缀改成 .tif,图像软件能打开、能看,他就以为成了。等到训练时才发现,读出来的是那张 1/4 缩略图,比例尺全错,右下角还有一条重复的边缘。SVS 本质上是 TIFF 容器的一种私有变体:多级金字塔、固定尺寸的分块、块内存的是 JPEG 流,还挂着 MPP 这类扫描参数。把它转成通用 tiff,真正的工作量不在“转格式”,而在重排 IFD 链、决定要不要重新编码、把比例尺和标签补齐。这份资源适合三类人:要做全切片预处理的数据工程、要把切片接进自己分析框架的算法同学、以及需要长期归档又要保证下游能读的运维。下面按我实际跑通的顺序拆。
2. 拆开 SVS 容器:IFD 链、240 像素 tile 与嵌在里面的 JPEG 流
动手之前得先把容器结构摸清楚,否则后面每一步都是在猜。SVS 之所以能被通用解析器打开,是因为它老老实实遵循了 TIFF 的基本约定:文件头指向第一个 IFD,IFD 里是一串标签,标签值指向实际数据。区别在于它把这个结构用到了极致——一条 IFD 链上挂着好几个层级,每一层又按 tile 切开存储。
2.1 IFD 链读层级:先数清楚金字塔到底有几层
一条 SVS 的 IFD 链里,通常包含:若干张按 2 的幂次递减的层级图、一张很小的缩略图、一张标签图(切片上贴的那张纸质标签的扫描)、有时还有一张宏观图(扫描台全景)。它们的区别不明显,都是合法的 IFD,尺寸却差着好几个数量级。最省事的判定方式是看每层的尺寸比例和 ImageDescription 里的键值对——真正的主层级会在描述串里带上 AppMag、MPP 这类字段,缩略图和标签图没有。
我一般先跑一遍探测,把层数、每层尺寸、降采样倍率、tile 大小全打出来,再决定后面怎么写:
# slice_io 是我自己封的一层薄封装,内部接本机的切片读取库 # 这层只需要提供四件事:层数、某层尺寸、某层降采样倍率、按坐标取 tile from slice_io import SlideReader def probe(path): s = SlideReader(path) meta = { "level_count": s.level_count, "level_dims": [s.level_dimensions(i) for i in range(s.level_count)], "downsamples": [round(s.level_downsample(i), 4) for i in range(s.level_count)], # 下面几项来自文件里的描述串和 TIFF 标签,缺一不可 "mpp": s.properties.get("mpp"), # 微米/像素,主层才有 "appmag": s.properties.get("appmag"), # 扫描物镜倍数 "tile_size": s.properties.get("tile_size"), # (w, h),常见 240 或 256 "compression": s.properties.get("compression"), } s.close() return meta if __name__ == "__main__": for k, v in probe("case_001.svs").items(): print(f"{k:>12}: {v}")这段代码的价值在于把“看得见的图”翻译成“可判断的数”。跑完你会看到类似level_dims: [(100352, 82944), (25088, 20736), (6272, 5184), (1568, 1296)]这样的输出,降采样倍率依次是 1、4、16、64。如果某一层的倍率不是整数、或者尺寸比例对不上,说明这一层是缩略图或标签图,必须从金字塔里剔掉。
提示:判断层级的可靠依据是“尺寸比例 + 描述串里的 MPP”,只看 IFD 顺序会被坑。不同扫描仪固件对层的排列顺序并不统一,有的把缩略图放在最前面。
2.2 tile 与边缘 padding:240×240 不等于有效像素
SVS 的每一层是按固定边长切块的,块边长通常是 240 或 256。问题出在“切不整”的时候:假设某层宽度是 100352,tile 边长 256,正好整除;但缩略层的宽度 1568 除以 256 是 6.125,最后一块只用了 32 像素,剩下 224 像素是扫描仪填进去的凑数像素——通常是黑的,也可能是上一块边缘的复制。
读 tile 的接口几乎都按“整块”返回,不会帮你裁。所以转换脚本里必须自己算有效区域:
def valid_size(level_w, level_h, x, y, tile_w, tile_h): """返回 (x, y) 处这一块真正属于图像内容的宽高""" w = min(tile_w, level_w - x) h = min(tile_h, level_h - y) return max(w, 0), max(h, 0) def crop_tile(arr, w, h): # arr 形状为 (tile_h, tile_w, 3),右侧和下侧可能有填充 return arr[:h, :w, :]别小看这两行。不裁的直接后果是:拼出来的图在右边界和下边界出现一条重复的窄带,肉眼在大图上几乎看不出来,但做像素级统计、做切片分割训练时,这条带会被当成真实组织学特征学进去。血泪经验是——第一次发现有重复条带,我排查了半天的拼接逻辑,最后发现是边缘块没裁。
2.3 JPEG 流原样搬运的前提:Photometric 与 JPEGTables
这是整个转换里最容易翻车的地方。SVS 的每个 tile 内部存的是 JPEG 压缩数据,颜色空间是 YCbCr 而不是 RGB。如果你想做“不重编码”的快速转换,直接把这段字节搬进新的 TIFF 里,就必须同时把 PhotometricInterpretation 写成 YCbCr(值 6),而不是默认的 RGB(值 2)。写成 RGB,读图软件会拿 YCbCr 的数据当 RGB 解释,结果就是整图偏色、红蓝互换,看着像滤镜没关。
第二个坑是量化表和哈夫曼表。JPEG 允许把这些表抽出来放在文件级共享,tile 数据流里只剩扫描数据。SVS 就常这么干,共享表放在 TIFF 的一个独立标签里。你如果只搬 tile 的那段字节,解码器找不到表,直接报错或者输出花屏。常见的处理方式有两种:一是把共享表和每个 tile 的流拼成一段完整的 JPEG 再写;二是干脆放弃“原样搬”,走解码重编码路线。
我一般这么判断:如果下游只需要“能看”,走重封装;如果下游是各种分析框架、还要保证十年后能读,老老实实解码重编码。这个选择下一章展开。
3. 三条路线选型:重封装、无损重编码、金字塔重建的代价对比
同样是 svs 转 tif,实际有三条完全不同的路,代价差着数量级。选之前先问自己一个问题:转换后的文件,是给谁读的?给人看、给通用图像库读、还是给自己的训练管道读?答案不同,路线就不同。
3.1 路线一:JPEG 流直搬,速度最快但挑读取器
做法是把原文件每个 tile 的压缩字节原样取出,按新的 tile 布局塞进目标 TIFF,压缩方式仍然标记为 JPEG,同时把颜色空间、子采样、共享表这些元信息一并搬过去。整条路不碰像素解码,CPU 占用极低,一个几 GB 的切片通常几分钟到十几分钟就能出结果,输出体积和原始文件基本持平。
代价是兼容性。JPEG-in-TIFF 加上 YCbCr,属于 TIFF 规范里“可以存在但不强制支持”的组合,很多轻量图像库只实现了 RGB + LZW/Deflate 这一条窄路。你转出来的文件自己写得出来,别人的工具打不开,这种坑最难解释。
这条路适合的场景很明确:内部归档、格式统一、下游是同一套自己维护的读取栈。对外分发、要过第三方质检的,别走这条。
3.2 路线二:Deflate 无损重编码,通用性最好但体积翻几倍
把每个 tile 解码成 RGB 像素,再用 Deflate(或 LZW)无损压缩写出去。这条路的输出是“最标准”的 TIFF:RGB、分块、无损压缩、带金字塔,绝大多数图像库和框架都能直接打开。
体积代价必须提前说清楚:病理切片是高频细节极多的图像,JPEG 在有损前提下能压到 1/10 甚至更低,换成无损压缩后,同样的内容通常要涨 3 到 8 倍,具体取决于组织类型和切片厚度。一个原本 1.5 GB 的 40 倍切片,转完变成 8 到 12 GB 是常态,超过 4 GB 就必须开 BigTIFF,否则写到一半直接失败。
CPU 代价也不小。解码 JPEG + Deflate 压缩,单文件十几分钟到一小时都有可能,批量跑的时候并行度要控制好,否则内存先炸。
3.3 路线三:金字塔重建,为下游分析量身定制
第三条路不是“转格式”,是“重做一遍金字塔”。常见动机有三个:原文件的层级太少,最大层降采样倍率跳得太狠,训练时按固定倍率采样拿不到合适尺寸;原文件 tile 边长太小,随机读取开销大;需要统一不同来源切片的层级结构。
做法是先读原文件的最高分辨率层,按自己需要的倍率序列重新降采样,逐层写出。这里有两条硬规矩。第一,降采样只能用面积平均或高斯预滤波后再抽样,不能直接隔点取样,否则会出现明显的摩尔纹,组织纹理变成网格。第二,倍率序列要写进元数据,下游才能算回真实物理尺寸。
注意:重建金字塔会引入重采样,这一步不可逆。归档用途的文件不要走这条路,它是给分析管道用的派生物,不是原始数据的替代品。
3.4 三套路线的参数对照与选择建议
把关键差异摆到一张表里,选型就不用纠结了:
| 维度 | 路线一 重封装 | 路线二 无损重编码 | 路线三 金字塔重建 |
|---|---|---|---|
| 是否碰像素 | 否 | 是 | 是 |
| 输出体积 | 约等于原文件 | 原文件的 3~8 倍 | 视层级与压缩而定 |
| 单文件耗时 | 分钟级 | 十几分钟到一小时 | 数十分钟起 |
| 内存峰值 | 低,可做到几百 MB | 中,取决于并行块数 | 高,需要缓存整层或分带 |
| 兼容性 | 挑读取器 | 最好 | 好,但依赖自定义层级 |
| 是否损失信息 | 无(保留原 JPEG) | 无(解码后无损) | 有(重采样) |
| 典型用途 | 内部归档、格式统一 | 对外分发、长期保存 | 训练样本生成 |
参数上我一般这么定:输出 tile 边长 256(兼容性最好,读取器实现最成熟);压缩用 Deflate,级别 6,再高收益很小但耗时明显上升;predictor 不开,水平差分对 RGB 三通道的收益不稳定,部分读取器还会读错;超过 4 GB 一律 BigTIFF。分辨率标签按 MPP 换算,1 厘米等于 10000 微米,所以每厘米像素数就是 10000 除以 MPP,单位标厘米。这一项不写,下游算出来的比例尺能差出好几倍。
4. 逐级流式转换:一个能跑完整切片的脚本骨架
结构摸清了、路线选定了,剩下就是把脚本写扎实。这一章给的是路线二(解码重编码)的骨架,它最通用,也最能暴露各种边界问题。骨架跑通之后,改成路线一只是把“解码 + 重压”换成“取原始字节”。
4.1 先定接口:读取端和写入端各需要什么
我把读取端封成一个薄层,只暴露四个能力:层数、某层尺寸、某层降采样倍率、按 (层, x, y, w, h) 取一块像素。写入端同样薄:建文件、写一层的一块、收尾。这样换库、换实现都不影响主流程,转换逻辑只写一遍。
关键是要“按块走”,绝不能“整层读”。一个 40 倍切片的最高层,十万乘八万像素,RGB 三通道算下来 24 GB,任何整层读入都会当场把机器打挂。按块走的峰值内存只跟块大小和并行数有关,跟图像尺寸无关。
4.2 转换主体:按层从大到小,按块从左到右
import math def convert(src_path, dst_path, out_tile=256, deflate_level=6, bigtiff_threshold=4 << 30): src = SlideReader(src_path) meta = probe_meta(src) # 复用 2.1 的探测逻辑 # 只保留真正的金字塔层:降采样倍率是整数、且描述串带 MPP levels = [i for i in range(src.level_count) if is_pyramid_level(src, i)] levels.sort(key=lambda i: src.level_dimensions(i)[0], reverse=True) est_bytes = sum(w * h * 3 for w, h in (src.level_dimensions(i) for i in levels)) use_bigtiff = est_bytes > bigtiff_threshold # 粗估,宁可早开 writer = TileWriter(dst_path, tile=out_tile, compression="deflate", level=deflate_level, bigtiff=use_bigtiff, resolution=_mpp_to_resolution(meta["mpp"])) for out_idx, lv in enumerate(levels): lw, lh = src.level_dimensions(lv) writer.add_level(out_idx, lw, lh) for y in range(0, lh, out_tile): for x in range(0, lw, out_tile): w, h = valid_size(lw, lh, x, y, out_tile, out_tile) if w == 0 or h == 0: continue # 读取端按原 tile 边界对齐,避免重复解码 block = src.read_region(lv, x, y, out_tile, out_tile) # RGBA 或 RGB block = to_rgb(block) # 丢掉 alpha,统一三通道 block = crop_tile(block, w, h) # 裁掉边缘填充 writer.write_tile(out_idx, x, y, block) writer.set_description(build_description(meta, levels)) writer.close() src.close() def _mpp_to_resolution(mpp): if not mpp: return None per_cm = 10000.0 / float(mpp) # 微米 -> 厘米 return (per_cm, per_cm, "CENTIMETER")逻辑上分四步,每一步都有讲究。第一步筛层,把缩略图和标签图剔掉,否则金字塔里会混进一张比例完全不对的小图。第二步估体积决定是否开 BigTIFF,这里用“宽 × 高 × 3”粗估未压缩大小,实际压缩后小很多,但宁可早开——写到 4 GB 边界才失败,前面几十分钟全白跑。第三步双层循环,外层按层、内层按块,读取时按原 tile 边界对齐,因为读取库内部本来就是按块解码的,你对齐了它命中缓存,不对齐就会反复解码相邻块。第四步收尾时补描述串,把层数、倍率、MPP 全写进去。
参数怎么调:out_tile定 256,兼容性最好;如果你的下游只吃 512,那就统一改 512,但要知道有些老读取器只认 256,混着来会出问题。deflate_level6 是性价比拐点,从 6 提到 9 通常只多压 2% 到 3%,耗时翻倍。bigtiff_threshold我一般设得比真实阈值低一些,留出安全余量。
4.3 元数据补齐:描述串、分辨率、通道顺序
像素对了不代表文件对了。三类元数据必须补:一是层级描述串,写明每层的尺寸和降采样倍率;二是分辨率标签,用 MPP 换算,缺了它下游算出来的物理尺寸毫无意义;三是通道顺序,统一成 RGB,别混进 RGBA,alpha 通道在病理图像里没有任何信息量,只会让下游多一层判断。
描述串我一般写成紧凑的键值格式:
def build_description(meta, levels): dims = "|".join(f"{i}:{w}x{h}" for i, (w, h) in enumerate(meta["level_dims"])) return (f"source=svs-converted;levels={len(levels)};dims={dims};" f"mpp={meta['mpp']};appmag={meta['appmag']};tile={meta['tile_size']}")这样写的好处是人和程序都能读:人看一眼知道这份文件从哪来、几层、什么倍率;程序拿正则一抽就能还原参数。别小看这一行字符串,半年后你回头找“这批文件到底是哪个扫描仪出的”,全靠它。
4.4 校验:像素、层级、比例尺三项对齐
转完必须验,不能靠“能打开”三个字。我固定验三项。
第一项,层级数和尺寸。输出文件的每一层尺寸要和源文件完全一致,可以在转换完成后回读一次,逐层比对宽高。
第二项,像素抽样。从每层随机抽 20 个块,比较均值和标准差,差异应该在小数点后两位内。如果某一块差异很大,大概率是边缘裁剪或通道顺序出了问题:
def sample_check(src, dst, n=20, seed=42): import random rnd = random.Random(seed) report = [] for lv in range(src.level_count): lw, lh = src.level_dimensions(lv) for _ in range(n): x = rnd.randrange(0, max(lw - 256, 1)) y = rnd.randrange(0, max(lh - 256, 1)) a = to_rgb(src.read_region(lv, x, y, 256, 256)).astype("float32") b = to_rgb(dst.read_region(lv, x, y, 256, 256)).astype("float32") report.append((lv, x, y, float(abs(a.mean() - b.mean())), float(abs(a.std() - b.std())))) return report第三项,比例尺。读回输出文件的分辨率标签,反算出 MPP,和源文件比对。这一项最容易被跳过,也最容易出错——差一个单位(微米和厘米搞混),下游的细胞面积统计能差出四个数量级。
5. 避坑排查:五类翻车现场的现象、成因与止血方案
下面这五条,都是我在实际转换里真踩过的,每条按“看到什么 → 为什么 → 怎么修”写。排查顺序建议从上往下,前两条是最常见的。
5.1 整图偏色、红蓝互换:Photometric 写错还是共享表丢了
现象:输出文件能打开,但整体发红或发蓝,组织结构看着还是那个形状,颜色完全不对;有的版本还会出现一半正常一半错乱。
原因:两个可能。一是走了重封装路线,把 YCbCr 的 JPEG 字节写进了标为 RGB 的容器,解码器按 RGB 解释 YCbCr 数据。二是共享的量化表没搬过去,部分 tile 解码时用了错误的表或者缺表。
解决:先确认走的是哪条路线。重封装路线,把 PhotometricInterpretation 改成 YCbCr,并把子采样方式一并写对;如果读取器不认 YCbCr,就退回解码重编码路线,让输出变成标准 RGB。共享表的问题好判断——错乱只出现在部分块、或者呈现规律的花屏,这时把表拼回每块流里即可。
5.2 右边缘和下边缘出现重复窄带
现象:把转换后的图缩小看,右边和下边各有一条几像素到两百多像素宽的条带,内容和紧邻区域重复,或者干脆是黑的。
原因:边缘 tile 被填充到整块,转换时没裁有效区域。填充区有时候是黑色,有时候是扫描仪顺手复制的邻近像素,后者更像“重复条带”。
解决:在写入前按min(tile, level_size - offset)裁掉多余部分,见 2.2 的valid_size。修完之后重新抽样比对边缘块的均值和内部块,如果边缘块均值明显偏离,说明还没裁干净。
提示:这个坑在小图上完全看不出来,只有在大尺寸切片的边界才显现。验收时一定要专门放大看右下角。
5.3 读出来只有一张缩略图
现象:转换脚本跑完了,输出文件也能打开,但尺寸只有一两千像素,放大全是马赛克。
原因:读取端默认返回第 0 层,而这个文件的第 0 层恰好是缩略图。不同固件对层顺序的约定不一致,不能假设“第 0 层就是最高分辨率”。
解决:不要用索引定位层,改用“尺寸最大”或“带 MPP 描述”来选主层。转换前先打印每层的尺寸和倍率,确认层级序列表和你的预期一致,再进循环。
5.4 转换中途 OOM、进程被杀,日志停在某一层
现象:前几层跑得飞快,到某一层突然内存暴涨,随后进程消失,没有异常栈。
原因:多半是某一步整层读入了像素,或者并行块数开太多。也可能是写出端在内存里缓存了整层再落盘。还有一种隐蔽情况:读取库内部有缓存,你没有主动释放块引用,缓存越滚越大。
解决:把转换循环改成严格的一进一出——读一块、写一块、立刻释放引用,不要在同一作用域里累积。并行度控制在物理核数的一半以内,每块 256×256×3 大约 200 KB,看起来很小,但解码中间态会放大几十倍。跑之前先用小样本压测,看内存曲线的斜率,斜率是平的才放到全量。
5.5 下游打不开,或者比例尺差了四个数量级
现象:文件自己写得出来,自己也能读,但换个工具就报“不支持的压缩方式”;或者能读,但测量出来的组织面积明显不对。
原因:兼容性问题的根源是压缩方式和颜色空间的组合太偏门,比如 JPEG 加 YCbCr,很多轻量读取器只实现了 RGB 加无损压缩。比例尺问题则是分辨率标签没写、写错单位,或者只写了一个方向。
解决:对外分发一律用 RGB 加 Deflate,别图省事走重封装。比例尺按10000 / MPP换算成每厘米像素数,X 和 Y 两个方向都写,单位标厘米。写完回读一次,用已知尺寸的标尺图核对——这一步花不了两分钟,能省掉后面一堆返工。
6. 进阶收口:把单文件转换做成带自检的批处理流水线
单文件脚本跑通之后,真正的工作量在于批量。几十上百个切片,来源不同、层级不同、有的还带损坏块,手工一个个跑不现实。我的做法是做一个薄批处理层,把“探测 → 选路线 → 转换 → 校验”串起来,失败的文件单独落到一个失败目录,不阻塞整批。
import json, pathlib, traceback def batch(src_dir, out_dir, fail_dir, workers=4): src_dir, out_dir, fail_dir = map(pathlib.Path, (src_dir, out_dir, fail_dir)) fail_dir.mkdir(parents=True, exist_ok=True) jobs = sorted(src_dir.glob("*.svs")) ok, bad = 0, [] for p in jobs: try: meta = probe(str(p)) # 按体积和用途选路线:大文件优先重封装,归档件走无损 route = "rewrap" if meta["level_count"] >= 3 and p.stat().st_size > 3 << 30 else "deflate" dst = out_dir / (p.stem + ".tif") if route == "rewrap": rewrap(str(p), str(dst)) else: convert(str(p), str(dst)) # 校验不通过也归到失败目录,避免半成品流出去 if not quick_verify(str(dst), meta): raise RuntimeError("verify failed") ok += 1 except Exception: bad.append(p.name) (fail_dir / (p.stem + ".log")).write_text(traceback.format_exc()) return ok, bad这段批处理的三个关键点在注释里都体现了。路线选择用“层数 + 文件体积”两个信号做粗判:层数多、体积大的走重封装省时间,其余走无损保证兼容性。校验放在转换之后、计入成功之前,半成品绝不进入交付目录。失败文件连异常栈一起落盘,第二天集中看日志,比一个个重跑高效得多。
如果想再进一步,可以做增量:把每个文件的源大小、修改时间、输出路径记进一个清单文件,批处理启动时先读清单,已完成的直接跳过。切片文件往往一次几十 GB,重跑一遍的时间成本很高,这个清单能省下大量重复劳动。
几个我固定会加的兜底。一是磁盘空间预检,转换前按源文件大小乘以 3 到 8 倍估算输出体积,空间不够直接拒绝,别跑到 90% 才失败。二是单文件超时,某些损坏块会让解码卡死,设一个上限时间强制中断。三是输出文件命名规整,源文件名加压缩方式后缀,后面按用途筛选不用再打开文件。
最后留一张我自己用的验收表,每次批量跑完照着过一遍:
| 检查项 | 合格线 | 不合格时的第一怀疑对象 |
|---|---|---|
| 层数与每层尺寸 | 与源文件完全一致 | 层级筛选逻辑 |
| 抽样块均值/标准差 | 差异小于 0.01 | 通道顺序或边缘裁剪 |
| 分辨率标签 | 反算 MPP 与源一致 | 单位写成了微米 |
| 文件能否被第三方工具打开 | 能 | 压缩与颜色空间组合 |
| 输出体积 | 在预估区间内 | 压缩参数未生效 |
从那以后我每次批量转换,都会先挑三个文件做全流程验证,确认层级、抽样、比例尺三项都过,再放全量跑。图省事直接开跑的那次,两小时后发现整批文件的边缘都没裁,全部重来。这套自检流程算不上高明,但它替我挡掉的返工,比脚本本身值钱得多。希望帮到你。
本文还有配套的精品资源,点击获取