Skip to content

Reading n_frames corrupts subsequent loading of mixed RGB/P LZW TIFF frames #9851

Description

@dwt

What did you do?

This is derived from a real world bug I observed while trying to interact with a company internal archive that archives customer messages like emails or paper letters. This bug report was formulated by an agent guided reproduction that tried to to achieve bug reproduction parity without exposing any of the private data that originally triggered this that I cannot share. I hope this is enough to fix this issue - should it not be, then I would be happy to provide more details as needed.

I created a synthetic three-frame LZW TIFF:

  • frame 0: RGB, PhotometricInterpretation=2
  • frames 1 and 2: palette (P), PhotometricInterpretation=3

I read n_frames and then reused the same Image handle to seek and copy every frame.

The failure is deterministic: 10/10 runs with Pillow 12.3.0.

The following controls succeed:

  • The same seek/copy loop without first reading n_frames.
  • The same seek/load/copy loop without first reading n_frames.
  • Homogeneous RGB-only and P-only LZW TIFFs.
  • Reopening the TIFF for each requested output frame, then sequentially loading frames through that target.

This appears related to #2229, which also involved n_frames and multi-page TIFFs. However, that closed issue failed with a closed-file error, while current Pillow raises ValueError: unrecognized image mode.

What did you expect to happen?

Reading n_frames should not affect subsequent frame decoding. The loop should copy all three frames successfully, with modes RGB, P, and P.

What actually happened?

The first image.copy() after reading n_frames raises:

ValueError: unrecognized image mode

The synthetic TIFF has no orientation tag, but the traceback enters ImageOps.exif_transpose() during TIFF loading.

Traceback (most recent call last):
  File ".../PIL/Image.py", line 1364, in copy
    self.load()
  File ".../PIL/TiffImagePlugin.py", line 1306, in load
    return self._load_libtiff()
  File ".../PIL/TiffImagePlugin.py", line 1417, in _load_libtiff
    self.load_end()
  File ".../PIL/TiffImagePlugin.py", line 1328, in load_end
    ImageOps.exif_transpose(self, in_place=True)
  File ".../PIL/ImageOps.py", line 702, in exif_transpose
    image.load()
  File ".../PIL/TiffImagePlugin.py", line 1307, in load
    return super().load()
  File ".../PIL/ImageFile.py", line 298, in load
    pixel = Image.Image.load(self)
  File ".../PIL/Image.py", line 986, in load
    self.im.putpalette(self.palette.mode, mode, arr)
ValueError: unrecognized image mode

Workaround: do not reuse the handle used to read n_frames; reopen the TIFF per requested output frame and sequentially load through that frame before copying.

What are your OS, Python and Pillow versions?

  • OS: macOS 26.6 arm64
  • Python: 3.14.6
  • Pillow: 12.3.0
--------------------------------------------------------------------
Pillow 12.3.0
Python 3.14.6 (main, Jun 10 2026, 10:03:53) [Clang 21.1.8 ]
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 12.3.0
--- FREETYPE2 support ok, loaded 2.14.3
--- LITTLECMS2 support ok, loaded 2.19
--- WEBP support ok, loaded 1.6.0
--- AVIF support ok, loaded 1.4.2
--- JPEG support ok, compiled for libjpeg-turbo 3.1.4.1
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.4
--- ZLIB (PNG/ZIP) support ok, loaded 1.3.1.zlib-ng,
    compiled for zlib-ng 2.3.3
--- LIBTIFF support ok, loaded 4.7.1
--- XCB (X protocol) support ok
--------------------------------------------------------------------
from PIL import Image

path = "mixed-rgb-p-p.tif"

palette = Image.new("P", (2, 2))
palette.putpalette([0, 0, 255, 255, 255, 255] + [0, 0, 0] * 254)

Image.new("RGB", (2, 2), "red").save(
    path,
    format="TIFF",
    save_all=True,
    append_images=[palette, palette],
    compression="tiff_lzw",
)

with Image.open(path) as image:
    assert image.n_frames == 3  # Required trigger.
    for index in range(3):
        image.seek(index)
        page = image.copy()  # ValueError on the first iteration.
        print(index, page.mode)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions