diff --git a/README.md b/README.md index 2318637..e1a070b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Remove AI provenance marks from images and video you generated yourself: - known visible labels such as the Google Gemini sparkle watermark and vendor text marks; -- invisible pixel watermarks through diffusion regeneration; +- invisible pixel watermarks through direct local-format disruption or diffusion regeneration; - C2PA, EXIF, XMP, IPTC, and related AI metadata. Video support covers provenance identification, complete visible-plus-metadata @@ -49,7 +49,9 @@ removal. Microsoft Paint and Photos InvisMark declarations are routed automatically to pixel regeneration. The `all` command removes both the hidden pixel watermark and -its linked C2PA manifest; metadata stripping alone removes only the manifest. +its linked C2PA manifest; metadata stripping alone removes only the manifest. A +specialized Python API can inspect and disrupt the validated local +`Watermarker.dll` payload without diffusion. ## Installation modes @@ -61,6 +63,7 @@ its linked C2PA manifest; metadata stripping alone removes only the manifest. | Visible video processing | `remove-ai-watermarks[video]` | | Video SynthID removal | `remove-ai-watermarks[video,diffusion]` | | Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` | +| Direct local Paint InvisMark disruption | `remove-ai-watermarks[pixels]` | | Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` | | Every production feature available on the active Python | `remove-ai-watermarks[all]` | @@ -213,8 +216,10 @@ is not a product result state. The CLI therefore describes video-pixel regeneration instead of claiming a per-file SynthID verdict, and reports the copied audio watermark as `UNVERIFIED`. -For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU -is required**: all profiles are CUDA-only, and there is no CPU or MPS fallback. +For invisible watermark removal through the CLI or high-level API, install the +`qwen-zimage` extra. **An NVIDIA GPU is required**: all diffusion profiles are +CUDA-only, and there is no CPU or MPS fallback. The specialized local Paint API +uses the `pixels` extra without CUDA. ```bash uv tool install --force "remove-ai-watermarks[qwen-zimage]" @@ -375,10 +380,10 @@ and keyed generation parameters are blanked without changing box sizes or media offsets. Other supported containers use their corresponding metadata path. -Invisible removal is different. It regenerates the image through a diffusion -pipeline to disrupt pixel and frequency domain watermarks. This changes the -image and cannot guarantee that a proprietary verifier will reject every -output. +Invisible removal through the CLI and high-level API regenerates the image through +a diffusion pipeline. Specialized callers can instead decode and disrupt the +positively identified local Microsoft Paint format with much smaller pixel changes. +Neither path can guarantee that every proprietary verifier will reject every output. See [supported signals](docs/supported-signals.md) and [known limitations](docs/known-limitations.md) for the full technical boundary. @@ -473,9 +478,10 @@ invisible removal. The shipped profile is oracle-certified, but no public local decoder can certify an arbitrary output at runtime. Recheck unusually important outputs after provider changes. -- Invisible-watermark removal requires CUDA. All profiles refuse any other - device at construction rather than falling back to one that cannot run them. - Visible removal, metadata stripping and `identify` still run anywhere. +- Invisible-watermark removal through the CLI and high-level API requires CUDA. + All diffusion profiles refuse any other device at construction rather than + falling back to one that cannot run them. The specialized local Paint API, + visible removal, metadata stripping and `identify` run without CUDA. - Provider watermark systems can change. Validate important outputs with the provider's own verifier when one is available. diff --git a/docs/installation.md b/docs/installation.md index d54ceb1..c5ce7f0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -94,11 +94,13 @@ Transformers cannot construct the Chroma tokenizer. It includes the `diffusion` dependencies; `diffusion` on its own covers the torch and diffusers imports but not the face stage or Chroma tokenizer, so it is not enough to run a removal. -**An NVIDIA GPU is required.** `qwen-zimage`, `sdxl-zimage` and `chroma-zimage` -are CUDA-only, and +**An NVIDIA GPU is required for the diffusion profiles.** `qwen-zimage`, +`sdxl-zimage` and `chroma-zimage` are CUDA-only, and construction refuses any other device rather than falling back to a slow or broken -one. There is no CPU, MPS or XPU path for invisible-watermark removal. Visible-mark -removal, metadata stripping and every `identify` command still run anywhere. +one. There is no CPU, MPS or XPU path for those profiles. The specialized API for +the validated local Microsoft Paint format uses the `pixels` extra and no GPU. +Visible-mark removal, metadata stripping and every `identify` command still run +anywhere. Video SynthID regeneration is a separate VAE path and does still run on CPU or MPS; it needs the `diffusion` extra, not this one. @@ -119,7 +121,7 @@ application actually uses: | Extra | Capability | Automatically includes | Torch or model download | | --- | --- | --- | --- | -| `pixels` | Shared BGR image runtime | NumPy, headless OpenCV | No | +| `pixels` | Shared BGR image runtime and direct local Paint InvisMark disruption | NumPy, headless OpenCV | No | | `heif` | HEIC, HEIF, and AVIF pixel decoding | pillow-heif | No | | `visible` | Visible mark detection, OpenCV inpainting, and manual erasing | `pixels` | No | | `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No | diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 1d2423e..6363b5b 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -87,12 +87,20 @@ detections in the removed experimental mode. ## Invisible removal -### Regeneration is lossy +### Invisible processing changes pixels -Invisible removal does not decode and delete a payload. It regenerates the +Most invisible removal does not decode and delete a payload. It regenerates the image through a diffusion pipeline. Faces, text, colors, and fine detail can change even when the watermark is successfully disrupted. +The local Microsoft Paint `Watermarker.dll` format is a narrower exception. Its +direct path validates the repeated message and can require agreement with the +signed C2PA UUID before rewriting confirmed carriers without redrawing the image. +It still changes many pixels slightly, deliberately leaves an inverted periodic +signal, and has no external detector that recognizes the positive control. The +supported claim is therefore local payload invalidation, not certified removal by +Microsoft. + Text, tables, and UI screenshots are the worst case for a forced scrub: the model redraws glyphs as plausible-but-wrong shapes. Do not run the invisible stage on such content unless a locally-detectable invisible signal exists. diff --git a/docs/module-internals.md b/docs/module-internals.md index 7ae85b9..a837a4a 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -484,6 +484,19 @@ not added to the watermark inventory, do not trigger pixel regeneration, and do not suppress independently established SynthID watermark evidence. A registered watermark or an unknown soft-binding algorithm keeps that inference fail-safe. +The local Paint reader treats C2PA as corroboration, not as the pixel detector. +It extracts 4-by-4 blocks from the luminance Haar LL plane, reads the largest +singular-value residue of the 15 DCT AC coefficients, and votes over the repeating +12-by-12 map. A payload is actionable only when every bit has at least three +supporting carriers and the `0x4c` prefix and GUID checksum validate. A caller can +also require the decoded Windows-order GUID to equal the one in pristine C2PA. +Direct disruption moves each confirmed carrier to the opposite residue center, +encodes to a temporary file, and re-runs the reader before atomically publishing +it. Any missing or ambiguous +condition returns no detection so a caller can retain its existing fallback. The +implementation contains no Microsoft binary, PDB data, or extracted writer-quality +forest. + The SDK default enables trust verification but supplies no production trust anchors. Consequently, an installation without an explicitly maintained C2PA trust bundle reports every otherwise valid signer chain as untrusted -- diff --git a/docs/python-api.md b/docs/python-api.md index 9192096..8b4c149 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -153,6 +153,32 @@ engine's own parameter names and defaults. `force`, which decides whether the engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside `backend` and `sensitivity`. +Specialized callers can inspect or apply the clean-room local Paint pixel +operation directly. This is deliberately separate from the high-level diffusion +route. Passing the UUID signed in pristine C2PA makes agreement with that UUID a +write precondition: + +```python +from remove_ai_watermarks.microsoft_invismark import ( + detect_local_invismark, + disrupt_local_invismark, + invismark_id_from_c2pa, +) + +expected = invismark_id_from_c2pa("paint.png") +detection = detect_local_invismark("paint.png") +if expected is not None and detection is not None: + result = disrupt_local_invismark( + "paint.png", + "clean.png", + expected_watermark_id=expected, + ) +``` + +The direct call needs the `pixels` extra. It writes atomically only after the +encoded output no longer decodes as a valid local payload. The result does not +claim acceptance by a proprietary Microsoft detector. + The complete field set, with the shipped defaults: | Field | Default | Effect | diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 7178895..9d299ad 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -148,11 +148,11 @@ use their format specific paths. ## Invisible watermarks -The `invisible` command uses diffusion regeneration. It targets watermark -patterns by changing the image rather than decoding and deleting a known -payload. +The `invisible` command uses diffusion regeneration. A specialized Python API can +instead apply decoder-guided disruption to a positively identified local +Microsoft Paint payload. -Current pipeline values, all CUDA-only: +Current diffusion pipeline values, all CUDA-only: - `qwen-zimage`, the default; - `sdxl-zimage`, the same recipe and the same face stage on an SDXL global pass; @@ -180,19 +180,24 @@ Microsoft Paint can name `com.microsoft.invismark.1` in a C2PA soft-binding assertion. Inspection reports both that exact algorithm and its signed `value`, which Paint uses as the identifier carried by the pixel watermark, and emits an additive `invismark` signal so callers can select pixel removal without parsing -the generic `soft_binding` detail. Photos uses a parallel local writer path. -Metadata stripping removes only the embedded manifest; `invisible` and `all` -guarantee the supported InvisMark removal contract by regenerating the pixel -layer as well. The project has no validated local InvisMark decoder, so local -inspection cannot independently verify the output. Microsoft's official -[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection) -is the external oracle: it reports pixel `Watermark` and embedded `C2PA` results -separately; a control-positive, output-negative pair is the available per-file -verification path. The API needs Azure credentials; the page a human can check -without an account is . That page is the -public provider oracle used for the measured removal certification. Its clean -outcome is rendered as `Inconclusive`; the authenticated API exposes separate -watermark and C2PA fields but is not a different class of certification. +the generic `soft_binding` detail. + +For Paint's local `Watermarker.dll` format, the reader recovers the repeated +144-bit message and requires its prefix, GUID byte order, checksum, and per-bit +support. A caller can additionally require agreement with the UUID signed in +pristine C2PA. Only a positive local payload uses decoder-guided bit disruption; +the encoded output is read back and must no longer contain a valid local payload +before it is published. A Microsoft +soft-binding declaration without a matching local payload, including the +committed cloud Image Creator fixture, is rejected by this decoder. The CLI and +high-level API retain their Chroma diffusion route. Metadata stripping alone still +removes only the manifest. + +This is local payload invalidation, not external removal certification. The +available Azure Content Provenance Detection API did not detect a known-positive +local-format control, so it cannot certify this direct output. Photos and future +Paint builds may use another pixel format and therefore remain on the regeneration +fallback unless this decoder positively identifies them. Meta Muse Image stamps every output with Content Seal, a proprietary invisible pixel watermark, and ships no visible mark (the legacy `Imagined with AI` @@ -243,7 +248,8 @@ not a universal clean verdict. | Google Veo video | Veo diamond and legacy text | Oracle-certified VAE removal for SynthID | C2PA and related source signals | | OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance | | Meta Muse Image | None on Muse output (legacy `Imagined with AI` unregistered) | Diffusion regeneration for Content Seal, oracle-verified on the default profile | XMP IPTC `trainedAlgorithmicMedia` companion tag; no local Content Seal decoder | -| Microsoft Paint and Photos | None registered | External Microsoft oracle for InvisMark; no validated local decoder | Paint C2PA soft-binding algorithm and identifier | +| Microsoft Paint local `Watermarker.dll` format | None registered | Local repeated-payload decoder and decoder-guided disruption; no usable external oracle for this format | Paint C2PA soft-binding algorithm and matching UUID | +| Microsoft cloud and other InvisMark variants | None registered | Chroma regeneration fallback | Microsoft C2PA attribution and soft-binding declaration | | Microsoft image outputs (measured variant) | One top-right white AI-badge variant | No registered pixel decoder | C2PA attribution | | Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata | | FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources | diff --git a/src/remove_ai_watermarks/_internal/image_math.py b/src/remove_ai_watermarks/_internal/image_math.py new file mode 100644 index 0000000..6ed4000 --- /dev/null +++ b/src/remove_ai_watermarks/_internal/image_math.py @@ -0,0 +1,14 @@ +"""Small shared numerical transforms used by independent pixel analyzers.""" + +from __future__ import annotations + +from typing import Any + + +def dct_matrix(np: Any, size: int = 8) -> Any: + """Return an orthonormal ``size`` by ``size`` DCT-II basis.""" + frequencies = np.arange(size)[:, None] + samples = np.arange(size)[None, :] + matrix = np.cos(np.pi * (2 * samples + 1) * frequencies / (2 * size)) + matrix[0, :] *= 1 / np.sqrt(2) + return matrix * np.sqrt(2 / size) diff --git a/src/remove_ai_watermarks/microsoft_invismark.py b/src/remove_ai_watermarks/microsoft_invismark.py new file mode 100644 index 0000000..f9dfd42 --- /dev/null +++ b/src/remove_ai_watermarks/microsoft_invismark.py @@ -0,0 +1,266 @@ +"""Detect and disrupt the pixel payload written by local Microsoft Paint. + +This is a clean-room implementation of the observed ``Watermarker.dll`` wire +format. It carries no Microsoft binary, symbols, or writer-quality model. The +reader validates the repeated 144-bit message before the remover touches pixels; +callers with pristine C2PA should also pass its signed watermark UUID. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from numpy.typing import NDArray + +_BLOCK_SIZE = 4 +_MAP_SIZE = 12 +_PAYLOAD_BITS = _MAP_SIZE * _MAP_SIZE +_QUANTUM = 24.0 +_CENTERS = (6.0, 18.0) +_MAX_CENTER_ERROR = 2.0 +_MIN_SUPPORT = 3 + + +@dataclass(frozen=True, slots=True) +class LocalInvisMarkDetection: + """A validated local Paint payload recovered from repeated pixel carriers.""" + + watermark_id: uuid.UUID + payload: bytes + minimum_support: int + + +@dataclass(frozen=True, slots=True) +class LocalInvisMarkRemoval(LocalInvisMarkDetection): + """A disrupted local Paint payload and the number of rewritten carriers.""" + + changed_blocks: int + + +@dataclass(frozen=True, slots=True) +class _Analysis: + ll: NDArray[Any] + bits: NDArray[Any] + eligible: NDArray[Any] + bit_indices: NDArray[Any] + + +def _dct_matrix() -> NDArray[Any]: + import numpy as np + + from remove_ai_watermarks._internal.image_math import dct_matrix + + return dct_matrix(np, _BLOCK_SIZE) + + +def _luminance(rgb: NDArray[Any]) -> NDArray[Any]: + import numpy as np + + return rgb.astype(np.float64) @ np.array((0.299, 0.587, 0.114), dtype=np.float64) + + +def _ll_plane(luminance: NDArray[Any]) -> NDArray[Any]: + height = luminance.shape[0] - luminance.shape[0] % 2 + width = luminance.shape[1] - luminance.shape[1] % 2 + plane = luminance[:height, :width] + return (plane[0::2, 0::2] + plane[0::2, 1::2] + plane[1::2, 0::2] + plane[1::2, 1::2]) * 0.5 + + +def _analyze(rgb: NDArray[Any]) -> _Analysis: + import numpy as np + + ll = _ll_plane(_luminance(rgb)) + block_rows = ll.shape[0] // _BLOCK_SIZE + block_columns = ll.shape[1] // _BLOCK_SIZE + blocks = ( + ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE] + .reshape(block_rows, _BLOCK_SIZE, block_columns, _BLOCK_SIZE) + .transpose(0, 2, 1, 3) + ) + transform = _dct_matrix() + coefficients = transform @ blocks @ transform.T + matrices = coefficients.reshape(block_rows, block_columns, -1)[..., 1:].reshape(block_rows, block_columns, 3, 5) + singular_values = np.linalg.svd(matrices, compute_uv=False)[..., 0] + residue = singular_values % _QUANTUM + center_errors = np.stack( + [np.minimum(abs(residue - center), _QUANTUM - abs(residue - center)) for center in _CENTERS], + axis=-1, + ) + bits = np.argmin(center_errors, axis=-1).astype(np.uint8) + eligible = np.min(center_errors, axis=-1) <= _MAX_CENTER_ERROR + block_y, block_x = np.indices((block_rows, block_columns)) + bit_indices = (block_y % _MAP_SIZE) * _MAP_SIZE + block_x % _MAP_SIZE + return _Analysis(ll=ll, bits=bits, eligible=eligible, bit_indices=bit_indices) + + +def _validated_detection(rgb: NDArray[Any]) -> tuple[LocalInvisMarkDetection, _Analysis] | None: + import numpy as np + + analysis = _analyze(rgb) + support = np.zeros((_PAYLOAD_BITS, 2), dtype=np.int32) + np.add.at(support, (analysis.bit_indices[analysis.eligible], analysis.bits[analysis.eligible]), 1) + winning_support = support.max(axis=1) + if np.any(winning_support < _MIN_SUPPORT) or np.any(support[:, 0] == support[:, 1]): + return None + + bits = (support[:, 1] > support[:, 0]).astype(np.uint8) + payload = np.packbits(bits).tobytes() + if len(payload) != 18 or payload[0] != 0x4C or payload[-1] != sum(payload[1:17]) & 0xFF: + return None + watermark_id = uuid.UUID(bytes_le=payload[1:17]) + if watermark_id.int == 0: + return None + detection = LocalInvisMarkDetection( + watermark_id=watermark_id, + payload=payload, + minimum_support=int(winning_support.min()), + ) + return detection, analysis + + +def _read_rgb(source: str | Path) -> tuple[NDArray[Any], NDArray[Any] | None]: + from remove_ai_watermarks import image_io + + bgr, alpha = image_io.read_bgr_and_alpha(source) + if bgr is None: + raise ValueError(f"Could not read image: {source}") + return bgr[..., ::-1], alpha + + +def detect_local_invismark(source: str | Path) -> LocalInvisMarkDetection | None: + """Return a validated local Paint payload, or ``None`` for an uncertain image.""" + rgb, _alpha = _read_rgb(source) + recovered = _validated_detection(rgb) + return recovered[0] if recovered is not None else None + + +def invismark_id_from_c2pa_info(info: dict[str, Any]) -> uuid.UUID | None: + """Return the one signed UUID for a Microsoft InvisMark soft binding.""" + algorithms = {value.strip() for value in str(info.get("soft_binding_algorithm") or "").split(",") if value.strip()} + if "com.microsoft.invismark.1" not in algorithms: + return None + values = [value.strip() for value in str(info.get("soft_binding_value") or "").split(",") if value.strip()] + parsed: list[uuid.UUID] = [] + for value in values: + try: + parsed.append(uuid.UUID(value)) + except ValueError: + continue + unique = tuple(dict.fromkeys(parsed)) + return unique[0] if len(unique) == 1 else None + + +def invismark_id_from_c2pa(source: str | Path) -> uuid.UUID | None: + """Read the signed Microsoft InvisMark UUID from a pristine image.""" + from remove_ai_watermarks._internal.c2pa import extract_c2pa_info + + try: + return invismark_id_from_c2pa_info(extract_c2pa_info(Path(source))) + except Exception: + return None + + +def disrupt_local_invismark( + source: str | Path, + output: str | Path, + *, + expected_watermark_id: str | uuid.UUID | None = None, +) -> LocalInvisMarkRemoval | None: + """Invert confirmed local Paint carriers and atomically write the result. + + No output is written unless the payload validates, an optional signed UUID + agrees, and the encoded output no longer contains a valid local payload. + This invalidates the observed message; it is not a claim about an unavailable + external Microsoft detector. + """ + import numpy as np + + from remove_ai_watermarks import image_io + + source_path, output_path = Path(source), Path(output) + rgb, alpha = _read_rgb(source_path) + recovered = _validated_detection(rgb) + if recovered is None: + return None + detection, analysis = recovered + if expected_watermark_id is not None: + try: + expected = uuid.UUID(str(expected_watermark_id)) + except ValueError: + return None + if detection.watermark_id != expected: + return None + + luminance = _luminance(rgb) + ll = analysis.ll + original_ll = ll.copy() + transform = _dct_matrix() + payload_bits = np.unpackbits(np.frombuffer(detection.payload, dtype=np.uint8)) + selected = analysis.eligible & (analysis.bits == payload_bits[analysis.bit_indices]) + selected_y, selected_x = np.nonzero(selected) + changed_blocks = len(selected_y) + block_rows, block_columns = analysis.bits.shape + block_grid = ( + ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE] + .reshape(block_rows, _BLOCK_SIZE, block_columns, _BLOCK_SIZE) + .transpose(0, 2, 1, 3) + .copy() + ) + for start in range(0, changed_blocks, 4096): + batch_y = selected_y[start : start + 4096] + batch_x = selected_x[start : start + 4096] + coefficients = transform @ block_grid[batch_y, batch_x] @ transform.T + matrices = coefficients.reshape(-1, 16)[:, 1:].reshape(-1, 3, 5) + target_bits = 1 - analysis.bits[batch_y, batch_x] + u, singular, vh = np.linalg.svd(matrices, full_matrices=False) + singular[:, 0] = (np.floor(singular[:, 0] / _QUANTUM) + 0.25 + 0.5 * target_bits) * _QUANTUM + rewritten = coefficients.reshape(-1, 16).copy() + rewritten[:, 1:] = ((u * singular[:, None, :]) @ vh).reshape(-1, 15) + block_grid[batch_y, batch_x] = transform.T @ rewritten.reshape(-1, 4, 4) @ transform + ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE] = block_grid.transpose(0, 2, 1, 3).reshape( + block_rows * _BLOCK_SIZE, block_columns * _BLOCK_SIZE + ) + + height, width = original_ll.shape + delta = (ll - original_ll) * 0.5 + pixel_delta = np.zeros(luminance.shape, dtype=np.float64) + pixel_delta[: height * 2 : 2, : width * 2 : 2] = delta + pixel_delta[: height * 2 : 2, 1 : width * 2 : 2] = delta + pixel_delta[1 : height * 2 : 2, : width * 2 : 2] = delta + pixel_delta[1 : height * 2 : 2, 1 : width * 2 : 2] = delta + cleaned_rgb = np.clip(np.rint(rgb.astype(np.float64) + pixel_delta[..., None]), 0, 255).astype(np.uint8) + + output_path.parent.mkdir(parents=True, exist_ok=True) + file_descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{output_path.stem}-", suffix=output_path.suffix, dir=output_path.parent + ) + os.close(file_descriptor) + temporary_path = Path(temporary_name) + try: + if not image_io.write_bgr_with_alpha( + temporary_path, + cleaned_rgb[..., ::-1], + alpha, + display_tags_from=source_path, + orientation_applied=False, + ): + raise OSError(f"failed to write output (is the destination writable?): {output_path}") + if detect_local_invismark(temporary_path) is not None: + return None + os.replace(temporary_path, output_path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + return LocalInvisMarkRemoval( + watermark_id=detection.watermark_id, + payload=detection.payload, + minimum_support=detection.minimum_support, + changed_blocks=changed_blocks, + ) diff --git a/src/remove_ai_watermarks/pixel_evidence.py b/src/remove_ai_watermarks/pixel_evidence.py index a78cccc..01dd59f 100644 --- a/src/remove_ai_watermarks/pixel_evidence.py +++ b/src/remove_ai_watermarks/pixel_evidence.py @@ -48,6 +48,7 @@ import time from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from remove_ai_watermarks._internal.image_math import dct_matrix as _dct_matrix from remove_ai_watermarks._internal.schema import require_schema_version if TYPE_CHECKING: @@ -148,15 +149,6 @@ def _numpy() -> Any: return np -def _dct_matrix(np: Any, n: int = 8) -> Any: - """Orthonormal n x n DCT-II basis: M[i, j] = cos(pi (2j + 1) i / 2n).""" - i = np.arange(n)[:, None] - j = np.arange(n)[None, :] - m = np.cos(np.pi * (2 * j + 1) * i / (2 * n)) - m[0, :] *= 1 / np.sqrt(2) - return m * np.sqrt(2 / n) - - def read_gray(image_path: Path) -> tuple[Any, Any, dict[str, Any]]: """Decode to float32 grayscale (and RGB for color stats), downscaled. diff --git a/tests/test_microsoft_invismark_local.py b/tests/test_microsoft_invismark_local.py new file mode 100644 index 0000000..262f2bf --- /dev/null +++ b/tests/test_microsoft_invismark_local.py @@ -0,0 +1,128 @@ +"""Clean-room tests for Paint's local ``Watermarker.dll`` pixel format.""" + +from __future__ import annotations + +import uuid +from pathlib import Path + +import numpy as np +from PIL import Image + +from remove_ai_watermarks.microsoft_invismark import ( + detect_local_invismark, + disrupt_local_invismark, + invismark_id_from_c2pa_info, +) + +_WATERMARK_ID = uuid.UUID("83424621-03cb-40e3-9808-a9fae837156d") + + +def _dct_matrix() -> np.ndarray: + matrix = np.empty((4, 4), dtype=np.float64) + for frequency in range(4): + scale = np.sqrt(1 / 4) if frequency == 0 else np.sqrt(2 / 4) + for sample in range(4): + matrix[frequency, sample] = scale * np.cos(np.pi * (2 * sample + 1) * frequency / 8) + return matrix + + +def _synthetic_writer(image: np.ndarray, watermark_id: uuid.UUID, *, valid_checksum: bool = True) -> np.ndarray: + """Independent format producer; it deliberately does not call production helpers.""" + guid = watermark_id.bytes_le + checksum = sum(guid) & 0xFF + if not valid_checksum: + checksum ^= 1 + payload = bytes((0x4C,)) + guid + bytes((checksum,)) + bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8)) + + rgb = image.astype(np.float64) + luminance = rgb @ np.array((0.299, 0.587, 0.114), dtype=np.float64) + ll = (luminance[0::2, 0::2] + luminance[0::2, 1::2] + luminance[1::2, 0::2] + luminance[1::2, 1::2]) * 0.5 + original_ll = ll.copy() + dct_matrix = _dct_matrix() + + for block_y in range(ll.shape[0] // 4): + for block_x in range(ll.shape[1] // 4): + top, left = block_y * 4, block_x * 4 + block = ll[top : top + 4, left : left + 4] + coefficients = dct_matrix @ block @ dct_matrix.T + ac = coefficients.reshape(-1)[1:].reshape(3, 5) + u, singular, vh = np.linalg.svd(ac, full_matrices=False) + bit = int(bits[(block_y % 12) * 12 + (block_x % 12)]) + singular[0] = (np.floor(singular[0] / 24) + 0.25 + 0.5 * bit) * 24 + rewritten = coefficients.reshape(-1).copy() + rewritten[1:] = (u @ np.diag(singular) @ vh).reshape(-1) + ll[top : top + 4, left : left + 4] = dct_matrix.T @ rewritten.reshape(4, 4) @ dct_matrix + + delta = (ll - original_ll) * 0.5 + pixel_delta = np.repeat(np.repeat(delta, 2, axis=0), 2, axis=1) + return np.clip(np.rint(rgb + pixel_delta[..., None]), 0, 255).astype(np.uint8) + + +def _write_carrier(path: Path, *, valid_checksum: bool = True) -> np.ndarray: + original = np.random.default_rng(20260913).integers(24, 232, size=(192, 192, 3), dtype=np.uint8) + encoded = _synthetic_writer(original, _WATERMARK_ID, valid_checksum=valid_checksum) + Image.fromarray(encoded, "RGB").save(path) + return original + + +def test_detects_repeated_payload_and_windows_guid_order(tmp_path: Path) -> None: + source = tmp_path / "paint.png" + _write_carrier(source) + + detection = detect_local_invismark(source) + + assert detection is not None + assert detection.watermark_id == _WATERMARK_ID + assert detection.payload == b"\x4c" + _WATERMARK_ID.bytes_le + bytes((sum(_WATERMARK_ID.bytes_le) & 0xFF,)) + assert detection.minimum_support >= 3 + + +def test_rejects_clean_and_malformed_payloads(tmp_path: Path) -> None: + clean = tmp_path / "clean.png" + malformed = tmp_path / "malformed.png" + Image.fromarray( + np.random.default_rng(20260913).integers(24, 232, size=(192, 192, 3), dtype=np.uint8), + "RGB", + ).save(clean) + _write_carrier(malformed, valid_checksum=False) + + assert detect_local_invismark(clean) is None + assert detect_local_invismark(malformed) is None + + +def test_disruption_requires_expected_guid_and_invalidates_payload(tmp_path: Path) -> None: + source = tmp_path / "paint.png" + wrong_output = tmp_path / "wrong.png" + output = tmp_path / "clean.png" + original = _write_carrier(source) + + assert disrupt_local_invismark(source, wrong_output, expected_watermark_id=uuid.uuid4()) is None + assert not wrong_output.exists() + + result = disrupt_local_invismark(source, output, expected_watermark_id=_WATERMARK_ID) + + assert result is not None + assert result.watermark_id == _WATERMARK_ID + assert result.changed_blocks >= 432 + assert output.exists() + assert detect_local_invismark(output) is None + cleaned = np.asarray(Image.open(output).convert("RGB")) + assert np.mean(np.abs(cleaned.astype(np.int16) - original.astype(np.int16))) < 2.0 + + +def test_cloud_image_creator_fixture_is_not_the_local_dll_format() -> None: + fixture = Path(__file__).parents[1] / "data/fixtures/provenance/microsoft-paint-invismark.png" + + assert detect_local_invismark(fixture) is None + + +def test_c2pa_uuid_requires_exact_algorithm_and_one_valid_value() -> None: + info = { + "soft_binding_algorithm": "com.microsoft.invismark.1", + "soft_binding_value": str(_WATERMARK_ID), + } + + assert invismark_id_from_c2pa_info(info) == _WATERMARK_ID + assert invismark_id_from_c2pa_info({**info, "soft_binding_algorithm": "io.iscc.v0"}) is None + assert invismark_id_from_c2pa_info({**info, "soft_binding_value": f"{_WATERMARK_ID}, {uuid.uuid4()}"}) is None