Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

Rotated TIFF gets scrambled when opened by filename

Aperta
#10,220 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@lazerg ci sta già lavorando.

Dal 16/10/2026.

  • #10222 di @lazerg — aperta

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
30/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
c, python
Ambito
backend

Direzione di ricerca

Start with the memory-map branch of ImageFile.load() in src/PIL/ImageFile.py, then TiffImageFile._setup() and load_end() in src/PIL/TiffImagePlugin.py, where _tile_size is set and used. Reproduce with the logo script: the by-name and by-file-object results should match. Done means both print True, and the proposed test_orientation_memory_mapped in Tests/test_file_tiff.py passes for orientations 5 to 8.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

🤖-assisted TIFF

Hi, this bug was found by AI when I was doing some tests. It's not something we encountered in the wild, but since it's something that's easily reproduced and there appears to already have been several commits that address similar issues it seemed useful to open a bug report on it. The report below was written up by AI, but went through multiple rounds of revisions and was reproduced by myself before creating this ticket. What I couldn't fully verify is the cause and the proposed fix, as I don't have enough expertise in image handling or Pillow to be able to make a confident judgement call on that.

What did you do?

While testing how Pillow reads TIFFs, we made a test TIFF from Pillow's logo and read it back by file name. The logo is an upright PNG (its XMP says Orientation 1); the script turns a copy on its side and saves it tagged with EXIF Orientation 6, so that it should show upright again. We have not met this in a real file. It takes an uncommon combination of conditions (below), so it is unlikely to come up often, but when it does, the image is silently scrambled.

import urllib.request
from io import BytesIO

from PIL import Image

# Pillow's logo at tag 12.3.0: an upright RGBA PNG, 200 x 71 (its XMP says Orientation 1)
URL = "https://raw.githubusercontent.com/python-pillow/Pillow/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/docs/resources/pillow-logo.png"
with urllib.request.urlopen(URL) as response:
    logo = Image.open(BytesIO(response.read()))
    logo.load()

# 1. The test file's pixels: a copy of the logo turned on its side, 71 x 200
stored = logo.transpose(Image.Transpose.ROTATE_90)
stored.save("logo_stored.png")

# 2. Save them with Pillow's TIFF writer (uncompressed, one strip), tagged
#    Orientation 6: "turn 90 degrees clockwise to display", which makes them upright
stored.save("logo.tiff", tiffinfo={274: 6})

# 3. Open it by file name and from a file object: both should give the upright logo
with Image.open("logo.tiff") as im:
    im.load()
    print("by name:       ", im.size, im.tobytes() == logo.tobytes())
    im.save("by_name.png")

with open("logo.tiff", "rb") as f, Image.open(f) as im:
    im.load()
    print("by file object:", im.size, im.tobytes() == logo.tobytes())
    im.save("by_file_object.png")

To run it in a clean container, save it as logo.py in an empty directory and run one of these there (-u keeps the files it writes owned by you):

# Pillow 12.3.0
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/work" -w /work python:3.14 \
  sh -c 'pip install -q --user pillow==12.3.0 && python logo.py'

# Pillow main at fef1035, built from source
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/work" -w /work python:3.14 \
  sh -c 'pip install -q --user git+https://github.com/python-pillow/Pillow@fef103594eba1315548e94ef03e6137b7d5119ad && python logo.py'
What did you expect to happen?

The upright logo both ways, as Pillow 10.4.0 gives:

by name:        (200, 71) True
by file object: (200, 71) True
What actually happened?
by name:        (71, 200) False
by file object: (200, 71) True

Opened by file name, the TIFF comes back at its stored size with scrambled pixels, and without an error (by_name.png). Opened from a file object it is right (by_file_object.png).

by_file_object.png
Image

by_name.png
Image

The logo script gives this on 11.0.0, 12.3.0 and main (fef1035). It needs all of these at once:

  • Pillow 11.0.0 or newer;
  • the TIFF is opened by path (str or pathlib.Path), so that ImageFile.load() memory-maps it; a file object or BytesIO decodes correctly;
  • the image data is uncompressed, in one strip or tile: Pillow's own writer writes one strip unless a RowsPerStrip tag is passed in tiffinfo, and its libtiff writer does when the data fits strip_size. LZW, PackBits or several strips decode correctly;
  • the mode is one Pillow memory-maps: L, P, RGBA (unassociated alpha, as Pillow writes it), CMYK, I;16 and I;16B fail; 1, LA, PA, RGB, LAB, I and F decode correctly;
  • Orientation 5, 6, 7 or 8; 1 to 4 are right;
  • the image is not square: cropped to 71 x 71, the logo is right.

Turning the pixels on their side first only makes the expected result the upright logo; upright pixels tagged 6 fail too.

