diff --git a/docs/index.md b/docs/index.md index fde1bc9..7335234 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,6 +42,7 @@ The current behavior is defined by the code, tests, README, and user guides. - [Doubao reverse-alpha research](research-doubao-distillation.md) - [AI-generated image classifiers](ai-generated-image-classifiers.md) (photo AI-versus-camera freeze consumed by `classify_pixels`, not `identify`) - [Photo classifier Hugging Face card](photo-classify-hf/README.md) (Hub publication text of the 2026-08-31 freeze) +- [New image-source classifier card](image-source-hf/README.md) (derivative weights, aggregate evaluation, and non-watermark claim) - [SynthID source classifiers](synthid-classifiers.md) (metadata-free OpenAI/Gemini source finding and provider-lineage experiments) - [Source-pipeline classifier model card](source-classify-hf/README.md) (published derivative weights, aggregate evaluation, and intended-use boundary) - [SynthID local detector research](synthid-detector-research.md) diff --git a/docs/module-internals.md b/docs/module-internals.md index 48bb7d2..69deca3 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1161,6 +1161,17 @@ and the threshold mutation guard live in `tests/test_source_classify.py`; evaluation and attack limits live in [`synthid-classifiers.md`](synthid-classifiers.md). +`source_classify_v2.py` provides a separate, opt-in `classify_image_source` +entry point. Its NPZ schema contains only complex spectral filters, spatial +templates, and thresholds; the runtime pins the Hub revision and SHA-256, +rejects unknown fields and invalid shapes, and never loads pickles. The result +has `watermark_truth="unknown"` even when a provider pattern matches. A +simultaneous provider match abstains. Tests in +`tests/test_source_classify_v2.py` cover the public seam and changed-threshold +behavior; the [model card](image-source-hf/README.md) records the frozen +aggregate evidence and limits. This does not change the earlier +`classify_source` API. + 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 aeddebb..25558b3 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -29,6 +29,11 @@ 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. +For the newer, separately pinned model, call `raiw.classify_image_source(path)`. +It returns `label`, `reason`, branch `scores`, and +`watermark_truth="unknown"`. A simultaneous provider match abstains with +`reason="conflict"`; it is never reported as SynthID evidence. + ## 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 b8e6d3d..89112ed 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -104,7 +104,9 @@ 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. Package publication and distribution jobs run only for non-prerelease `v*` tags, so model-only prereleases cannot enter PyPI or the -package fan-out. +package fan-out. A draft release keeps its target commit when published; target +the commit containing those guards before creating a model-only release, not +merely the then-current `main` after the draft already exists. The newer `wiltodelta/openai-google-image-source-classifier` uses the separate `publish-image-source-hf.yml` workflow. Its release asset is only the diff --git a/docs/source-classify.md b/docs/source-classify.md index 24515dd..bde6c39 100644 --- a/docs/source-classify.md +++ b/docs/source-classify.md @@ -1,6 +1,6 @@ # Source-pipeline classification -`classify_source` is an opt-in, metadata-free pixel classifier for images that +`classify_source` is the earlier, opt-in, metadata-free pixel classifier for images that still resemble an original provider export. It returns `openai`, `google`, or an explicit `unknown` abstention. @@ -40,3 +40,23 @@ 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. + +## New image-source classifier + +`classify_image_source` is a separate, frozen model that incorporates selected +spectral and spatial evidence from the earlier research. It does not silently +change `classify_source` or its thresholds. Call it explicitly: + +```python +result = raiw.classify_image_source("metadata-stripped.png") +print(result.label) # "openai" | "google" | "unknown" +print(result.reason) # "classified" | "abstained" | "conflict" | "feature_unavailable" +print(result.watermark_truth) # always "unknown" +``` + +Its first call downloads a hash-verified artifact from the immutable revision +of [`wiltodelta/openai-google-image-source-classifier`](https://huggingface.co/wiltodelta/openai-google-image-source-classifier). +For offline use, set `RAIW_IMAGE_SOURCE_WEIGHTS` to the NPZ file or its +directory. It classifies likely export-source patterns in decoded pixels, not +SynthID presence. The [model card](image-source-hf/README.md) records its +aggregate tests and limitations. diff --git a/scripts/README.md b/scripts/README.md index ffdde43..114e88c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -56,6 +56,7 @@ explicitly names a tracked canonical result. | `retrain_photo_classify.py` | CPU-retrain the 2026-08-31 photo heads from a sha256-keyed cache pack, no images. | | `publish_photo_classify_hf.py` | Upload the photo-classify card and freeze weights to Hugging Face `wiltodelta/raiw-photo-classify`. Manual; the Action `publish-photo-classify-hf.yml` is the write-token path. | | `publish_source_classify_hf.py` | Upload the derivative source-pipeline model and its public card to Hugging Face `wiltodelta/raiw-source-classify`. Manual; the Action `publish-source-classify-hf.yml` is the write-token path. | +| `publish_image_source_hf.py` | Upload only the hash-verified new image-source model and public card to `wiltodelta/openai-google-image-source-classifier`. Manual; `publish-image-source-hf.yml` is the write-token path. | | `verify_engine_selection_fixtures.py` | Verify hashes, dimensions, and prompt pairing in the tracked auto-engine content matrix. | ## Research and diagnostic prototypes diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 6031622..e8d9165 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -15,6 +15,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap):: 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 + raiw.classify_image_source("in.png") # newer source patterns, not SynthID detection For a provenance verdict use the ``identify`` submodule:: @@ -39,12 +40,14 @@ __version__ = "0.42.0" __all__ = [ "BatchItemResult", "BatchSummary", + "ImageSourceClassification", "InvisibleOptions", "MetadataStripIncomplete", "RemoveAllResult", "SourceClassification", "VisibleRemovalResult", "__version__", + "classify_image_source", "classify_source", "identify_video", "inspect_video_metadata", @@ -75,6 +78,7 @@ if TYPE_CHECKING: visible_provenance, ) from remove_ai_watermarks.source_classify import SourceClassification, classify_source + from remove_ai_watermarks.source_classify_v2 import ImageSourceClassification, classify_image_source from remove_ai_watermarks.video import ( identify_video, inspect_video_metadata, @@ -109,6 +113,10 @@ def __getattr__(name: str) -> object: from remove_ai_watermarks import source_classify return getattr(source_classify, name) + if name in ("ImageSourceClassification", "classify_image_source"): + from remove_ai_watermarks import source_classify_v2 + + return getattr(source_classify_v2, name) if name in ( "identify_video", "inspect_video_metadata", diff --git a/src/remove_ai_watermarks/source_classify_v2.py b/src/remove_ai_watermarks/source_classify_v2.py new file mode 100644 index 0000000..ca2a00e --- /dev/null +++ b/src/remove_ai_watermarks/source_classify_v2.py @@ -0,0 +1,254 @@ +"""Opt-in OpenAI/Google export-source patterns in decoded image pixels. + +This classifier does not decode SynthID or establish watermark presence. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from functools import lru_cache +from hashlib import file_digest +from importlib import import_module +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from numpy.typing import NDArray + +log = logging.getLogger(__name__) + +WEIGHTS_REPO = "wiltodelta/openai-google-image-source-classifier" +WEIGHTS_REVISION = "4563fb97e5572030ad8d6acbb6f20eaab7fc3009" +WEIGHTS_ENV = "RAIW_IMAGE_SOURCE_WEIGHTS" +MODEL_FILE = "openai-google-source-v1.npz" +MODEL_SHA256 = "0e94e565c181416e4f63a2bfc06cd5179f4201befda7481ac510f6a168c612f4" +MODEL_SCHEMA = "openai-google-source-v1" +GROUPS = ("square", "portrait", "landscape") +ROUTES = ("qwen", "mark", "direct") +_FILTER_NAMES = ( + "old_google_default_photo_fit", + "old_google_sigma24", + "r152_google", + "r152_openai", + "r161_google", + "r161_openai", + *(f"r163_{group}" for group in GROUPS), +) +_THRESHOLD_NAMES = ( + "old_google_default_parent", + "old_google_sigma24_parent", + "r152_google", + "r152_openai", + "r161_google", + "r161_openai", + "r183_openai_gate", + *(f"r163_{group}" for group in GROUPS), + *(f"openai_{route}" for route in ROUTES), +) +CLAIM = "likely image source/export pattern; not SynthID detection" + +SourceLabel = Literal["openai", "google", "unknown"] +SourceReason = Literal["classified", "abstained", "conflict", "feature_unavailable"] + + +@dataclass(frozen=True) +class ImageSourceClassification: + """Narrow source attribution with honest abstention and watermark unknown.""" + + label: SourceLabel + reason: SourceReason + scores: dict[str, float] + watermark_truth: Literal["unknown"] = "unknown" + 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, + "watermark_truth": self.watermark_truth, + "claim": self.claim, + "model_revision": self.model_revision, + } + + +@dataclass(frozen=True) +class _Model: + filters: dict[str, tuple[NDArray[Any], NDArray[Any]]] + templates: dict[str, NDArray[Any]] + thresholds: dict[str, float] + + +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("Install remove-ai-watermarks[source-classify] for image-source weights") 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) -> _Model: + import numpy as np + + with path.open("rb") as source: + actual_sha256 = file_digest(source, "sha256").hexdigest() + if actual_sha256 != expected_sha256: + raise ValueError("image-source model SHA-256 mismatch") + required = { + "schema", + *(f"{name}_{field}" for name in _FILTER_NAMES for field in ("mean", "precision")), + *(f"openai_{route}_unit" for route in ROUTES), + *(f"threshold_{name}" for name in _THRESHOLD_NAMES), + } + with np.load(path, allow_pickle=False) as payload: + if set(payload.files) != required or str(payload["schema"]) != MODEL_SCHEMA: + raise ValueError("image-source model schema mismatch") + loaded_filters: dict[str, tuple[NDArray[Any], NDArray[Any]]] = {} + for name in _FILTER_NAMES: + mean = payload[f"{name}_mean"] + precision = payload[f"{name}_precision"] + if mean.shape != (256, 256, 3) or mean.dtype != np.complex64: + raise ValueError(f"invalid {name} mean") + if precision.shape != mean.shape or precision.dtype != np.float32: + raise ValueError(f"invalid {name} precision") + if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(precision)) or np.any(precision < 0): + raise ValueError(f"nonfinite or negative {name} filter") + loaded_filters[name] = (mean, precision) + templates: dict[str, NDArray[Any]] = {} + for route in ROUTES: + unit = payload[f"openai_{route}_unit"] + if unit.shape != (992, 992) or unit.dtype != np.float32 or not np.all(np.isfinite(unit)): + raise ValueError(f"invalid {route} template") + if not np.isclose(np.linalg.norm(unit), 1.0, rtol=1e-5): + raise ValueError(f"nonunit {route} template") + templates[route] = unit.ravel() + limits: dict[str, float] = {} + for name in _THRESHOLD_NAMES: + value = payload[f"threshold_{name}"] + if value.shape != () or value.dtype != np.float64 or not np.isfinite(value): + raise ValueError(f"invalid {name} threshold") + limits[name] = float(value) + return _Model(loaded_filters, templates, limits) + + +def _spectrum(rgb: NDArray[Any], sigma: float) -> NDArray[Any]: + import cv2 + import numpy as np + from PIL import Image + + small = np.asarray(Image.fromarray(rgb).resize((256, 256), Image.Resampling.BILINEAR)) + values = cv2.cvtColor(small.astype(np.float32) / 255, cv2.COLOR_RGB2YCrCb) + high = values - cv2.GaussianBlur(values, (0, 0), sigma) + energy = cv2.GaussianBlur(high * high, (0, 0), 3) + high = np.clip(high / np.sqrt(energy + (1 / 255) ** 2), -4, 4) + if sigma == 1.2: + high -= high.mean(axis=(0, 1), keepdims=True) + else: + high -= high.mean(axis=(0, 1), keepdims=True, dtype=np.float64).astype(np.float32) + high /= np.maximum(np.sqrt(np.sum(high * high, axis=(0, 1), keepdims=True)), 1e-12) + return np.fft.fft2(high, axes=(0, 1), norm="ortho").astype(np.complex64) + + +def _template_score(values: NDArray[Any], weights: tuple[NDArray[Any], NDArray[Any]]) -> float: + import numpy as np + + mean, precision = weights + numerator = np.real(values * np.conj(mean) * precision).sum(axis=(0, 1)) + denominator = max( + float(np.sqrt((np.abs(values) ** 2 * precision).sum() * (np.abs(mean) ** 2 * precision).sum())), 1e-12 + ) + return float(numerator.sum() / denominator) + + +def _openai_template_scores(rgb: NDArray[Any], model: _Model) -> dict[str, float]: + import cv2 + import numpy as np + + if rgb.shape[:2] != (1024, 1024): + return {} + luminance = cv2.cvtColor(rgb, cv2.COLOR_RGB2YCrCb)[..., 0].astype(np.float32) + high = luminance - cv2.GaussianBlur(luminance, (0, 0), 1.2, borderType=cv2.BORDER_REFLECT) + energy = cv2.GaussianBlur(high * high, (0, 0), 3.0, borderType=cv2.BORDER_REFLECT) + residual = np.clip(high / np.sqrt(energy + 1), -4, 4)[8:1000, 8:1000].ravel().astype(np.float32) + residual -= residual.mean() + norm = np.linalg.norm(residual) + if norm <= 0: + return {} + return {route: float(np.dot(residual, unit) / norm) for route, unit in model.templates.items()} + + +def _aspect(width: int, height: int) -> str: + ratio = width / height + if 0.87 <= ratio <= 1.15: + return "square" + if 0.55 <= ratio < 0.87: + return "portrait" + if 1.15 < ratio <= 1.82: + return "landscape" + return "other" + + +def classify_image_source(source: str | os.PathLike[str]) -> ImageSourceClassification: + """Return likely OpenAI/Google source or abstain; never verify SynthID.""" + import numpy as np + from PIL import Image + + with Image.open(source) as opened: + if min(opened.size) < 256: + return ImageSourceClassification("unknown", "feature_unavailable", {}) + rgb = np.asarray(opened.convert("RGB"), dtype=np.uint8) + model = _load_model(_model_path(), MODEL_SHA256) + height, width = rgb.shape[:2] + regular = _spectrum(rgb, 1.2) + old_default = _template_score(regular, model.filters["old_google_default_photo_fit"]) + old_sigma24 = _template_score(_spectrum(rgb, 2.4), model.filters["old_google_sigma24"]) + limits = model.thresholds + old_google = ( + old_default / limits["old_google_default_parent"] + old_sigma24 / limits["old_google_sigma24_parent"] + ) / 2 + r152_google = _template_score(regular, model.filters["r152_google"]) + r152_openai = _template_score(regular, model.filters["r152_openai"]) + r161_google = _template_score(regular, model.filters["r161_google"]) + r161_openai = _template_score(regular, model.filters["r161_openai"]) + aspect = _aspect(width, height) + r163_openai = _template_score(regular, model.filters[f"r163_{aspect}"]) if aspect in GROUPS else None + old_openai_scores = _openai_template_scores(rgb, model) + google = old_google >= 1.0 or r152_google >= limits["r152_google"] or r161_google >= limits["r161_google"] + openai = ( + any(score >= limits[f"openai_{route}"] for route, score in old_openai_scores.items()) + or r152_openai >= limits["r152_openai"] + or r161_openai >= limits["r161_openai"] + or ( + r163_openai is not None + and r163_openai >= limits[f"r163_{aspect}"] + and r152_openai >= limits["r183_openai_gate"] + ) + ) + scores = { + "old_google": old_google, + "r152_google": r152_google, + "r152_openai": r152_openai, + "r161_google": r161_google, + "r161_openai": r161_openai, + } + if r163_openai is not None: + scores["r163_openai"] = r163_openai + scores.update({f"openai_{route}": score for route, score in old_openai_scores.items()}) + if google and openai: + return ImageSourceClassification("unknown", "conflict", scores) + if google: + return ImageSourceClassification("google", "classified", scores) + if openai: + return ImageSourceClassification("openai", "classified", scores) + return ImageSourceClassification("unknown", "abstained", scores) diff --git a/tests/test_source_classify_v2.py b/tests/test_source_classify_v2.py new file mode 100644 index 0000000..cc5fd7d --- /dev/null +++ b/tests/test_source_classify_v2.py @@ -0,0 +1,105 @@ +"""Contracts for the portable OpenAI/Google source-pattern runtime.""" + +from __future__ import annotations + +from hashlib import sha256 +from typing import TYPE_CHECKING + +import numpy as np +import pytest +from PIL import Image, PngImagePlugin + +from remove_ai_watermarks import source_classify_v2 as model + +if TYPE_CHECKING: + from pathlib import Path + + +def _synthetic_weights(path: Path, *, gate: float) -> None: + zero_mean = np.zeros((256, 256, 3), dtype=np.complex64) + zero_precision = np.zeros((256, 256, 3), dtype=np.float32) + unit = np.zeros((992, 992), dtype=np.float32) + unit[0, 0] = 1 + fields: dict[str, np.ndarray] = {"schema": np.asarray(model.MODEL_SCHEMA)} + for name in model._FILTER_NAMES: + fields[f"{name}_mean"] = zero_mean + fields[f"{name}_precision"] = zero_precision + for route in model.ROUTES: + fields[f"openai_{route}_unit"] = unit + limits = dict.fromkeys(model._THRESHOLD_NAMES, 1.0) + limits["r183_openai_gate"] = gate + limits.update({f"r163_{group}": -0.5 for group in model.GROUPS}) + fields.update({f"threshold_{name}": np.asarray(value, dtype=np.float64) for name, value in limits.items()}) + np.savez_compressed(path, **fields) + + +def test_r163_gate_changes_real_prediction(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + image = tmp_path / "photo.png" + Image.new("RGB", (512, 512), (80, 110, 145)).save(image) + blocked = tmp_path / "blocked.npz" + allowed = tmp_path / "allowed.npz" + _synthetic_weights(blocked, gate=0.5) + _synthetic_weights(allowed, gate=-0.5) + + def predict(path: Path) -> model.ImageSourceClassification: + monkeypatch.setenv(model.WEIGHTS_ENV, str(path)) + monkeypatch.setattr(model, "MODEL_SHA256", sha256(path.read_bytes()).hexdigest()) + return model.classify_image_source(image) + + no_gate = predict(blocked) + with_gate = predict(allowed) + + assert no_gate.label == "unknown" + assert no_gate.reason == "abstained" + assert with_gate.label == "openai" + assert with_gate.reason == "classified" + assert with_gate.watermark_truth == "unknown" + + +def test_top_level_api_is_lazy_and_points_to_new_runtime() -> None: + import remove_ai_watermarks as raiw + + assert raiw.classify_image_source is model.classify_image_source + assert raiw.ImageSourceClassification is model.ImageSourceClassification + assert len(model.WEIGHTS_REVISION) == 40 + assert all(character in "0123456789abcdef" for character in model.WEIGHTS_REVISION) + + +def test_metadata_does_not_change_pixel_decision(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + weights = tmp_path / model.MODEL_FILE + _synthetic_weights(weights, gate=-0.5) + monkeypatch.setenv(model.WEIGHTS_ENV, str(weights)) + monkeypatch.setattr(model, "MODEL_SHA256", sha256(weights.read_bytes()).hexdigest()) + pixels = Image.new("RGB", (512, 512), (80, 110, 145)) + clean = tmp_path / "clean.png" + tagged = tmp_path / "tagged.png" + pixels.save(clean) + metadata = PngImagePlugin.PngInfo() + metadata.add_text("Comment", "arbitrary metadata") + pixels.save(tagged, pnginfo=metadata) + + first = model.classify_image_source(clean) + second = model.classify_image_source(tagged) + + assert first.label == second.label == "openai" + assert first.scores == second.scores + + +def test_rejects_wrong_artifact_hash(tmp_path: Path) -> None: + weights = tmp_path / model.MODEL_FILE + _synthetic_weights(weights, gate=0.5) + + with pytest.raises(ValueError, match="SHA-256 mismatch"): + model._load_model(weights, "0" * 64) + + +def test_small_image_abstains_without_watermark_claim(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(model, "_model_path", lambda: pytest.fail("small image loaded weights")) + image = tmp_path / "small.png" + Image.new("RGB", (128, 128), "white").save(image) + + result = model.classify_image_source(image) + + assert result.label == "unknown" + assert result.reason == "feature_unavailable" + assert result.watermark_truth == "unknown"