From c7021b900b9721b931d2c6a9f17b0ffa9c7b61bd Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Tue, 15 Sep 2026 23:17:48 -0700 Subject: [PATCH] Add source pipeline classifier runtime --- .claude/rules/development.md | 4 + CLAUDE.md | 2 +- README.md | 16 +- docs/index.md | 1 + docs/installation.md | 2 + docs/known-limitations.md | 8 + docs/module-internals.md | 24 ++ docs/python-api.md | 19 +- docs/release-and-distribution.md | 4 +- docs/source-classify-hf/README.md | 1 + docs/source-classify.md | 42 ++++ docs/supported-signals.md | 4 + docs/synthid-classifiers.md | 3 +- pyproject.toml | 11 +- scripts/spectral_pipeline_probe.py | 68 +---- src/remove_ai_watermarks/__init__.py | 10 +- .../_internal/source_spectral.py | 78 ++++++ src/remove_ai_watermarks/source_classify.py | 238 ++++++++++++++++++ tests/test_api.py | 4 + tests/test_source_classify.py | 123 +++++++++ uv.lock | 15 +- 21 files changed, 607 insertions(+), 70 deletions(-) create mode 100644 docs/source-classify.md create mode 100644 src/remove_ai_watermarks/_internal/source_spectral.py create mode 100644 src/remove_ai_watermarks/source_classify.py create mode 100644 tests/test_source_classify.py diff --git a/.claude/rules/development.md b/.claude/rules/development.md index 0ca41f7..88ae0b5 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -129,6 +129,10 @@ Do not classify an entire module as untestable because its main path downloads a - the photo-classify gate in `test_classify.py`: DEFINITELY is ridge AND MLP, POSSIBLY does not emit a provider, and `identify` does not import `remove_ai_watermarks.classify`. +- the source-export classifier in `test_source_classify.py`: the Hub revision + and artifact hash are immutable, the pickle-free schema and exact GELU are + validated, threshold mutation changes the real decision, and `identify` + never consumes the result as watermark evidence. - the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to mirror another, compare them field by field rather than pinning the values you happen to know about, so the next field added on one side and not the other fails at the diff --git a/CLAUDE.md b/CLAUDE.md index 2876e67..3826182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-w [`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem. -Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); it is not `identify`. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md). +Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); the separate OpenAI/Google/unknown export-pipeline signal is [`docs/source-classify.md`](docs/source-classify.md). Neither runs inside `identify` or constitutes SynthID detection. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md). ## Data safety diff --git a/README.md b/README.md index 3b625bc..de8a3f6 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ specialized Python API can inspect and disrupt the validated local | --- | --- | | Metadata inspection and stripping | `remove-ai-watermarks` | | Photograph AI-versus-camera classification | `remove-ai-watermarks[classify]` | +| OpenAI/Google/unknown source-export classification | `remove-ai-watermarks[source-classify]` | | Visible detection and removal | `remove-ai-watermarks[visible]` | | Visible video processing | `remove-ai-watermarks[video]` | | Video SynthID removal | `remove-ai-watermarks[video,diffusion]` | @@ -68,7 +69,7 @@ specialized Python API can inspect and disrupt the validated local | Every production feature available on the active Python | `remove-ai-watermarks[all]` | Lower-level and specialized extras include `pixels`, `heif`, `trustmark`, -`migan`, `lama`, `diffusion`, and `classify-onnx`. The +`migan`, `lama`, `diffusion`, `classify-onnx`, and `source-classify`. The [installation guide](docs/installation.md#feature-extras) documents their exact dependency composition, Python compatibility, and model requirements. @@ -96,6 +97,19 @@ remove-ai-watermarks classify image.png Guide: [photo pixel classification](docs/photo-classify.md). +For a lightweight, abstaining OpenAI/Google/unknown source-export signal after +metadata removal, use the separate Python API. It is not a SynthID detector: + +```python +import remove_ai_watermarks as raiw + +result = raiw.classify_source("image.png") +print(result.label, result.reason) +``` + +Install `remove-ai-watermarks[source-classify]`. Guide: +[source-pipeline classification](docs/source-classify.md). + Signed provenance is the supported route for SynthID and `identify` reads it. There is no local SynthID pixel detector in the package. Research on a periodic lattice expert is in `scripts/synthid_runtime/` and diff --git a/docs/index.md b/docs/index.md index 63d03d2..fde1bc9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ to run the tool. Use the maintainer references only when changing the code. | [Supported signals](supported-signals.md) | You need to know which visible marks, metadata formats, and invisible signals are covered. | | [Known limitations](known-limitations.md) | You need the quality, device, format, or verification boundaries. | | [Photo pixel classification](photo-classify.md) | You want `classify` / `classify_pixels`: AI versus camera, optional provider. | +| [Source-pipeline classification](source-classify.md) | You want an abstaining OpenAI/Google/unknown export-pipeline signal after metadata removal. | | [Photo classifier training](photo-classify-training.md) | You need the retrain pack, public sources, and what is not published. | | [Scope, safety, and legal notes](legal-and-safety.md) | You need the intended use and legal context. | diff --git a/docs/installation.md b/docs/installation.md index c5ce7f0..34623e2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -129,6 +129,7 @@ application actually uses: | `trustmark` | Adobe TrustMark detection on Python 3.11-3.12 | trustmark | Yes | | `classify` | Metadata-free photo AI-versus-camera classifier plus gated provider | `pixels`, Torch, Transformers | Yes | | `classify-onnx` | Optional CPU-only ONNX vision runtime for `classify` | `classify`, ONNX Runtime | Yes | +| `source-classify` | Abstaining OpenAI/Google/unknown source-export classifier, not SynthID detection | `pixels`, Hugging Face Hub | Model download, no Torch | | `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `pixels`, Torch, Diffusers | Yes | | `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | | `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | @@ -156,6 +157,7 @@ flowchart LR trustmark classify --> pixels onnx["classify-onnx"] --> classify + source["source-classify"] --> pixels ``` `heif`, `trustmark`, and `text-draft` are independent branches. Combine them diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 789210e..5c5dd76 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -20,6 +20,14 @@ The command is never started by `identify`. A no-signal provenance result stays unknown. This is not a clean verdict and it does not run cleanup. Full guide: [photo pixel classification](photo-classify.md). +`classify_source` is a different, lightweight OpenAI/Google/unknown +source-export classifier. It is useful only while the pixels still resemble an +original export. A 95% resize reduced measured Google recall to 1%, and a JPEG +quality-75 round trip made every public evaluation row abstain. Its provider +label is not evidence that SynthID is present, and `unknown` is not evidence +that SynthID is absent. It is also never started by `identify`. Full guide: +[source-pipeline classification](source-classify.md). + ## Visible removal ### Fill quality depends on the background diff --git a/docs/module-internals.md b/docs/module-internals.md index a774b40..7e4f19f 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1116,6 +1116,30 @@ both backends explicitly (`tests/test_classify.py` pins the seam). User guide: [photo-classify.md](photo-classify.md). Hub card: [photo-classify-hf/README.md](photo-classify-hf/README.md). +### Source-pipeline classifier + +[`source_classify.py`](../src/remove_ai_watermarks/source_classify.py) is a +separate, lightweight OpenAI/Google/unknown source-export classifier. It fuses +the existing 124-dimensional patch descriptor with the 768-dimensional +phase-free spectrum in +[`_internal/source_spectral.py`](../src/remove_ai_watermarks/_internal/source_spectral.py), +then evaluates the frozen 892 -> 128 -> 64 -> 3 GELU MLP in NumPy. Provider +labels clear independent calibrated margins; every other row is `unknown`. +The NumPy decision treats values within `1e-5` of the stored margin as the +inclusive training threshold, absorbing float32 BLAS differences; this matches +the frozen training-runtime decisions on all 27,811 cached internal and +external feature rows. + +The runtime pins both the immutable Hub revision and the NPZ SHA-256, loads +with `allow_pickle=False`, and rejects any field, shape, class, activation, or +non-finite-value drift. `RAIW_SOURCE_CLASSIFY_WEIGHTS` is the offline override. +The `source-classify` extra adds `pixels` and Hugging Face Hub but no Torch or +Transformers. `identify` never imports or runs this path. Its output names a +complete source/export pipeline and is not SynthID detection. Unit contracts +and the threshold mutation guard live in `tests/test_source_classify.py`; +evaluation and attack limits live in +[`synthid-classifiers.md`](synthid-classifiers.md). + The CLIP loader suppresses discarded random parameter initialization with Transformers' `no_init_weights` context before applying the complete frozen state dict. Normal `CLIPModel(config)` construction initialized 427 million diff --git a/docs/python-api.md b/docs/python-api.md index 31320c0..71be52e 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -6,12 +6,29 @@ and pipeline modules are intended for maintainers and specialized workflows. Dependency groups are identical for the CLI and Python API. The default install covers metadata extraction, normalization, verdict logic, and stripping. Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection -uses `detect`; pixel photo classification uses `classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and +uses `detect`; pixel photo classification uses `classify`; lightweight source-pipeline classification uses `source-classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and visible video processing uses `video`. The video-pixel SynthID-removal profile is a separate VAE path that still runs on CPU and combines `video` and `diffusion`. Add `heif` independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See the complete [feature-extra matrix](installation.md#feature-extras). +## Classify a source/export pipeline + +Install `remove-ai-watermarks[source-classify]`, then call the explicit API: + +```python +import remove_ai_watermarks as raiw + +result = raiw.classify_source("metadata-stripped.png") +print(result.label) # "openai" | "google" | "unknown" +print(result.reason) # "classified" | "abstained" | "feature_unavailable" +``` + +This signal classifies a complete image-generation and export pipeline. It is +not a SynthID detector and does not establish watermark presence or absence. +See [source-pipeline classification](source-classify.md) for the result fields, +model pin, evaluation boundary, and offline weights override. + ## Remove visible marks Install `remove-ai-watermarks[visible]` before using the visible-removal API. diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index 8d85335..a034a0c 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -100,7 +100,9 @@ and aggregate metrics under `docs/source-classify-hf/`. The private corpus, catalog, paths, hashes, embeddings, and feature caches are outside this flow. The workflow is manual and requires the model-freeze release tag plus an -explicit `publish` confirmation. +explicit `publish` confirmation. Keep model-freeze GitHub releases marked as +pre-releases so a model artifact cannot replace the latest package release in +GitHub's release UI. If a distribution job fails because a repository or Hugging Face credential is invalid, rotate the corresponding GitHub secret and rerun the failed job. A diff --git a/docs/source-classify-hf/README.md b/docs/source-classify-hf/README.md index c9fd5dd..e9b7a75 100644 --- a/docs/source-classify-hf/README.md +++ b/docs/source-classify-hf/README.md @@ -62,3 +62,4 @@ should expose the abstention and the model's narrow source-pipeline claim. Feature extraction and inference are versioned in the `remove-ai-watermarks` Python project rather than in this model repository. +Use its explicit `classify_source` API; `identify` does not run this model. diff --git a/docs/source-classify.md b/docs/source-classify.md new file mode 100644 index 0000000..24515dd --- /dev/null +++ b/docs/source-classify.md @@ -0,0 +1,42 @@ +# Source-pipeline classification + +`classify_source` is an opt-in, metadata-free pixel classifier for images that +still resemble an original provider export. It returns `openai`, `google`, or +an explicit `unknown` abstention. + +This is not a SynthID detector. It does not decode or verify a watermark +payload, and it must not be used to claim that SynthID is present or absent. +The model describes the complete source/export pipeline, where renderer, +geometry, encoding, post-processing, and watermark state can be confounded. + +Install the lightweight runtime: + +```bash +uv add "remove-ai-watermarks[source-classify]" +``` + +Call the Python API explicitly: + +```python +import remove_ai_watermarks as raiw + +result = raiw.classify_source("metadata-stripped.png") +print(result.label) # "openai" | "google" | "unknown" +print(result.reason) # "classified" | "abstained" | "feature_unavailable" +print(result.margins) # provider margins before thresholding +print(result.thresholds) # frozen provider thresholds +``` + +`feature_unavailable` is returned for images below the required 256x256 +geometry or for a degenerate feature vector. `abstained` means neither provider +cleared its independent calibrated margin. Both are honest `unknown` results. + +The first call downloads the exact pinned revision of +[`wiltodelta/raiw-source-classify`](https://huggingface.co/wiltodelta/raiw-source-classify). +Set `RAIW_SOURCE_CLASSIFY_WEIGHTS` to the NPZ itself or a directory containing +`source-pipeline-mlp.npz` for an offline deployment. + +The model is useful as a routing suggestion for a processing workflow or as one +probabilistic signal in a broader forensic report. Do not use it as evidence of +authorship, fraud, policy compliance, provenance, or watermark presence. The +published model card records the evaluation and robustness limits. diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 7c5fae3..5112638 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -121,6 +121,10 @@ entries enter the watermark inventory, while a fingerprint remains a re-linkability warning, never selects pixel regeneration, and does not suppress independent SynthID evidence. +The opt-in `classify_source` API is not an `identify` signal. It can suggest an +OpenAI or Google source/export pipeline after metadata removal, but it neither +detects nor decodes a watermark and never changes the provenance verdict. + ## File and container formats Pixel based image commands discover these extensions: diff --git a/docs/synthid-classifiers.md b/docs/synthid-classifiers.md index 2469ade..e4c2968 100644 --- a/docs/synthid-classifiers.md +++ b/docs/synthid-classifiers.md @@ -144,7 +144,8 @@ metadata removal. It neither decodes nor detects SynthID, and its `unknown` label does not mean that SynthID is absent. The model card and aggregate metrics are tracked under [`docs/source-classify-hf/`](source-classify-hf/README.md). Runtime integration -belongs in the library rather than in the model repository. +is provided by the explicit `classify_source` library API and never feeds the +`identify` watermark verdict. Krawetz's Gemini-chat TPR critique is a verifier-quality claim, not a feature we can ship. [Lead Stories, 2026-07](https://leadstories.com/analysis/2026/07/google-gemini-synthid-detector-confuses-results-within-same-chat.html) diff --git a/pyproject.toml b/pyproject.toml index 40e9d01..f9cb68c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ # on it, including the ComfyUI node package. The console script below carries # the same weight, since users have it on PATH. name = "remove-ai-watermarks" -version = "0.40.6" +version = "0.41.0" description = "AI watermark remover for visible, invisible, and provenance marks in images and video" readme = "README.md" requires-python = ">=3.11,<3.15" @@ -160,6 +160,13 @@ classify-onnx = [ "remove-ai-watermarks[classify]", "onnxruntime>=1.24.0", ] +# Compact OpenAI/Google/unknown source-export classifier. This is intentionally +# separate from the heavier photo AI-versus-camera classifier and is not a +# SynthID detector. Weights download on first use. +source-classify = [ + "remove-ai-watermarks[pixels]", + "huggingface-hub>=0.20.0", +] # Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX, # Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and # cached by huggingface_hub; it is never bundled in this repo. The default cv2 @@ -205,7 +212,7 @@ dev = [ ] # ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is # independently sufficient for a removal, which it is not. -all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify]"] +all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify,source-classify]"] [project.scripts] remove-ai-watermarks = "remove_ai_watermarks.cli:main" diff --git a/scripts/spectral_pipeline_probe.py b/scripts/spectral_pipeline_probe.py index 311de14..207363c 100644 --- a/scripts/spectral_pipeline_probe.py +++ b/scripts/spectral_pipeline_probe.py @@ -30,6 +30,14 @@ import numpy as np from numpy.typing import NDArray from PIL import Image, ImageFilter +from remove_ai_watermarks._internal import source_spectral as _spectral + +SpectralGeometry = _spectral.SpectralGeometry +make_geometry = _spectral.make_geometry +phase_free_log_spectrum = _spectral.phase_free_log_spectrum +spectral_feature = _spectral.spectral_feature +_source_feature_from_image = _spectral.feature_from_image + log = logging.getLogger(__name__) IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp"}) @@ -40,17 +48,6 @@ IntArray = NDArray[np.int64] BoolArray = NDArray[np.bool_] -@dataclass(frozen=True) -class SpectralGeometry: - """Precomputed coordinates for one phase-free spectral representation.""" - - size: int - grid: int - radius: NDArray[np.int32] - radius_counts: IntArray - passband: BoolArray - - @dataclass(frozen=True) class FoldModel: """One standardized dual-ridge model and its held-out rows.""" @@ -62,51 +59,6 @@ class FoldModel: weights: FloatArray -def make_geometry(size: int = 256, grid: int = 32) -> SpectralGeometry: - """Build the radial detrending and pooling geometry.""" - if size < 16 or grid < 2 or size % grid: - raise ValueError("size must be at least 16 and divisible by grid") - yy, xx = np.mgrid[:size, :size] - radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32) - radius_counts = np.bincount(radius.ravel()) - passband = (radius >= 4) & (radius <= size * 0.48) - return SpectralGeometry(size, grid, radius, radius_counts, passband) - - -def phase_free_log_spectrum(channel: FloatArray) -> FloatArray: - """Return centered log FFT magnitude, deliberately discarding phase.""" - if channel.ndim != 2: - raise ValueError("channel must be a two-dimensional array") - centered = np.asarray(channel, dtype=np.float64) - float(np.mean(channel)) - return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered)))) - - -def spectral_feature(pixels: FloatArray, geometry: SpectralGeometry) -> FloatArray: - """Extract pooled radial-residual spectra from luma and opponent color.""" - if pixels.shape != (geometry.size, geometry.size, 3): - raise ValueError(f"pixels must have shape {(geometry.size, geometry.size, 3)}, got {pixels.shape}") - red, green, blue = np.moveaxis(np.asarray(pixels, dtype=np.float64), 2, 0) - channels = ( - 0.299 * red + 0.587 * green + 0.114 * blue, - red - green, - blue - (red + green) / 2.0, - ) - block = geometry.size // geometry.grid - features: list[FloatArray] = [] - for channel in channels: - spectrum = phase_free_log_spectrum(channel) - radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel()) - radial_mean = radial_sum / geometry.radius_counts - residual = np.where( - geometry.passband, - spectrum - radial_mean[geometry.radius], - 0.0, - ) - pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3)) - features.append(pooled.ravel()) - return np.concatenate(features) - - def _apply_attack(image: Image.Image, attack: str) -> Image.Image: """Apply one declared robustness transform before canonical resizing.""" if attack == "original": @@ -140,9 +92,7 @@ def feature_from_path(path: Path, geometry: SpectralGeometry, attack: str) -> Fl def feature_from_image(image: Image.Image, geometry: SpectralGeometry, attack: str) -> FloatArray: """Extract one attacked feature from an already decoded RGB image.""" attacked = _apply_attack(image, attack) - canonical = attacked.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS) - pixels = np.asarray(canonical, dtype=np.float64) / 255.0 - return spectral_feature(pixels, geometry) + return _source_feature_from_image(attacked, geometry) def discover_grouped_paths( diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 35a7aec..f9e13f5 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -14,6 +14,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap):: raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip raiw.remove_video_invisible("in.mp4", "out.mp4") # calibrated video-pixel regeneration raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal + raiw.classify_source("in.png") # source pipeline, not SynthID detection For a provenance verdict use the ``identify`` submodule:: @@ -33,7 +34,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*") -__version__ = "0.40.6" +__version__ = "0.41.0" __all__ = [ "BatchItemResult", @@ -41,8 +42,10 @@ __all__ = [ "InvisibleOptions", "MetadataStripIncomplete", "RemoveAllResult", + "SourceClassification", "VisibleRemovalResult", "__version__", + "classify_source", "identify_video", "inspect_video_metadata", "remove_all", @@ -71,6 +74,7 @@ if TYPE_CHECKING: remove_visible_detailed, visible_provenance, ) + from remove_ai_watermarks.source_classify import SourceClassification, classify_source from remove_ai_watermarks.video import ( identify_video, inspect_video_metadata, @@ -101,6 +105,10 @@ def __getattr__(name: str) -> object: from remove_ai_watermarks import api return getattr(api, name) + if name in ("SourceClassification", "classify_source"): + from remove_ai_watermarks import source_classify + + return getattr(source_classify, name) if name in ( "identify_video", "inspect_video_metadata", diff --git a/src/remove_ai_watermarks/_internal/source_spectral.py b/src/remove_ai_watermarks/_internal/source_spectral.py new file mode 100644 index 0000000..ae1f4f7 --- /dev/null +++ b/src/remove_ai_watermarks/_internal/source_spectral.py @@ -0,0 +1,78 @@ +"""Phase-free spectral features for source/export-pipeline classification.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +@dataclass(frozen=True) +class SpectralGeometry: + """Precomputed coordinates for one phase-free spectral representation.""" + + size: int + grid: int + radius: NDArray[np.int32] + radius_counts: NDArray[np.int64] + passband: NDArray[np.bool_] + + +@lru_cache(maxsize=4) +def make_geometry(size: int = 256, grid: int = 16) -> SpectralGeometry: + """Build radial detrending and pooling geometry.""" + if size < 16 or grid < 2 or size % grid: + raise ValueError("size must be at least 16 and divisible by grid") + yy, xx = np.mgrid[:size, :size] + radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32) + radius_counts = np.bincount(radius.ravel()) + passband = (radius >= 4) & (radius <= size * 0.48) + return SpectralGeometry(size, grid, radius, radius_counts, passband) + + +def phase_free_log_spectrum(channel: NDArray[np.float64]) -> NDArray[np.float64]: + """Return centered log FFT magnitude, deliberately discarding phase.""" + if channel.ndim != 2: + raise ValueError("channel must be a two-dimensional array") + centered = channel - float(np.mean(channel)) + return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered)))) + + +def spectral_feature(pixels: NDArray[np.float64], geometry: SpectralGeometry) -> NDArray[np.float64]: + """Extract pooled radial-residual spectra from luma and opponent color.""" + expected = (geometry.size, geometry.size, 3) + if pixels.shape != expected: + raise ValueError(f"pixels must have shape {expected}, got {pixels.shape}") + red, green, blue = np.moveaxis(pixels, 2, 0) + channels = ( + 0.299 * red + 0.587 * green + 0.114 * blue, + red - green, + blue - (red + green) / 2.0, + ) + block = geometry.size // geometry.grid + features: list[NDArray[np.float64]] = [] + for channel in channels: + spectrum = phase_free_log_spectrum(channel) + radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel()) + radial_mean = radial_sum / geometry.radius_counts + residual = np.where( + geometry.passband, + spectrum - radial_mean[geometry.radius], + 0.0, + ) + pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3)) + features.append(pooled.ravel()) + return np.concatenate(features) + + +def feature_from_image(image: Image.Image, geometry: SpectralGeometry) -> NDArray[np.float64]: + """Resize an RGB image canonically and extract its phase-free feature.""" + canonical = image.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS) + pixels = np.asarray(canonical, dtype=np.float64) / 255.0 + return spectral_feature(pixels, geometry) diff --git a/src/remove_ai_watermarks/source_classify.py b/src/remove_ai_watermarks/source_classify.py new file mode 100644 index 0000000..0f0cecf --- /dev/null +++ b/src/remove_ai_watermarks/source_classify.py @@ -0,0 +1,238 @@ +"""Abstaining source/export-pipeline classification from image pixels. + +This module does not detect or decode SynthID. It labels original-export-looking +pixel pipelines as OpenAI, Google, or unknown after metadata may have been +removed. Call it explicitly; ``identify`` never imports or runs it. +""" + +from __future__ import annotations + +import math +import os +from dataclasses import dataclass +from functools import lru_cache +from hashlib import sha256 +from importlib import import_module +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from numpy.typing import NDArray + +SOURCE_CLASSIFY_EXTRA = "'remove-ai-watermarks[source-classify]'" +WEIGHTS_ENV = "RAIW_SOURCE_CLASSIFY_WEIGHTS" +WEIGHTS_REPO = "wiltodelta/raiw-source-classify" +WEIGHTS_REVISION = "4b8b408d03dac20231d46217977fd164414b6b9e" +MODEL_FILE = "source-pipeline-mlp.npz" +MODEL_SHA256 = "6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a" +CLASSES = ("openai", "google", "unknown") +PROVIDERS = CLASSES[:2] +FEATURE_WIDTH = 892 +SPECTRAL_GRID = 16 +CLAIM = "source/export pipeline classification; not SynthID detection" +# NumPy and the training runtime can differ by a few float32 ULPs at a matrix +# boundary. Treat values within this tolerance as the stored threshold so the +# exported runtime preserves the training runtime's >= decision. +THRESHOLD_TOLERANCE = 1e-5 + +SourceLabel = Literal["openai", "google", "unknown"] +SourceReason = Literal["classified", "abstained", "feature_unavailable"] + + +@dataclass(frozen=True) +class SourceClassification: + """One narrow source-pipeline result with an explicit abstention.""" + + label: SourceLabel + reason: SourceReason + scores: dict[str, float] + margins: dict[str, float] + thresholds: dict[str, float] + claim: str = CLAIM + model_revision: str = WEIGHTS_REVISION + + def to_dict(self) -> dict[str, Any]: + return { + "label": self.label, + "reason": self.reason, + "scores": self.scores, + "margins": self.margins, + "thresholds": self.thresholds, + "claim": self.claim, + "model_revision": self.model_revision, + } + + +@dataclass(frozen=True) +class _Model: + mean: NDArray[Any] + scale: NDArray[Any] + margins: NDArray[Any] + layer0_weight: NDArray[Any] + layer0_bias: NDArray[Any] + layer1_weight: NDArray[Any] + layer1_bias: NDArray[Any] + output_weight: NDArray[Any] + output_bias: NDArray[Any] + + +def is_available() -> bool: + """Return whether the lightweight source-classify runtime is installed.""" + from remove_ai_watermarks.optional_deps import module_available + + return module_available("numpy", "cv2", "huggingface_hub") + + +def _model_path() -> Path: + override = os.environ.get(WEIGHTS_ENV, "").strip() + if override: + path = Path(override).expanduser() + candidate = path / MODEL_FILE if path.is_dir() else path + if not candidate.is_file(): + raise RuntimeError(f"{WEIGHTS_ENV} does not contain {MODEL_FILE}: {path}") + return candidate + try: + hub: Any = import_module("huggingface_hub") + except ModuleNotFoundError as error: + raise RuntimeError(f"Install {SOURCE_CLASSIFY_EXTRA} to download the source classifier") from error + return Path( + hub.hf_hub_download( + repo_id=WEIGHTS_REPO, + filename=MODEL_FILE, + revision=WEIGHTS_REVISION, + ) + ) + + +@lru_cache(maxsize=2) +def _load_model(path: Path, expected_sha256: str | None = MODEL_SHA256) -> _Model: + import numpy as np + + if expected_sha256 is not None: + digest = sha256(path.read_bytes()).hexdigest() + if digest != expected_sha256: + raise ValueError(f"model SHA-256 mismatch: expected {expected_sha256}, got {digest}") + required = { + "classes", + "kind", + "mean", + "scale", + "margins", + "layer0_weight", + "layer0_bias", + "layer1_weight", + "layer1_bias", + "output_weight", + "output_bias", + "activation", + } + with np.load(path, allow_pickle=False) as payload: + if set(payload.files) != required: + raise ValueError(f"model fields must be exactly {sorted(required)}") + if tuple(str(value) for value in payload["classes"]) != CLASSES: + raise ValueError(f"model classes must be {CLASSES}") + if str(payload["kind"]) != "fused": + raise ValueError("model kind must be fused") + if str(payload["activation"]) != "gelu_exact": + raise ValueError("model activation must be gelu_exact") + arrays = { + name: np.asarray( + payload[name], + dtype=np.float64 if name in {"mean", "scale", "margins"} else np.float32, + ) + for name in required - {"classes", "kind", "activation"} + } + shapes = { + "mean": (FEATURE_WIDTH,), + "scale": (FEATURE_WIDTH,), + "margins": (len(PROVIDERS),), + "layer0_weight": (128, FEATURE_WIDTH), + "layer0_bias": (128,), + "layer1_weight": (64, 128), + "layer1_bias": (64,), + "output_weight": (len(CLASSES), 64), + "output_bias": (len(CLASSES),), + } + for name, expected in shapes.items(): + if arrays[name].shape != expected: + raise ValueError(f"model field {name} must have shape {expected}") + if not np.all(np.isfinite(arrays[name])): + raise ValueError(f"model field {name} must be finite") + if np.any(arrays["scale"] <= 0) or np.any(arrays["margins"] < 0): + raise ValueError("model scales must be positive and margins non-negative") + return _Model(**arrays) + + +def _gelu_exact(values: NDArray[Any]) -> NDArray[Any]: + import numpy as np + + flat = values.ravel() + errors = np.fromiter((math.erf(float(value) / math.sqrt(2.0)) for value in flat), dtype=np.float32) + return (0.5 * flat * (1.0 + errors)).reshape(values.shape).astype(np.float32) + + +def _scores(features: NDArray[Any], model: _Model) -> NDArray[Any]: + import numpy as np + + values = np.asarray((features - model.mean) / model.scale, dtype=np.float32) + hidden0 = _gelu_exact(model.layer0_weight @ values + model.layer0_bias) + hidden1 = _gelu_exact(model.layer1_weight @ hidden0 + model.layer1_bias) + return np.asarray(model.output_weight @ hidden1 + model.output_bias, dtype=np.float64) + + +def _result(raw_scores: NDArray[Any], thresholds: NDArray[Any]) -> SourceClassification: + import numpy as np + + margins = np.empty(len(PROVIDERS), dtype=np.float64) + for index in range(len(PROVIDERS)): + competitors = np.delete(raw_scores, index) + margins[index] = raw_scores[index] - float(np.max(competitors)) + excess = margins - thresholds + best = int(np.argmax(excess)) + accepted = bool(excess[best] >= -THRESHOLD_TOLERANCE) + label: SourceLabel = PROVIDERS[best] if accepted else "unknown" + return SourceClassification( + label=label, + reason="classified" if accepted else "abstained", + scores={name: float(raw_scores[index]) for index, name in enumerate(CLASSES)}, + margins={name: float(margins[index]) for index, name in enumerate(PROVIDERS)}, + thresholds={name: float(thresholds[index]) for index, name in enumerate(PROVIDERS)}, + ) + + +def _unavailable(model: _Model) -> SourceClassification: + return SourceClassification( + label="unknown", + reason="feature_unavailable", + scores={}, + margins={}, + thresholds={name: float(model.margins[index]) for index, name in enumerate(PROVIDERS)}, + ) + + +def classify_source(source: str | os.PathLike[str]) -> SourceClassification: + """Classify an original-looking image export, abstaining as ``unknown``. + + The result describes a complete source/export pipeline. It is not evidence + that SynthID is present or absent. + """ + import numpy as np + from PIL import Image + + from remove_ai_watermarks import image_io + from remove_ai_watermarks._internal.forensic_124d import image_features + from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry + + model = _load_model(_model_path(), expected_sha256=MODEL_SHA256) + image_io._register_heif() # pyright: ignore[reportPrivateUsage] + with Image.open(source) as opened: + image = opened.convert("RGB") + pixels = np.asarray(image, dtype=np.uint8) + forensic = image_features(pixels) + if forensic is None: + return _unavailable(model) + spectral = feature_from_image(image, make_geometry(grid=SPECTRAL_GRID)) + features = np.concatenate((forensic, spectral)) + if features.shape != (FEATURE_WIDTH,) or not np.all(np.isfinite(features)): + return _unavailable(model) + return _result(_scores(features, model), model.margins) diff --git a/tests/test_api.py b/tests/test_api.py index 6ade360..5a18988 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -23,6 +23,10 @@ class TestTopLevelExports: assert raiw.remove_visible_detailed is api.remove_visible_detailed assert raiw.VisibleRemovalResult is api.VisibleRemovalResult assert raiw.visible_provenance is api.visible_provenance + from remove_ai_watermarks import source_classify + + assert raiw.classify_source is source_classify.classify_source + assert raiw.SourceClassification is source_classify.SourceClassification def test_unknown_attribute_raises(self): with pytest.raises(AttributeError): diff --git a/tests/test_source_classify.py b/tests/test_source_classify.py new file mode 100644 index 0000000..f7bdbe8 --- /dev/null +++ b/tests/test_source_classify.py @@ -0,0 +1,123 @@ +"""Unit contracts for the lightweight source-pipeline runtime.""" + +from __future__ import annotations + +import re +from hashlib import sha256 +from typing import TYPE_CHECKING + +import numpy as np +import pytest + +from remove_ai_watermarks import source_classify + +if TYPE_CHECKING: + from pathlib import Path + + +def test_model_identity_is_immutable() -> None: + assert re.fullmatch(r"[0-9a-f]{40}", source_classify.WEIGHTS_REVISION) + assert re.fullmatch(r"[0-9a-f]{64}", source_classify.MODEL_SHA256) + + +def _artifact(path: Path, *, activation: str = "gelu_exact", output_bias: tuple[float, ...] = (0.0, 0.0, 0.0)) -> None: + np.savez_compressed( + path, + classes=np.asarray(source_classify.CLASSES), + kind=np.asarray("fused"), + mean=np.zeros(source_classify.FEATURE_WIDTH), + scale=np.ones(source_classify.FEATURE_WIDTH), + margins=np.asarray((0.5, 0.5)), + layer0_weight=np.zeros((128, source_classify.FEATURE_WIDTH), dtype=np.float32), + layer0_bias=np.zeros(128, dtype=np.float32), + layer1_weight=np.zeros((64, 128), dtype=np.float32), + layer1_bias=np.zeros(64, dtype=np.float32), + output_weight=np.zeros((3, 64), dtype=np.float32), + output_bias=np.asarray(output_bias, dtype=np.float32), + activation=np.asarray(activation), + ) + + +def test_loader_accepts_the_exact_pickle_free_schema(tmp_path: Path) -> None: + path = tmp_path / source_classify.MODEL_FILE + _artifact(path) + + model = source_classify._load_model(path, expected_sha256=None) + + assert model.mean.shape == (source_classify.FEATURE_WIDTH,) + assert model.margins.tolist() == pytest.approx([0.5, 0.5]) + + +def test_loader_rejects_the_mislabeled_activation(tmp_path: Path) -> None: + path = tmp_path / source_classify.MODEL_FILE + _artifact(path, activation="gelu_tanh") + + with pytest.raises(ValueError, match="activation must be gelu_exact"): + source_classify._load_model(path, expected_sha256=None) + + +def test_provider_threshold_mutation_changes_the_real_decision(tmp_path: Path) -> None: + path = tmp_path / source_classify.MODEL_FILE + _artifact(path, output_bias=(2.0, 0.0, 0.0)) + model = source_classify._load_model(path, expected_sha256=None) + features = np.zeros(source_classify.FEATURE_WIDTH, dtype=np.float32) + scores = source_classify._scores(features, model) + + accepted = source_classify._result(scores, np.asarray((1.5, 0.5), dtype=np.float32)) + rejected = source_classify._result(scores, np.asarray((2.5, 0.5), dtype=np.float32)) + + assert accepted.label == "openai" + assert accepted.reason == "classified" + assert rejected.label == "unknown" + assert rejected.reason == "abstained" + + +def test_a_float32_threshold_tie_preserves_the_inclusive_training_decision() -> None: + scores = np.asarray((2.0, 0.0, 0.0), dtype=np.float32) + result = source_classify._result(scores, np.asarray((2.0, 0.5), dtype=np.float32)) + + assert result.label == "openai" + assert result.reason == "classified" + + +def test_small_image_returns_honest_unknown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from PIL import Image + + weights = tmp_path / "weights" + weights.mkdir() + _artifact(weights / source_classify.MODEL_FILE) + monkeypatch.setenv(source_classify.WEIGHTS_ENV, str(weights)) + monkeypatch.setattr( + source_classify, + "MODEL_SHA256", + sha256((weights / source_classify.MODEL_FILE).read_bytes()).hexdigest(), + ) + image = tmp_path / "small.png" + Image.new("RGB", (128, 128), "white").save(image) + + result = source_classify.classify_source(image) + + assert result.label == "unknown" + assert result.reason == "feature_unavailable" + assert result.scores == {} + + +def test_model_hash_is_a_runtime_precondition(tmp_path: Path) -> None: + path = tmp_path / source_classify.MODEL_FILE + _artifact(path) + + with pytest.raises(ValueError, match="model SHA-256 mismatch"): + source_classify._load_model(path, expected_sha256="0" * 64) + + +def test_spectral_feature_is_finite_and_has_the_frozen_width() -> None: + from PIL import Image + + from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry + + rng = np.random.default_rng(20260916) + pixels = rng.integers(0, 256, size=(300, 400, 3), dtype=np.uint8) + feature = feature_from_image(Image.fromarray(pixels), make_geometry()) + + assert feature.shape == (768,) + assert np.all(np.isfinite(feature)) diff --git a/uv.lock b/uv.lock index c666d27..b9bf787 100644 --- a/uv.lock +++ b/uv.lock @@ -3449,7 +3449,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.40.6" +version = "0.41.0" source = { editable = "." } dependencies = [ { name = "c2pa-python" }, @@ -3584,6 +3584,13 @@ qwen-zimage = [ { name = "torchvision" }, { name = "transformers" }, ] +source-classify = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "opencv-python-headless", version = "4.11.0.86", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "opencv-python-headless", version = "4.14.0.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, +] text-draft = [ { name = "huggingface-hub" }, { name = "paddleocr" }, @@ -3637,6 +3644,7 @@ requires-dist = [ { name = "huggingface-hub", marker = "extra == 'dev'", specifier = ">=0.20.0" }, { name = "huggingface-hub", marker = "extra == 'lama'", specifier = ">=0.20.0" }, { name = "huggingface-hub", marker = "extra == 'migan'", specifier = ">=0.20.0" }, + { name = "huggingface-hub", marker = "extra == 'source-classify'", specifier = ">=0.20.0" }, { name = "huggingface-hub", marker = "extra == 'text-draft'", specifier = ">=0.20.0" }, { name = "invisible-watermark", marker = "extra == 'dev'", specifier = ">=0.2.0" }, { name = "numpy", marker = "python_full_version >= '3.13' and extra == 'pixels'", specifier = ">=2" }, @@ -3665,10 +3673,11 @@ requires-dist = [ { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'classify'" }, { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'detect'" }, { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'diffusion'" }, + { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'source-classify'" }, { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" }, { name = "remove-ai-watermarks", extras = ["qwen-zimage", "lama"], marker = "extra == 'text-restoration'" }, { name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" }, - { name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify"], marker = "extra == 'all'" }, + { name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify", "source-classify"], marker = "extra == 'all'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'migan'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" }, @@ -3684,7 +3693,7 @@ requires-dist = [ { name = "transformers", marker = "extra == 'diffusion'", specifier = ">=5,<6" }, { name = "trustmark", marker = "python_full_version < '3.13' and extra == 'trustmark'", specifier = ">=0.8.0" }, ] -provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "lama", "migan", "dev", "all"] +provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "source-classify", "lama", "migan", "dev", "all"] [[package]] name = "requests"