From 003dabfae415887795b87ac826d82f2087f40d55 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Wed, 9 Sep 2026 12:22:50 -0700 Subject: [PATCH] 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 --- docs/module-internals.md | 9 ++++++++- src/remove_ai_watermarks/image_io.py | 11 +++++++++++ tests/test_image_io.py | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/module-internals.md b/docs/module-internals.md index 4b3898b..95996d8 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -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 diff --git a/src/remove_ai_watermarks/image_io.py b/src/remove_ai_watermarks/image_io.py index ff0069a..af05718 100644 --- a/src/remove_ai_watermarks/image_io.py +++ b/src/remove_ai_watermarks/image_io.py @@ -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: diff --git a/tests/test_image_io.py b/tests/test_image_io.py index d7615dc..0fccd6e 100644 --- a/tests/test_image_io.py +++ b/tests/test_image_io.py @@ -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