Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
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
|