File size: 8,046 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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""Public API: ``load`` a detector and ``predict`` on an image or video.

This wraps the web-interface backend, which already encapsulates the single source of truth
for every detector: the registry (per-model Hydra config, eval preprocessing, checkpoint +
official-weights remap, and score convention) and the inference engine (lazy build, checkpoint
load, and the forward -> P(fake) math). We reuse it rather than duplicate that logic.
"""
from __future__ import annotations

import dataclasses
import io
from pathlib import Path
from typing import Union

from PIL import Image

# Importing the backend adds the repo root to sys.path and wires cache locations; it pulls
# torch/torchvision and the repo decoders, so keep it at module scope (this is an ML library).
from interface.backend import config as _config
from interface.backend import media as _media
from interface.backend.engine import Engine, get_engine
from interface.backend.registry import ModelSpec, get_spec, list_specs

PathOrImage = Union[str, Path, bytes, Image.Image]

__all__ = ["Detector", "load", "list_models", "available_models"]


def list_models() -> list[dict]:
    """Return one dict per implemented detector, including whether its weights are present.

    Keys: ``key``, ``name``, ``paper``, ``modality`` (``image``/``video``), ``available``
    (checkpoint resolvable, or training-free), and ``notes``.
    """
    eng = get_engine()
    return [
        {
            "key": s.key,
            "name": s.name,
            "paper": s.paper,
            "modality": s.modality,
            "available": eng.is_available(s),
            "notes": s.notes,
        }
        for s in list_specs()
    ]


def available_models() -> list[str]:
    """Keys of detectors that can run right now (weights present, or training-free)."""
    eng = get_engine()
    return [s.key for s in list_specs() if eng.is_available(s)]


def load(
    model: str,
    *,
    checkpoint: str | Path | None = None,
    device: str | None = None,
    auto_download: bool = True,
    repo: str | None = None,
) -> "Detector":
    """Build a :class:`Detector` for ``model`` (e.g. ``"fat_former"``, ``"d3"``).

    Parameters
    ----------
    model:        detector key (see :func:`list_models`).
    checkpoint:   optional path to a ``.pth`` to load instead of the one in ``$SYNTHSCOPE_CKPT_DIR``.
    device:       ``"cuda"``/``"cpu"`` (default: CUDA if available). The model is built lazily on
                  the first :meth:`Detector.predict` call.
    auto_download: if the weights are absent locally, fetch them from the Hugging Face model zoo
                  on first use (default True). Ignored when ``checkpoint`` is given.
    repo:         Hub repo to download from (default ``$SYNTHSCOPE_HUB_REPO`` / the project default).
    """
    return Detector(get_spec(model), checkpoint=checkpoint, device=device,
                    auto_download=auto_download, repo=repo)


class Detector:
    """A single loaded detector. Build via :func:`synthscope.load`.

    The underlying model is constructed lazily on the first :meth:`predict` and then kept
    resident for the lifetime of this object.
    """

    def __init__(
        self,
        spec: ModelSpec,
        *,
        checkpoint: str | Path | None = None,
        device: str | None = None,
        auto_download: bool = True,
        repo: str | None = None,
    ):
        self._custom_ckpt = checkpoint is not None
        if checkpoint is not None:
            # An absolute checkpoint path overrides the file resolved under $SYNTHSCOPE_CKPT_DIR
            # (CHECKPOINTS_DIR / "<abs>" collapses to the absolute path in pathlib).
            ckpt = str(Path(checkpoint).expanduser().resolve())
            spec = dataclasses.replace(spec, checkpoint=ckpt)
        self.spec = spec
        self._auto_download = auto_download
        self._repo = repo
        # A custom device or checkpoint gets a dedicated engine so it never collides with the
        # shared LRU cache; the common path reuses the process-wide singleton.
        self._engine = Engine(device=device) if (device or checkpoint) else get_engine()

    # -- introspection ------------------------------------------------------
    @property
    def key(self) -> str:
        return self.spec.key

    @property
    def name(self) -> str:
        return self.spec.name

    @property
    def modality(self) -> str:
        return self.spec.modality

    @property
    def device(self) -> str:
        return self._engine.device

    @property
    def available(self) -> bool:
        """Whether this detector's weights are present (always True for training-free models)."""
        return self._engine.is_available(self.spec)

    def __repr__(self) -> str:
        return f"Detector(key={self.spec.key!r}, modality={self.spec.modality!r}, device={self.device!r})"

    # -- prediction ---------------------------------------------------------
    def predict(self, source: PathOrImage, *, num_frames: int | None = None, return_input_image: bool = False) -> dict:
        """Score ``source`` and return a result dict.

        ``source`` may be a file path, raw ``bytes``, or a PIL ``Image``. Result keys include
        ``fake_probability``, ``real_probability``, ``verdict`` (``"fake"``/``"real"``),
        ``confidence``, and ``elapsed_ms``. For an image detector run on a video, the result
        also carries ``per_frame`` probabilities (mean-aggregated); ``num_frames`` overrides
        how many frames are sampled. ``input_size``/``orig_size``/``input_note`` describe what the detector saw;
        pass ``return_input_image=True`` to also get that image as a PNG data URL (the web page shows it).
        """
        self._ensure_weights()
        kind, pil, path = self._classify(source)

        if kind == "image":
            if self.spec.modality == "video" and self.spec.transform is None:
                raise ValueError(
                    f"'{self.spec.key}' is a video-only detector and cannot score a still image."
                )
            result = self._engine.predict_image(self.spec, pil)
            if not return_input_image:
                result.pop("input_image", None)      # a large base64 string; only the web page needs it
            return result

        # kind == "video"
        if self.spec.modality == "video":
            return self._engine.predict_video_clip(self.spec, path)
        # An image detector on a video: sample frames and aggregate.
        n = num_frames or _config.VIDEO_IMAGE_FRAMES
        frames = _media.sample_video_pil(path, num_frames=n)
        return self._engine.predict_video_frames(self.spec, frames)

    def _ensure_weights(self) -> None:
        """Fetch weights from the model zoo on first use if they're not present locally."""
        if self.available or self._custom_ckpt or self.spec.checkpoint is None:
            return  # already present, user-managed, or training-free
        if not self._auto_download:
            raise FileNotFoundError(
                f"No local checkpoint for '{self.spec.key}'. Enable auto_download, place the "
                f"file under $SYNTHSCOPE_CKPT_DIR, or pass checkpoint=PATH."
            )
        from . import hub

        print(f"[synthscope] downloading '{self.spec.key}' weights from the model zoo ...")
        hub.download(self.spec.key, repo_id=self._repo)

    # -- helpers ------------------------------------------------------------
    def _classify(self, source: PathOrImage):
        """Resolve ``source`` to ``(kind, pil_image_or_None, path_or_None)``."""
        if isinstance(source, Image.Image):
            return "image", source.convert("RGB"), None
        if isinstance(source, (bytes, bytearray)):
            return "image", Image.open(io.BytesIO(bytes(source))).convert("RGB"), None
        path = Path(source)
        kind = _media.media_kind(path.name)
        if kind == "image":
            return "image", _media.decode_image(path.read_bytes()), None
        return "video", None, path