Rotated TIFF gets scrambled when opened by filename
维护者通常 1 天内回复
已经有一个关联 PR 被合并了。
- #10222 来自 @lazerg —— 已合并
评估
调研方向
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.
由索引模型根据 Issue 内容生成。
描述
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
--------------------------------------------------------------------
- 主要语言
- Python
- 星标
- 13.9k
- 派生
- 2.6k
- 平均合并
- 1 天 3 小时
- 30 天内合并 PR
- 174
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
python-pillow/Pillow 的其他 Issue
-
Warn that pre-applying arabic_reshaper + python-bidi corrupts text on a Raqm build可能已有人在做 @Syamjith-NK 于 63 天前认领。 未关闭Documentation Font Rendering PNG Third Party Framework
难度 2/5 1-3 小时 新手友好度 78/100
python-pillow/Pillow#9907 · 2 条评论 ·
维护者通常 1 天内回复
-
Image.putpixel incorrectly labels supported arguments in type annotations可能已有人在做 @radarhere 于 191 天前认领。 未关闭PNG
难度 2/5 1-3 小时 新手友好度 70/100
python-pillow/Pillow#9584 · 1 条评论 ·
维护者通常 1 天内回复
-
难度 5/5 一周以上 新手友好度 35/100
python-pillow/Pillow#10362 ·
维护者通常 1 天内回复
-
`ImageDraw.arc()` segfaults on a tuple subclass whose `__len__` is too large可能已有人在做 @Parsiffall1 于 1 天前认领。 未关闭
难度 4/5 1-2 天 新手友好度 58/100
python-pillow/Pillow#10358 · 2 条评论 ·
维护者通常 1 天内回复
-
JPEG saved with keep_rgb=True and dpi reopens with incorrect colors可能已有人在做 @Muguett-DBY 于 3 天前认领。 未关闭🤖-assisted JPEG
难度 4/5 1-2 天 新手友好度 25/100
python-pillow/Pillow#10344 ·
维护者通常 1 天内回复
查看 python-pillow/Pillow 的全部 Issue
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
namespace operations
难度 1/5 1 小时以内 新手友好度 65/100
EclipseFdn/open-vsx.org#15057 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 76/100
FinanceFlash/unvibecode#349 ·
-
Mission statement: why this is free, in the README, Why This Exists and the wiki Home可能已有人在做 @ChiefGyk3D 今天认领。 未关闭
难度 2/5 1-3 小时 新手友好度 76/100
ChiefGyk3D/Clouds-Against-Humanity#348 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 74/100
vllm-project/vime#508 ·
维护者通常 4 天内回复