diff --git a/.github/workflows/publish-image-source-hf.yml b/.github/workflows/publish-image-source-hf.yml new file mode 100644 index 0000000..52d2e0a --- /dev/null +++ b/.github/workflows/publish-image-source-hf.yml @@ -0,0 +1,59 @@ +name: Publish image-source classifier to Hugging Face + +# Manual only. The derivative weights live in a GitHub model-freeze release; +# private training images and caches never enter this repository. +on: + workflow_dispatch: + inputs: + confirm: + description: "Type publish" + required: true + weights_tag: + description: "GitHub Release tag holding openai-google-source-v1.npz" + required: true + default: image-source-freeze-2026-09-25-v1 + +jobs: + publish: + if: ${{ github.event.inputs.confirm == 'publish' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + - name: Download derivative weights + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.weights_tag }} + run: | + set -euo pipefail + mkdir -p weights + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern openai-google-source-v1.npz \ + --dir weights + test -s weights/openai-google-source-v1.npz + - name: Verify artifact hash + run: | + set -euo pipefail + expected="$(jq -r .artifact_sha256 docs/image-source-hf/metrics.json)" + test "${#expected}" -eq 64 + echo "$expected weights/openai-google-source-v1.npz" | sha256sum --check + - name: Publish to Hugging Face + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [ -z "${HF_TOKEN:-}" ]; then + echo "HF_TOKEN secret is not set" >&2 + exit 1 + fi + uv run --no-project --with huggingface-hub \ + python scripts/publish_image_source_hf.py \ + --src weights \ + --message "Image-source classifier freeze (GitHub ${SHA:0:7})" diff --git a/docs/image-source-hf/README.md b/docs/image-source-hf/README.md new file mode 100644 index 0000000..88df59e --- /dev/null +++ b/docs/image-source-hf/README.md @@ -0,0 +1,64 @@ +--- +license: apache-2.0 +library_name: numpy +pipeline_tag: image-classification +tags: + - image-forensics + - source-attribution + - openai + - google + - open-set-classification +--- + +# OpenAI and Google image source classifier + +This opt-in model classifies decoded image pixels as a likely `openai` or +`google` export pattern, or abstains as `unknown`. It is designed for images +whose metadata was removed without deliberately erasing or regenerating the +pixel pattern. It is **not a SynthID detector**: a provider label does not prove +that SynthID is present, and `unknown` does not prove that it is absent. + +## Artifact and runtime + +The pickle-free `openai-google-source-v1.npz` contains only derivative +spectral templates and numeric thresholds. It contains no training images, +paths, image hashes, embeddings, catalog, or per-image predictions. Its SHA-256 +is `0e94e565c181416e4f63a2bfc06cd5179f4201befda7481ac510f6a168c612f4`. +The corpus used to fit the templates is private and is not redistributed. + +Install `remove-ai-watermarks[source-classify]` and call the explicit Python +API `classify_image_source(path)`. The installed library pins this repository +to a verified commit and verifies the artifact hash. It can use a local NPZ +through `RAIW_IMAGE_SOURCE_WEIGHTS`. The old `classify_source` API is a separate, +earlier model and is not silently replaced by this one. + +The result exposes `label`, `reason`, numeric branch `scores`, and +`watermark_truth="unknown"`. A simultaneous OpenAI and Google match is returned +as label `unknown` with reason `conflict`, not guessed as one provider. + +## Evidence and limits + +On 600 public [Qwen Image Bench](https://huggingface.co/datasets/Qwen/Qwen-Image-Bench) +originals from unused prompt IDs, the frozen model classified 198/200 +OpenAI-labeled, 196/200 Google-labeled, and abstained on 200/200 FLUX-labeled +files. These are uploader source labels, not per-file authenticated provenance; +this public route was opened during earlier model development, so it is a +regression check, not an independent final test. + +An additional post-freeze camera control selected 42 JPEG photographs with +camera EXIF and pre-2022 capture dates from a pinned prefix of +[lightcella/photo-corpus](https://huggingface.co/datasets/lightcella/photo-corpus). +The model returned `unknown` for all 42 original decoded-pixel views and their +JPEG95, JPEG75 and bilinear-1024 transformations. The photos come from one +archive shard; EXIF and the dataset's Flickr-original description are not +cryptographic proof of origin. No originals are redistributed here. + +The model can be wrong on other providers, camera types, editing pipelines, +resizes, crops and compression settings. Some non-target generator and +photographic stress panels found false provider matches in related candidates. +It must not be used as evidence of watermark presence, authorship, fraud, +policy compliance, or provenance. Report the abstention and keep independent +metadata/provenance findings separate from this source-pattern prediction. + +The artifact is Apache-2.0 licensed. Input photographs and their respective +licenses are not part of this repository. diff --git a/docs/image-source-hf/metrics.json b/docs/image-source-hf/metrics.json new file mode 100644 index 0000000..b50f36a --- /dev/null +++ b/docs/image-source-hf/metrics.json @@ -0,0 +1,28 @@ +{ + "claim": "OpenAI/Google export-source patterns; not SynthID detection", + "artifact_sha256": "0e94e565c181416e4f63a2bfc06cd5179f4201befda7481ac510f6a168c612f4", + "classes": ["openai", "google", "unknown"], + "public_qwen_originals": { + "dataset": "Qwen/Qwen-Image-Bench", + "revision": "d2493deb153b020cf169c7e3f57d15e4dd697038", + "label_provenance": "dataset uploader assertion, not per-file signed source", + "selection_status": "opened before this candidate; regression, not independent validation", + "openai": {"n": 200, "correct": 198}, + "google": {"n": 200, "correct": 196}, + "flux_unknown": {"n": 200, "correct": 200} + }, + "camera_controls": { + "dataset": "lightcella/photo-corpus", + "revision": "8d960ee416e50a13f5c73d71f47dc37ee3c1da11", + "shard": "oi/oi-0002.tar", + "selection_status": "prefix and qualification fixed before scoring this model", + "origin_caveat": "camera EXIF is not cryptographic human-origin proof", + "n_sources": 42, + "false_provider_matches": { + "original": 0, + "jpeg95_420": 0, + "jpeg75_420": 0, + "bilinear_max1024": 0 + } + } +} diff --git a/scripts/publish_image_source_hf.py b/scripts/publish_image_source_hf.py new file mode 100644 index 0000000..edcfdd3 --- /dev/null +++ b/scripts/publish_image_source_hf.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Publish the derivative image-source artifact with an explicit allowlist.""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import shutil +import tempfile +from hashlib import file_digest +from pathlib import Path + +from hf_publish import publish_model_folder + +log = logging.getLogger(__name__) + +HUB_REPO = "wiltodelta/openai-google-image-source-classifier" +MODEL_FILE = "openai-google-source-v1.npz" +REPO_ROOT = Path(__file__).resolve().parents[1] +CARD_DIR = REPO_ROOT / "docs" / "image-source-hf" +PUBLIC_FILES = ("README.md", "metrics.json", MODEL_FILE) + + +def stage_release(dest: Path, src: Path) -> None: + """Copy only the public card, metrics, and hash-verified derivative weights.""" + weights = src.expanduser().resolve() / MODEL_FILE + sources = { + "README.md": CARD_DIR / "README.md", + "metrics.json": CARD_DIR / "metrics.json", + MODEL_FILE: weights, + } + missing = [str(path) for path in sources.values() if not path.is_file()] + if missing: + raise ValueError(f"missing publication files: {', '.join(missing)}") + expected = json.loads(sources["metrics.json"].read_text())["artifact_sha256"] + with weights.open("rb") as source: + actual = file_digest(source, "sha256").hexdigest() + if actual != expected: + raise ValueError("image-source artifact SHA-256 mismatch") + dest.mkdir(parents=True, exist_ok=True) + for name, path in sources.items(): + shutil.copy2(path, dest / name) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--src", type=Path, required=True) + parser.add_argument("--message", default="Publish image-source classifier") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(message)s") + token = os.environ.get("HF_TOKEN", "").strip() + if not token: + raise SystemExit("HF_TOKEN is not set (write role required)") + with tempfile.TemporaryDirectory(prefix="raiw-image-source-hf-") as tmp: + stage = Path(tmp) + stage_release(stage, args.src) + revision = publish_model_folder( + stage, + repo_id=HUB_REPO, + token=token, + message=args.message, + allow_patterns=PUBLIC_FILES, + ) + log.info("published %s revision=%s", HUB_REPO, revision or "(unknown)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_publish_image_source_hf.py b/tests/test_publish_image_source_hf.py new file mode 100644 index 0000000..cc10b94 --- /dev/null +++ b/tests/test_publish_image_source_hf.py @@ -0,0 +1,33 @@ +"""The public model staging boundary accepts only verified derivatives.""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import publish_image_source_hf as publisher + + +def test_stage_release_rejects_changed_weights(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + card = tmp_path / "card" + card.mkdir() + (card / "README.md").write_text("# Public card\n") + weights = tmp_path / "source" + weights.mkdir() + original = b"derivative weights" + (weights / publisher.MODEL_FILE).write_bytes(original) + (card / "metrics.json").write_text(json.dumps({"artifact_sha256": hashlib.sha256(original).hexdigest()})) + monkeypatch.setattr(publisher, "CARD_DIR", card) + + stage = tmp_path / "stage" + publisher.stage_release(stage, weights) + assert sorted(item.name for item in stage.iterdir()) == sorted(publisher.PUBLIC_FILES) + + (weights / publisher.MODEL_FILE).write_bytes(b"changed weights") + with pytest.raises(ValueError, match="SHA-256 mismatch"): + publisher.stage_release(tmp_path / "second", weights)