truscan / models /aidetector /model.py
Manar55's picture
Deploy a5180b1
82d7501 verified
Raw History Blame Contribute Delete
29.8 kB
from __future__ import annotations
import os
from pathlib import Path
from typing import Iterable, Protocol, Sequence
from PIL import Image, ImageOps
from .config import (
DEFAULT_BACKEND,
DEFAULT_HF_MODEL_ID,
DEFAULT_HYBRID_UNIVFD_WEIGHT,
DEFAULT_HYBRID_PLUS_PRIMARY_WEIGHT,
DEFAULT_MODEL_NAME,
DEFAULT_ULTRA_PRIMARY_WEIGHT,
DEFAULT_PRETRAINED,
FC_WEIGHT_PATH_IN_REPO,
IMAGE_EXTENSIONS,
MODEL_REPO_ID,
)
from .nonescape_adapter import NonescapeFullDetector, NonescapeMiniDetector
from .sentry_adapter import SentryConvNeXtDetector
from .types import DetectionResult
class Detector(Protocol):
threshold: float
def predict_image(self, image: Image.Image, path: Path | None = None) -> DetectionResult:
...
def predict_images(
self,
images: Sequence[Image.Image],
paths: Sequence[Path | None] | None = None,
) -> list[DetectionResult]:
...
def predict_path(self, path: str | Path) -> DetectionResult:
...
def model_info(self) -> dict:
...
def _select_device(torch_module, requested: str | None) -> str:
if requested and requested != "auto":
return requested
if torch_module.cuda.is_available():
return "cuda"
mps = getattr(torch_module.backends, "mps", None)
if mps is not None and mps.is_available():
return "mps"
return "cpu"
def _normalise_image(image: Image.Image) -> Image.Image:
return ImageOps.exif_transpose(image).convert("RGB")
def _load_torch_checkpoint(torch_module, weight_path: str | Path):
try:
return torch_module.load(str(weight_path), map_location="cpu", weights_only=True)
except TypeError:
return torch_module.load(str(weight_path), map_location="cpu")
def _build_detection_result(
*,
path: Path | None,
probability_ai: float,
raw_score: float,
threshold: float,
backend: str,
) -> DetectionResult:
probability_real = 1.0 - probability_ai
label = "ai" if probability_ai >= threshold else "real"
confidence = probability_ai if label == "ai" else probability_real
return DetectionResult(
path=path,
label=label,
probability_ai=probability_ai,
probability_real=probability_real,
confidence=confidence,
raw_score=raw_score,
backend=backend,
)
class AIImageDetector:
"""UnivFD-style detector: CLIP image encoder plus a linear fake/real head."""
backend_name = "univfd"
def __init__(
self,
device: str | None = None,
threshold: float = 0.5,
model_name: str = DEFAULT_MODEL_NAME,
pretrained: str = DEFAULT_PRETRAINED,
weight_path: str | Path | None = None,
repo_id: str = MODEL_REPO_ID,
) -> None:
try:
import torch
except ImportError as exc:
raise RuntimeError("Missing dependency: install torch to use the UnivFD backend.") from exc
try:
import open_clip
except ImportError as exc:
raise RuntimeError("Missing dependency: install open_clip_torch to use the UnivFD backend.") from exc
self._torch = torch
self.device = torch.device(_select_device(torch, device))
self.threshold = threshold
self.model_name = model_name
self.pretrained = pretrained
self.repo_id = repo_id
self.clip_model, _, self.preprocess = open_clip.create_model_and_transforms(
model_name,
pretrained=pretrained,
device=self.device,
)
self.clip_model.eval()
output_dim = getattr(getattr(self.clip_model, "visual", None), "output_dim", 768)
self.head = torch.nn.Linear(int(output_dim), 1).to(self.device)
self._load_head(weight_path)
self.head.eval()
def _load_head(self, weight_path: str | Path | None) -> None:
if weight_path is None:
try:
from huggingface_hub import hf_hub_download
except ImportError as exc:
raise RuntimeError(
"Missing dependency: install huggingface_hub or pass --weight-path."
) from exc
weight_path = hf_hub_download(
repo_id=self.repo_id,
filename=FC_WEIGHT_PATH_IN_REPO,
)
state = _load_torch_checkpoint(self._torch, weight_path)
if isinstance(state, dict) and "state_dict" in state:
state = state["state_dict"]
if isinstance(state, dict):
cleaned = {}
for key, value in state.items():
key = key.replace("module.", "")
key = key.replace("model.", "")
key = key.replace("fc.", "")
key = key.replace("head.", "")
if key in {"weight", "bias"}:
cleaned[key] = value
if cleaned:
self.head.load_state_dict(cleaned, strict=True)
return
try:
self.head.load_state_dict(state, strict=False)
return
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"Could not load UnivFD head weights from {weight_path}: {exc}") from exc
raise RuntimeError(f"Unsupported checkpoint format: {type(state)!r}")
def model_info(self) -> dict:
return {
"backend": self.backend_name,
"model_name": self.model_name,
"pretrained": self.pretrained,
"head_repo": self.repo_id,
"head_file": FC_WEIGHT_PATH_IN_REPO,
"device": str(self.device),
"threshold": self.threshold,
}
@property
def _torch_inference_mode(self):
return self._torch.inference_mode
def predict_image(self, image: Image.Image, path: Path | None = None) -> DetectionResult:
return self.predict_images([image], paths=[path])[0]
def predict_images(
self,
images: Sequence[Image.Image],
paths: Sequence[Path | None] | None = None,
) -> list[DetectionResult]:
if not images:
return []
if paths is None:
paths = [None] * len(images)
if len(paths) != len(images):
raise ValueError("paths must have the same length as images")
torch = self._torch
tensors = [self.preprocess(_normalise_image(image)) for image in images]
batch = torch.stack(tensors, dim=0).to(self.device)
with torch.inference_mode():
features = self.clip_model.encode_image(batch)
features = features / features.norm(dim=-1, keepdim=True)
logits = self.head(features).flatten().float().detach().cpu()
probabilities = torch.sigmoid(logits)
results: list[DetectionResult] = []
for probability_ai_tensor, logit_tensor, path in zip(probabilities, logits, paths, strict=True):
results.append(
_build_detection_result(
path=path,
probability_ai=float(probability_ai_tensor.item()),
raw_score=float(logit_tensor.item()),
threshold=self.threshold,
backend=self.backend_name,
)
)
return results
def predict_path(self, path: str | Path) -> DetectionResult:
path = Path(path)
with Image.open(path) as image:
return self.predict_image(image, path=path)
def predict_many(
self,
paths: Iterable[str | Path],
batch_size: int = 16,
) -> list[DetectionResult]:
results: list[DetectionResult] = []
batch_images: list[Image.Image] = []
batch_paths: list[Path] = []
for path_like in paths:
path = Path(path_like)
with Image.open(path) as image:
batch_images.append(_normalise_image(image))
batch_paths.append(path)
if len(batch_images) >= batch_size:
results.extend(self.predict_images(batch_images, paths=batch_paths))
batch_images = []
batch_paths = []
if batch_images:
results.extend(self.predict_images(batch_images, paths=batch_paths))
return results
class HuggingFaceImageDetector:
"""Generic Hugging Face image-classification backend."""
backend_name = "hf"
def __init__(
self,
model_id: str = DEFAULT_HF_MODEL_ID,
device: str | None = None,
threshold: float = 0.5,
) -> None:
try:
import torch
except ImportError as exc:
raise RuntimeError("Missing dependency: install torch to use the Hugging Face backend.") from exc
try:
from transformers import AutoImageProcessor, AutoModelForImageClassification
except ImportError as exc:
raise RuntimeError("Missing dependency: install transformers to use the HF backend.") from exc
self._torch = torch
self.device = torch.device(_select_device(torch, device))
self.threshold = threshold
self.model_id = model_id
model_source = _resolve_local_hf_snapshot(model_id) or model_id
local_only = model_source != model_id
try:
self.model = AutoModelForImageClassification.from_pretrained(
model_source,
local_files_only=local_only,
).to(self.device)
except Exception:
try:
self.model = AutoModelForImageClassification.from_pretrained(model_id).to(self.device)
except Exception as second_exc:
raise RuntimeError(
"Could not load the requested Hugging Face model as a standard "
"Transformers image-classification checkpoint. Some open-source "
"detectors, such as custom Sentry or Nonescape releases, need a "
"dedicated adapter instead of the generic --backend hf path. "
f"Model: {model_id}. Last error: {second_exc}"
) from second_exc
self.model.eval()
self.id2label = self.model.config.id2label
try:
self.processor = AutoImageProcessor.from_pretrained(
model_source,
local_files_only=local_only,
)
except Exception: # noqa: BLE001
self.processor = _build_fallback_image_processor(self.model.config)
def model_info(self) -> dict:
return {
"backend": self.backend_name,
"model_id": self.model_id,
"device": str(self.device),
"threshold": self.threshold,
"id2label": {str(k): v for k, v in self.id2label.items()},
}
def predict_image(self, image: Image.Image, path: Path | None = None) -> DetectionResult:
return self.predict_images([image], paths=[path])[0]
def predict_images(
self,
images: Sequence[Image.Image],
paths: Sequence[Path | None] | None = None,
) -> list[DetectionResult]:
if not images:
return []
if paths is None:
paths = [None] * len(images)
if len(paths) != len(images):
raise ValueError("paths must have the same length as images")
torch = self._torch
normalised = [_normalise_image(image) for image in images]
inputs = self.processor(images=normalised, return_tensors="pt")
inputs = {key: value.to(self.device) for key, value in inputs.items()}
with torch.inference_mode():
logits = self.model(**inputs).logits.detach().cpu()
probabilities = torch.softmax(logits, dim=-1)
fake_indices = self._fake_label_indices()
if not fake_indices:
raise RuntimeError(f"Could not infer fake/AI label from model labels: {self.id2label}")
results: list[DetectionResult] = []
for row, logit_row, path in zip(probabilities, logits, paths, strict=True):
results.append(
_build_detection_result(
path=path,
probability_ai=float(row[fake_indices].sum().item()),
raw_score=float(logit_row[fake_indices].mean().item()),
threshold=self.threshold,
backend=self.backend_name,
)
)
return results
def predict_path(self, path: str | Path) -> DetectionResult:
path = Path(path)
with Image.open(path) as image:
return self.predict_image(image, path=path)
def predict_many(
self,
paths: Iterable[str | Path],
batch_size: int = 16,
) -> list[DetectionResult]:
results: list[DetectionResult] = []
batch_images: list[Image.Image] = []
batch_paths: list[Path] = []
for path_like in paths:
path = Path(path_like)
with Image.open(path) as image:
batch_images.append(_normalise_image(image))
batch_paths.append(path)
if len(batch_images) >= batch_size:
results.extend(self.predict_images(batch_images, paths=batch_paths))
batch_images = []
batch_paths = []
if batch_images:
results.extend(self.predict_images(batch_images, paths=batch_paths))
return results
def _fake_label_indices(self) -> list[int]:
fake_tokens = ("fake", "ai", "aigc", "synthetic", "generated")
real_tokens = ("real", "human", "authentic", "natural")
fake_indices: list[int] = []
for index, label in self.id2label.items():
label_l = str(label).lower()
is_fake = any(token in label_l for token in fake_tokens)
is_real = any(token in label_l for token in real_tokens)
if is_fake and not is_real:
fake_indices.append(int(index))
return fake_indices
class HybridImageDetector:
"""Blend UnivFD and HF image classifier scores for a stronger practical baseline."""
backend_name = "hybrid"
def __init__(
self,
*,
device: str | None = None,
threshold: float = 0.5,
univfd_weight: float = DEFAULT_HYBRID_UNIVFD_WEIGHT,
weight_path: str | Path | None = None,
model_name: str = DEFAULT_MODEL_NAME,
pretrained: str = DEFAULT_PRETRAINED,
hf_model: str = DEFAULT_HF_MODEL_ID,
) -> None:
if not 0.0 <= univfd_weight <= 1.0:
raise ValueError("univfd_weight must be between 0 and 1")
self.threshold = threshold
self.univfd_weight = univfd_weight
self.univfd = AIImageDetector(
device=device,
threshold=threshold,
model_name=model_name,
pretrained=pretrained,
weight_path=weight_path,
)
self.hf = HuggingFaceImageDetector(
model_id=hf_model,
device=device,
threshold=threshold,
)
def model_info(self) -> dict:
return {
"backend": self.backend_name,
"threshold": self.threshold,
"univfd_weight": self.univfd_weight,
"hf_weight": round(1.0 - self.univfd_weight, 6),
"components": {
"univfd": self.univfd.model_info(),
"hf": self.hf.model_info(),
},
}
def predict_image(self, image: Image.Image, path: Path | None = None) -> DetectionResult:
return self.predict_images([image], paths=[path])[0]
def predict_images(
self,
images: Sequence[Image.Image],
paths: Sequence[Path | None] | None = None,
) -> list[DetectionResult]:
if not images:
return []
if paths is None:
paths = [None] * len(images)
if len(paths) != len(images):
raise ValueError("paths must have the same length as images")
univfd_results = self.univfd.predict_images(images, paths=paths)
hf_results = self.hf.predict_images(images, paths=paths)
results: list[DetectionResult] = []
for univfd_result, hf_result, path in zip(univfd_results, hf_results, paths, strict=True):
probability_ai = (
self.univfd_weight * univfd_result.probability_ai
+ (1.0 - self.univfd_weight) * hf_result.probability_ai
)
raw_score = (
self.univfd_weight * univfd_result.raw_score
+ (1.0 - self.univfd_weight) * hf_result.raw_score
)
results.append(
_build_detection_result(
path=path,
probability_ai=probability_ai,
raw_score=raw_score,
threshold=self.threshold,
backend=self.backend_name,
)
)
return results
class HybridPlusDetector:
"""Blend the existing hybrid detector with Nonescape Mini for a stronger practical ensemble."""
backend_name = "hybrid-plus"
def __init__(
self,
*,
device: str | None = None,
threshold: float = 0.5,
primary_weight: float = DEFAULT_HYBRID_PLUS_PRIMARY_WEIGHT,
weight_path: str | Path | None = None,
model_name: str = DEFAULT_MODEL_NAME,
pretrained: str = DEFAULT_PRETRAINED,
hf_model: str = DEFAULT_HF_MODEL_ID,
hybrid_univfd_weight: float = DEFAULT_HYBRID_UNIVFD_WEIGHT,
) -> None:
if not 0.0 <= primary_weight <= 1.0:
raise ValueError("primary_weight must be between 0 and 1")
self.threshold = threshold
self.primary_weight = primary_weight
self.primary = HybridImageDetector(
device=device,
threshold=threshold,
univfd_weight=hybrid_univfd_weight,
weight_path=weight_path,
model_name=model_name,
pretrained=pretrained,
hf_model=hf_model,
)
self.secondary = NonescapeMiniDetector(
threshold=threshold,
device=device,
)
def model_info(self) -> dict:
return {
"backend": self.backend_name,
"threshold": self.threshold,
"primary_weight": self.primary_weight,
"secondary_weight": round(1.0 - self.primary_weight, 6),
"components": {
"primary": self.primary.model_info(),
"secondary": self.secondary.model_info(),
},
}
def predict_image(self, image: Image.Image, path: Path | None = None) -> DetectionResult:
return self.predict_images([image], paths=[path])[0]
def predict_images(
self,
images: Sequence[Image.Image],
paths: Sequence[Path | None] | None = None,
) -> list[DetectionResult]:
if not images:
return []
if paths is None:
paths = [None] * len(images)
if len(paths) != len(images):
raise ValueError("paths must have the same length as images")
primary_results = self.primary.predict_images(images, paths=paths)
secondary_results = self.secondary.predict_images(images, paths=paths)
results: list[DetectionResult] = []
for primary_result, secondary_result, path in zip(primary_results, secondary_results, paths, strict=True):
probability_ai = (
self.primary_weight * primary_result.probability_ai
+ (1.0 - self.primary_weight) * secondary_result.probability_ai
)
raw_score = (
self.primary_weight * primary_result.raw_score
+ (1.0 - self.primary_weight) * secondary_result.raw_score
)
results.append(
_build_detection_result(
path=path,
probability_ai=probability_ai,
raw_score=raw_score,
threshold=self.threshold,
backend=self.backend_name,
)
)
return results
def predict_path(self, path: str | Path) -> DetectionResult:
path = Path(path)
with Image.open(path) as image:
return self.predict_image(image, path=path)
class UltraDetector:
"""Blend HybridPlus and Sentry ConvNeXt into the strongest current practical ensemble."""
backend_name = "ultra"
def __init__(
self,
*,
device: str | None = None,
threshold: float = 0.5,
primary_weight: float = DEFAULT_ULTRA_PRIMARY_WEIGHT,
weight_path: str | Path | None = None,
model_name: str = DEFAULT_MODEL_NAME,
pretrained: str = DEFAULT_PRETRAINED,
hf_model: str = DEFAULT_HF_MODEL_ID,
hybrid_univfd_weight: float = DEFAULT_HYBRID_UNIVFD_WEIGHT,
hybrid_plus_primary_weight: float = DEFAULT_HYBRID_PLUS_PRIMARY_WEIGHT,
) -> None:
if not 0.0 <= primary_weight <= 1.0:
raise ValueError("primary_weight must be between 0 and 1")
self.threshold = threshold
self.primary_weight = primary_weight
self.primary = HybridPlusDetector(
device=device,
threshold=threshold,
primary_weight=hybrid_plus_primary_weight,
weight_path=weight_path,
model_name=model_name,
pretrained=pretrained,
hf_model=hf_model,
hybrid_univfd_weight=hybrid_univfd_weight,
)
self.secondary = SentryConvNeXtDetector(
threshold=threshold,
device=device,
)
def model_info(self) -> dict:
return {
"backend": self.backend_name,
"threshold": self.threshold,
"primary_weight": self.primary_weight,
"secondary_weight": round(1.0 - self.primary_weight, 6),
"components": {
"primary": self.primary.model_info(),
"secondary": self.secondary.model_info(),
},
}
def predict_image(self, image: Image.Image, path: Path | None = None) -> DetectionResult:
return self.predict_images([image], paths=[path])[0]
def predict_images(
self,
images: Sequence[Image.Image],
paths: Sequence[Path | None] | None = None,
) -> list[DetectionResult]:
if not images:
return []
if paths is None:
paths = [None] * len(images)
if len(paths) != len(images):
raise ValueError("paths must have the same length as images")
primary_results = self.primary.predict_images(images, paths=paths)
secondary_results = self.secondary.predict_images(images, paths=paths)
results: list[DetectionResult] = []
for primary_result, secondary_result, path in zip(primary_results, secondary_results, paths, strict=True):
probability_ai = (
self.primary_weight * primary_result.probability_ai
+ (1.0 - self.primary_weight) * secondary_result.probability_ai
)
raw_score = (
self.primary_weight * primary_result.raw_score
+ (1.0 - self.primary_weight) * secondary_result.raw_score
)
results.append(
_build_detection_result(
path=path,
probability_ai=probability_ai,
raw_score=raw_score,
threshold=self.threshold,
backend=self.backend_name,
)
)
return results
def predict_path(self, path: str | Path) -> DetectionResult:
path = Path(path)
with Image.open(path) as image:
return self.predict_image(image, path=path)
def predict_many(
self,
paths: Iterable[str | Path],
batch_size: int = 16,
) -> list[DetectionResult]:
results: list[DetectionResult] = []
batch_images: list[Image.Image] = []
batch_paths: list[Path] = []
for path_like in paths:
path = Path(path_like)
with Image.open(path) as image:
batch_images.append(_normalise_image(image))
batch_paths.append(path)
if len(batch_images) >= batch_size:
results.extend(self.predict_images(batch_images, paths=batch_paths))
batch_images = []
batch_paths = []
if batch_images:
results.extend(self.predict_images(batch_images, paths=batch_paths))
return results
def create_detector(
backend: str = DEFAULT_BACKEND,
*,
device: str | None = None,
threshold: float = 0.5,
weight_path: str | Path | None = None,
model_name: str = DEFAULT_MODEL_NAME,
pretrained: str = DEFAULT_PRETRAINED,
hf_model: str = DEFAULT_HF_MODEL_ID,
hybrid_univfd_weight: float = DEFAULT_HYBRID_UNIVFD_WEIGHT,
hybrid_plus_primary_weight: float = DEFAULT_HYBRID_PLUS_PRIMARY_WEIGHT,
ultra_primary_weight: float = DEFAULT_ULTRA_PRIMARY_WEIGHT,
) -> Detector:
backend = backend.lower()
if backend == "univfd":
return AIImageDetector(
device=device,
threshold=threshold,
model_name=model_name,
pretrained=pretrained,
weight_path=weight_path,
)
if backend in {"hf", "huggingface"}:
return HuggingFaceImageDetector(model_id=hf_model, device=device, threshold=threshold)
if backend in {"nonescape", "nonescape-full"}:
return NonescapeFullDetector(
threshold=threshold,
device=device,
weight_path=weight_path,
)
if backend in {"nonescape-mini", "nonescape_mini"}:
return NonescapeMiniDetector(
threshold=threshold,
device=device,
weight_path=weight_path,
)
if backend in {"sentry", "sentry-convnext", "sentry-convnext-small"}:
return SentryConvNeXtDetector(
threshold=threshold,
device=device,
weight_path=weight_path,
)
if backend in {"hybrid", "ensemble"}:
return HybridImageDetector(
device=device,
threshold=threshold,
univfd_weight=hybrid_univfd_weight,
weight_path=weight_path,
model_name=model_name,
pretrained=pretrained,
hf_model=hf_model,
)
if backend in {"hybrid-plus", "hybrid_plus", "stacked"}:
return HybridPlusDetector(
device=device,
threshold=threshold,
primary_weight=hybrid_plus_primary_weight,
weight_path=weight_path,
model_name=model_name,
pretrained=pretrained,
hf_model=hf_model,
hybrid_univfd_weight=hybrid_univfd_weight,
)
if backend in {"ultra", "hybrid-ultra", "sentry-plus"}:
return UltraDetector(
device=device,
threshold=threshold,
primary_weight=ultra_primary_weight,
weight_path=weight_path,
model_name=model_name,
pretrained=pretrained,
hf_model=hf_model,
hybrid_univfd_weight=hybrid_univfd_weight,
hybrid_plus_primary_weight=hybrid_plus_primary_weight,
)
raise ValueError(
"Unsupported backend: "
f"{backend!r}. Choose 'univfd', 'hf', 'nonescape-mini', 'nonescape-full', 'sentry-convnext-small', 'hybrid', 'hybrid-plus', or 'ultra'."
)
def iter_images(path: str | Path, recursive: bool = True) -> list[Path]:
root = Path(path)
if root.is_file():
return [root] if root.suffix.lower() in IMAGE_EXTENSIONS else []
if not root.exists():
return []
globber = root.rglob if recursive else root.glob
return sorted(p for p in globber("*") if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS)
def _build_fallback_image_processor(config):
try:
from transformers import ViTImageProcessor
except ImportError as exc:
raise RuntimeError("Missing dependency: install transformers to use the HF backend.") from exc
image_size = getattr(config, "image_size", 224)
if isinstance(image_size, int):
size = {"height": image_size, "width": image_size}
else:
size = image_size
image_mean = getattr(config, "image_mean", [0.5, 0.5, 0.5])
image_std = getattr(config, "image_std", [0.5, 0.5, 0.5])
return ViTImageProcessor(
do_resize=True,
size=size,
resample=3,
do_rescale=True,
rescale_factor=1 / 255,
do_normalize=True,
image_mean=image_mean,
image_std=image_std,
)
def _resolve_local_hf_snapshot(model_id: str) -> str | None:
candidates = []
env_hf_home = os.environ.get("HF_HOME")
if env_hf_home:
candidates.append(Path(env_hf_home))
candidates.append(Path.home() / ".cache" / "huggingface")
for hf_home in candidates:
hub_dir = hf_home / "hub" / f"models--{model_id.replace('/', '--')}"
if not hub_dir.exists():
continue
ref_path = hub_dir / "refs" / "main"
if ref_path.exists():
revision = ref_path.read_text(encoding="utf-8").strip()
snapshot_dir = hub_dir / "snapshots" / revision
if snapshot_dir.exists():
return str(snapshot_dir)
snapshots_dir = hub_dir / "snapshots"
if snapshots_dir.exists():
for snapshot_dir in sorted(snapshots_dir.iterdir()):
if snapshot_dir.is_dir():
return str(snapshot_dir)
return None