ArtShumov commited on
Commit
b3d35e4
·
1 Parent(s): dee3744

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 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 item only checks the box (see on_tag_rerun).
 
 
1870
  with_caption = bool(caption_toggle)
1871
- return on_tag_image(image, gen_threshold, char_threshold, effective_fmt, lang, progress=progress, skip_pose=skip_pose, with_caption=with_caption)
 
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 runs
1896
- # the caption in the same event, so the checkbox stays the visible source
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 == "nl"
1903
- caption_update = gr.update(value=True) if mode == "nl" else gr.skip()
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.45,<5
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) and stores the result under
305
- `nl_caption`. It is honored only in "ensemble" mode — single-backbone
306
- and legacy modes never attach a caption. It is never enabled by
307
- default because CPU VLM inference is slow on the HF Spaces free tier.
 
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._impl: Optional[object] = None
 
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 self._impl is None:
164
- kind = self._kind
165
- if kind == "smolvlm":
166
- self._impl = _SmolVLMCaptioner()
167
- elif kind == "moondream":
168
- self._impl = _MoondreamCaptioner()
169
- else:
170
- self._impl = _FlorenceCaptioner()
 
 
171
  try:
172
- return self._impl.caption(pil) or ""
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 == {}