- 图像处理
- 计算机视觉
【免费下载链接】Pillow
Python Imaging Library (fork)
Pillow 7.1.2 是一次针对性的补丁发布,核心任务是修复 7.1.0 引入 APNG(Animated PNG)支持时造成的一个回归:对普通 PNG 文件调用seek(n)(n > 0)时,未能按规范抛出EOFError,反而暴露出AttributeError: 'NoneType' object has no attribute 'read'这一令人困惑的错误。本文将结合 docs/releasenotes/7.1.2.rst 的发布说明,从帧寻址 API 的契约、PNG 插件的底层实现(PngImagePlugin.py)与对应测试用例三个层面,完整还原该回归的来龙去脉,并给出正确的帧遍历与异常处理实践。
版本背景:7.1.0 为 PNG 插件引入的 APNG 支持
要理解 7.1.2 修复的问题,必须先回顾其直接上游版本。根据 docs/releasenotes/7.1.0.rst 的记载,Pillow 7.1.0 的重大 API 变化之一就是“Improved APNG support”:PNG 插件开始支持使用Image.seek方法与ImageSequence.Iterator类读取 APNG 帧序列,也支持通过append_images参数写出 APNG 帧序列。
这一改动把 PNG 解码器从"单一静态图像"推进到了"多帧序列"的模型,也意味着seek()的语义需要在普通 PNG 与 APNG 之间统一。而正是在这个统一的边界上,7.1.2 发现并修复了一个回归。
回归现象:seek(1) 抛出了错误的异常类型
发布说明中给出的问题描述非常精确:
When calling
seek(n)on a regular PNG wheren > 0, it failed to raise an :py:exc:EOFErroras it should have done, resulting in:AttributeError: 'NoneType' object has no attribute 'read'
也就是说,在 7.1.0 与 7.1.1 中,对一张只有一帧的普通 PNG执行:
from PIL import Image im = Image.open("photo.png") im.seek(0) # 正常 im.seek(1) # 应当抛出 EOFError,实际却抛出 AttributeErrorseek(1)意味着请求跳到第 2 帧,而普通 PNG 并不存在第 2 帧。按序列 API 的契约,这里应当抛出EOFError;但回归版本中这一异常没有正确上抛,程序继续执行后续的读取逻辑,此时底层的文件对象已经被解码器关闭(指向None),于是产生了'NoneType' object has no attribute 'read'这一极具误导性的错误——它没有告诉用户"没有更多帧了",而是暴露了内部实现细节。
修复内容:确保 seek 越界时抛出的正确异常
7.1.2 的修复本质上是补上了帧越界时的异常路径,使seek(n)(n 超出实际帧数)恢复为抛出EOFError。
这一契约在当前的 PngImageFile.seek 实现 中得到了完整保留与强化:
def seek(self, frame: int) -> None: if not self._seek_check(frame): return if frame < self.__frame: self._seek(0, True) last_frame = self.__frame try: for f in range(self.__frame + 1, frame + 1): self._seek(f) except EOFError as e: self.seek(last_frame) msg = "no more images in APNG file" raise EOFError(msg) from e从源码结构看,seek()的健壮性由三层逻辑共同保证:
_seek_check(frame)前置校验:若请求帧号与当前状态不匹配则提前返回或拒绝;- 单帧推进模型:
seek()不会直接跳到任意帧,而是从当前帧开始逐帧调用内部的_seek(f),这一步在实现上是线性的,也正因如此,任何一帧读取失败都能在循环内被捕获; EOFError捕获并重新包装:内部_seek()抛出的EOFError会被捕获,回退到上一帧,再以"no more images in APNG file"为消息重新抛出同一个异常类型,保证对调用方的语义始终一致。
底层 _seek 的越界判定
真正负责发现"没有更多帧"的是内部方法_seek。它在逐帧扫描 chunk 流时,通过两个显式分支判定序列终结:
- 遇到
IENDchunk 时,抛出EOFError("No more images in APNG file")(见 src/PIL/PngImagePlugin.py#L953-L955); - 若当前帧扫描完毕后没有任何可用 tile 数据,同样抛出
EOFError("image not found in APNG frame")(见 src/PIL/PngImagePlugin.py#L984-L986)。
对于一张普通 PNG,文件中只有一个IDAT块、一个IEND块。当seek(1)触发第二次帧扫描时,IEND分支会立即命中,EOFError由此产生。这正是 7.1.2 所保证的"now raises the correct exception"在源码层面的落点。
EOFError 是帧序列 API 的统一契约
为什么发布说明强调"应当抛出EOFError"?因为这不仅是PngImageFile的局部约定,而是 Pillow 整个帧序列机制共同依赖的协议。
Image.seek 的文档契约
在 Image.seek 的 docstring 中明确写道:
If you seek beyond the end of the sequence, the method raises an
EOFErrorexception. When a sequence file is opened, the library automatically seeks to frame 0.
即:EOFError就是"已超出序列末尾"的官方信号,任何遵循序列协议的对象都必须遵守。
ImageSequence.Iterator 依赖 EOFError 结束迭代
一旦seek()不再可靠地抛出EOFError,依赖它的所有高层 API 都会连锁出错。ImageSequence.Iterator 正是这样工作的:
__getitem__调用im.seek(ix),捕获EOFError后转换为IndexError("end of sequence")(见 src/PIL/ImageSequence.py#L46-L52);__next__同样依赖EOFError来结束迭代,并将其转换为StopIteration(见 src/PIL/ImageSequence.py#L57-L64)。
可以推断:如果 7.1.2 没有修复异常类型,那么不仅裸调用seek()的用户会看到AttributeError,连for frame in ImageSequence.Iterator(im)这类标准遍历方式也可能出现未预期的崩溃或死循环,因为迭代终止信号(EOFError→StopIteration)永远无法被正确触发。从这个意义上说,7.1.2 修复的不只是一个异常类型,而是整个多帧读取管线的终止语义。
测试验证:回归被固化在测试套件中
Pillow 对这次回归的防护体现在 Tests/test_file_png.py 的test_seek用例中(见 Tests/test_file_png.py#L874-L879):
def test_seek(self) -> None: with Image.open(TEST_PNG_FILE) as im: im.seek(0) with pytest.raises(EOFError): im.seek(1)这段测试的行为与发布说明的描述一一对应:
im.seek(0)必须成功——回退到第 0 帧永远合法;im.seek(1)必须抛出EOFError——对单帧 PNG 请求第 2 帧是非法的,且错误类型必须正确。
该用例直接守护了 7.1.2 的修复内容,任何未来改动若再次破坏异常语义,都会在 CI 中被立即拦截。此外,测试文件中还有一个辅助函数get_chunks用PngStream手工解析 chunk 并以EOFError作为解析终止信号,从侧面印证了EOFError在 PNG 解析路径中的"流结束"语义。
实践建议:如何健壮地遍历 PNG/APNG 帧
基于上述源码与契约,在实际项目中处理 PNG/APNG 帧时建议遵循以下模式:
1. 用 ImageSequence 做遍历,自动处理终止
from PIL import Image, ImageSequence with Image.open("animation.png") as im: for i, frame in enumerate(ImageSequence.Iterator(im)): # 对 APNG:逐帧处理;对普通 PNG:仅一帧 print(f"frame {i}: {frame.size}")Iterator在内部捕获EOFError并转换为StopIteration,普通 PNG 与 APNG 可以共用同一套代码,无需自行判断帧数。
2. 手动 seek 时必须捕获 EOFError
from PIL import Image with Image.open("photo.png") as im: im.seek(0) try: im.seek(1) except EOFError: print("no more frames") # 正确、可控的终止信号如果你的代码此前依赖"seek 越界会抛某种异常"来终止循环,请务必按EOFError处理——这是 7.1.2 之后的稳定契约,也是ImageSequence内部依赖的信号。
3. 判断是否为动画,可借助 n_frames / is_animated
PNG 插件在_open()末尾设置了self.n_frames = self.png.im_n_frames or 1与self.is_animated = self.n_frames > 1(见 src/PIL/PngImagePlugin.py#L833-L854),读取动画帧前可以先检查这些属性,减少对异常流程的依赖。
小结
Pillow 7.1.2 的发布说明虽然简短,但修复意义明确:它把 7.1.0 引入 APNG 支持时被打破的seek()异常契约重新立了起来——对普通 PNG 越界seek(n)(n > 0)必须抛出EOFError,而不是泄露内部实现细节的AttributeError。这一修复不仅让seek()本身行为正确,更保障了ImageSequence.Iterator等高层遍历 API 的终止语义。对于仍在 7.1.0/7.1.1 上遇到'NoneType' object has no attribute 'read'异常的用户,升级到 7.1.2 即可解决问题;而对于需要自行实现帧遍历逻辑的开发者,应始终以EOFError作为"没有更多帧"的唯一正确信号。
- 图像处理
- 计算机视觉
【免费下载链接】Pillow
Python Imaging Library (fork)
相关推荐
Pillow 7.1.1 回归修复解析:APNG 支持引入的 PNG `seek`/`tell` 崩溃与根治方案
Pillow 7.1.1 回归修复解析:APNG 支持引入的 PNG seek / tell 崩溃与根治方案 导读 Pillow 7.1.0 在引入 APNG(
图像处理计算机视觉从 8.3.2 版本发布说明看 Pillow 的安全修复与回归修复实践
从 8.3.2 版本发布说明看 Pillow 的安全修复与回归修复实践 Pillow 8.3.2 是一个以安全修复为核心的小版本,重点修复了 ImageColo
图像处理计算机视觉pandas 1.3.2 发布说明深度解析:回归修复、Bug 修复与升级迁移指南
pandas 1.3.2 发布说明深度解析:回归修复、Bug 修复与升级迁移指南 导读 本文基于 pandas 官方发布说明 doc/source/whatsn
数据分析数据科学数据处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考