mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 21:40:47 +02:00
546 lines
20 KiB
Python
546 lines
20 KiB
Python
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
|
"""Metadata-free photo classifier: Model 1 then gated Model 2.
|
|
|
|
This is not provenance and is not a cleanup command. ``identify``,
|
|
``has_invisible_target``, and ``all`` must not import this module.
|
|
Call :func:`classify_pixels` explicitly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
CLASSIFY_EXTRA = "'remove-ai-watermarks[classify]'"
|
|
CLASSIFY_ONNX_EXTRA = "'remove-ai-watermarks[classify-onnx]'"
|
|
WEIGHTS_ENV = "RAIW_CLASSIFY_WEIGHTS"
|
|
WEIGHTS_REPO = "wiltodelta/raiw-photo-classify"
|
|
WEIGHTS_REVISION = "de668f0d8a53412923478cc21382a56890748310"
|
|
CLIP_FILE = "clip-l-ft.pt"
|
|
CLIP_ONNX_FILE = "clip-l-ft-vision-fp32.onnx"
|
|
PROBE_FILE = "probe-weights-clip-l-ft.npz"
|
|
DETECTOR_FILE = "detector.pt"
|
|
PROVIDER_FILE = "provider.pt"
|
|
OPERATING_POINT_FILE = "operating-point.json"
|
|
_COMMON_WEIGHT_FILES = (PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE)
|
|
_WEIGHT_FILES = (CLIP_FILE, *_COMMON_WEIGHT_FILES)
|
|
|
|
# 2026-09-02 receipt-document gate: linear head on the same CLIP-L-ft
|
|
# vector, trained on CORD-v2 train (800, CC BY 4.0) plus 200 synthetic
|
|
# capture-style receipts against 2,400 ai_train negatives. Threshold is the
|
|
# min of the 1st-percentile scores on CORD validation and a synthetic
|
|
# holdout; no field-receipt pixels were used for training or the threshold.
|
|
# The head versions with the model (Hub snapshot or RAIW_CLASSIFY_WEIGHTS
|
|
# directory); the package asset is the fallback for pre-gate freezes.
|
|
# 2026-09-07: the gate is MODEL-SIDE. The weights directory carries the
|
|
# artifact under the STABLE name ``receipt-gate.npz`` with the threshold
|
|
# inside; the dated name below stays readable as the legacy spelling, and
|
|
# the bundled package asset is the last-resort fallback. Head and
|
|
# threshold always come from ONE artifact, so a model-side gate update
|
|
# needs no lib release; ``RECEIPT_GATE_THRESHOLD`` is only the legacy
|
|
# pinned value kept for the package-asset fallback and its pinned tests.
|
|
RECEIPT_GATE_STABLE_FILE = "receipt-gate.npz"
|
|
RECEIPT_GATE_FILE = "receipt-gate-2026-09-02.npz"
|
|
RECEIPT_GATE_THRESHOLD = 2.0432573877459697
|
|
|
|
# The complete file set the runtime's snapshot_download may request. Deploy-time
|
|
# pre-caches in offline environments (HF_HUB_OFFLINE=1) must install exactly
|
|
# this list: a subset leaves the runtime resolution unsatisfiable and every
|
|
# classify call fails on weights that are four-fifths present. Exported so a
|
|
# pre-cache imports one name instead of re-listing the files and silently
|
|
# drifting the next time a release adds one.
|
|
WEIGHTS_ALLOW_PATTERNS: tuple[str, ...] = (
|
|
*_WEIGHT_FILES,
|
|
CLIP_ONNX_FILE,
|
|
OPERATING_POINT_FILE,
|
|
RECEIPT_GATE_STABLE_FILE,
|
|
RECEIPT_GATE_FILE,
|
|
)
|
|
|
|
# 2026-08-31 freeze operating point. Ridge threshold is also in the probe file;
|
|
# the runtime prefers the file when weights load.
|
|
MLP_THRESHOLD = 5.9586493237495395
|
|
RIDGE_THRESHOLD = 0.3056212276800537
|
|
PROVIDER_MARGIN = 0.30
|
|
CLIP_WIDTH = 768
|
|
# Checkpoint keys in provider.pt, the SUPERSET across snapshot generations.
|
|
# openai/google are provider classes; meta_muse_image is Muse Image;
|
|
# bytedance is the shared ByteDance generator lineage (Doubao and Jimeng
|
|
# render with the same model family -- measured 2026-09-07: separate heads
|
|
# cross-fire 32/22 ways even at 3.5x train mass, the union holds 83.9%
|
|
# on the frozen test cell). tc260 is NOT one class of anything: it covers
|
|
# the REST of China's generator ecosystem (Qwen, Yuanbao, Kling, ...,
|
|
# producers that are peers of openai/google/meta), whose heads do not
|
|
# exist yet at honest mass, so its argmax win abstains (provider=None).
|
|
# The loader tolerates older snapshots that predate bytedance: a head
|
|
# absent from provider.pt is skipped, and those rows fall to the veto.
|
|
# Measured on the shipped weights: deleting the veto from the argmax
|
|
# would falsely name 207/379 China test rows openai/google/muse-image
|
|
# (153 of them muse-image); the veto leaves every other cell
|
|
# byte-identical (openai 345/380, google 339/373, meta v3 177/198).
|
|
PROVIDER_LABELS = ("openai", "google", "bytedance", "tc260", "meta_muse_image", "no_ai")
|
|
|
|
DetectorLevel = Literal["definitely", "possibly", "likely_human"]
|
|
PixelLabel = Literal["ai", "human", "unknown"]
|
|
PixelDomain = Literal["photo"]
|
|
PixelProvider = Literal["openai", "google", "muse-image", "bytedance"]
|
|
ClassifierBackend = Literal["torch", "onnx"]
|
|
PUBLIC_PROVIDER: dict[str, PixelProvider | None] = {
|
|
"openai": "openai",
|
|
"google": "google",
|
|
"bytedance": "bytedance",
|
|
"tc260": None,
|
|
"meta_muse_image": "muse-image",
|
|
}
|
|
|
|
_runtime: _Runtime | None = None
|
|
_runtime_lock = threading.Lock()
|
|
_receipt_gate_cache: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
def _load_receipt_gate(folder: Path | None = None) -> dict[str, Any]:
|
|
"""Load the receipt-gate head, preferring the weights directory.
|
|
|
|
Model-side first: the stable ``receipt-gate.npz`` in the Hub snapshot
|
|
or ``RAIW_CLASSIFY_WEIGHTS`` directory wins, then the legacy dated
|
|
spelling, with the bundled package asset as the fallback for weights
|
|
directories frozen before the gate existed. The head is fitted on the
|
|
freeze CLIP-L-ft embedding space, which is why it versions with the
|
|
model, not with the code; the threshold travels inside the artifact.
|
|
"""
|
|
import numpy as np
|
|
|
|
key = str(folder) if folder is not None else "<package>"
|
|
if key in _receipt_gate_cache:
|
|
return _receipt_gate_cache[key]
|
|
source: Path | None = None
|
|
if folder is not None:
|
|
source = _find_weight(folder, RECEIPT_GATE_STABLE_FILE) or _find_weight(folder, RECEIPT_GATE_FILE)
|
|
if source is None:
|
|
source = Path(__file__).parent / "assets" / RECEIPT_GATE_FILE
|
|
payload = np.load(source)
|
|
gate = {
|
|
"w": np.asarray(payload["w"], dtype=np.float64),
|
|
"b": float(payload["b"]),
|
|
"mu": np.asarray(payload["mu"], dtype=np.float64),
|
|
"sd": np.asarray(payload["sd"], dtype=np.float64),
|
|
"threshold": float(payload["threshold"]),
|
|
}
|
|
_receipt_gate_cache[key] = gate
|
|
return gate
|
|
|
|
|
|
def receipt_gate_score(clip_vector: Any, gate: dict[str, Any] | None = None) -> float:
|
|
"""Receipt-document score of a CLIP-L-ft vector; higher is more receipt-like.
|
|
|
|
``gate`` must be the SAME artifact whose threshold decides the hit
|
|
(``classify_pixels`` passes the weights-directory gate, so head and
|
|
threshold can never come from different files); the default loads the
|
|
package-fallback artifact for standalone probing.
|
|
"""
|
|
import numpy as np
|
|
|
|
g = _load_receipt_gate() if gate is None else gate
|
|
v = np.asarray(clip_vector, dtype=np.float64).ravel()
|
|
v = v / (np.linalg.norm(v) + 1e-12)
|
|
return float((((v - g["mu"]) / g["sd"]) @ g["w"]) + g["b"])
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PixelClassification:
|
|
"""Combined Model 1 and Model 2 result for one image."""
|
|
|
|
label: PixelLabel
|
|
domain: PixelDomain
|
|
detector: DetectorLevel
|
|
provider: PixelProvider | None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"label": self.label,
|
|
"domain": self.domain,
|
|
"detector": self.detector,
|
|
"provider": self.provider,
|
|
}
|
|
|
|
|
|
def is_available() -> bool:
|
|
"""True when the ``classify`` extra can import torch, transformers, numpy, and cv2."""
|
|
from remove_ai_watermarks.optional_deps import module_available
|
|
|
|
return module_available("torch", "transformers", "numpy", "cv2")
|
|
|
|
|
|
def detector_level(
|
|
ridge_score: float,
|
|
mlp_score: float,
|
|
*,
|
|
ridge_threshold: float = RIDGE_THRESHOLD,
|
|
mlp_threshold: float = MLP_THRESHOLD,
|
|
) -> DetectorLevel:
|
|
"""Map the freeze AND/OR rule onto definitely / possibly / likely_human."""
|
|
ridge = ridge_score > ridge_threshold
|
|
mlp = mlp_score > mlp_threshold
|
|
if ridge and mlp:
|
|
return "definitely"
|
|
if ridge or mlp:
|
|
return "possibly"
|
|
return "likely_human"
|
|
|
|
|
|
def label_for(level: DetectorLevel) -> PixelLabel:
|
|
"""Public label: only DEFINITELY is ``ai``. POSSIBLY abstains."""
|
|
if level == "definitely":
|
|
return "ai"
|
|
if level == "likely_human":
|
|
return "human"
|
|
return "unknown"
|
|
|
|
|
|
def provider_from_scores(scores: dict[str, float], *, margin: float = PROVIDER_MARGIN) -> PixelProvider | None:
|
|
"""Argmax among the named classes that beat ``no_ai`` by ``margin``.
|
|
|
|
Candidates are the heads the LOADED snapshot actually carries, so
|
|
older provider.pt files without a generation's newest class keep
|
|
working (their rows fall to the tc260 veto and abstain). The tc260
|
|
head joins the argmax only as a group veto: it covers the rest of
|
|
China's generator ecosystem, producers that are peers of
|
|
openai/google/meta, so until per-producer classes exist its win
|
|
abstains and ``None`` is published instead of a false company claim.
|
|
"""
|
|
no_ai = scores["no_ai"]
|
|
ai_names = [name for name in scores if name != "no_ai"]
|
|
passed = [name for name in ai_names if scores[name] > no_ai + margin]
|
|
if not passed:
|
|
return None
|
|
best = max(passed, key=lambda name: scores[name])
|
|
return PUBLIC_PROVIDER[best]
|
|
|
|
|
|
def classify_from_scores(
|
|
ridge_score: float,
|
|
mlp_score: float,
|
|
provider_scores: dict[str, float] | None,
|
|
*,
|
|
ridge_threshold: float = RIDGE_THRESHOLD,
|
|
mlp_threshold: float = MLP_THRESHOLD,
|
|
provider_margin: float = PROVIDER_MARGIN,
|
|
receipt_score: float | None = None,
|
|
receipt_threshold: float | None = None,
|
|
) -> PixelClassification:
|
|
"""Pure gate: detector AND provider, no file I/O.
|
|
|
|
A DEFINITELY verdict whose ``receipt_score`` meets ``receipt_threshold``
|
|
downgrades the public label to ``unknown`` (document-domain abstain):
|
|
the raw detector level stays ``definitely`` and no provider is read.
|
|
``receipt_threshold=None`` (the default) resolves from the loaded gate
|
|
artifact, so the operating point versions with the model.
|
|
"""
|
|
if receipt_threshold is None:
|
|
receipt_threshold = float(_load_receipt_gate()["threshold"])
|
|
level = detector_level(
|
|
ridge_score,
|
|
mlp_score,
|
|
ridge_threshold=ridge_threshold,
|
|
mlp_threshold=mlp_threshold,
|
|
)
|
|
label = label_for(level)
|
|
if level == "definitely" and receipt_score is not None and receipt_score >= receipt_threshold:
|
|
return PixelClassification(label="unknown", domain="photo", detector=level, provider=None)
|
|
provider: PixelProvider | None = None
|
|
if label == "ai" and provider_scores is not None:
|
|
provider = provider_from_scores(provider_scores, margin=provider_margin)
|
|
return PixelClassification(label=label, domain="photo", detector=level, provider=provider)
|
|
|
|
|
|
def classify_pixels(
|
|
path: Path,
|
|
*,
|
|
device: str | None = None,
|
|
backend: ClassifierBackend = "torch",
|
|
) -> PixelClassification:
|
|
"""Run Model 1 then, on DEFINITELY, the receipt gate and Model 2.
|
|
|
|
Never called by ``identify``. A DEFINITELY verdict that the receipt gate
|
|
accepts is published as ``unknown`` before the 124-d provider pass.
|
|
"""
|
|
if not is_available():
|
|
raise RuntimeError(f"pixel classification requires the classify extra. Install: pip install {CLASSIFY_EXTRA}")
|
|
if backend == "onnx":
|
|
from remove_ai_watermarks.optional_deps import module_available
|
|
|
|
if not module_available("onnxruntime"):
|
|
raise RuntimeError(
|
|
f"the ONNX photo-classifier backend requires ONNX Runtime. Install: pip install {CLASSIFY_ONNX_EXTRA}"
|
|
)
|
|
runtime = _get_runtime(device, backend)
|
|
from PIL import Image
|
|
|
|
with Image.open(path) as image:
|
|
rgb = image.convert("RGB")
|
|
clip_vector = _embed(runtime, rgb)
|
|
ridge = float(((clip_vector - runtime.ridge_mean) / runtime.ridge_scale) @ runtime.ridge_weights)
|
|
mlp = _mlp_score(runtime, clip_vector)
|
|
level = detector_level(
|
|
ridge,
|
|
mlp,
|
|
ridge_threshold=runtime.ridge_threshold,
|
|
mlp_threshold=runtime.mlp_threshold,
|
|
)
|
|
gate = _load_receipt_gate(runtime.weights_dir)
|
|
r_score = receipt_gate_score(clip_vector, gate) if level == "definitely" else None
|
|
provider_scores = None
|
|
if r_score is not None and r_score < gate["threshold"]:
|
|
forensic = _forensic(rgb)
|
|
if forensic is not None:
|
|
provider_scores = _provider_scores(runtime, forensic)
|
|
else:
|
|
log.info("124-d features unavailable for %s, provider abstains", path)
|
|
result = classify_from_scores(
|
|
ridge,
|
|
mlp,
|
|
provider_scores,
|
|
ridge_threshold=runtime.ridge_threshold,
|
|
mlp_threshold=runtime.mlp_threshold,
|
|
provider_margin=runtime.provider_margin,
|
|
receipt_score=r_score,
|
|
receipt_threshold=gate["threshold"],
|
|
)
|
|
if result.label == "unknown" and r_score is not None:
|
|
log.info("receipt-document gate abstained on %s (score %.2f)", path, r_score)
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class _Runtime:
|
|
device: Any
|
|
backend: ClassifierBackend
|
|
clip_model: Any
|
|
processor: Any
|
|
detector: Any
|
|
provider_heads: dict[str, Any]
|
|
ridge_mean: Any
|
|
ridge_scale: Any
|
|
ridge_weights: Any
|
|
ridge_threshold: float
|
|
mlp_threshold: float
|
|
provider_margin: float
|
|
weights_dir: Path | None
|
|
|
|
|
|
def _resolve_device(device: str | None) -> Any:
|
|
import torch
|
|
|
|
if device in (None, "auto"):
|
|
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
if device == "cpu":
|
|
return torch.device("cpu")
|
|
if device == "cuda":
|
|
if not torch.cuda.is_available():
|
|
raise ValueError("cuda was requested but is not available")
|
|
return torch.device("cuda")
|
|
raise ValueError(f"unsupported classify device {device!r}")
|
|
|
|
|
|
def _resolve_backend(backend: ClassifierBackend, device: Any) -> ClassifierBackend:
|
|
if backend == "onnx" and device.type != "cpu":
|
|
raise ValueError("the ONNX photo-classifier backend supports CPU only")
|
|
if backend not in ("torch", "onnx"):
|
|
raise ValueError(f"unsupported classify backend {backend!r}")
|
|
return backend
|
|
|
|
|
|
def _backend_allow_patterns(backend: ClassifierBackend) -> tuple[str, ...]:
|
|
model_file = CLIP_ONNX_FILE if backend == "onnx" else CLIP_FILE
|
|
return (
|
|
model_file,
|
|
*_COMMON_WEIGHT_FILES,
|
|
OPERATING_POINT_FILE,
|
|
RECEIPT_GATE_STABLE_FILE,
|
|
RECEIPT_GATE_FILE,
|
|
)
|
|
|
|
|
|
def _weights_dir(backend: ClassifierBackend = "torch") -> Path:
|
|
override = os.environ.get(WEIGHTS_ENV)
|
|
if override:
|
|
path = Path(override)
|
|
if not path.is_dir():
|
|
raise RuntimeError(f"{WEIGHTS_ENV} is not a directory: {path}")
|
|
return path
|
|
from huggingface_hub import snapshot_download
|
|
|
|
try:
|
|
return Path(
|
|
snapshot_download(
|
|
repo_id=WEIGHTS_REPO,
|
|
revision=WEIGHTS_REVISION,
|
|
allow_patterns=list(_backend_allow_patterns(backend)),
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
raise RuntimeError(
|
|
"pixel classification weights are not installed. Set "
|
|
f"{WEIGHTS_ENV} to a directory with {', '.join(_WEIGHT_FILES)}, "
|
|
f"or install {CLASSIFY_EXTRA} and download {WEIGHTS_REPO}@{WEIGHTS_REVISION}."
|
|
) from exc
|
|
|
|
|
|
def _find_weight(folder: Path, name: str) -> Path | None:
|
|
direct = folder / name
|
|
nested = folder / "run1" / name
|
|
if direct.is_file():
|
|
return direct
|
|
if nested.is_file():
|
|
return nested
|
|
return None
|
|
|
|
|
|
def _weight_path(folder: Path, name: str) -> Path:
|
|
found = _find_weight(folder, name)
|
|
if found is None:
|
|
raise RuntimeError(f"pixel classification weights are missing ({name}) in {folder}")
|
|
return found
|
|
|
|
|
|
def _require_files(folder: Path, *, backend: ClassifierBackend = "torch") -> None:
|
|
model_file = CLIP_ONNX_FILE if backend == "onnx" else CLIP_FILE
|
|
required = (model_file, *_COMMON_WEIGHT_FILES)
|
|
missing = [name for name in required if _find_weight(folder, name) is None]
|
|
if missing:
|
|
raise RuntimeError(
|
|
"pixel classification weights are missing "
|
|
f"({', '.join(missing)}). Set {WEIGHTS_ENV} to a directory with the "
|
|
f"2026-08-31 freeze files, or install {CLASSIFY_EXTRA} and allow the "
|
|
f"Hugging Face download from {WEIGHTS_REPO}."
|
|
)
|
|
|
|
|
|
def _operating_point(folder: Path) -> tuple[float, float]:
|
|
path = folder / OPERATING_POINT_FILE
|
|
if not path.is_file():
|
|
return MLP_THRESHOLD, PROVIDER_MARGIN
|
|
payload = json.loads(path.read_text())
|
|
return float(payload["model1"]["mlp_threshold"]), float(payload["model2"]["margin"])
|
|
|
|
|
|
def _build_detector() -> Any:
|
|
from torch import nn
|
|
|
|
return nn.Sequential(
|
|
nn.Linear(CLIP_WIDTH, 512),
|
|
nn.ReLU(),
|
|
nn.Dropout(0.3),
|
|
nn.Linear(512, 128),
|
|
nn.ReLU(),
|
|
nn.Dropout(0.1),
|
|
nn.Linear(128, 1),
|
|
)
|
|
|
|
|
|
def _build_provider_head() -> Any:
|
|
from torch import nn
|
|
|
|
return nn.Sequential(
|
|
nn.Linear(124, 64),
|
|
nn.ReLU(),
|
|
nn.Dropout(0.2),
|
|
nn.Linear(64, 1),
|
|
)
|
|
|
|
|
|
def _load_runtime(device: Any, backend: ClassifierBackend = "torch") -> _Runtime:
|
|
import numpy as np
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal.clip_l_ft import load_headed_clip, load_onnx_vision, load_processor
|
|
|
|
folder = _weights_dir(backend)
|
|
_require_files(folder, backend=backend)
|
|
probe = np.load(_weight_path(folder, PROBE_FILE))
|
|
detector = _build_detector()
|
|
detector.load_state_dict(torch.load(_weight_path(folder, DETECTOR_FILE), map_location="cpu", weights_only=True))
|
|
detector.to(device).eval()
|
|
packed = torch.load(_weight_path(folder, PROVIDER_FILE), map_location="cpu", weights_only=True)
|
|
heads = {}
|
|
for name in PROVIDER_LABELS:
|
|
if name not in packed:
|
|
log.info("provider head %s absent from this snapshot; rows it would name abstain", name)
|
|
continue
|
|
head = _build_provider_head()
|
|
head.load_state_dict(packed[name])
|
|
heads[name] = head.to(device).eval()
|
|
ridge_threshold = float(probe["thr_oi_1pct"])
|
|
mlp_threshold, provider_margin = _operating_point(folder)
|
|
log.info("loaded photo-classify freeze from %s", folder)
|
|
clip_model = (
|
|
load_onnx_vision(_weight_path(folder, CLIP_ONNX_FILE))
|
|
if backend == "onnx"
|
|
else load_headed_clip(_weight_path(folder, CLIP_FILE), device)
|
|
)
|
|
return _Runtime(
|
|
device=device,
|
|
backend=backend,
|
|
clip_model=clip_model,
|
|
processor=load_processor(),
|
|
detector=detector,
|
|
provider_heads=heads,
|
|
ridge_mean=np.asarray(probe["mean"], dtype=np.float64),
|
|
ridge_scale=np.asarray(probe["scale"], dtype=np.float64),
|
|
ridge_weights=np.asarray(probe["weights"], dtype=np.float64),
|
|
ridge_threshold=ridge_threshold,
|
|
mlp_threshold=mlp_threshold,
|
|
provider_margin=provider_margin,
|
|
weights_dir=folder,
|
|
)
|
|
|
|
|
|
def _get_runtime(device: str | None, backend: ClassifierBackend = "torch") -> _Runtime:
|
|
global _runtime
|
|
resolved = _resolve_device(device)
|
|
resolved_backend = _resolve_backend(backend, resolved)
|
|
with _runtime_lock:
|
|
if _runtime is None or _runtime.device != resolved or _runtime.backend != resolved_backend:
|
|
_runtime = _load_runtime(resolved, resolved_backend)
|
|
return _runtime
|
|
|
|
|
|
def _embed(runtime: _Runtime, image: Any) -> Any:
|
|
from remove_ai_watermarks._internal.clip_l_ft import embed_image, embed_image_onnx
|
|
|
|
if runtime.backend == "onnx":
|
|
return embed_image_onnx(runtime.clip_model, runtime.processor, image)
|
|
return embed_image(runtime.clip_model, runtime.processor, image, runtime.device)
|
|
|
|
|
|
def _forensic(image: Any) -> Any:
|
|
import numpy as np
|
|
|
|
from remove_ai_watermarks._internal.forensic_124d import image_features
|
|
|
|
pixels = np.asarray(image, dtype=np.uint8)
|
|
return image_features(pixels)
|
|
|
|
|
|
def _mlp_score(runtime: _Runtime, clip_vector: Any) -> float:
|
|
import torch
|
|
|
|
tensor = torch.from_numpy(clip_vector.astype("float32")).unsqueeze(0).to(runtime.device)
|
|
with torch.inference_mode():
|
|
return float(runtime.detector(tensor).squeeze().cpu())
|
|
|
|
|
|
def _provider_scores(runtime: _Runtime, forensic: Any) -> dict[str, float]:
|
|
import torch
|
|
|
|
tensor = torch.from_numpy(forensic.astype("float32")).unsqueeze(0).to(runtime.device)
|
|
with torch.inference_mode():
|
|
return {name: float(head(tensor).squeeze().cpu()) for name, head in runtime.provider_heads.items()}
|