On this path it also undoes what #8390 set out to do. Opened by name, logo.tiff reports (200, 71) until load(), and (71, 200) after it, and resize() and thumbnail() without an explicit load() raise ValueError: box can't exceed original image size, the error of #6185. From a file object the size stays (200, 71) and both work.

Cause

Since #8390, TiffImageFile._setup() sets _size to the displayed size for Orientation 5 to 8 and keeps the stored size in _tile_size. TiffImageFile.load_prepare() creates the core image at _tile_size, and load_end() turns it with ImageOps.exif_transpose(self, in_place=True).

The memory-map branch of ImageFile.load() creates the core image itself, with Image.core.map_buffer() at self.size, the displayed size. load_prepare() still runs afterwards, but does not create a new core image because one exists, and the length check before the map does not object, because the displayed size holds as many bytes as the stored size. So the stored rows are read at the displayed width, and load_end() then turns the scrambled image back to the stored size.

Suggested fix

Map the buffer at the stored size, and run the length check from #10147 at that size too. Only TiffImageFile defines _tile_size, and it differs from size only for Orientation 5 to 8 on an image that is not square, which is exactly where the result is wrong now. Against main (fef1035):

--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -346,18 +346,19 @@ class ImageFile(Image.Image, metaclass=abc.ABCMeta):
                     with open(self.filename) as fp:
                         self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ)
 
+                    size = getattr(self, "_tile_size", self.size)
                     if self.mode in {"L", "P"}:
                         pixelsize = 1
                     elif self.mode.startswith("I;16"):
                         pixelsize = 2
                     else:
                         pixelsize = 4
-                    stride = max(args[1], self.size[0] * pixelsize)
-                    if offset + self.size[1] * stride > self.map.size():
+                    stride = max(args[1], size[0] * pixelsize)
+                    if offset + size[1] * stride > self.map.size():
                         msg = "buffer is not large enough"
                         raise OSError(msg)
                     self.im = Image.core.map_buffer(
-                        self.map, self.size, decoder_name, offset, args
+                        self.map, size, decoder_name, offset, args
                     )
                     readonly = 1
                     # After trashing self.im,

The check needs the same size as the map. It repeats map.c's own length check, and load() falls back to the decoder on the OSError it raises, but not on the ValueError from map_buffer(). With the size changed in the map call only, a truncated turned TIFF whose single tile is wider than the image, and whose stored image is taller than wide, can pass the check at the displayed size and then raise ValueError: buffer is not large enough from map_buffer(), with or without LOAD_TRUNCATED_IMAGES: the error #10147 removed.

With the change, the script prints True on both lines, every affected mode decodes correctly by name with every orientation, and the size no longer changes on load(). Pillow's test suite on main gives the same results with and without it (5729 cases), apart from the test below. On 12.3.0, which does not have #10147's check, putting size in the old check and in the map gives the same results (5192 cases). If ImageFile should not read a TIFF attribute, a hook that TiffImageFile overrides would do the same. Mapping at the tile's extents would fix these files as well, but it also changes how other single raw tiles are mapped, which _tile_size leaves alone.

A test for TestFileTiff in Tests/test_file_tiff.py; it fails for all four orientations without the change and passes with it:

    @pytest.mark.parametrize(
        "orientation, method",
        (
            (5, Image.Transpose.TRANSPOSE),
            (6, Image.Transpose.ROTATE_270),
            (7, Image.Transpose.TRANSVERSE),
            (8, Image.Transpose.ROTATE_90),
        ),
    )
    def test_orientation_memory_mapped(
        self, orientation: int, method: Image.Transpose, tmp_path: Path
    ) -> None:
        # An uncompressed image in a single strip is memory mapped
        # when it is opened by filename
        im = hopper("L").crop((0, 0, 128, 64))
        filename = tmp_path / "temp.tif"
        im.save(filename, tiffinfo={274: orientation})

        with Image.open(filename) as reloaded:
            assert reloaded.size == (64, 128)
            reloaded.load()
            assert_image_equal(reloaded, im.transpose(method))

It does not cover the size in the check: the two sizes only give different answers for a single tile wider than the image, and Pillow's writers cannot write tiles.

Also seen: two other files that are memory mapped wrongly by name

Neither has to do with the orientation, and both were found with hand-made files only, so we cannot say whether real files hit them. On main, with or without the change above, both decode correctly from a file object and wrongly by name, without an error:

  • A MIC file holding an uncompressed TIFF. Opened by name, ImageFile.load() memory-maps the .mic file itself, at the TIFF's strip offset, which counts from the start of the image stream, not of the .mic file. Tests/images/hopper.mic is LZW and never takes that path; with its image stream replaced by an uncompressed greyscale TIFF, it does.
  • A PSD layer smaller than the canvas. A greyscale layer with raw data is one raw tile with the layer's bounds as its extents. Opened by name, it is mapped at the canvas size from the layer's data offset: in the file below, 64 x 48 bytes from offset 920 instead of 20 x 10 into the layer's bounds. This is still wrong on main. #10089 fixed only the file-object read.

