Spaces:
Running
Running
feat(caption): Qwen2.5-VL-3B captioner option (Anima-style NL captions); per-call kind dispatch in Captioner facade, kind in tagger cache key, transformers>=4.53
Browse files- app.py +13 -8
- requirements.txt +1 -1
- src/ensemble_tagger.py +8 -6
- src/i18n.py +4 -2
- src/vlm_caption.py +106 -12
- tests/test_captioner.py +79 -0
app.py
CHANGED
|
@@ -507,7 +507,7 @@ def _build_tagger_html(result: dict, lc: str) -> str:
|
|
| 507 |
return "\n".join(lines)
|
| 508 |
|
| 509 |
|
| 510 |
-
def on_tag_image(image, gen_threshold, char_threshold, fmt, lang, progress=gr.Progress(), skip_pose=False, with_caption=False):
|
| 511 |
lc = "ru" if lang == "RU" else "en"
|
| 512 |
# fmt carries "prompt|<mode>" from the tagger-mode dropdown: "prompt|wd:eva02", "prompt|ensemble".
|
| 513 |
if "|" in fmt:
|
|
@@ -542,6 +542,7 @@ def on_tag_image(image, gen_threshold, char_threshold, fmt, lang, progress=gr.Pr
|
|
| 542 |
result = get_ensemble_tagger().tag_image(
|
| 543 |
image, gen_threshold, char_threshold,
|
| 544 |
mode=mode, skip_pose=skip_pose, with_caption=with_caption,
|
|
|
|
| 545 |
)
|
| 546 |
progress(0.9, desc=t("tagger_results", lc))
|
| 547 |
if not result["general"] and not result["characters"] and not result.get("nl_caption"):
|
|
@@ -1643,6 +1644,7 @@ with gr.Blocks(**_blocks_kw) as demo:
|
|
| 1643 |
(t("tagger_mode_eva_large", "en"), "wd:eva02"),
|
| 1644 |
(t("tagger_mode_dd", "en"), "deepdanbooru"),
|
| 1645 |
(t("tagger_mode_florence", "en"), "nl"),
|
|
|
|
| 1646 |
],
|
| 1647 |
value="ensemble",
|
| 1648 |
label="",
|
|
@@ -1866,9 +1868,12 @@ with gr.Blocks(**_blocks_kw) as demo:
|
|
| 1866 |
effective_fmt = f"{fmt}|{mode_key}" if mode_key != "ensemble" else fmt
|
| 1867 |
skip_pose = not bool(pose_toggle)
|
| 1868 |
# The caption checkbox is the single source of truth for the NL
|
| 1869 |
-
# caption; the "nl" radio
|
|
|
|
|
|
|
| 1870 |
with_caption = bool(caption_toggle)
|
| 1871 |
-
|
|
|
|
| 1872 |
|
| 1873 |
tagger_outputs = [
|
| 1874 |
tagger_output, tagger_apply_btn, tagger_chips, tagger_chars,
|
|
@@ -1892,15 +1897,15 @@ with gr.Blocks(**_blocks_kw) as demo:
|
|
| 1892 |
|
| 1893 |
# Auto re-run when the model or the pose toggle changes (never for the
|
| 1894 |
# caption checkbox — captioning is opt-in and slow). No-op without an
|
| 1895 |
-
# image. Selecting the "nl" radio item checks the caption box and
|
| 1896 |
-
# the caption in the same event, so the checkbox stays the visible
|
| 1897 |
-
# of truth. The component outputs are deliberately NOT read back as
|
| 1898 |
# inputs here: in Gradio 6, CheckboxGroup.preprocess validates the value
|
| 1899 |
# against the current choices and raises on stale selections, which
|
| 1900 |
# crashed re-runs ("Value: ... is not in the list of choices").
|
| 1901 |
def on_tag_rerun(image, gen_threshold, char_threshold, fmt, mode, pose_toggle, caption_toggle, lang, progress=gr.Progress()):
|
| 1902 |
-
with_caption = bool(caption_toggle) or mode
|
| 1903 |
-
caption_update = gr.update(value=True) if mode
|
| 1904 |
if image is None:
|
| 1905 |
return (gr.skip(),) * 8 + (caption_update,)
|
| 1906 |
return tuple(on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt, mode, pose_toggle, with_caption, lang, progress=progress)) + (caption_update,)
|
|
|
|
| 507 |
return "\n".join(lines)
|
| 508 |
|
| 509 |
|
| 510 |
+
def on_tag_image(image, gen_threshold, char_threshold, fmt, lang, progress=gr.Progress(), skip_pose=False, with_caption=False, caption_kind="florence"):
|
| 511 |
lc = "ru" if lang == "RU" else "en"
|
| 512 |
# fmt carries "prompt|<mode>" from the tagger-mode dropdown: "prompt|wd:eva02", "prompt|ensemble".
|
| 513 |
if "|" in fmt:
|
|
|
|
| 542 |
result = get_ensemble_tagger().tag_image(
|
| 543 |
image, gen_threshold, char_threshold,
|
| 544 |
mode=mode, skip_pose=skip_pose, with_caption=with_caption,
|
| 545 |
+
caption_kind=caption_kind,
|
| 546 |
)
|
| 547 |
progress(0.9, desc=t("tagger_results", lc))
|
| 548 |
if not result["general"] and not result["characters"] and not result.get("nl_caption"):
|
|
|
|
| 1644 |
(t("tagger_mode_eva_large", "en"), "wd:eva02"),
|
| 1645 |
(t("tagger_mode_dd", "en"), "deepdanbooru"),
|
| 1646 |
(t("tagger_mode_florence", "en"), "nl"),
|
| 1647 |
+
(t("tagger_mode_qwen", "en"), "qwen"),
|
| 1648 |
],
|
| 1649 |
value="ensemble",
|
| 1650 |
label="",
|
|
|
|
| 1868 |
effective_fmt = f"{fmt}|{mode_key}" if mode_key != "ensemble" else fmt
|
| 1869 |
skip_pose = not bool(pose_toggle)
|
| 1870 |
# The caption checkbox is the single source of truth for the NL
|
| 1871 |
+
# caption; the "nl"/"qwen" radio items only check the box (see
|
| 1872 |
+
# on_tag_rerun). "qwen" selects the Qwen2.5-VL-3B captioner
|
| 1873 |
+
# (Anima-style descriptions), everything else uses Florence-2-base.
|
| 1874 |
with_caption = bool(caption_toggle)
|
| 1875 |
+
caption_kind = "qwen" if mode == "qwen" else "florence"
|
| 1876 |
+
return on_tag_image(image, gen_threshold, char_threshold, effective_fmt, lang, progress=progress, skip_pose=skip_pose, with_caption=with_caption, caption_kind=caption_kind)
|
| 1877 |
|
| 1878 |
tagger_outputs = [
|
| 1879 |
tagger_output, tagger_apply_btn, tagger_chips, tagger_chars,
|
|
|
|
| 1897 |
|
| 1898 |
# Auto re-run when the model or the pose toggle changes (never for the
|
| 1899 |
# caption checkbox — captioning is opt-in and slow). No-op without an
|
| 1900 |
+
# image. Selecting the "nl"/"qwen" radio item checks the caption box and
|
| 1901 |
+
# runs the caption in the same event, so the checkbox stays the visible
|
| 1902 |
+
# source of truth. The component outputs are deliberately NOT read back as
|
| 1903 |
# inputs here: in Gradio 6, CheckboxGroup.preprocess validates the value
|
| 1904 |
# against the current choices and raises on stale selections, which
|
| 1905 |
# crashed re-runs ("Value: ... is not in the list of choices").
|
| 1906 |
def on_tag_rerun(image, gen_threshold, char_threshold, fmt, mode, pose_toggle, caption_toggle, lang, progress=gr.Progress()):
|
| 1907 |
+
with_caption = bool(caption_toggle) or mode in ("nl", "qwen")
|
| 1908 |
+
caption_update = gr.update(value=True) if mode in ("nl", "qwen") else gr.skip()
|
| 1909 |
if image is None:
|
| 1910 |
return (gr.skip(),) * 8 + (caption_update,)
|
| 1911 |
return tuple(on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt, mode, pose_toggle, with_caption, lang, progress=progress)) + (caption_update,)
|
requirements.txt
CHANGED
|
@@ -6,7 +6,7 @@ numpy>=1.26.4
|
|
| 6 |
torch>=2.3,<3
|
| 7 |
torchvision>=0.18,<1
|
| 8 |
timm>=1.0.12,<2
|
| 9 |
-
transformers>=4.
|
| 10 |
onnxruntime>=1.16
|
| 11 |
einops>=0.8.0
|
| 12 |
|
|
|
|
| 6 |
torch>=2.3,<3
|
| 7 |
torchvision>=0.18,<1
|
| 8 |
timm>=1.0.12,<2
|
| 9 |
+
transformers>=4.53,<5
|
| 10 |
onnxruntime>=1.16
|
| 11 |
einops>=0.8.0
|
| 12 |
|
src/ensemble_tagger.py
CHANGED
|
@@ -296,15 +296,17 @@ class EnsembleTagger:
|
|
| 296 |
# | "deepdanbooru" (legacy) | legacy singles "wd:vit"/"wd:swinv2"/"joytag"
|
| 297 |
skip_pose: bool = False,
|
| 298 |
with_caption: bool = False,
|
|
|
|
| 299 |
) -> dict:
|
| 300 |
"""Run the ensemble tagger on one image.
|
| 301 |
|
| 302 |
`skip_pose=True` suppresses the YOLO pose pass (faster on restricted VMs).
|
| 303 |
`with_caption=True` additionally runs the *optional* lightweight VLM
|
| 304 |
-
captioner (Florence-2-base by default
|
| 305 |
-
`
|
| 306 |
-
|
| 307 |
-
|
|
|
|
| 308 |
"""
|
| 309 |
if not _TAGGER_DEPS_OK and not _ORT_AVAILABLE:
|
| 310 |
raise RuntimeError("Tagger dependencies are not available.")
|
|
@@ -317,7 +319,7 @@ class EnsembleTagger:
|
|
| 317 |
raise ValueError(f"Invalid image input for tagging: {exc}") from exc
|
| 318 |
|
| 319 |
# Cache lookups are per-mode so changing the model doesn't reuse stale results.
|
| 320 |
-
key = f"{mode}:{skip_pose}:{with_caption}:{self._image_key(pil_img)}"
|
| 321 |
cached = self._cache.get(key)
|
| 322 |
if cached is not None:
|
| 323 |
return cached
|
|
@@ -429,7 +431,7 @@ class EnsembleTagger:
|
|
| 429 |
if with_caption:
|
| 430 |
try:
|
| 431 |
from src.vlm_caption import get_captioner
|
| 432 |
-
result["nl_caption"] = get_captioner().caption(pil_img) or ""
|
| 433 |
except Exception: # pragma: no cover
|
| 434 |
result["nl_caption"] = ""
|
| 435 |
|
|
|
|
| 296 |
# | "deepdanbooru" (legacy) | legacy singles "wd:vit"/"wd:swinv2"/"joytag"
|
| 297 |
skip_pose: bool = False,
|
| 298 |
with_caption: bool = False,
|
| 299 |
+
caption_kind: str = "florence",
|
| 300 |
) -> dict:
|
| 301 |
"""Run the ensemble tagger on one image.
|
| 302 |
|
| 303 |
`skip_pose=True` suppresses the YOLO pose pass (faster on restricted VMs).
|
| 304 |
`with_caption=True` additionally runs the *optional* lightweight VLM
|
| 305 |
+
captioner (Florence-2-base by default, Qwen2.5-VL-3B with
|
| 306 |
+
`caption_kind="qwen"`) and stores the result under `nl_caption`. It is
|
| 307 |
+
honored only in "ensemble" mode — single-backbone and legacy modes
|
| 308 |
+
never attach a caption. It is never enabled by default because CPU VLM
|
| 309 |
+
inference is slow on the HF Spaces free tier.
|
| 310 |
"""
|
| 311 |
if not _TAGGER_DEPS_OK and not _ORT_AVAILABLE:
|
| 312 |
raise RuntimeError("Tagger dependencies are not available.")
|
|
|
|
| 319 |
raise ValueError(f"Invalid image input for tagging: {exc}") from exc
|
| 320 |
|
| 321 |
# Cache lookups are per-mode so changing the model doesn't reuse stale results.
|
| 322 |
+
key = f"{mode}:{skip_pose}:{with_caption}:{caption_kind}:{self._image_key(pil_img)}"
|
| 323 |
cached = self._cache.get(key)
|
| 324 |
if cached is not None:
|
| 325 |
return cached
|
|
|
|
| 431 |
if with_caption:
|
| 432 |
try:
|
| 433 |
from src.vlm_caption import get_captioner
|
| 434 |
+
result["nl_caption"] = get_captioner().caption(pil_img, kind=caption_kind) or ""
|
| 435 |
except Exception: # pragma: no cover
|
| 436 |
result["nl_caption"] = ""
|
| 437 |
|
src/i18n.py
CHANGED
|
@@ -329,8 +329,9 @@ L10N = {
|
|
| 329 |
"tagger_mode_eva_large": "WD14 EVA Large Mod",
|
| 330 |
"tagger_mode_dd": "DeepDanbooru (legacy)",
|
| 331 |
"tagger_mode_florence": "Florence NL Caption (Natural Language Mod)",
|
|
|
|
| 332 |
"tagger_caption_toggle_label": "Add NL caption",
|
| 333 |
-
"tagger_caption_toggle_info": "Adds a natural-language description (Florence-2-base). Only applies with the Smart tagger; slow on CPU — off by default.",
|
| 334 |
"tagger_pose_label": "Pose",
|
| 335 |
"tagger_pose_single": "{n} person detected",
|
| 336 |
"tagger_pose_many": "{n} people detected",
|
|
@@ -699,8 +700,9 @@ L10N = {
|
|
| 699 |
"tagger_mode_eva_large": "WD14 EVA Large Mod",
|
| 700 |
"tagger_mode_dd": "DeepDanbooru (legacy)",
|
| 701 |
"tagger_mode_florence": "Florence NL-описание (языковая модель)",
|
|
|
|
| 702 |
"tagger_caption_toggle_label": "Добавить NL-описание",
|
| 703 |
-
"tagger_caption_toggle_info": "Добавляет описание на естественном языке (Florence-2-base). Работает только с умным теггером; на CPU медленно — выключено по умолчанию.",
|
| 704 |
"tagger_nl_caption": "🗣️ Описание сцены",
|
| 705 |
"tagger_pose_label": "Поза",
|
| 706 |
"tagger_pose_single": "Обнаружен {n} человек",
|
|
|
|
| 329 |
"tagger_mode_eva_large": "WD14 EVA Large Mod",
|
| 330 |
"tagger_mode_dd": "DeepDanbooru (legacy)",
|
| 331 |
"tagger_mode_florence": "Florence NL Caption (Natural Language Mod)",
|
| 332 |
+
"tagger_mode_qwen": "Qwen NL Caption (Qwen2.5-VL-3B)",
|
| 333 |
"tagger_caption_toggle_label": "Add NL caption",
|
| 334 |
+
"tagger_caption_toggle_info": "Adds a natural-language description (Florence-2-base by default, Qwen2.5-VL-3B for Anima-style captions). Only applies with the Smart tagger; slow on CPU — off by default.",
|
| 335 |
"tagger_pose_label": "Pose",
|
| 336 |
"tagger_pose_single": "{n} person detected",
|
| 337 |
"tagger_pose_many": "{n} people detected",
|
|
|
|
| 700 |
"tagger_mode_eva_large": "WD14 EVA Large Mod",
|
| 701 |
"tagger_mode_dd": "DeepDanbooru (legacy)",
|
| 702 |
"tagger_mode_florence": "Florence NL-описание (языковая модель)",
|
| 703 |
+
"tagger_mode_qwen": "Qwen NL-описание (Qwen2.5-VL-3B)",
|
| 704 |
"tagger_caption_toggle_label": "Добавить NL-описание",
|
| 705 |
+
"tagger_caption_toggle_info": "Добавляет описание на естественном языке (Florence-2-base по умолчанию, Qwen2.5-VL-3B для капшнов в стиле Anima). Работает только с умным теггером; на CPU медленно — выключено по умолчанию.",
|
| 706 |
"tagger_nl_caption": "🗣️ Описание сцены",
|
| 707 |
"tagger_pose_label": "Поза",
|
| 708 |
"tagger_pose_single": "Обнаружен {n} человек",
|
src/vlm_caption.py
CHANGED
|
@@ -6,10 +6,13 @@ opt-in only and uses a much smaller model by default:
|
|
| 6 |
|
| 7 |
- ``WHYX_CAPTION_MODEL=florence`` (default) -> microsoft/Florence-2-base
|
| 8 |
- ``WHYX_CAPTION_MODEL=smolvlm`` -> HuggingFaceTB/SmolVLM-256M-Instruct
|
|
|
|
| 9 |
- ``WHYX_CAPTION_MODEL=moondream`` -> vikhyatk/moondream2 (legacy)
|
| 10 |
|
| 11 |
The captioner loads lazily on first use and never runs inside the default
|
| 12 |
-
ensemble path. ``WHYX_DISABLE_CAPTION=1`` turns it off entirely.
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -29,6 +32,20 @@ _CAPTION_DEFAULT = os.environ.get("WHYX_CAPTION_MODEL", "florence").strip().lowe
|
|
| 29 |
# "new version of the code file" download warning.
|
| 30 |
_FLORENCE_REVISION = "5ca5edf5bd017b9919c05d08aebef5e4c7ac3bac"
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
def _caption_enabled() -> bool:
|
| 34 |
return os.environ.get("WHYX_DISABLE_CAPTION", "0").strip().lower() not in ("1", "true", "yes", "on")
|
|
@@ -148,32 +165,109 @@ class _MoondreamCaptioner:
|
|
| 148 |
return self._impl.caption(pil, length="short")
|
| 149 |
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
class Captioner:
|
| 152 |
"""Facade over the selected lightweight caption model."""
|
| 153 |
|
| 154 |
def __init__(self, kind: str = _CAPTION_DEFAULT):
|
| 155 |
self._kind = kind
|
| 156 |
-
self.
|
|
|
|
| 157 |
self._lock = threading.Lock()
|
| 158 |
|
| 159 |
-
def caption(self, pil) -> str:
|
| 160 |
if not _caption_enabled():
|
| 161 |
return ""
|
|
|
|
| 162 |
with self._lock:
|
| 163 |
-
if
|
| 164 |
-
|
| 165 |
-
if
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
self.
|
|
|
|
|
|
|
| 171 |
try:
|
| 172 |
-
return self.
|
| 173 |
except Exception as exc: # pragma: no cover
|
| 174 |
logger.warning("Captioner failed: %s", exc)
|
| 175 |
return ""
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
_captioner_instance: Captioner | None = None
|
| 179 |
|
|
|
|
| 6 |
|
| 7 |
- ``WHYX_CAPTION_MODEL=florence`` (default) -> microsoft/Florence-2-base
|
| 8 |
- ``WHYX_CAPTION_MODEL=smolvlm`` -> HuggingFaceTB/SmolVLM-256M-Instruct
|
| 9 |
+
- ``WHYX_CAPTION_MODEL=qwen`` -> Qwen/Qwen2.5-VL-3B-Instruct (slow on CPU)
|
| 10 |
- ``WHYX_CAPTION_MODEL=moondream`` -> vikhyatk/moondream2 (legacy)
|
| 11 |
|
| 12 |
The captioner loads lazily on first use and never runs inside the default
|
| 13 |
+
ensemble path. ``WHYX_DISABLE_CAPTION=1`` turns it off entirely. The active
|
| 14 |
+
impl can be chosen per call via ``Captioner.caption(pil, kind=...)``; only the
|
| 15 |
+
most recent impl stays resident so two VLMs don't double the memory footprint.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
|
|
| 32 |
# "new version of the code file" download warning.
|
| 33 |
_FLORENCE_REVISION = "5ca5edf5bd017b9919c05d08aebef5e4c7ac3bac"
|
| 34 |
|
| 35 |
+
# Qwen2.5-VL-3B captioner. Qwen-style dense descriptions match video models
|
| 36 |
+
# whose text encoder was trained on Qwen embeddings (e.g. Anima). Needs
|
| 37 |
+
# transformers>=4.53 (AutoModelForImageTextToText mapping); bf16 on CPU to fit
|
| 38 |
+
# the 16 GB free tier alongside the WD14 ensemble.
|
| 39 |
+
_QWEN_MODEL_ID = os.environ.get("WHYX_QWEN_MODEL", "Qwen/Qwen2.5-VL-3B-Instruct")
|
| 40 |
+
_QWEN_PROMPT = os.environ.get(
|
| 41 |
+
"WHYX_QWEN_PROMPT",
|
| 42 |
+
"Describe this anime illustration in one dense paragraph as a caption for a "
|
| 43 |
+
"video model: character appearance, pose, action, clothing, lighting, "
|
| 44 |
+
"background and mood.",
|
| 45 |
+
).strip()
|
| 46 |
+
_QWEN_MAX_TOKENS = int(os.environ.get("WHYX_QWEN_MAX_TOKENS", "256"))
|
| 47 |
+
_QWEN_MAX_PIXELS = int(os.environ.get("WHYX_QWEN_MAX_PIXELS", "4000000"))
|
| 48 |
+
|
| 49 |
|
| 50 |
def _caption_enabled() -> bool:
|
| 51 |
return os.environ.get("WHYX_DISABLE_CAPTION", "0").strip().lower() not in ("1", "true", "yes", "on")
|
|
|
|
| 165 |
return self._impl.caption(pil, length="short")
|
| 166 |
|
| 167 |
|
| 168 |
+
class _QwenCaptioner:
|
| 169 |
+
"""Qwen2.5-VL-3B captioning via the chat template.
|
| 170 |
+
|
| 171 |
+
Produces dense Qwen-style descriptions aimed at video models with a
|
| 172 |
+
Qwen-based text encoder (e.g. Anima). Heavy (~6.1 GB bf16) and slow on
|
| 173 |
+
CPU — opt-in only.
|
| 174 |
+
"""
|
| 175 |
+
|
| 176 |
+
def __init__(self, model_id: str = _QWEN_MODEL_ID):
|
| 177 |
+
self._model_id = model_id
|
| 178 |
+
self._processor = None
|
| 179 |
+
self._model = None
|
| 180 |
+
|
| 181 |
+
def _load(self) -> bool:
|
| 182 |
+
try:
|
| 183 |
+
from transformers import AutoProcessor
|
| 184 |
+
except Exception:
|
| 185 |
+
return False
|
| 186 |
+
try:
|
| 187 |
+
from transformers import AutoModelForImageTextToText as _M
|
| 188 |
+
except Exception:
|
| 189 |
+
try:
|
| 190 |
+
from transformers import AutoModelForVision2Seq as _M
|
| 191 |
+
except Exception:
|
| 192 |
+
return False
|
| 193 |
+
try:
|
| 194 |
+
import torch
|
| 195 |
+
self._processor = AutoProcessor.from_pretrained(self._model_id)
|
| 196 |
+
self._model = _M.from_pretrained(
|
| 197 |
+
self._model_id, torch_dtype=torch.bfloat16
|
| 198 |
+
).to("cuda" if torch.cuda.is_available() else "cpu")
|
| 199 |
+
except Exception as exc: # pragma: no cover
|
| 200 |
+
logger.warning("Qwen captioner failed to load: %s", exc)
|
| 201 |
+
return False
|
| 202 |
+
return True
|
| 203 |
+
|
| 204 |
+
def caption(self, pil) -> str:
|
| 205 |
+
if self._processor is None or self._model is None:
|
| 206 |
+
if not self._load():
|
| 207 |
+
return ""
|
| 208 |
+
import torch
|
| 209 |
+
messages = [
|
| 210 |
+
{
|
| 211 |
+
"role": "user",
|
| 212 |
+
"content": [
|
| 213 |
+
{"type": "image", "image": pil},
|
| 214 |
+
{"type": "text", "text": _QWEN_PROMPT},
|
| 215 |
+
],
|
| 216 |
+
}
|
| 217 |
+
]
|
| 218 |
+
text = self._processor.apply_chat_template(messages, add_generation_prompt=True)
|
| 219 |
+
inputs = self._processor(
|
| 220 |
+
text=[text], images=[pil], return_tensors="pt", max_pixels=_QWEN_MAX_PIXELS
|
| 221 |
+
).to(self._model.device)
|
| 222 |
+
with torch.inference_mode():
|
| 223 |
+
gen = self._model.generate(
|
| 224 |
+
**inputs, max_new_tokens=_QWEN_MAX_TOKENS, do_sample=False
|
| 225 |
+
)
|
| 226 |
+
return self._processor.batch_decode(
|
| 227 |
+
gen[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True
|
| 228 |
+
)[0].strip()
|
| 229 |
+
|
| 230 |
+
|
| 231 |
class Captioner:
|
| 232 |
"""Facade over the selected lightweight caption model."""
|
| 233 |
|
| 234 |
def __init__(self, kind: str = _CAPTION_DEFAULT):
|
| 235 |
self._kind = kind
|
| 236 |
+
self._impls: dict[str, object] = {}
|
| 237 |
+
self._active: Optional[str] = None
|
| 238 |
self._lock = threading.Lock()
|
| 239 |
|
| 240 |
+
def caption(self, pil, kind: Optional[str] = None) -> str:
|
| 241 |
if not _caption_enabled():
|
| 242 |
return ""
|
| 243 |
+
kind = (kind or self._kind or "").strip().lower()
|
| 244 |
with self._lock:
|
| 245 |
+
if kind != self._active:
|
| 246 |
+
impl = self._impls.get(kind)
|
| 247 |
+
if impl is None:
|
| 248 |
+
impl = self._make_impl(kind)
|
| 249 |
+
# Keep only the most recent captioner resident: Qwen
|
| 250 |
+
# (~6.1 GB) plus Florence alongside the WD14 ensemble
|
| 251 |
+
# would strain the 16 GB free tier.
|
| 252 |
+
self._impls.clear()
|
| 253 |
+
self._impls[kind] = impl
|
| 254 |
+
self._active = kind
|
| 255 |
try:
|
| 256 |
+
return self._impls[kind].caption(pil) or ""
|
| 257 |
except Exception as exc: # pragma: no cover
|
| 258 |
logger.warning("Captioner failed: %s", exc)
|
| 259 |
return ""
|
| 260 |
|
| 261 |
+
@staticmethod
|
| 262 |
+
def _make_impl(kind: str):
|
| 263 |
+
if kind == "smolvlm":
|
| 264 |
+
return _SmolVLMCaptioner()
|
| 265 |
+
if kind == "moondream":
|
| 266 |
+
return _MoondreamCaptioner()
|
| 267 |
+
if kind == "qwen":
|
| 268 |
+
return _QwenCaptioner()
|
| 269 |
+
return _FlorenceCaptioner()
|
| 270 |
+
|
| 271 |
|
| 272 |
_captioner_instance: Captioner | None = None
|
| 273 |
|
tests/test_captioner.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Facade dispatch for the tagger captioner (no model downloads)."""
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
from src.vlm_caption import (
|
| 5 |
+
_FlorenceCaptioner,
|
| 6 |
+
_MoondreamCaptioner,
|
| 7 |
+
_QwenCaptioner,
|
| 8 |
+
_SmolVLMCaptioner,
|
| 9 |
+
Captioner,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class _Fake:
|
| 14 |
+
def __init__(self, kind):
|
| 15 |
+
self.kind = kind
|
| 16 |
+
self.calls = 0
|
| 17 |
+
|
| 18 |
+
def caption(self, pil):
|
| 19 |
+
self.calls += 1
|
| 20 |
+
return f"caption from {self.kind}"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def fake_impls(monkeypatch):
|
| 25 |
+
made = {}
|
| 26 |
+
|
| 27 |
+
def make(kind):
|
| 28 |
+
impl = _Fake(kind)
|
| 29 |
+
made[kind] = impl
|
| 30 |
+
return impl
|
| 31 |
+
|
| 32 |
+
monkeypatch.setattr(Captioner, "_make_impl", staticmethod(make))
|
| 33 |
+
return made
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_default_kind_is_florence(fake_impls):
|
| 37 |
+
c = Captioner(kind="florence")
|
| 38 |
+
assert c.caption(None) == "caption from florence"
|
| 39 |
+
assert "florence" in fake_impls
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_impl_reused_across_calls(fake_impls):
|
| 43 |
+
c = Captioner(kind="florence")
|
| 44 |
+
assert c.caption(None) == "caption from florence"
|
| 45 |
+
assert c.caption(None) == "caption from florence"
|
| 46 |
+
assert fake_impls["florence"].calls == 2
|
| 47 |
+
assert len(fake_impls) == 1
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_kind_param_overrides_default(fake_impls):
|
| 51 |
+
c = Captioner(kind="florence")
|
| 52 |
+
assert c.caption(None, kind="qwen") == "caption from qwen"
|
| 53 |
+
assert "qwen" in fake_impls
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_switching_kind_keeps_single_resident_impl(fake_impls):
|
| 57 |
+
c = Captioner(kind="florence")
|
| 58 |
+
assert c.caption(None) == "caption from florence"
|
| 59 |
+
assert c.caption(None, kind="qwen") == "caption from qwen"
|
| 60 |
+
assert c.caption(None) == "caption from florence"
|
| 61 |
+
assert len(fake_impls) == 2
|
| 62 |
+
assert list(c._impls) == ["florence"]
|
| 63 |
+
assert c._active == "florence"
|
| 64 |
+
assert fake_impls["qwen"].calls == 1
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_make_impl_kinds():
|
| 68 |
+
assert isinstance(Captioner._make_impl("florence"), _FlorenceCaptioner)
|
| 69 |
+
assert isinstance(Captioner._make_impl("smolvlm"), _SmolVLMCaptioner)
|
| 70 |
+
assert isinstance(Captioner._make_impl("moondream"), _MoondreamCaptioner)
|
| 71 |
+
assert isinstance(Captioner._make_impl("qwen"), _QwenCaptioner)
|
| 72 |
+
assert isinstance(Captioner._make_impl("unknown"), _FlorenceCaptioner)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_disabled_returns_empty(fake_impls, monkeypatch):
|
| 76 |
+
monkeypatch.setenv("WHYX_DISABLE_CAPTION", "1")
|
| 77 |
+
c = Captioner(kind="florence")
|
| 78 |
+
assert c.caption(None) == ""
|
| 79 |
+
assert fake_impls == {}
|