Rotated TIFF gets scrambled when opened by filename
メンテナーはふだん 1 日以内に返信
関連するプルリクエストがすでにマージされています。
- #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.5k
- 平均マージ
- 1日 2時間
- マージ済み PR(30日)
- 143
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
python-pillow/Pillow のほかの issue
-
Warn that pre-applying arabic_reshaper + python-bidi corrupts text on a Raqm build対応中かも @Syamjith-NK が 52 日前に担当しました。 オープン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 が 180 日前に担当しました。 オープンPNG
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
python-pillow/Pillow#9584 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Saving grayscale images as AVIF fails with libavif older than 1.3.0対応中かも @lazerg が今日担当しました。 オープン
難易度 3/5 半日 初心者へのやさしさ 65/100
python-pillow/Pillow#10262 ·
メンテナーはふだん 1 日以内に返信
-
JPEG
難易度 5/5 1週間以上 初心者へのやさしさ 20/100
python-pillow/Pillow#10241 · コメント 4 件 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
`frombytes()` and in-place `ImageCms` transforms write into read-only image memory対応中かも @tongwentao が今日担当しました。 オープン
難易度 4/5 3〜5日 初心者へのやさしさ 50/100
python-pillow/Pillow#10235 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信
python-pillow/Pillow の issue をすべて見る
似ている issue
-
documentation good first issue
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
selimfirat/pysad#379 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
open-webui/open-webui#32805 ·
メンテナーはふだん 1 日以内に返信
-
fix: [bug description] HTML CVE metric table shows only the last CVE for each product対応中かも @Chris-wh233 が今日担当しました。 オープンbug
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
ossf/cve-bin-tool#5976 ·
メンテナーはふだん 1 日以内に返信
-
needs-triage
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
gke-labs/kube-agents#3615 ·
メンテナーはふだん 1 日以内に返信
-
deferred
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
メンテナーはふだん 1 日以内に返信