| from __future__ import annotations |
|
|
| import gc |
| import logging |
| import os |
| import threading |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import pandas as pd |
| from PIL import Image |
| from huggingface_hub import hf_hub_download |
|
|
| logger = logging.getLogger(__name__) |
|
|
| TAGGER_MODEL_ML = "ml_danbooru" |
| TAGGER_MODEL_WD_SWINV2 = "wd_swinv2_v3" |
| TAGGER_MODEL_WD_EVA02 = "wd_eva02_large" |
|
|
| WD_MODEL_NAMES = { |
| TAGGER_MODEL_WD_SWINV2: "SwinV2_v3", |
| TAGGER_MODEL_WD_EVA02: "EVA02_Large", |
| } |
|
|
| WD_HF_REPOS = { |
| "SwinV2_v3": "SmilingWolf/wd-swinv2-tagger-v3", |
| "EVA02_Large": "SmilingWolf/wd-eva02-large-tagger-v3", |
| } |
|
|
| |
| WD_DEEPGHS_PREFIX = { |
| "SwinV2_v3": "SmilingWolf/wd-swinv2-tagger-v3", |
| "EVA02_Large": "SmilingWolf/wd-eva02-large-tagger-v3", |
| } |
|
|
| |
| |
| WD_REALISM_ALWAYS_TAGS = frozenset({"realistic", "photorealistic"}) |
| |
| WD_REALISM_FLOOR = 0.18 |
|
|
|
|
| def _force_cpu() -> bool: |
| return os.getenv("FORCE_CPU_INFERENCE", "").strip().lower() in {"1", "true", "yes", "on"} |
|
|
|
|
| def ort_providers() -> list[str]: |
| from .providers import ensure_nvidia_dll_search_path, preload_onnx_runtime_dlls |
|
|
| ensure_nvidia_dll_search_path() |
| preload_onnx_runtime_dlls() |
| import onnxruntime as ort |
|
|
| if _force_cpu(): |
| return ["CPUExecutionProvider"] |
| available = set(ort.get_available_providers()) |
| providers: list[str] = [] |
| if "CUDAExecutionProvider" in available: |
| providers.append("CUDAExecutionProvider") |
| providers.append("CPUExecutionProvider") |
| return providers |
|
|
|
|
| def _load_rgb(image: Path | Image.Image | str) -> Image.Image: |
| if isinstance(image, Image.Image): |
| return image.convert("RGB") |
| from imgutils.data import load_image |
|
|
| return load_image(str(image), mode="RGB") |
|
|
|
|
| def preprocess_wd14(image: Path | Image.Image | str, target_size: int) -> np.ndarray: |
| """White-pad square, bicubic resize, BGR float32 NHWC — matches imgutils WD14.""" |
| if isinstance(image, Image.Image): |
| pil = image.convert("RGBA") if image.mode == "RGBA" else image.convert("RGB") |
| |
| from imgutils.data import load_image |
|
|
| pil = load_image(pil, force_background=None, mode=None) |
| else: |
| from imgutils.data import load_image |
|
|
| pil = load_image(str(image), force_background=None, mode=None) |
|
|
| image_shape = pil.size |
| max_dim = max(image_shape) |
| pad_left = (max_dim - image_shape[0]) // 2 |
| pad_top = (max_dim - image_shape[1]) // 2 |
|
|
| padded = Image.new("RGB", (max_dim, max_dim), (255, 255, 255)) |
| try: |
| padded.paste(pil, (pad_left, pad_top), mask=pil) |
| except ValueError: |
| padded.paste(pil, (pad_left, pad_top)) |
|
|
| if max_dim != target_size: |
| padded = padded.resize((target_size, target_size), Image.BICUBIC) |
|
|
| array = np.asarray(padded, dtype=np.float32) |
| array = array[:, :, ::-1] |
| return np.expand_dims(array, axis=0) |
|
|
|
|
| def _resize_align_ml(image: Image.Image, size: int, keep_ratio: bool = True, align: int = 4) -> Image.Image: |
| if not keep_ratio: |
| target_size = (size, size) |
| else: |
| min_edge = min(image.size) |
| target_size = ( |
| int(image.size[0] / min_edge * size), |
| int(image.size[1] / min_edge * size), |
| ) |
| target_size = ( |
| (target_size[0] // align) * align, |
| (target_size[1] // align) * align, |
| ) |
| return image.resize(target_size, resample=Image.BILINEAR) |
|
|
|
|
| def _to_tensor_ml(image: Image.Image) -> np.ndarray: |
| img = np.array(image, dtype=np.uint8, copy=True) |
| img = img.reshape((image.size[1], image.size[0], len(image.getbands()))) |
| img = img.transpose((2, 0, 1)) |
| return img.astype(np.float32) / 255.0 |
|
|
|
|
| def preprocess_mldanbooru( |
| image: Path | Image.Image | str, |
| *, |
| size: int = 448, |
| keep_ratio: bool = True, |
| ) -> np.ndarray: |
| pil = _load_rgb(image) |
| tensor = _to_tensor_ml(_resize_align_ml(pil, size, keep_ratio)) |
| return tensor.reshape(1, *tensor.shape) |
|
|
|
|
| def _normalize_tag(tag: str) -> str: |
| text = tag.strip().lower() |
| parts: list[str] = [] |
| for ch in text: |
| if ch.isalnum(): |
| parts.append(ch) |
| elif ch in {" ", "-", ".", "/", "_"}: |
| parts.append("_") |
| return "".join(parts).strip("_") |
|
|
|
|
| def _normalize_scores(scores: dict[str, float]) -> dict[str, float]: |
| normalized: dict[str, float] = {} |
| for tag, score in scores.items(): |
| key = _normalize_tag(str(tag)) |
| if not key: |
| continue |
| current = normalized.get(key) |
| if current is None or float(score) > current: |
| normalized[key] = float(score) |
| return normalized |
|
|
|
|
| def _sigmoid(x: np.ndarray) -> np.ndarray: |
| return 1.0 / (1.0 + np.exp(-x)) |
|
|
|
|
| class InferenceEngine: |
| """Owned ORT sessions with preprocess outside the run lock.""" |
|
|
| def __init__(self) -> None: |
| self._run_lock = threading.Lock() |
| self._meta_lock = threading.Lock() |
| self._sessions: dict[str, Any] = {} |
| self._wd_labels: dict[str, tuple[list[str], list[int], list[int], list[int]]] = {} |
| self._ml_labels: list[str] | None = None |
| self._wd_target_size: dict[str, int] = {} |
|
|
| def warm(self, tagger_model: str) -> None: |
| from .providers import ensure_nvidia_dll_search_path, preload_onnx_runtime_dlls |
|
|
| ensure_nvidia_dll_search_path() |
| preload_onnx_runtime_dlls() |
| session = self._get_session(tagger_model) |
| if tagger_model == TAGGER_MODEL_ML: |
| feed = np.zeros((1, 3, 448, 448), dtype=np.float32) |
| with self._run_lock: |
| session.run(["output"], {"input": feed}) |
| self._get_ml_labels() |
| logger.info("inference_engine_warmed model=%s", tagger_model) |
| return |
|
|
| wd_name = WD_MODEL_NAMES[tagger_model] |
| target = self._wd_target_size[wd_name] |
| feed = np.zeros((1, target, target, 3), dtype=np.float32) |
| outputs = session.get_outputs() |
| input_name = session.get_inputs()[0].name |
| with self._run_lock: |
| session.run([outputs[0].name, outputs[1].name], {input_name: feed}) |
| self._get_wd_labels(wd_name) |
| logger.info("inference_engine_warmed model=%s", tagger_model) |
|
|
| def score_one( |
| self, |
| image: Path | Image.Image | str, |
| *, |
| tagger_model: str, |
| wd_general_threshold: float = 0.35, |
| raw_general: bool = False, |
| ) -> dict[str, float]: |
| return self.score_many( |
| [image], |
| tagger_model=tagger_model, |
| wd_general_threshold=wd_general_threshold, |
| batch_size=1, |
| raw_general=raw_general, |
| )[0] |
|
|
| def score_many( |
| self, |
| images: list[Path | Image.Image | str], |
| *, |
| tagger_model: str, |
| wd_general_threshold: float = 0.35, |
| batch_size: int = 1, |
| raw_general: bool = False, |
| ) -> list[dict[str, float]]: |
| if not images: |
| return [] |
| if tagger_model == TAGGER_MODEL_ML: |
| return [ |
| self._score_ml_one(image) |
| for image in images |
| ] |
| return self._score_wd_many( |
| images, |
| tagger_model=tagger_model, |
| general_threshold=wd_general_threshold, |
| batch_size=max(1, int(batch_size)), |
| raw_general=raw_general, |
| ) |
|
|
| def _score_ml_one(self, image: Path | Image.Image | str) -> dict[str, float]: |
| tensor = preprocess_mldanbooru(image, size=448, keep_ratio=True) |
| session = self._get_session(TAGGER_MODEL_ML) |
| with self._run_lock: |
| native_output, = session.run(["output"], {"input": tensor}) |
| probs = _sigmoid(native_output).reshape(-1) |
| labels = self._get_ml_labels() |
| scores = { |
| labels[i]: float(probs[i]) |
| for i in range(min(len(labels), len(probs))) |
| } |
| return _normalize_scores(scores) |
|
|
| def _score_wd_many( |
| self, |
| images: list[Path | Image.Image | str], |
| *, |
| tagger_model: str, |
| general_threshold: float, |
| batch_size: int, |
| raw_general: bool = False, |
| ) -> list[dict[str, float]]: |
| wd_name = WD_MODEL_NAMES.get(tagger_model) |
| if wd_name is None: |
| raise ValueError(f"Unsupported tagger_model: {tagger_model}") |
|
|
| session = self._get_session(tagger_model) |
| target = self._wd_target_size[wd_name] |
| |
| tensors = [preprocess_wd14(image, target) for image in images] |
|
|
| preds_list: list[np.ndarray] = [] |
| input_name = session.get_inputs()[0].name |
| out0 = session.get_outputs()[0].name |
| out1 = session.get_outputs()[1].name |
|
|
| for start in range(0, len(tensors), batch_size): |
| chunk = tensors[start : start + batch_size] |
| if len(chunk) == 1: |
| feed = chunk[0] |
| else: |
| feed = np.concatenate(chunk, axis=0) |
| try: |
| with self._run_lock: |
| preds, _embeddings = session.run([out0, out1], {input_name: feed}) |
| except Exception: |
| if len(chunk) == 1: |
| raise |
| logger.warning( |
| "wd_batch_run_failed size=%d; falling back to serial", |
| len(chunk), |
| ) |
| preds_rows = [] |
| for single in chunk: |
| with self._run_lock: |
| pred, _emb = session.run([out0, out1], {input_name: single}) |
| preds_rows.append(pred[0]) |
| preds = np.stack(preds_rows, axis=0) |
| for row in preds: |
| preds_list.append(row) |
|
|
| tag_names, _rating_idx, general_idx, _char_idx = self._get_wd_labels(wd_name) |
| results: list[dict[str, float]] = [] |
| for pred in preds_list: |
| labels = list(zip(tag_names, pred.astype(float))) |
| general: dict[str, float] = {} |
| for i in general_idx: |
| name, score = labels[i] |
| value = float(score) |
| if raw_general: |
| |
| general[name] = value |
| elif value > general_threshold: |
| general[name] = value |
| elif name in WD_REALISM_ALWAYS_TAGS and value >= WD_REALISM_FLOOR: |
| |
| |
| general[name] = value |
| results.append(_normalize_scores(general)) |
| return results |
|
|
| def _get_session(self, tagger_model: str) -> Any: |
| with self._meta_lock: |
| cached = self._sessions.get(tagger_model) |
| if cached is not None: |
| return cached |
|
|
| import onnxruntime as ort |
| from onnxruntime import GraphOptimizationLevel, SessionOptions |
|
|
| from .providers import ensure_nvidia_dll_search_path, preload_onnx_runtime_dlls |
|
|
| ensure_nvidia_dll_search_path() |
| preload_onnx_runtime_dlls() |
|
|
| options = SessionOptions() |
| options.graph_optimization_level = GraphOptimizationLevel.ORT_ENABLE_ALL |
| providers = ort_providers() |
| if providers == ["CPUExecutionProvider"]: |
| options.intra_op_num_threads = os.cpu_count() or 4 |
|
|
| if tagger_model == TAGGER_MODEL_ML: |
| model_path = hf_hub_download( |
| "deepghs/ml-danbooru-onnx", |
| "ml_caformer_m36_dec-5-97527.onnx", |
| ) |
| else: |
| wd_name = WD_MODEL_NAMES[tagger_model] |
| prefix = WD_DEEPGHS_PREFIX[wd_name] |
| model_path = hf_hub_download( |
| "deepghs/wd14_tagger_with_embeddings", |
| f"{prefix}/model.onnx", |
| ) |
|
|
| session = ort.InferenceSession(model_path, options, providers=providers) |
| logger.info( |
| "inference_session_created model=%s providers=%s active=%s", |
| tagger_model, |
| providers, |
| session.get_providers(), |
| ) |
| if tagger_model != TAGGER_MODEL_ML: |
| wd_name = WD_MODEL_NAMES[tagger_model] |
| _, target_size, _, _ = session.get_inputs()[0].shape |
| self._wd_target_size[wd_name] = int(target_size) |
|
|
| self._sessions[tagger_model] = session |
| return session |
|
|
| def _get_wd_labels( |
| self, wd_name: str |
| ) -> tuple[list[str], list[int], list[int], list[int]]: |
| with self._meta_lock: |
| cached = self._wd_labels.get(wd_name) |
| if cached is not None: |
| return cached |
| repo = WD_HF_REPOS[wd_name] |
| path = hf_hub_download(repo, "selected_tags.csv") |
| df = pd.read_csv(path) |
| tag_names = df["name"].tolist() |
| rating_indexes = list(np.where(df["category"] == 9)[0]) |
| general_indexes = list(np.where(df["category"] == 0)[0]) |
| character_indexes = list(np.where(df["category"] == 4)[0]) |
| packed = (tag_names, rating_indexes, general_indexes, character_indexes) |
| self._wd_labels[wd_name] = packed |
| return packed |
|
|
| def _get_ml_labels(self) -> list[str]: |
| with self._meta_lock: |
| if self._ml_labels is not None: |
| return self._ml_labels |
| path = hf_hub_download( |
| "deepghs/imgutils-models", |
| "mldanbooru/mldanbooru_tags.csv", |
| ) |
| df = pd.read_csv(path) |
| self._ml_labels = df["name"].tolist() |
| return self._ml_labels |
|
|
| def loaded_models(self) -> list[str]: |
| with self._meta_lock: |
| return sorted(self._sessions.keys()) |
|
|
| def clear(self) -> list[str]: |
| """Drop ORT sessions so CUDA can reclaim VRAM. Returns unloaded model ids.""" |
| with self._run_lock: |
| with self._meta_lock: |
| names = sorted(self._sessions.keys()) |
| sessions = list(self._sessions.values()) |
| self._sessions.clear() |
| self._wd_labels.clear() |
| self._ml_labels = None |
| self._wd_target_size.clear() |
| for session in sessions: |
| closer = getattr(session, "close", None) |
| if callable(closer): |
| try: |
| closer() |
| except Exception: |
| logger.exception("inference_session_close_failed") |
| del session |
| if names: |
| logger.info("inference_sessions_unloaded models=%s", names) |
| return names |
|
|
|
|
| def get_engine() -> InferenceEngine: |
| global _ENGINE |
| with _ENGINE_LOCK: |
| if _ENGINE is None: |
| _ENGINE = InferenceEngine() |
| return _ENGINE |
|
|
|
|
| def peek_loaded_models() -> list[str]: |
| with _ENGINE_LOCK: |
| if _ENGINE is None: |
| return [] |
| return _ENGINE.loaded_models() |
|
|
|
|
| def reset_engine() -> list[str]: |
| global _ENGINE |
| with _ENGINE_LOCK: |
| names: list[str] = [] |
| if _ENGINE is not None: |
| names = _ENGINE.clear() |
| _ENGINE = None |
| return names |
|
|
|
|
| def release_cuda_caches() -> None: |
| """Best-effort VRAM trim after sessions are dropped (ORT has no official flush).""" |
| gc.collect() |
| try: |
| import torch |
|
|
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
| torch.cuda.ipc_collect() |
| except Exception: |
| pass |
| gc.collect() |
|
|
|
|
| _ENGINE: InferenceEngine | None = None |
| _ENGINE_LOCK = threading.Lock() |
|
|