File size: 7,673 Bytes
cd24b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
"""Model zoo: publish SynthScope checkpoints to the Hugging Face Hub and auto-download them.

``synthscope.load(...)`` transparently pulls a detector's weights from the Hub when they are not
already present under ``$SYNTHSCOPE_CKPT_DIR``. Maintainers publish with ``synthscope push``.

License safety (see LICENSES.md): by default ``push`` uploads only **SynthScope-trained** checkpoints,
which are this project's to redistribute. Checkpoints derived from authors' **official released
weights** (``ckpt_kind`` starting with ``official_`` — e.g. FatFormer, AIDE, NPR) are skipped
unless you pass ``--include-official`` AND have confirmed the upstream license permits it.
"""
from __future__ import annotations

import os
from pathlib import Path

from interface.backend import config as _config
from interface.backend.registry import list_specs

# Where the public zoo lives by default; override with $SYNTHSCOPE_HUB_REPO.
DEFAULT_REPO = os.environ.get("SYNTHSCOPE_HUB_REPO", "SMSD75/synthscope-checkpoints")

# Small SynthScope-produced auxiliary files (calibration stats) that some detectors read from the
# checkpoint dir. Safe to redistribute (produced by this project).
AUX_FILES = ["d3_calibration.json", "stall_params_genvideo.npz"]


def _ckpt_dir() -> Path:
    return _config.CHECKPOINTS_DIR


def is_official(spec) -> bool:
    """Whether a spec's checkpoint comes from the authors' official released weights."""
    return str(spec.ckpt_kind).startswith("official")


def repo_filename(key: str) -> str:
    """Canonical name of a detector's checkpoint inside the Hub repo."""
    return f"{key}.pth"


# ---------------------------------------------------------------------------
# Download (used by the public API)
# ---------------------------------------------------------------------------
def download(key: str, repo_id: str | None = None, token: str | None = None) -> Path:
    """Fetch ``<key>.pth`` from the Hub into ``$SYNTHSCOPE_CKPT_DIR`` and return its local path."""
    from huggingface_hub import hf_hub_download

    repo_id = repo_id or DEFAULT_REPO
    dest_dir = _ckpt_dir()
    dest_dir.mkdir(parents=True, exist_ok=True)
    path = hf_hub_download(
        repo_id=repo_id,
        filename=repo_filename(key),
        repo_type="model",
        local_dir=str(dest_dir),
        token=token,
    )
    # Best-effort: also pull a known aux file for this detector if the repo has one.
    _maybe_download_aux(key, repo_id, token)
    return Path(path)


def _maybe_download_aux(key: str, repo_id: str, token: str | None) -> None:
    from huggingface_hub import hf_hub_download

    wanted = {"d3": "d3_calibration.json", "stall": "stall_params_genvideo.npz"}.get(key)
    if not wanted:
        return
    try:
        hf_hub_download(repo_id=repo_id, filename=wanted, repo_type="model",
                        local_dir=str(_ckpt_dir()), token=token)
    except Exception:  # the aux file is optional — absence is fine
        pass


# ---------------------------------------------------------------------------
# Upload (maintainer side, via `synthscope push`)
# ---------------------------------------------------------------------------
def _select(keys: list[str] | None, include_official: bool):
    """Return (to_upload: list[(spec, resolved_path)], skipped: list[(key, reason)])."""
    ckpt_dir = _ckpt_dir()
    to_upload, skipped = [], []
    specs = [s for s in list_specs() if s.checkpoint]
    if keys:
        wanted = set(keys)
        specs = [s for s in specs if s.key in wanted]
    for s in specs:
        path = ckpt_dir / s.checkpoint
        if not path.exists():
            skipped.append((s.key, "no local checkpoint"))
            continue
        if is_official(s) and not include_official:
            skipped.append((s.key, "official weights (license-restricted; use --include-official)"))
            continue
        to_upload.append((s, path.resolve()))
    return to_upload, skipped


