ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TIF合并与拆分:多页TIFF的IFD链、压缩参数与BigTIFF

TIF合并与拆分:多页TIFF的IFD链、压缩参数与BigTIFF

简介:TIF/TIFF文件的合并及拆分工具面向图形设计、扫描归档、印刷制版及图像处理场景中需要管理多页TIFF文件的用户,重点解决多页图像合并、单页拆分以及元数据保留等常见问题,适合具备基础图像处理经验、希望以轻量工具完成批量整理的使用者。压缩包共3个文件,以2个htm说明/界面文档和1个exe可执行程序为主,整体约370KB,体积小巧却覆盖工具运行与使用指引两部分内容。已有2722人学习,说明其在TIFF文件整理需求中具备一定参考价值。工具支持将多幅图像组合为多页TIFF、按页码或预览选择拆分页面,并关注无损压缩方式、灰度/RGB/CMYK等色彩模式及拍摄日期、分辨率等元数据的处理;配合帮助文档可了解批量操作与参数设置思路,便于在不明显降低图像质量的前提下提升归档、扫描件整理和印刷前处理的效率。

1. TIF 合并与拆分的分界:先分清三种“多页”

手上拿到一个装满 TIF 的目录,或者一份几百页的扫描件,很多人的第一反应是找个按钮点一下,把 TIFF 合并成一个文件,或者把多页 TIFF 拆成散页。真上手才发现,同样是“多页”三个字,背后的东西完全不同:扫描件的多页是 IFD 链,切片影像的“多页”是金字塔层级,测绘栅格的拼图则是带地理参考的镶嵌。三类混为一谈,轻则合并出来只剩第一页,重则坐标系、分辨率全部丢失,返工一整批。

这篇笔记拆的是一套能同时覆盖“散页合成多页 TIFF”和“多页拆成散页”的工具思路,顺带把压缩、分块、BigTIFF 这些参数的取值讲清楚。适合手上压着一批 TIF 待归档的影像从业者、做档案数字化的实施人员,以及偶尔需要临时拼图又不想装商业软件的人。

2. 拆开 TIFF 的容器:IFD 链、Strip 与 Tile 决定合并成败

2.1 IFD 链才是“页”的本质

TIFF 不是把像素按顺序堆在文件里的裸格式,它是一套标签容器。文件头指向第一个 IFD(Image File Directory),每个 IFD 记录着这张图宽高、位深、压缩方式、色彩解释,以及像素数据在文件中的偏移;IFD 末尾还有一个 next IFD offset,指向下一个 IFD,多个 IFD 串起来就成了多页 TIFF。

这意味着“合并多页”在二进制层面并不是把一堆文件首尾相接,而是每读一张源图,就新写一个 IFD,再更新前一页的 next 指针。用cat a.tif b.tif > c.tif拼出来的东西,绝大多数阅读器只会认第一页,后面的字节被当成垃圾数据跳过,这是血泪经验里最常见的一种翻车。

要判断一个文件到底是真多页还是假多页,最直接的办法是数 IFD。常见做法是用 tifffile 打开遍历:

from tifffile import TiffFile def inspect_tiff(path): """打印 TIFF 的页数、是否 BigTIFF、每页的关键标签""" with TiffFile(path) as tif: print(f"{path} pages={len(tif.pages)} bigtiff={tif.is_bigtiff}") for i, page in enumerate(tif.pages[:5]): # 只看前 5 页,避免大文件刷屏 layout = (f"tile={page.tilewidth}x{page.tilelength}" if page.is_tiled else f"strip_rows={page.rowsperstrip}") print(f" [{i}] shape={page.shape} dtype={page.dtype} " f"comp={page.compression.name} photo={page.photometric.name} {layout}") inspect_tiff("merged.tif")

len(tif.pages)返回的就是 IFD 个数,不是文件里有几张缩略图;is_bigtiff用来判断这份文件是否已经采用了 64 位偏移,后面处理大文件时要用到;page.is_tiled和rowsperstrip决定了像素数据是按块存还是按条存,这两个值在合并时如果没有继承,往往就是体积异常的直接原因。

