Let the TIFF geometry override a caller's raw-decode claim

Both readers in the imread stack apply the TIFF orientation tag (cv2's
libtiff path under IMREAD_UNCHANGED too, and Pillow's TIFF plugin,
identically), so the visible path's blanket orientation_applied=False
carried the tag onto an already-turned raster. _read_display_tags now
ignores the explicit claim for TIFF and decides on the IFD's stored size,
which also keeps a hypothetical non-turning build correct: a stored-shape
raster still carries the tag. Guarded at the api seam, not just imwrite.

pre-commit: 1) maintain.sh - exit 2 at the uv-secure gate, the three
triaged no-fix advisories in docs/development.md (rechecked 2026-09-08);
ruff check+format clean, pyright src/ 0 errors, 2363 passed 3 skipped
run separately; 2) /simplify - single pass, no fan-out available, nothing
to fix (api-state plumbing rejected as disproportionate); 3) docs sync -
identifiers live only in docs/module-internals.md, updated, no other
refs; 4) CLAUDE.md - audited against the structure rule, no additions,
revise-claude-md: no additions
This commit is contained in:
Victor Kuznetsov committed 2026-09-09 12:56:14 -07:00
1 parent 4c090c5ae2
commit 003dabfae4
3 files changed
+36 -1

No files matched your search

+8 -1
View File
@@ -2148,7 +2148,14 @@ Contracts:
pixels on save. Callers that know the decode state pass
`orientation_applied=False` for raw pixels and `True` for a transposed
raster. Dimensions alone cannot distinguish mirrors or 180-degree rotation.
The omitted value retains the compatibility shape heuristic. Display tags
TIFF is the container exception: every reader in the stack (cv2's libtiff path
under `IMREAD_UNCHANGED` too, and Pillow's TIFF plugin, identically) applies
the orientation tag on decode, so `_read_display_tags` ignores the explicit
claim for TIFF and decides on the IFD's stored `ImageWidth`/`ImageLength`
against the raster, via `_stored_size` -- `Image.size` already reports the
upright geometry there, which is what let a turned raster be tagged into a
second rotation (issue #106). The omitted value retains the compatibility shape
heuristic. Display tags
are captured before encoding, including when source and destination match.
The metadata strip is the other half of that contract: Pillow cannot hold 16-bit
+11
View File
@@ -219,6 +219,14 @@ def _read_display_tags(
in every case: it describes colour, not geometry. Failures read as "no tags":
the restore is an enhancement, never a reason to fail a write.
TIFF is the exception on the explicit state: every reader in this module's
stack applies the TIFF orientation tag on decode (cv2's libtiff path under
``IMREAD_UNCHANGED`` too, and Pillow's TIFF plugin, identically), so a caller's
blanket ``False`` -- which encodes cv2's JPEG/PNG/WebP contract -- is not true
for TIFF. For TIFF sources the parameter is ignored and the geometry check
decides against the IFD's stored size: a raster that no longer has the stored
dimensions was turned by the decode and must not be tagged again (issue #106).
The stored size cannot be read straight off ``Image``: Pillow's TIFF reader
reports the upright one, so it comes through :func:`_stored_size`.
"""
@@ -231,11 +239,14 @@ def _read_display_tags(
with Image.open(source) as im:
icc = im.info.get("icc_profile")
orient = im.getexif().get(_ORIENT_EXIF_TAG)
fmt = im.format
stored_w, stored_h = _stored_size(im)
except Exception:
return None, None
if not (isinstance(icc, bytes) and icc):
icc = None
if fmt == "TIFF":
orientation_applied = None
if orientation_applied is True:
orient = None
elif orientation_applied is False:
+17
View File
@@ -445,3 +445,20 @@ class TestTiffOrientation:
out = tmp_path / "out.png"
assert image_io.imwrite(out, raster, display_tags_from=src) is True
assert self._out_orientation(out) is None
def test_the_visible_api_path_does_not_tag_a_turned_tiff(self, tmp_path: Path) -> None:
# The visible write passes orientation_applied=False ("raw decode"), which is
# a lie for TIFF: both readers turn the raster. _read_display_tags must ignore
# that claim for TIFF and let the IFD geometry decide, or a CLI/library user
# gets a turned raster TAGGED for the same turn again (issue #106).
from PIL import Image
from remove_ai_watermarks import api
src = self._tagged(tmp_path / "src.tif", self.ORIENT)
out = tmp_path / "out.png"
_, removed = api.remove_visible(src, out, strip_metadata=False)
assert removed == [] # plain red: no mark, the passthrough write is exercised
with Image.open(out) as im:
assert im.size == (64, 96) # the upright raster, not the stored landscape
assert im.getexif().get(0x0112) is None # and no tag asking for another turn