Rotated TIFF gets scrambled when opened by filename
Maintainer thường phản hồi trong vòng 1 ngày
Một pull request liên quan đã được merge.
- #10222 của @lazerg — đã merge
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 30/100
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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
--------------------------------------------------------------------
- Ngôn ngữ chính
- Python
- Star
- 13.9k
- Fork
- 2.5k
- Merge trung bình
- 1 ngày 8 phút
- Pull request đã merge (30 ngày)
- 149
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của python-pillow/Pillow
-
Warn that pre-applying arabic_reshaper + python-bidi corrupts text on a Raqm buildCó thể đã có người làm @Syamjith-NK đã nhận 55 ngày trước. Đang mởDocumentation Font Rendering PNG Third Party Framework
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
python-pillow/Pillow#9907 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Image.putpixel incorrectly labels supported arguments in type annotationsCó thể đã có người làm @radarhere đã nhận 183 ngày trước. Đang mởPNG
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
python-pillow/Pillow#9584 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
SystemError in `ImagePath.Path` item assignment and `ImageDraw` coordinatesCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 66/100
python-pillow/Pillow#10284 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Release Pillow 13.1.0 on January 2, 2027Có thể đã có người làm @radarhere đã nhận 1 ngày trước. Đang mởRelease
python-pillow/Pillow#10276 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Fix attach SBOM to GitHub release againCó thể đã có người làm @radarhere đã nhận 1 ngày trước. Đang mởRelease
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
python-pillow/Pillow#10275 · 6 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của python-pillow/Pillow
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
foxglove/mcap#1907 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
namespace operations
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 72/100
EclipseFdn/open-vsx.org#14623 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
developmentseed/titiler-cmr#261 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
andrewyng/openworker#774 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
aggfuncĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
worldbank/blackmarblepy#160 ·