顺带说一个容易忽略的标签:PageNumber(标签号 297),它记录当前页在整份文档里的序号。不少合图工具不写这个标签,导致某些阅读器翻页顺序和文件名顺序对不上。如果下游对页序敏感,合并时显式写一下更稳。

2.2 Strip 与 Tile:合并后体积忽大忽小的根源

TIFF 的像素数据不是一整块,而是被切成若干“压缩单元”。按行切叫 Strip,参数是 RowsPerStrip;按矩形块切叫 Tile,参数是 TileWidth 和 TileLength。压缩算法是逐单元独立作用的,所以单元切得越细,压缩器能看到的上下文越少,压缩率越低;单元切得越大,单次解码占用的内存越高。

常见的取值经验是这样的:扫描件这类以文字为主的黑白图,RowsPerStrip 设成让单条落在 8~64 KB 之间比较合适;照片类彩色图用 256×256 或 512×512 的 Tile,兼顾随机读取和压缩率;如果下游是影像金字塔或地图服务,Tile 基本是必须的,否则按区域裁剪时会退化成整行解码。

很多合并脚本的问题就出在这儿:源图本来是 512×512 分块、DEFLATE 压缩,合并脚本只把像素数组读出来重新写,压缩方式默认成了 NONE,分块也退化成默认的条带,结果文件体积从 200 MB 涨到 900 MB,打开还变慢。体积异常时,第一步永远是回到page.compression和page.is_tiled这两个值上对一遍。

2.3 位深、压缩、色彩空间三者必须联动

压缩方式选错,不只是体积问题,还可能直接让图打不开或者颜色发青。位深、Photometric(色彩解释)、压缩算法三者是绑定的:1 bit 的二值扫描件适合 CCITT G4 或 PackBits,用 JPEG 会直接失败;8 bit 灰度用 LZW 或 DEFLATE 都行;RGB 三通道可以用 JPEG 做有损压缩,但带 Alpha 的 RGBA 用 JPEG 就会丢掉透明通道;16 bit 的科学影像或医学切片,用 DEFLATE 加水平差分预测(Predictor=2)通常比 LZW 更划算。

下面这张表是这几年在归档场景里反复验证过的组合,可以直接照着选:

场景位深/通道推荐压缩关键参数
黑白文档扫描1 bitCCITT G4 / PackBitsPhotometric=MINISWHITE
灰度底片、胶片8 bit 单通道LZWRowsPerStrip 取 16~32
彩色照片归档8 bit RGBJPEG(质量 85~92)Tile 512×512
需要无损的彩图8 bit RGBDEFLATE level 6Predictor=2
医学/遥感切片16 bit 单通道DEFLATE / ZSTDTile 256×256、BigTIFF

还有两个标签经常被合并脚本弄丢:Photometric 和 ExtraSamples。前者决定像素值 0 代表黑还是白,后者决定第 4 个通道是透明还是别的附属数据。这两个不对,图能打开但看着不对劲,是最难排查的一类问题。

3. 合并实施:tifffile、tiffcp、ImageMagick 三条路的取舍

3.1 tifffile 逐页写入:可控性最高的一种合法

要继承源图的压缩、色彩解释和分辨率,逐页读、逐页写是最稳的做法。它比一行命令慢一点,但每个标签都在你手上:

