一张照片在呈现之前,处理过程就足以带来可观开销。解码、缩放、调整、保存:如果每一步都保留完整的像素数组,工作集里便会同时驻留同一张照片的多个版本。开源库 libvips 改变了这种安排。它把各项操作连在一起,让小块区域接续经过各步计算。沿着区域请求追问下去,看清请求的区域和发起请求的一方,也就能理解它为何节省资源,以及这种节省有哪些限制。[1][3]
这个问题最初出现在一幅画作面前。VIPS 于 1989 年为 VASARI 项目而诞生,后者旨在为艺术品制作高分辨率多光谱图像。英国国家美术馆 1993 年的文章记述了当时的采集方式:相机安装在定位设备上,拍下彼此重叠的局部,再由软件拼成完整图像。上方照片记录的正是这套设备:轨道、线缆,以及为精细测量画面而搭起的龙门架。[1][2]
这段来历让架构的用途变得具体。一幅画作产生的图像数据,足以超出工作站轻松容纳的范围。下文依据项目的 8.17 版文档及其 Python 绑定示例,梳理一张逻辑上完整的大图如何存在,而各步产生的中间像素又如何分批占用内存。
图像也可以保存一套计算方法
在 libvips 中,区域(region)是一块矩形像素。局部生成图像(partial image)可以保存生成这些矩形所需的函数,以此代替完整的像素数组。在 C 接口中,vips_region_prepare() 请求一个指定的矩形区域,库随后安排相关工作,让其中的像素可供使用。[3]
假设某个输出区域需要调整颜色,这项操作就会请求对应的输入区域。如果输入本身也是计算得到的图像,请求便继续沿处理链向上游传递。整条链描述的是完整图像,计算时实际占用的中间存储空间则可以仅限于当下正在处理的小块区域。
要让计算真正开始,还需要一个称为 sink 的消费端。写入文件是一种消费端;显示像素、生成内存图像或计算统计量,也都能牵动整条流水线。惰性求值的触发条件因此包含这些对结果的需求,保存文件只是其中一种。[3]
最后一行牵动前面的计算
下面这段简短的 Python 示例使用了 pyvips 文档中的加载、算术和文件输出接口。假设输入是一张普通的三波段 RGB JPEG:
import pyvips
source = pyvips.Image.new_from_file("painting.jpg", access="sequential")
adjusted = source * [1.0, 0.9, 1.0]
adjusted.write_to_file("painting-adjusted.jpg")
乘法降低了绿色通道的数值。这里刻意用简单操作展示数据流,这种调整与文物保护级别的色彩校正有别。打开文件时先读取文件头;算术表达式接入另一项操作;写出文件时才请求结果像素。文件名后缀决定输出格式。[4]
因此,adjusted 这样的变量可以描述如何得到那张照片,未必表示“RAM 中已经放着第二张解码后的照片”。阅读应用代码时,这一区别很有用:代码中接连出现的整图操作,未必对应接连发生的整图内存分配。
线程也遵循同样的组织方式。项目论文描述,工作线程各自持有流水线可写状态的轻量副本。不同输出区域可以同时沿处理链计算,各阶段由此省去了分别持有一张完整图像的开销。因此,除了 CPU 核数,区域大小和流水线的组织也会影响处理表现。[1]
文件格式同样左右处理方式
源图像能满足哪些请求,取决于加载器。分块 TIFF 可以借助相应的加载库读取单独的图块。有些格式或加载器则要求先完整解压,得到支持随机访问的数据形式。这份数据放在 RAM 中还是磁盘临时文件中,取决于图像大小和配置。[5]
access="sequential" 提示告诉加载器,计算将按从上到下的顺序访问图像。在支持这种方式的情况下,解码过程便可逐步向流水线送入数据。这项提示描述的是程序的访问模式,其作用范围仍受编解码器自身的流式处理能力约束。[4][5]
垂直翻转能清楚显现这一限制。为了从上到下写出结果,程序需要从下到上读取源图像。文档中描述的 JPEG 解码器按正向顺序读取,因此,系统必须先得到支持随机访问的数据形式,才能完成这种变换。[5]
实际使用中,这个影响很容易被忽略:对同一个压缩文件执行两种操作,所需的存储空间也会不同。源文件很小,也难以据此判断解码后的像素有多大。临时磁盘用量上升时,原因有时在于所请求的访问顺序,中间某项操作是否意外占用了大量空间,还要另行判断。
输出端可以索取整张图像
输出要求也会重新带来整图驻留内存的需求。write_to_memory() 生成未经图像格式封装的像素数组;write_to_buffer() 则在内存缓冲区中生成编码后的图像。两者分配的内容不同,也都与将文件流式写入磁盘有别。[6]
可以用一个假设例子看清量级:一张 20,000 × 20,000、带有三个八位通道的图像,仅原始像素就需要 12 亿字节,计算式为宽度 × 高度 × 通道数 × 每通道字节数。这是算术推算,尚未涉及 libvips 的实测基准。即使省去了大型中间结果,调用方明确要求的最终数组仍要占据相应空间。
缓存控制处理的是工作集中的另一部分。vips_cache_set_max_mem() 设置一个阈值,达到阈值后便开始逐出缓存中的操作。文档明确指出,外部库分配的内存排除在这项统计之外。因此,将这一设置当作进程内存的硬上限,就超出了它的适用范围。[7]
基准测试要覆盖完整流程
Studysapuri 产品团队的一篇独立工程实践记录,把这一设计与实际应用联系起来。2021 年 3 月,Yuta Hamada 介绍了团队如何用 libvips 的 Go 绑定 bimg 替换原有的 Go 图像处理库,以生成缩略图。比较采用指定的输入图像,输出宽度为 200 像素,并为两种实现明确设置了 JPEG 质量参数。[8]
文章报告,在受测工作负载下,处理时间和内存用量都有下降。它还考察了解码器行为、并发和运行细节。这是一份有明确任务范围的外部证据,可以据此了解该项应用的表现;将其中的提速倍数推广到所有图像变换,则缺乏依据。[8]
对于维护图像服务的小团队,评估也可以同样具体:选择有代表性的格式、变换和输出去向,在贴近实际的并发请求下观察进程内存与临时存储用量。那些会改变访问顺序、处理起来较棘手的输入也应纳入测试。缺少对这些资源的观测,仅凭缓存设置难以验证内存预算。
VASARI 的照片定格了一台在画作前细致移动的机器。延续至今的软件也向图像提出范围明确的问题:下一步需要哪块矩形区域,为生成它须读取哪些数据,结果又将送往何处?从解码器一路追到输出端,内存的使用方式便逐渐清楚。
来源
- John Cupitt、Kirk Martinez、Lovell Fuller 和 Kleis Wolthuizen,《The libvips Image Processing Library》,Electronic Imaging,2025 年——项目起源、需求驱动的处理方式与横向线程模型。
- David Saunders 和 John Cupitt,《Image Processing at the National Gallery: The VASARI Project》,《英国国家美术馆技术公报》第 14 卷,1993 年,第 72–85 页——相机采集系统及设备档案照片,见第 72 页图 1。
- libvips 8.17,《Technical background: Evaluation》——区域、局部生成图像、操作流水线与消费端。
- pyvips,《Introduction》——惰性加载、顺序访问、波段算术运算与图像写出。
- libvips 8.17,《Technical background: Opening files》——分块访问、完整解压、临时存储与垂直翻转示例。
- libvips 8.17,输出 API 文档——
write_to_memory()生成的原始像素数组与write_to_buffer()生成的编码图像缓冲区。 - libvips 8.17,
cache_set_max_mem()——操作缓存的逐出阈值,以及外部库内存分配被排除在统计之外的说明。 - Yuta Hamada,《Speeding up thumbnail creation with bimg (libvips Go bindings)》,Studysapuri 产品团队博客,2021 年 3 月 1 日——独立的实现记录及 libvips 原理说明;日文来源。