Rotated TIFF gets scrambled when opened by filename
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 30/100
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
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
by_name.png
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 (
strorpathlib.Path), so thatImageFile.load()memory-maps it; a file object orBytesIOdecodes 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 fitsstrip_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.micfile itself, at the TIFF's strip offset, which counts from the start of the image stream, not of the.micfile.Tests/images/hopper.micis 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
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di python-pillow/Pillow
-
Warn that pre-applying arabic_reshaper + python-bidi corrupts text on a Raqm buildForse già presa @Syamjith-NK l’ha presa 49 giorni fa. ApertaDocumentation Font Rendering PNG Third Party Framework
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
python-pillow/Pillow#9907 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Image.putpixel incorrectly labels supported arguments in type annotationsForse già presa @radarhere l’ha presa 177 giorni fa. ApertaPNG
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
python-pillow/Pillow#9584 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
ImageDraw is not translation-invariant for negative coordinates (truncation toward zero instead of floor)Forse già presa @Eryx-Z l’ha presa 1 giorno fa. Aperta
Difficoltà 5/5 Più di una settimana Idoneità per principianti 38/100
python-pillow/Pillow#10228 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
python-pillow/Pillow#10191 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
BOX filter missed some edge-case pixelsForse già presa @Dmao233 l’ha presa 45 giorni fa. ApertaPNG
Difficoltà 3/5 1-2 giorni Idoneità per principianti 68/100
python-pillow/Pillow#9939 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di python-pillow/Pillow
Issue simili
-
`lvm_compact` has no self-hosted twin, and `docs/158` §5 says every language-face component mustApertabug sev-3 vm
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
sierra-research/tau2-bench#635 ·
I maintainer di solito rispondono entro 1 giorno
-
The add-ssm-model skill requires a Cython simulator; the contributing guide allows Python onesForse già presa @fmuia l’ha presa oggi. Apertadocumentation skill
Difficoltà 2/5 1-3 ore Idoneità per principianti 83/100
lnccbrown/ssm-simulators#406 ·
I maintainer di solito rispondono entro 4 giorni
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 95/100
btclib-org/btclib-ecc#309 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
autowarefoundation/autoware_carla_scenario#281 ·
I maintainer di solito rispondono entro 1 giorno