import glob import tifffile def page_resolution(page): """从源页读回 DPI,横纵不一致时取纵向,读不到就返回 None""" try: x = page.tags["XResolution"].value y = page.tags["YResolution"].value unit = page.tags["ResolutionUnit"].value except KeyError: return None, None if x[0] * y[1] != y[0] * x[1]: x = y return (int(x[0]), int(x[1])), int(unit) def merge_pages(patterns, out_path, compression="deflate"): files = [] for p in patterns: files.extend(sorted(glob.glob(p))) if not files: raise SystemExit("没有匹配到输入文件") with tifffile.TiffWriter(out_path, bigtiff=True) as tw: for f in files: with tifffile.TiffFile(f) as src: page = src.pages[0] arr = page.asarray() res, unit = page_resolution(page) kwargs = dict( photometric=page.photometric, # 继承黑白/RGB 解释 compression=compression, metadata=None, # 不写默认描述,避免标签重复 contiguous=False, tile=(512, 512) if arr.ndim == 3 else None, ) if compression == "deflate": kwargs["compressionargs"] = {"level": 6} if res: kwargs["resolution"] = res kwargs["resolutionunit"] = unit tw.write(arr, **kwargs) return len(files) if __name__ == "__main__": n = merge_pages(["scan_*.tif"], "merged.tif") print("写入页数:", n)

逻辑很直白:先按模式展开并排序文件,再开一个 TiffWriter,对每份源文件只取第 0 页(散页场景下一份文件就是一页),读出数组后连同标签一起写进去。

参数上有几个点值得说清楚。bigtiff=True打开后写出的文件不再受 4 GB 偏移上限约束,代价是老软件可能不认;photometric必须显式传,否则 tifffile 会按数组形状猜,1 bit 二值图猜错就会反色;compression="deflate"配合compressionargs={"level": 6}是压缩率和速度比较平衡的一档,追求更小可以上level=9,但编码时间大概翻倍;tile=(512, 512)只对三通道图开,单通道扫描件保持条带存储更省空间;metadata=None的作用是阻止工具写入一份默认的 ImageDescription,避免和源页已有描述冲突。

glob展开后的排序是按字典序,scan_10.tif会排在scan_2.tif前面。这是批量合并最常见的页序错乱来源,文件名必须补零到固定宽度,或者在排序时提取数字键。

3.2 tiffcp:命令行批量合并与原地转码

如果只是要把一堆已存在的 TIFF 合成多页,顺手改个压缩方式,libtiff 自带的 tiffcp 是最省事的一条路,不依赖 Python 环境:

# 把三个文件合成一份多页 TIFF,同时改用 LZW 压缩 tiffcp -c lzw -r 16 page_0001.tif page_0002.tif page_0003.tif merged.tif # 大批量:通配符排序后合并,输出改为分块 DEFLATE,并生成 BigTIFF tiffcp -8 -c zip -t -w 512 -l 512 scan_[0-9][0-9][0-9][0-9].tif merged_big.tif # 只做转码不合并:单入单出,把旧文件压小 tiffcp -c lzw -r 32 old_scan.tif new_scan.tif

参数含义:-c指定压缩方式,lzw、zip、jpeg、packbits、g3、g4是常用值;-r设置每条的扫描行数,值太小压缩率差,太大内存高;-t打开分块存储,配套的-w、-l分别是块宽和块高;-8表示输出 BigTIFF。部分版本的 tiffcp 支持用-c zip:9的形式指定 zlib 级别,老版本只认-c zip,写脚本前最好先跑一遍空转确认。

注意上面第二个例子里,我故意把通配符写成[0-9][0-9][0-9][0-9],就是为了绕开字典序问题。如果没有补零,用 shell 通配符展开的结果基本一定不是你要的页序。

3.3 ImageMagick 一行合并的适用边界

ImageMagick 的门槛最低,一条命令能合,也能顺手加压缩和分块:

# ImageMagick 7 magick scan_*.tif -compress LZW -define tiff:tile-geometry=512x512 merged_im.tif # ImageMagick 6 及更早版本,命令名是 convert convert scan_*.tif -compress LZW merged_im6.tif

它能用的场景是:源图都是同尺寸、同色彩空间的规整扫描件,且你对标签继承没什么要求。不太适合的场景是:混合位深、需要保留原始 DPI、需要精确控制 Predictor 和 ExtraSamples。这些情况下 ImageMagick 会按自己的默认值重写标签,结果往往和源图对不上。

还有一个高频报错要提前知道:发行版打包的 ImageMagick 通常带一份安全策略文件(policy.xml),默认可能禁用了 TIFF 的写入权限,运行时报not authorized。这时候要么改策略文件放开 TIFF 的 write 权限,要么换 tifffile 或 tiffcp 绕过去,别在权限问题上耗太久。

3.4 合并结果的三项自检

合并跑完不等于交付完成。我一般会在脚本末尾加一段自检,只查三样东西:页数、单页尺寸集合、压缩标签集合:

import os from tifffile import TiffFile def verify(path, expect_pages): with TiffFile(path) as tif: pages = len(tif.pages) shapes = {(p.shape, str(p.dtype)) for p in tif.pages} comps = {p.compression.name for p in tif.pages} size_mb = os.path.getsize(path) / 1e6 print(f"pages={pages} expect={expect_pages} size={size_mb:.1f}MB") print("shapes:", shapes) print("compression:", comps) assert pages == expect_pages, "页数不符" assert len(comps) == 1, "压缩方式不统一,说明有页没继承参数" verify("merged.tif", 240)

这三项分别对应三类事故:页数不符说明有文件没被读到或者写入失败;尺寸集合里出现多个值说明混进了不同规格的图;压缩标签不统一说明某些页走了默认参数。最后那条断言是最有价值的,它能在一个几百页的文件里立刻指出参数继承断了。

4. 拆分实施:按页、按范围、按瓦片三种拆法各自的参数

4.1 逐页拆分与命名规范

拆分的核心矛盾不是“能不能拆”,而是“拆完怎么对得上原来的页序”。最稳的命名是固定宽度数字加前缀,从 0001 开始,和 PageNumber 标签对齐:

import os import tifffile def split_pages(src, outdir, prefix="page", start=1): os.makedirs(outdir, exist_ok=True) n = 0 with tifffile.TiffFile(src) as tif: for i, page in enumerate(tif.pages): comp = page.compression.name.lower() comp = None if comp == "none" else comp out = os.path.join(outdir, f"{prefix}_{i + start:04d}.tif") tifffile.imwrite( out, page.asarray(), compression=comp, photometric=page.photometric, metadata=None, ) n += 1 return n print(split_pages("merged.tif", "out", prefix="scan"))

page.asarray()会把当前页解码成 numpy 数组,注意这是解码后的原始像素,不是压缩数据块,所以拆分再做一次压缩是有损压缩会二次损失——JPEG 压缩的源页拆出来会经历一次“解码再编码”,质量会掉。对无损压缩(LZW、DEFLATE、PackBits)没这个问题,随便拆。

compression参数这里做了小写化处理,tifffile 的标签枚举名是大写,传给imwrite需要小写字符串或 None。photometric不传的话,单通道图默认按 MINISBLACK 写,原本是 MINISWHITE 的底片拆完就变负片了。

4.2 指定页范围与奇偶页拆分的实现

实际业务里经常不是全拆,而是只取某几页,或者按奇偶分给两台设备扫描后各自归档。做法是在遍历时加一个页号过滤器:

def split_selected(src, outdir, keep): """keep 是一个函数:接收页索引,返回 True 表示要保留""" os.makedirs(outdir, exist_ok=True) kept = 0 with tifffile.TiffFile(src) as tif: for i, page in enumerate(tif.pages): if not keep(i): continue out = os.path.join(outdir, f"p_{i:04d}.tif") tifffile.imwrite(out, page.asarray(), compression="lzw", metadata=None) kept += 1 return kept split_selected("merged.tif", "odd", lambda i: i % 2 == 0) # 奇数页(0 基) split_selected("merged.tif", "range", lambda i: 100 <= i < 200)

这个写法比“先全拆再删”省一半 IO,尤其是几百页的文件。要注意i是从 0 开始的索引,和阅读器显示的页码差 1,导出文件名里最好保留原始索引,别自己再加偏移,否则回溯时对不上原件。

如果不想写代码,libtiff 的 tiffsplit 也能用:

mkdir -p out tiffsplit multi.tif out/x_

它会给每页生成一个x_加三字母后缀的文件,字母序就是页序,页数不多的时候够用;页数上千之后,字母序和数字序的转换会变成新的麻烦,不如自己写循环。

4.3 大图切瓦片与金字塔生成

另一种“拆分”不是按页,而是把一张巨大的单页影像切成可管理的瓦片。这类需求常见于遥感、病理切片和大幅面扫描,输出往往还要带金字塔层级。GDAL 的 gdal_retile 是这个场景的常规选择:

# 按 512x512 切成瓦片,输出到 tiles/ 目录 gdal_retile.py -ps 512 512 \ -co COMPRESS=DEFLATE -co TILED=YES -co BIGTIFF=IF_SAFER \ -targetDir tiles huge.tif # 同时生成 5 级金字塔,供后续按层级加载 gdal_retile.py -ps 256 256 -levels 5 \ -co COMPRESS=LZW -targetDir tiles_l5 huge.tif

-ps是瓦片尺寸,两个值分别对应宽高;-levels是金字塔层数,层数越多,低分辨率预览越快,但文件总数会成倍增长;-co后面的创建选项会原样传给输出文件,TILED=YES与切瓦片是两回事,前者控制单个输出文件内部的存储方式。

如果需要的是给前端或地图服务用的金字塔目录,vips 的 dzsave 更快一些:

# 拆成单独页面 vips tiffsplt scan.tif ./split/page_ # 生成瓦片金字塔,瓦片格式指定为 tif vips dzsave huge.tif ./pyramid --layout google --tile-size 256 --suffix .tif

tiffsplt的输出前缀后会自动补页码;dzsave的--layout google对应常见的 XYZ 目录结构,--tile-size一般用 256 或 512,--suffix决定瓦片扩展名。瓦片数会随层数指数增长,生成前先算一下磁盘余量,这一步翻车的人不少。

5. 避坑清单:五类“合并看着对、打开全错”的现场排查

5.1 现象:合并后只剩第一页

原因基本只有两种。第一种是直接对文件做了二进制拼接,比如用复制命令把多个 TIFF 首尾接起来,这种文件只有第一个 IFD 链是完整的,后面的数据没人引用;第二种是用 Pillow 保存多帧时忘了加save_all=True,Image.save默认只写第一帧,后面传进去的append_images被静默忽略。

解决方式:要么走 tifffile 的 TiffWriter 逐页写,要么走 tiffcp。如果坚持用 Pillow,必须写成img.save(out, save_all=True, append_images=rest, compression="tiff_lzw"),并且合并后立刻用len(TiffFile(out).pages)数一遍,不要凭肉眼翻页确认。

5.2 现象:文件体积涨了三五倍

合并前 200 MB,合并后 900 MB,页数还是对的。原因通常是压缩标签没被继承,新写入的页全部按未压缩存储;另一种是分块参数退化,源图是 512×512 分块,输出变成了整行条带,压缩器拿不到局部相关性。

排查顺序是固定的:先看page.compression.name是不是都变成了 NONE,再看page.is_tiled和rowsperstrip和源图是否一致。解决方式是在写入时显式传compression和tile,tiffcp 场景下补上-c和-t -w -l。另外别忘了 Predictor:16 bit 灰度图不开 Predictor=2,DEFLATE 的压缩率会差一大截。

5.3 现象:打开是负片,或者整体偏青

负片是 Photometric 从 MINISWHITE 掉成了 MINISBLACK,黑白翻转;偏青是 RGB 三通道被当成单通道加两个附属通道解释,通道错位。这两种都属于“标签没继承但像素没变”,所以文件大小正常、页数正常,只有颜色不对。

解决方式是在写入时强制带上photometric=page.photometric,RGB 图还要确认samplesperpixel=3、planarconfig与源图一致。如果源图带 ICC 配置文件,合并时要么一并写进去,要么明确告诉下游“色彩按 sRGB 解释”,不要留一个没有 profile 的模糊状态。

5.4 现象:写到 4 GB 附近中断,或者文件打不开

经典 TIFF 的偏移量是 32 位,单文件理论上限 4 GB,实际到 3.9 GB 左右就开始出问题:要么写入进程报错退出,要么文件写完了但阅读器只认前半部分。原因就是没有启用 BigTIFF。

解决方式分三条路:tifffile 在构造 TiffWriter 时传bigtiff=True;tiffcp 加-8;GDAL 加-co BIGTIFF=IF_SAFER(超过阈值自动切换)或BIGTIFF=YES。这里有个反向的坑:BigTIFF 不是所有老系统都认,交付前必须确认下游的阅读器、打印软件、归档系统支持 64 位偏移,否则就得改成分卷输出。

5.5 现象:DPI 丢了,打印尺寸变成整页 A4

像素尺寸完全正确,但拿去打印或者插入排版软件时,物理尺寸对不上。原因是合并脚本只写了像素数组,没写 XResolution、YResolution 和 ResolutionUnit 三个标签,阅读器只能按默认 72 DPI 猜。

解决方式是从源页读回分辨率再写进去,也就是第 3 章里那个page_resolution辅助函数的用途。要注意 X 和 Y 分辨率不一致的图(少见但存在),写入时要么统一取纵向值,要么保留原值并在交付说明里写清楚。这条坑最隐蔽的地方在于:文件能打开、图也清楚,只有到输出环节才暴露。

6. 目录级流水线:页数、尺寸、校验和三方验收怎么固化

单文件处理跑通之后,真正的成本在批量。我现在的习惯是把合并拆成“扫描—执行—验收”三段,中间落一份 manifest,任何一段失败都能从断点续跑,不用整批重来。

6.1 带 manifest 的批处理骨架

import hashlib import json import os from pathlib import Path import tifffile def sha256(path, chunk=1 << 20): h = hashlib.sha256() with open(path, "rb") as f: for block in iter(lambda: f.read(chunk), b""): h.update(block) return h.hexdigest() def batch(src_dir, out_dir, manifest="manifest.json"): Path(out_dir).mkdir(parents=True, exist_ok=True) done = json.loads(Path(manifest).read_text()) if os.path.exists(manifest) else {} for sub in sorted(Path(src_dir).iterdir()): if not sub.is_dir() or sub.name in done: continue # 已完成的批次直接跳过 files = sorted(sub.glob("*.tif")) if not files: continue out = Path(out_dir) / f"{sub.name}.tif" with tifffile.TiffWriter(out, bigtiff=True) as tw: for f in files: with tifffile.TiffFile(f) as src: page = src.pages[0] tw.write(page.asarray(), photometric=page.photometric, compression="deflate", tile=(512, 512), compressionargs={"level": 6}, metadata=None) with tifffile.TiffFile(out) as chk: done[sub.name] = { "pages": len(chk.pages), "expect": len(files), "bytes": os.path.getsize(out), "sha256": sha256(out), } Path(manifest).write_text(json.dumps(done, ensure_ascii=False, indent=2)) return done

关键是 manifest 里存了四样东西:实际页数、预期页数、字节数、内容哈希。前两个用来抓“少写了几页”,后两个用来抓“文件被别的进程动过”。断点续跑靠的是sub.name in done这一句,中途断电重跑不会重复处理已完成的批次。

6.2 三方验收:页数、尺寸、校验和

批处理跑完,我用一张固定表做验收,三项都过才算可交付:

验收项判定标准不通过时先看什么
页数等于源目录文件数通配符是否漏匹配、是否有非 TIF 混入
单页尺寸集合只有一个值是否混进了不同扫描分辨率
内容哈希与 manifest 一致是否有下游进程改写过文件

这套东西是被坑出来的。早些年做一批档案合并,两百多页的文件合并完看着没问题,交付后对方反馈第 137 页开始全是空白,回头查才发现源目录里混进了两个 0 字节的占位文件,合并脚本读到空数组照样写了一个 IFD,页数对得上,内容却是空的。从那以后我每次批量合并都强制走一遍“页数 + 尺寸集合 + 哈希”三连,宁可多花十分钟,也不想到交付之后再返工。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进