Files
remove-ai-watermarks/src/remove_ai_watermarks/source_classify.py
T
Victor KuznetsovandClaude Opus 5.5 1dc70df289 Upgrade to OpenCV 5 with frozen Hershey templates and tighten video marks
- Lock OpenCV 5 and cap NumPy below 2.4 through uv constraint-dependencies so
  PaddleX 3.7 keeps resolving; Dependabot ignores mirror the cap.
- OpenCV 5 draws Hershey putText glyphs differently, so the Dola and Kling
  video templates ship as committed OpenCV 4 renders with a recipe script, and
  the OpenArt alpha recipe now refuses any OpenCV but 4.x.
- Break Sora/Veo cross-matches in favor of Veo under valid Google AI-video
  provenance, and require the full Sora mascot shape for mascot-only matches.
- Let the Doubao engine detect marks tinted by saturated backgrounds.
- Share C2PA claim-generator, credential-level and vendor-platform helpers,
  JPEG suffixes and the DCT basis instead of per-module copies.
- Log model download failures, document the optional GITHUB_TOKEN, mark the
  photo and source classifier model cards archived, and refresh the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:19:00 -07:00

246 lines
8.9 KiB
Python

"""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 logging
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
log = logging.getLogger(__name__)
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
try:
return Path(
hub.hf_hub_download(
repo_id=WEIGHTS_REPO,
filename=MODEL_FILE,
revision=WEIGHTS_REVISION,
)
)
except Exception as exc:
log.warning("Downloading %s from %s@%s failed: %s", MODEL_FILE, WEIGHTS_REPO, WEIGHTS_REVISION, exc)
raise
@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)