def _manifest_text(uploaded_keys: list[str], repo_id: str) -> str:
    lines = [
        "---", "license: apache-2.0", "library_name: synthscope",
        "tags:", "- ai-generated-image-detection", "- synthetic-image-detection",
        "- deepfake-detection", "---", "",
        "# SynthScope model zoo", "",
        "Checkpoints for [SynthScope](https://github.com/SMSD75/SynthScope) detectors. Load them with:", "",
        "```python", "import synthscope", f'# export SYNTHSCOPE_HUB_REPO="{repo_id}" (or pass repo=...)',
        'synthscope.load("clip").predict("image.png")  # auto-downloads from this repo', "```", "",
        "## Contents", "",
        "| Detector | File | Provenance |", "| --- | --- | --- |",
    ]
    for s in list_specs():
        if s.key in uploaded_keys:
            prov = "official weights" if is_official(s) else "SynthScope-trained"
            lines.append(f"| {s.key} | `{repo_filename(s.key)}` | {prov} |")
    lines += [
        "", "## License", "",
        "The SynthScope framework is Apache-2.0. Re-implementation-trained weights are released under "
        "Apache-2.0. Any weights derived from third-party official releases retain their upstream "
        "license — see the repo's `LICENSES.md`. Verify terms before downstream use.",
    ]
    return "\n".join(lines) + "\n"


def push(
    repo_id: str | None = None,
    keys: list[str] | None = None,
    include_official: bool = False,
    private: bool = True,
    token: str | None = None,
    dry_run: bool = False,
) -> dict:
    """Publish checkpoints to ``repo_id`` (default ``$SYNTHSCOPE_HUB_REPO``).

    Returns a summary dict. With ``dry_run=True`` nothing is uploaded — it only reports the
    selection (use this to sanity-check before an unattended run).
    """
    repo_id = repo_id or DEFAULT_REPO
    to_upload, skipped = _select(keys, include_official)
    uploaded_keys = [s.key for s, _ in to_upload]

    print(f"Target repo : {repo_id} ({'private' if private else 'PUBLIC'})")
    print(f"Checkpoint dir: {_ckpt_dir()}")
    print(f"Will upload ({len(to_upload)}): {', '.join(uploaded_keys) or '(none)'}")
    for key, reason in skipped:
        print(f"  skip {key}: {reason}")
    aux_present = [a for a in AUX_FILES if (_ckpt_dir() / a).exists()]
    if aux_present:
        print(f"Aux files   : {', '.join(aux_present)}")

    if dry_run:
        print("[dry-run] no upload performed.")
        return {"repo_id": repo_id, "uploaded": uploaded_keys, "skipped": skipped, "dry_run": True}
    if not to_upload:
        print("Nothing to upload.")
        return {"repo_id": repo_id, "uploaded": [], "skipped": skipped, "dry_run": False}

    from huggingface_hub import HfApi, create_repo

    create_repo(repo_id, repo_type="model", private=private, exist_ok=True, token=token)
    api = HfApi(token=token)
    for spec, path in to_upload:
        name = repo_filename(spec.key)
        sz = path.stat().st_size / 1e6
        print(f"  uploading {name}  ({sz:.0f} MB) <- {path}")
        api.upload_file(path_or_fileobj=str(path), path_in_repo=name,
                        repo_id=repo_id, repo_type="model", token=token)
    for a in aux_present:
        print(f"  uploading {a}")
        api.upload_file(path_or_fileobj=str(_ckpt_dir() / a), path_in_repo=a,
                        repo_id=repo_id, repo_type="model", token=token)

    readme = _manifest_text(uploaded_keys, repo_id)
    api.upload_file(path_or_fileobj=readme.encode(), path_in_repo="README.md",
                    repo_id=repo_id, repo_type="model", token=token)
    print(f"Done. https://huggingface.co/{repo_id}")
    return {"repo_id": repo_id, "uploaded": uploaded_keys, "skipped": skipped, "dry_run": False}