"""Train or run an abstaining source/export-pipeline classifier. This research tool combines the library's 124-d forensic descriptor with the phase-free spectral descriptor from ``spectral_pipeline_probe.py``. Its labels describe complete generation and export pipelines. It does not decode SynthID or establish that a watermark is present. Expected training layout:: train/openai/*.png train/google/*.png train/unknown/*.png calibration/openai/*.png calibration/google/*.png calibration/unknown/*.png Examples:: uv run python scripts/source_pipeline_classifier.py train \ train calibration --model-out model.npz uv run python scripts/source_pipeline_classifier.py predict \ model.npz image.png """ from __future__ import annotations import json import logging from dataclasses import dataclass, replace from pathlib import Path from typing import cast import click import numpy as np import spectral_pipeline_probe as spectral from numpy.typing import NDArray from PIL import Image from watermark_benchmark import sha256_file from remove_ai_watermarks._internal.forensic_124d import FEATURE_WIDTH, PATCH, image_features log = logging.getLogger(__name__) CLASSES = ("openai", "google", "unknown") UNKNOWN_INDEX = CLASSES.index("unknown") DEFAULT_GRID = 16 DEFAULT_PENALTY = 100.0 DEFAULT_UNKNOWN_FPR = 0.01 CLAIM = "source/export pipeline classification; not SynthID detection" FloatArray = NDArray[np.float64] IntArray = NDArray[np.int64] @dataclass(frozen=True) class RidgeModel: """One standardized three-class ridge head with an abstention margin.""" mean: FloatArray scale: FloatArray weights: FloatArray margin: float grid: int penalty: float def discover_labeled_paths(root: Path) -> tuple[list[Path], IntArray, list[str]]: """Find images under the three fixed label directories.""" paths: list[Path] = [] labels: list[int] = [] hashes: list[str] = [] for class_index, class_name in enumerate(CLASSES): directory = root / class_name if not directory.is_dir(): raise ValueError(f"class directory does not exist: {directory}") class_paths = sorted( path for path in directory.rglob("*") if path.is_file() and path.suffix.lower() in spectral.IMAGE_SUFFIXES ) if not class_paths: raise ValueError(f"class directory has no supported images: {directory}") for path in class_paths: paths.append(path) labels.append(class_index) hashes.append(sha256_file(path)) if len(hashes) != len(set(hashes)): raise ValueError(f"duplicate image bytes found inside {root}") return paths, np.asarray(labels, dtype=np.int64), hashes def _feature_from_path(path: Path, geometry: spectral.SpectralGeometry) -> FloatArray | None: """Return the fused representation for one path and prepared geometry.""" with Image.open(path) as source: image = source.convert("RGB") forensic = image_features(np.asarray(image, dtype=np.uint8)) if forensic is None: return None phase_free = spectral.feature_from_image(image, geometry, "original") return np.concatenate( ( np.asarray(forensic, dtype=np.float64), np.asarray(phase_free, dtype=np.float64), ) ) def feature_from_path(path: Path, grid: int) -> FloatArray | None: """Return the fused forensic and phase-free spectral representation.""" return _feature_from_path(path, spectral.make_geometry(size=PATCH, grid=grid)) def extract_features(paths: list[Path], labels: IntArray, grid: int) -> tuple[FloatArray, IntArray, list[Path]]: """Extract valid rows and report files whose forensic feature abstains.""" geometry = spectral.make_geometry(size=PATCH, grid=grid) features: list[FloatArray] = [] kept_labels: list[int] = [] skipped: list[Path] = [] for index, (path, label) in enumerate(zip(paths, labels, strict=True), start=1): try: feature = _feature_from_path(path, geometry) except (OSError, ValueError) as error: log.warning("Could not extract %s: %s", path, error) feature = None if feature is None: skipped.append(path) continue features.append(feature) kept_labels.append(int(label)) if index % 100 == 0: log.info("Extracted %s/%s images", index, len(paths)) if not features: raise ValueError("no image produced a valid feature vector") return np.asarray(features), np.asarray(kept_labels, dtype=np.int64), skipped def fit_ridge( features: FloatArray, labels: IntArray, penalty: float, grid: int = DEFAULT_GRID, ) -> RidgeModel: """Fit a class-balanced standardized ridge head before calibration.""" if features.ndim != 2 or len(features) != len(labels): raise ValueError("features and labels must describe the same rows") counts = np.bincount(labels, minlength=len(CLASSES)).astype(np.float64) if np.any(counts == 0): raise ValueError("training data must contain all three classes") if penalty <= 0: raise ValueError("penalty must be positive") mean = features.mean(axis=0) raw_scale = features.std(axis=0) active = raw_scale > 1e-10 scale = np.where(active, raw_scale, 1.0) normalized = (features[:, active] - mean[active]) / scale[active] sample_weight = len(labels) / (len(CLASSES) * counts[labels]) root_weight = np.sqrt(sample_weight)[:, None] weighted = normalized * root_weight targets = np.eye(len(CLASSES), dtype=np.float64)[labels] * root_weight gram = weighted.T @ weighted + penalty * np.eye(weighted.shape[1]) active_weights = cast("FloatArray", np.linalg.solve(gram, weighted.T @ targets)) weights = np.zeros((features.shape[1], len(CLASSES)), dtype=np.float64) weights[active] = active_weights return RidgeModel(mean, scale, weights, 0.0, grid, penalty) def scores(features: FloatArray, model: RidgeModel) -> FloatArray: """Return the uncalibrated ridge scores.""" return ((features - model.mean) / model.scale) @ model.weights def decision_margins(raw_scores: FloatArray) -> FloatArray: """Return each row's best-provider advantage over every alternative.""" target_scores = raw_scores[:, :UNKNOWN_INDEX] best = np.argmax(target_scores, axis=1) other = 1 - best rows = np.arange(len(raw_scores)) reference = np.maximum(raw_scores[rows, other], raw_scores[:, UNKNOWN_INDEX]) return target_scores[rows, best] - reference def calibrate_margin( calibration_scores: FloatArray, calibration_labels: IntArray, unknown_fpr: float, ) -> float: """Choose the smallest empirical cut meeting the unknown false-positive target.""" if not 0 <= unknown_fpr < 1: raise ValueError("unknown_fpr must be in [0, 1)") unknown_scores = calibration_scores[calibration_labels == UNKNOWN_INDEX] if not len(unknown_scores): raise ValueError("calibration data must contain unknown rows") return max( 0.0, float( np.quantile( decision_margins(unknown_scores), 1.0 - unknown_fpr, method="higher", ) + 1e-12 ), ) def predict_scores(raw_scores: FloatArray, margin: float) -> IntArray: """Return provider labels only when the calibrated margin is met.""" best = np.argmax(raw_scores[:, :UNKNOWN_INDEX], axis=1) accepted = decision_margins(raw_scores) >= margin return np.where(accepted, best, UNKNOWN_INDEX).astype(np.int64) def save_model(path: Path, model: RidgeModel) -> None: """Write a pickle-free model artifact.""" path.parent.mkdir(parents=True, exist_ok=True) np.savez_compressed( path, classes=np.asarray(CLASSES), mean=model.mean, scale=model.scale, weights=model.weights, margin=np.asarray(model.margin), spectral_grid=np.asarray(model.grid), penalty=np.asarray(model.penalty), ) def load_model(path: Path) -> RidgeModel: """Load and validate a pickle-free model artifact.""" with np.load(path, allow_pickle=False) as payload: required = { "classes", "mean", "scale", "weights", "margin", "spectral_grid", "penalty", } if set(payload.files) != required: raise ValueError(f"model fields must be exactly {sorted(required)}") classes = tuple(str(value) for value in payload["classes"]) mean = np.asarray(payload["mean"], dtype=np.float64) scale = np.asarray(payload["scale"], dtype=np.float64) weights = np.asarray(payload["weights"], dtype=np.float64) margin = float(payload["margin"]) grid = int(payload["spectral_grid"]) penalty = float(payload["penalty"]) expected_features = FEATURE_WIDTH + 3 * grid * grid if classes != CLASSES: raise ValueError(f"model classes must be {CLASSES}") if mean.shape != (expected_features,) or scale.shape != mean.shape: raise ValueError("model normalization vectors have the wrong shape") if weights.shape != (expected_features, len(CLASSES)): raise ValueError("model weights have the wrong shape") if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(scale)): raise ValueError("model normalization vectors must be finite") if not np.all(np.isfinite(weights)) or np.any(scale <= 0): raise ValueError("model weights must be finite and scales positive") if not np.isfinite(margin) or margin < 0: raise ValueError("model margin must be finite and non-negative") if grid < 2 or PATCH % grid or not np.isfinite(penalty) or penalty <= 0: raise ValueError("model calibration fields are invalid") return RidgeModel(mean, scale, weights, margin, grid, penalty) @click.group() def main() -> None: """Train or run the research-only source-pipeline classifier.""" logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") @main.command("train") @click.argument("train_root", type=click.Path(exists=True, file_okay=False, path_type=Path)) @click.argument("calibration_root", type=click.Path(exists=True, file_okay=False, path_type=Path)) @click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True) @click.option("--grid", type=click.IntRange(min=2), default=DEFAULT_GRID, show_default=True) @click.option("--penalty", type=click.FloatRange(min=0.0, min_open=True), default=DEFAULT_PENALTY, show_default=True) @click.option( "--unknown-fpr", type=click.FloatRange(min=0.0, max=1.0, max_open=True), default=DEFAULT_UNKNOWN_FPR, show_default=True, ) def train_command( train_root: Path, calibration_root: Path, model_out: Path, grid: int, penalty: float, unknown_fpr: float, ) -> None: """Fit on TRAIN_ROOT and calibrate abstention on CALIBRATION_ROOT.""" if PATCH % grid: raise click.UsageError(f"--grid must divide {PATCH}") try: train_paths, train_labels, train_hashes = discover_labeled_paths(train_root) calibration_paths, calibration_labels, calibration_hashes = discover_labeled_paths(calibration_root) overlap = set(train_hashes) & set(calibration_hashes) if overlap: raise ValueError(f"train/calibration byte overlap: {len(overlap)} images") train_features, kept_train_labels, train_skipped = extract_features(train_paths, train_labels, grid) calibration_features, kept_calibration_labels, calibration_skipped = extract_features( calibration_paths, calibration_labels, grid ) provisional = fit_ridge(train_features, kept_train_labels, penalty, grid) margin = calibrate_margin( scores(calibration_features, provisional), kept_calibration_labels, unknown_fpr, ) model = replace(provisional, margin=margin) save_model(model_out, model) except ValueError as error: raise click.ClickException(str(error)) from error click.echo( json.dumps( { "model": str(model_out), "claim": CLAIM, "train_rows": len(train_features), "calibration_rows": len(calibration_features), "feature_abstentions": len(train_skipped) + len(calibration_skipped), "margin": margin, }, indent=2, ) ) @main.command("predict") @click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path)) def predict_command(model_path: Path, images: tuple[Path, ...]) -> None: """Classify original-export IMAGES with MODEL_PATH.""" try: model = load_model(model_path) except (OSError, ValueError) as error: raise click.ClickException(str(error)) from error results: list[dict[str, object]] = [] geometry = spectral.make_geometry(size=PATCH, grid=model.grid) for path in images: try: feature = _feature_from_path(path, geometry) except (OSError, ValueError) as error: raise click.ClickException(f"could not extract {path}: {error}") from error if feature is None: results.append( { "path": str(path), "label": "unknown", "reason": "124-d feature unavailable", "claim": CLAIM, } ) continue raw_scores = scores(feature[None, :], model) prediction = int(predict_scores(raw_scores, model.margin)[0]) results.append( { "path": str(path), "label": CLASSES[prediction], "decision_margin": float(decision_margins(raw_scores)[0]), "required_margin": model.margin, "scores": {name: float(raw_scores[0, index]) for index, name in enumerate(CLASSES)}, "claim": CLAIM, } ) click.echo(json.dumps(results, indent=2)) if __name__ == "__main__": main()