Whyx-PROmpTea / src /ensemble_tagger.py
ArtShumov's picture
feat(tagger): swap Qwen-VL for Moondream2 NL captioner
17a1e39
Raw
History Blame
14.6 kB
"""Ensemble image tagger: combines multiple WD14-family taggers (ViT / SwinV2 /
EVA02) and optionally DeepDanbooru for architecture-diverse voting.
Strategy: each model emits per-tag probabilities; the ensemble merges them with
per-model weights (WD14s share fallback "0.4 / 0.6" default; DeepDanbooru, if
present, overrides to "0.25 / 0.75" to lower its heavier corpus bias)."""
from __future__ import annotations
import hashlib
import json
import os
from typing import Optional
import numpy as np
from PIL import Image, ImageOps
from src.image_tagger import (
ImageTagger, _TAGGER_DEPS_OK, _ensure_rgb, _pad_square,
_tags_to_caption, _to_pil_image,
)
from src.pose_tagger import get_pose_tagger
# ---------------------------------------------------------------------------
# Helpers shared with app.py (regex-based parsing of results dicts)
# ---------------------------------------------------------------------------
_IMG_TAGS_CACHE: dict[str, dict] = {}
def _normalize_tag(tag: str) -> str:
r"""WD14-style tags escape parens like \(symbol\); model output plain ones."""
return tag.replace("\\(", "(").replace("\\)", ")").strip()
def _esc_for_output(tag: str) -> str:
return tag.replace("(", "\\(").replace(")", "\\)")
# Order matters: the first available model becomes the primary and supplies
# the rating distribution. The rest contribute general/character votes.
try:
import onnxruntime as ort
_ORT_AVAILABLE = True
except Exception:
ort = None
_ORT_AVAILABLE = False
# ---------------------------------------------------------------------------
# Optional DeepDanbooru ONNX runner
# ---------------------------------------------------------------------------
_DD_REPO = "KichangKim/DeepDanbooru"
_DD_ONNX_FILES = ["deepdanbooru.onnx", "model-resnet_custom_v3.onnx"]
class _DeepDanbooruONNX:
"""Minimal DeepDanbooru v3 runner via ONNXRuntime, if the ONNX weights are
present in the HF cache. Tag set is loaded from DeepDanbooru's tags.txt.
DeepDanbooru has a ~9170-tag vocabulary that differs from WD14's 13k;
ratings are NOT predicted by this model, so ensemble falls back to the
WD14 rating distribution.
"""
def __init__(self, hf_repo: str | None = None, onnx_filename: str = "deepdanbooru.onnx"):
self._session: Optional["ort.InferenceSession"] = None
self._tags: list[str] = []
self._loaded = False
self._repo = hf_repo or _DD_REPO
self._onnx_name = onnx_filename
def ensure_loaded(self) -> bool:
if self._loaded:
return True
if not _ORT_AVAILABLE:
return False
try:
model_path = self._resolve_weight_path()
if not model_path:
return False
providers = ["CPUExecutionProvider"]
if "CUDAExecutionProvider" in ort.get_available_providers():
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
self._session = ort.InferenceSession(model_path, providers=providers)
self._tags = self._load_tags()
self._loaded = bool(self._tags)
except Exception:
return False
return self._loaded
def _resolve_weight_path(self) -> str | None:
from huggingface_hub import hf_hub_download
try:
return hf_hub_download(repo_id=self._repo, filename=self._onnx_name)
except Exception:
return None
def _load_tags(self) -> list[str]:
from huggingface_hub import hf_hub_download
try:
p = hf_hub_download(repo_id=self._repo, filename="tags.txt")
with open(p, encoding="utf-8") as fh:
return [line.strip() for line in fh if line.strip()]
except Exception:
return []
def predict(self, pil_img: Image.Image) -> dict[str, float]:
if not self.ensure_loaded():
return {}
img = pil_img.convert("RGB").resize((512, 512), Image.LANCZOS)
arr = np.asarray(img, dtype=np.float32) / 255.0
arr = np.transpose(arr, (2, 0, 1))[None, ...] # NCHW
input_name = self._session.get_inputs()[0].name
out = self._session.run(None, {input_name: arr})[0].reshape(-1)
# DeepDanbooru outputs logits — apply sigmoid for 0..1 comparability.
probs = 1.0 / (1.0 + np.exp(-out))
idx = np.argsort(-probs)[: len(self._tags)]
tags = {}
for i in idx:
name = self._tags[i]
score = float(probs[i])
if score < 0.30:
break
tags[name] = score
return tags
# ---------------------------------------------------------------------------
# Ensemble coordinator
# ---------------------------------------------------------------------------
_ENSEMBLE_DEFAULT_MODELS = (
("SmilingWolf/wd-eva02-large-tagger-v3", 0.50, "eva02"),
("SmilingWolf/wd-swinv2-tagger-v3", 0.30, "swinv2"),
("SmilingWolf/wd-vit-tagger-v3", 0.20, "vit"),
)
class EnsembleTagger:
"""Aggregates multiple WD14-family taggers and (optionally) DeepDanbooru."""
def __init__(self):
self._wd_taggers: list[tuple[ImageTagger, float, str]] = []
for repo, weight, name in _ENSEMBLE_DEFAULT_MODELS:
self._wd_taggers.append((ImageTagger(repo_id=repo), weight, name))
self._dd = _DeepDanbooruONNX()
self._cache: dict[str, dict] = {}
def _wd_by_arch(self, arch: str) -> ImageTagger | None:
for tagger, _, name in self._wd_taggers:
if name == arch:
return tagger
return None
def _image_key(self, pil_img: Image.Image) -> str:
buf = pil_img.tobytes()[: 8192] + str(pil_img.size).encode()
return hashlib.sha256(buf).hexdigest()[:16]
# ---- single-model dispatch ------------------------------------------------
def _run_wd(self, tagger: ImageTagger, pil_img: Image.Image,
gen_threshold: float, char_threshold: float) -> dict:
return tagger.tag_image(
np.asarray(pil_img),
gen_threshold=gen_threshold,
char_threshold=char_threshold,
)
# ---- ensemble -------------------------------------------------------------
def tag_image(
self,
image,
gen_threshold: float = 0.35,
char_threshold: float = 0.75,
mode: str = "ensemble", # "ensemble" | "wd:eva02" | "wd:swinv2" | "wd:vit" | "deepdanbooru" | "qwen"
skip_pose: bool = False,
skip_qwen: bool = True,
) -> dict:
"""Run the ensemble tagger on one image.
`skip_pose=True` suppresses the YOLO pose pass (faster on restricted VMs).
`skip_qwen=True` suppresses the Qwen-VL captioner (safer for RAM usage
on 16 GB VMs — the VL model alone can take ~8-9 GB in fp32).
"""
if not _TAGGER_DEPS_OK and not _ORT_AVAILABLE:
raise RuntimeError("Tagger dependencies are not available.")
try:
pil_img = _to_pil_image(image)
pil_img = ImageOps.exif_transpose(pil_img)
pil_img = _pad_square(_ensure_rgb(pil_img))
except Exception as exc:
raise ValueError(f"Invalid image input for tagging: {exc}") from exc
# Cache lookups are per-mode so changing the model doesn't reuse stale results.
key = f"{mode}:{skip_pose}:{skip_qwen}:{self._image_key(pil_img)}"
cached = self._cache.get(key)
if cached is not None:
return cached
# Pose estimation — optional (suppressed by default so it doesn't blow up
# RAM-constrained spaces; still safe because _keypoints_to_pose_tags returns
# [] when no model is loaded).
pose_info = {"pose_tags": [], "people_count": 0}
if not skip_pose:
try:
pose_info = get_pose_tagger().estimate(pil_img)
except Exception:
pose_info = {"pose_tags": [], "people_count": 0}
# DeepDanbooru-only path: no rating distribution, but pose tags are kept.
if mode == "deepdanbooru":
dd = self._dd.predict(pil_img)
gen = {k: round(v, 4) for k, v in dd.items() if v >= gen_threshold}
caption = ", ".join(gen.keys())
result = {
"caption": _esc_for_output(caption),
"taglist": _esc_for_output(caption.replace("_", " ")),
"ratings": {},
"characters": {},
"general": gen,
"pose_tags": pose_info.get("pose_tags", []),
"people_count": pose_info.get("people_count", 0),
"ensemble_votes": {"deepdanbooru": 1},
}
self._cache[key] = result
return result
# Single backbone by name — keeps pose metadata alive
if mode.startswith("wd:"):
arch = mode.split(":", 1)[1]
tagger = self._wd_by_arch(arch)
result = self._run_wd(tagger, pil_img, gen_threshold, char_threshold)
result["pose_tags"] = pose_info.get("pose_tags", [])
result["people_count"] = pose_info.get("people_count", 0)
return result
# Qwen-VL-only mode: skip WD14 inference, return NL caption + pose data
if mode == "qwen":
from src.qwen_vl_tagger import get_qwen_tagger
try:
caption = get_qwen_tagger().describe(pil_img)
except Exception:
caption = ""
if not caption:
return {
"caption": "", "taglist": "",
"ratings": {}, "characters": {}, "general": {},
"pose_tags": pose_info.get("pose_tags", []),
"people_count": pose_info.get("people_count", 0),
"nl_caption": "",
"ensemble_votes": {"qwen": 0},
}
return {
"caption": _esc_for_output(caption),
"taglist": _esc_for_output(caption.replace(".", ", ")).lower(),
"ratings": {}, "characters": {}, "general": {},
"pose_tags": pose_info.get("pose_tags", []),
"people_count": pose_info.get("people_count", 0),
"nl_caption": caption,
"ensemble_votes": {"qwen": 1},
}
# Primary WD14 (EVA02) first so its ratings survive.
primary = self._run_wd(self._wd_taggers[0][0], pil_img, gen_threshold, char_threshold)
if mode != "ensemble":
result = primary
else:
result = self._merge_wd_results(primary, [
self._run_wd(tagger, pil_img, gen_threshold, char_threshold)
for tagger, _, _ in self._wd_taggers[1:]
])
result["ensemble_votes"] = {name: 1 for _, _, name in self._wd_taggers}
# Merge pose metadata (pose_tags are already validated lists).
result["pose_tags"] = pose_info.get("pose_tags", [])
result["people_count"] = pose_info.get("people_count", 0)
# Moondream2 natural-language caption (replaces Qwen-VL for HF Spaces).
# Runs alongside the tagger — result always carries an `nl_caption` key
# (empty string when Moondream is disabled or unavailable).
result["nl_caption"] = ""
try:
from src.moondream_tagger import get_moondream_tagger
nl_txt = get_moondream_tagger().caption(pil_img, length="short")
result["nl_caption"] = nl_txt or ""
except Exception: # pragma: no cover
result["nl_caption"] = ""
# DeepDanbooru boosts general tags in ensemble. Only runs when the optional
# weights happen to be co-located with the app; the app keeps working when
# they are absent.
# Ensemble: DeepDanbooru optional secondary vote on top of merged WD14s.
if mode == "ensemble" and self._dd.ensure_loaded():
dd = self._dd.predict(pil_img)
if dd:
gen = result.get("general", {})
for tag, score in dd.items():
if score < gen_threshold:
continue
if tag in gen:
gen[tag] = round(max(gen[tag], score), 4)
else:
gen[tag] = round(score * 0.35, 4)
result["general"] = dict(
sorted(gen.items(), key=lambda kv: -kv[1])
)
result["caption"] = _esc_for_output(", ".join(result["general"].keys()))
result["taglist"] = _esc_for_output(", ".join(result["general"].keys()).replace("_", " "))
result["ensemble_votes"]["deepdanbooru"] = 1
self._cache[key] = result
return result
def _merge_wd_results(self, primary: dict, others: list[dict]) -> dict:
merged = {
"caption": primary["caption"],
"taglist": primary["taglist"],
"ratings": dict(primary.get("ratings", {})),
"characters": {},
"general": {},
}
gen_acc: dict[str, float] = {}
gen_cnt: dict[str, int] = {}
char_acc: dict[str, float] = {}
char_cnt: dict[str, int] = {}
def _accumulate(acc, cnt, tags, w):
for k, v in tags.items():
acc[k] = acc.get(k, 0.0) + v * w
cnt[k] = cnt.get(k, 0) + 1
for res, (_, w, _) in zip([primary, *others], self._wd_taggers):
_accumulate(gen_acc, gen_cnt, res.get("general", {}), w)
_accumulate(char_acc, char_cnt, res.get("characters", {}), w)
merged["general"] = dict(
sorted(
((k, round(v / max(gen_cnt[k], 1), 4)) for k, v in gen_acc.items()),
key=lambda kv: -kv[1],
)
)
merged["characters"] = dict(
sorted(
((k, round(v / max(char_cnt[k], 1), 4)) for k, v in char_acc.items()),
key=lambda kv: -kv[1],
)
)
merged["caption"] = _esc_for_output(", ".join(merged["general"].keys()))
merged["taglist"] = _esc_for_output(
", ".join(merged["general"].keys()).replace("_", " ")
)
return merged
_ensemble_instance: EnsembleTagger | None = None
def get_ensemble_tagger() -> EnsembleTagger:
global _ensemble_instance
if _ensemble_instance is None:
_ensemble_instance = EnsembleTagger()
return _ensemble_instance