Each script prints by name: False and by file object: True. They run in the same container as the logo script, with olefile added to pip install for the MIC script.

MIC script
import urllib.request
from io import BytesIO

import olefile
from PIL import Image

# Pillow's Tests/images/hopper.mic at tag 12.3.0; its image stream is LZW, so it is never memory mapped
URL = "https://raw.githubusercontent.com/python-pillow/Pillow/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/Tests/images/hopper.mic"
urllib.request.urlretrieve(URL, "uncompressed.mic")

# Replace the image stream with an uncompressed greyscale TIFF, padded to the stream's size
im = Image.linear_gradient("L").resize((120, 100))
tiff = BytesIO()
im.save(tiff, "TIFF")
ole = olefile.OleFileIO("uncompressed.mic", write_mode=True)
ole.write_stream("0001.ACI/Image", tiff.getvalue().ljust(ole.get_size("0001.ACI/Image"), b"\0"))
ole.close()

with Image.open("uncompressed.mic") as reloaded:
    reloaded.load()
    print("by name:       ", reloaded.tobytes() == im.tobytes())

with open("uncompressed.mic", "rb") as f, Image.open(f) as reloaded:
    reloaded.load()
    print("by file object:", reloaded.tobytes() == im.tobytes())
PSD script
import struct

from PIL import Image

# A greyscale PSD, 64 x 48, with two raw layers; layer 2 is 20 x 10 at (5, 7)
W, H = 64, 48
merged = Image.linear_gradient("L").resize((W, H))
layers = [((0, 0), Image.new("L", (32, 24), 50)), ((5, 7), Image.radial_gradient("L").resize((20, 10)))]

records = channels = b""
for (x, y), layer in layers:
    w, h = layer.size
    records += struct.pack(">4iH", y, x, y + h, x + w, 1)  # bounds, one channel
    records += struct.pack(">hI", 0, 2 + w * h)  # channel 0 and its length
    records += b"8BIMnorm" + bytes([255, 0, 0, 0])  # blend mode, opacity, clipping, flags
    records += struct.pack(">I3I", 12, 0, 0, 0)  # no mask, no blending ranges, no name
    channels += struct.pack(">H", 0) + layer.tobytes()  # raw
info = struct.pack(">h", 2) + records + channels
info += b"\0" * (len(info) % 2)
layer_and_mask = struct.pack(">I", len(info)) + info + struct.pack(">I", 0)
with open("layers.psd", "wb") as f:
    f.write(b"8BPS" + struct.pack(">H6xHIIHH", 1, 1, H, W, 8, 1) + struct.pack(">II", 0, 0))
    f.write(struct.pack(">I", len(layer_and_mask)) + layer_and_mask)
    f.write(struct.pack(">H", 0) + merged.tobytes())  # merged image, raw

expected = Image.new("L", (W, H))
expected.paste(layers[1][1], layers[1][0])

with Image.open("layers.psd") as im:
    im.seek(2)
    im.load()
    print("by name:       ", im.tobytes() == expected.tobytes())

with open("layers.psd", "rb") as f, Image.open(f) as im:
    im.seek(2)
    im.load()
    print("by file object:", im.tobytes() == expected.tobytes())
What are your OS, Python and Pillow versions?
  • OS: Debian GNU/Linux 13 (trixie), x86_64, in Docker
  • Python: 3.14.8
  • Pillow: 12.3.0, and main at fef1035 (2026-10-16), built from source in the same container
--------------------------------------------------------------------
Pillow 12.3.0
Python 3.14.8 (main, Oct  6 2026, 04:36:55) [GCC 14.2.0]
--------------------------------------------------------------------
Python executable is /opt/app/.venv/bin/python3
Environment Python files loaded from /opt/app/.venv
System Python files loaded from /usr/local
--------------------------------------------------------------------
Python Pillow modules loaded from /opt/app/.venv/lib/python3.14/site-packages/PIL
Binary Pillow modules loaded from /opt/app/.venv/lib/python3.14/site-packages/PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 12.3.0
--- TKINTER support ok, loaded 8.6
--- 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, compiled for zlib-ng 2.3.3
--- LIBTIFF support ok, loaded 4.7.1
--- RAQM (Bidirectional Text) support ok, loaded 0.10.5, fribidi 1.0.16, harfbuzz 14.2.1
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------
Lingua principale
Python
Stelle
13.9k
Fork
2.5k
Merge medio
1g 2h
PR unite (30g)
145

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di python-pillow/Pillow

Tutte le issue di python-pillow/Pillow

Issue simili

Altre issue su Python

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.