diff --git a/.claude/rules/development.md b/.claude/rules/development.md index af098c0..0ca41f7 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -289,3 +289,9 @@ cache path survives teardown and later model tests silently attempt downloads. Dotenv configuration tests must clear every fixture variable from the invoked process environment. Seed conflicting inherited values to prove isolation; clearing only some fields lets the remaining host settings override the fixture. + +Cross-platform tests must use host-absolute synthetic interpreter paths and +explicit unchanged decodes when comparing stored pixels. Execute Ubuntu Bash +workflow harnesses only on POSIX hosts with Bash; keep their pure Python checks +active on every platform. Round floating-point image output before uint8 +conversion when quantization must preserve constant values across OpenCV builds. diff --git a/docs/code-and-documentation-audit.md b/docs/code-and-documentation-audit.md index a7891e9..5b26385 100644 --- a/docs/code-and-documentation-audit.md +++ b/docs/code-and-documentation-audit.md @@ -31,6 +31,18 @@ inherited Azure settings overrode the test's dotenv fixture. The test now clears every fixture variable and seeds conflicting inherited values to verify that isolation without making external calls. +The first pushed CI matrix exposed additional portability gaps: newer OpenCV +builds produced a one-byte darkening when unsharp output was truncated and +applied WebP orientation during a test's default color decode. Unsharp now +rounds before byte conversion, and the lossless test compares stored pixels. +Synthetic interpreter paths are host-absolute; Ubuntu Bash workflow tests run +only on POSIX hosts with Bash, while their Python checks remain cross-platform. + +The accompanying Dependabot update attempt could not resolve Transformers +5.16.1 against the retained `tokenizers<0.23` compatibility constraint. This is +an unresolved candidate update, not a failure to install the committed lockfile; +changing that model-stack constraint requires separate validation. + | Post-repair check | Result | | --- | --- | | `bash maintain.sh` | Exit 2 at the existing Accelerate/Lightning security findings; versions retained by explicit decision | diff --git a/docs/module-internals.md b/docs/module-internals.md index 16c9cf6..4b3898b 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -2082,6 +2082,11 @@ Regression coverage: [`humanizer.py`](../src/remove_ai_watermarks/humanizer.py) contains explicit grain, unsharp masking, and adaptive polish helpers. +Unsharp masking rounds its floating-point output before clipping to uint8. +Truncation can turn a flat 128-valued image into 127 on OpenCV builds with +slightly different floating-point kernels. The explicit rounding regression +also covers fractional values and saturation at both byte limits. + `_apply_postprocessing` in `invisible_engine.py` owns the stage ORDER, and the order is a contract: restore the original resolution, unsharp, adaptive polish, humanize. Grain runs LAST because `adaptive_polish` measures the image it is given @@ -2131,6 +2136,8 @@ Contracts: - `read_bgr_and_alpha` reads with `IMREAD_UNCHANGED`, so a 16-bit source stays 16-bit through the pixel path and out through `imwrite`. Anything downstream that needs 8 bits narrows a copy for itself. + Lossless-write tests likewise compare explicit unchanged decodes: default + color reads may apply EXIF orientation, including for WebP on newer OpenCV builds. - `imwrite` accepts `display_tags_from` naming the file whose decode produced the pixels, and carries that file's ICC profile and EXIF orientation into the re-encoded output (issue #98: cv2's encoders write no container metadata, so a diff --git a/src/remove_ai_watermarks/humanizer.py b/src/remove_ai_watermarks/humanizer.py index fe769a8..12b295f 100644 --- a/src/remove_ai_watermarks/humanizer.py +++ b/src/remove_ai_watermarks/humanizer.py @@ -85,7 +85,7 @@ def unsharp_mask(image: NDArray, amount: float = 0.5, sigma: float = 1.0) -> NDA img_f = image.astype(np.float32) blurred = cv2.GaussianBlur(img_f, (0, 0), sigmaX=sigma, sigmaY=sigma) sharpened = cv2.addWeighted(img_f, 1.0 + amount, blurred, -amount, 0.0) - return np.clip(sharpened, 0, 255).astype(np.uint8) + return np.clip(np.rint(sharpened, out=sharpened), 0, 255).astype(np.uint8) # ── Adaptive polish (target the input's detail level; spare text) ────────────── diff --git a/src/remove_ai_watermarks/image_io.py b/src/remove_ai_watermarks/image_io.py index 0fa669b..148148f 100644 --- a/src/remove_ai_watermarks/image_io.py +++ b/src/remove_ai_watermarks/image_io.py @@ -72,7 +72,7 @@ def _pil_read(path: str | Path, flags: int) -> NDArray[Any] | None: """Decode via Pillow (HEIC/AVIF and any other Pillow-readable container) into the cv2 layout ``flags`` implies: grayscale, 3-channel BGR, or BGRA when the source has alpha and ``IMREAD_UNCHANGED`` was requested. Returns None if Pillow (with the - optional HEIF plugin) still cannot open it. No EXIF auto-rotation, matching cv2.""" + optional HEIF plugin) still cannot open it. This fallback does not apply EXIF orientation.""" import cv2 import numpy as np @@ -184,9 +184,9 @@ def _read_display_tags( The contract is that ``source`` is the file whose decode produced the pixels being written, and ``raster_shape`` is that raster's ``(rows, cols)``. Whether the decode turned the raster upright cannot be assumed from the container: cv2 - applies EXIF orientation for JPEG and (build-dependent) PNG -- but only under - ``IMREAD_COLOR``, never ``IMREAD_UNCHANGED``, which is what the pixel paths read - with -- and the reporter of issue #98 ran a build that left PNG flat too. + applies EXIF orientation under ``IMREAD_COLOR`` depending on the codec and + build, including for WebP in newer builds. ``IMREAD_UNCHANGED``, which the + pixel paths use, never applies orientation. ``orientation_applied`` carries that decode state explicitly when known. Otherwise the legacy inference uses raster geometry: for the transposing orientations (5-8) the tag is carried exactly when the raster still diff --git a/tests/test_agent_skill.py b/tests/test_agent_skill.py index 519ab35..0c2bfeb 100644 --- a/tests/test_agent_skill.py +++ b/tests/test_agent_skill.py @@ -637,8 +637,8 @@ def test_probe_video_advice_uses_the_installed_video_runtime( def test_video_probe_runs_the_cli_interpreter_without_loading_models(tmp_path: Path, wrapper: bool) -> None: probe = _probe_module() cli = tmp_path / "remove-ai-watermarks" - interpreter = "/synthetic/venv/bin/python3" - cli.write_text(f"#!/bin/sh\n'''exec' {interpreter} \"$0\" \"$@\"\n' '''\n" if wrapper else f"#!{interpreter}\n") + interpreter = (tmp_path / "venv" / "bin" / "python3").as_posix() + cli.write_text(f"#!/bin/sh\n'''exec' '{interpreter}' \"$0\" \"$@\"\n' '''\n" if wrapper else f"#!'{interpreter}'\n") calls: list[list[str]] = [] def run(argv: list[str], timeout: float = 8) -> tuple[int, str, str]: diff --git a/tests/test_humanizer.py b/tests/test_humanizer.py index 65a017d..a65c18b 100644 --- a/tests/test_humanizer.py +++ b/tests/test_humanizer.py @@ -114,6 +114,22 @@ def test_unsharp_flat_image_is_a_noop(): assert np.array_equal(result, img) +def test_unsharp_rounds_and_clips_float_output(monkeypatch): + import cv2 + + values = np.array([-0.75, 0.75, 127.99999, 128.75, 254.9, 255.75], dtype=np.float32).reshape(1, 2, 3) + calls = [] + + def weighted(*args): + calls.append(args) + return values.copy() + + monkeypatch.setattr(cv2, "addWeighted", weighted) + result = unsharp_mask(np.full((1, 2, 3), 128, dtype=np.uint8)) + assert len(calls) == 1 + assert np.array_equal(result, np.array([0, 1, 128, 129, 255, 255], dtype=np.uint8).reshape(1, 2, 3)) + + class TestAdaptivePolish: """Adaptive polish: target the reference's detail level, sparing text/edges.""" diff --git a/tests/test_image_io.py b/tests/test_image_io.py index 3d8457f..1608a6b 100644 --- a/tests/test_image_io.py +++ b/tests/test_image_io.py @@ -289,12 +289,12 @@ class TestDisplayTagCarry: assert icc == source_icc assert orient == self.ORIENT # The lossless Pillow re-save must stay pixel-identical. - back = image_io.imread(out) + back = self._stored_raster(out) assert back is not None assert np.array_equal(back, raster) def test_webp_source_orientation_travels(self, tmp_path: Path) -> None: - # cv2 never rotates a WebP decode, so even a fresh PNG output must be + # IMREAD_UNCHANGED preserves stored pixels, so even a fresh PNG output must be # tagged, or the portrait source would display sideways. src = self._source(tmp_path / "src.webp") raster = self._stored_raster(src) @@ -303,8 +303,7 @@ class TestDisplayTagCarry: assert self._tags(out)[1] == self.ORIENT def test_an_upright_raster_is_not_tagged_into_a_second_rotation(self, tmp_path: Path) -> None: - # A raster the decode already turned upright (this cv2 build does that for - # JPEG/PNG under IMREAD_COLOR) must not receive the tag again. Stored 96x64 + # A raster already turned upright must not receive the tag again. Stored 96x64 # with Orientation 6 displays as 64x96, so the upright raster is 64 wide. src = self._source(tmp_path / "src.png") source_icc, _ = self._tags(src) diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 124eb07..8d445c4 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import os import re +import shutil import subprocess import sys from pathlib import Path @@ -51,6 +52,7 @@ def test_registry_check_parses_distribution_and_exact_floor(dependency: str, acc assert (result.returncode == 0) == accepted, result.stderr +@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow") def test_distributed_version_survives_a_newer_pypi_release(tmp_path: Path) -> None: producer = _step("distribute.yml", "resolve", "Record distributed release") consumer = _step("verify-release.yml", "verify", "Resolve target version") @@ -133,6 +135,7 @@ def test_distributed_version_survives_a_newer_pypi_release(tmp_path: Path) -> No ) +@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow") def test_clawhub_uses_local_install(tmp_path: Path) -> None: script = _step("distribute.yml", "clawhub", "Publish the agent skill when its version moved")["run"] script = script.replace("${{ github.event.release.name || github.ref_name }}", "test")