ArtShumov commited on
Commit
17a1e39
·
1 Parent(s): 2b22e2d

feat(tagger): swap Qwen-VL for Moondream2 NL captioner

Browse files
src/ensemble_tagger.py CHANGED
@@ -278,17 +278,16 @@ class EnsembleTagger:
278
  result["pose_tags"] = pose_info.get("pose_tags", [])
279
  result["people_count"] = pose_info.get("people_count", 0)
280
 
281
- # Optional Qwen3-VL natural-language caption. Lazy-loaded on first call.
282
- # On HF Spaces (CPU only) this is skipped by default because the model
283
- # alone is ~8-9 GB even in fp16 CPU; set WHYX_ENABLE_QWEN_VL=1 to enable.
284
- if not skip_qwen and mode in ("ensemble", "qwen"):
285
- try:
286
- from src.qwen_vl_tagger import get_qwen_tagger
287
- caption = get_qwen_tagger().describe(pil_img)
288
- if caption:
289
- result["nl_caption"] = caption
290
- except Exception:
291
- pass
292
 
293
  # DeepDanbooru boosts general tags in ensemble. Only runs when the optional
294
  # weights happen to be co-located with the app; the app keeps working when
 
278
  result["pose_tags"] = pose_info.get("pose_tags", [])
279
  result["people_count"] = pose_info.get("people_count", 0)
280
 
281
+ # Moondream2 natural-language caption (replaces Qwen-VL for HF Spaces).
282
+ # Runs alongside the tagger result always carries an `nl_caption` key
283
+ # (empty string when Moondream is disabled or unavailable).
284
+ result["nl_caption"] = ""
285
+ try:
286
+ from src.moondream_tagger import get_moondream_tagger
287
+ nl_txt = get_moondream_tagger().caption(pil_img, length="short")
288
+ result["nl_caption"] = nl_txt or ""
289
+ except Exception: # pragma: no cover
290
+ result["nl_caption"] = ""
 
291
 
292
  # DeepDanbooru boosts general tags in ensemble. Only runs when the optional
293
  # weights happen to be co-located with the app; the app keeps working when
src/moondream_tagger.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Moondream2 captioner for natural-language scene descriptions.
2
+
3
+ Moondream2 is a much smaller VLM than Qwen3-VL (~3.7 GB bf16, ~2B params) and
4
+ runs well on CPU-only HF Spaces free tier (2 vCPU / 16 GB RAM). The model card
5
+ states the public API over the *model class itself* — caption / query / detect /
6
+ point — so we call the high-level helpers directly instead of a generic
7
+ "generate()" loop.
8
+
9
+ By default the model is loaded lazily on first call (CPU). The runtime RAM
10
+ footprint stays well under 8 GB when stacked alongside WD14 taggers, pose
11
+ estimators and the SD pipeline.
12
+
13
+ Set ``WHYX_DISABLE_MOONDREAM=1`` to keep Moondream off in constrained
14
+ deployments.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import io
20
+ import logging
21
+ import os
22
+ import threading
23
+ from typing import Optional
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ try:
28
+ import torch
29
+ from PIL import Image, ImageOps
30
+ from transformers import AutoModelForCausalLM, AutoTokenizer
31
+ except Exception: # pragma: no cover - keep import errors soft
32
+ AutoModelForCausalLM = AutoTokenizer = None
33
+ torch = None
34
+ Image = ImageOps = None
35
+
36
+ _MODEL_ID = os.environ.get("WHYX_MOONDREAM_MODEL", "vikhyatk/moondream2")
37
+ _MD_INSTANCE: "MoondreamTagger | None" = None
38
+
39
+
40
+ def _md_enabled() -> bool:
41
+ return os.environ.get("WHYX_DISABLE_MOONDREAM", "0").strip().lower() not in ("1", "true", "yes", "on")
42
+
43
+
44
+ class MoondreamTagger:
45
+ """One-stop wrapper around `vikhyatk/moondream2` for caption + query.
46
+ """
47
+
48
+ def __init__(self, model_id: str = _MODEL_ID):
49
+ self._model_id = model_id
50
+ self._model: Optional[AutoModelForCausalLM] = None
51
+ self._tokenizer: Optional[AutoTokenizer] = None
52
+ self._loaded = False
53
+ self._lock = threading.Lock()
54
+
55
+ def ensure_loaded(self) -> bool:
56
+ if self._loaded:
57
+ return True
58
+ if not _md_enabled() or AutoModelForCausalLM is None:
59
+ return False
60
+ with self._lock:
61
+ if self._loaded:
62
+ return True
63
+ try:
64
+ # bf16 halves RAM vs fp32 when a GPU is present; CPU gets fp32
65
+ # (still well under the 16 GB cap once the model is alive).
66
+ dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
67
+ self._model = AutoModelForCausalLM.from_pretrained(
68
+ self._model_id,
69
+ dtype=dtype,
70
+ # moondream2's anti-hallucination / EOS logic lives in its config
71
+ trust_remote_code=True,
72
+ # Keep all RAM on CPU; avoid any accelerate/gpu dispatch.
73
+ device_map={"": "cpu"},
74
+ )
75
+ self._tokenizer = AutoTokenizer.from_pretrained(self._model_id)
76
+ except Exception as exc: # pragma: no cover
77
+ logger.warning("Moondream2 failed to load: %s", exc)
78
+ return False
79
+ self._loaded = True
80
+ return True
81
+
82
+ @staticmethod
83
+ def _to_pil(image) -> "Image.Image":
84
+ # Accept PIL/numpy/raw bytes; EXIF orientation is normalised.
85
+ if Image is None:
86
+ raise RuntimeError("PIL is not available")
87
+ if isinstance(image, Image.Image):
88
+ pil = image.convert("RGB")
89
+ else:
90
+ try:
91
+ import numpy as np
92
+ if isinstance(image, np.ndarray):
93
+ pil = Image.fromarray(image)
94
+ else:
95
+ pil = Image.open(io.BytesIO(image)).convert("RGB")
96
+ except Exception as exc:
97
+ raise ValueError(f"cannot convert input to PIL image: {exc}") from exc
98
+ if ImageOps is not None:
99
+ pil = ImageOps.exif_transpose(pil)
100
+ return pil
101
+
102
+ def caption(self, image, length: str = "normal") -> str:
103
+ """Return a natural-language caption for the image.
104
+
105
+ ``length`` may be ``"short"`` (one phrase) or ``"normal"`` (one sentence).
106
+ """
107
+ if not self.ensure_loaded():
108
+ return ""
109
+ pil = self._to_pil(image)
110
+ try:
111
+ result = self._model.caption(pil, length=length)
112
+ except Exception as exc: # pragma: no cover
113
+ logger.warning("Moondream2 caption failed: %s", exc)
114
+ return ""
115
+ # The public API returns a dict with a "caption" key. Helper text is
116
+ # stripped to one sentence (and one line) so it can join tags cleanly.
117
+ text = result.get("caption", "")
118
+ return " ".join(text.split())
119
+
120
+ def query(self, image, question: str) -> str:
121
+ """Free-form visual Q&A. E.g. "How many people are in the image?" """
122
+ if not self.ensure_loaded():
123
+ return ""
124
+ pil = self._to_pil(image)
125
+ try:
126
+ result = self._model.query(pil, question)
127
+ except Exception as exc: # pragma: no cover
128
+ logger.warning("Moondream2 query failed: %s", exc)
129
+ return ""
130
+ return " ".join(result.get("answer", "").split())
131
+
132
+ def detect(self, image, thing: str) -> int:
133
+ """Detect instances of `thing` in `image`, return count."""
134
+ if not self.ensure_loaded():
135
+ return 0
136
+ pil = self._to_pil(image)
137
+ try:
138
+ result = self._model.detect(pil, thing)
139
+ except Exception:
140
+ return 0
141
+ return len(result.get("objects", []))
142
+
143
+
144
+ import io
145
+ import threading
146
+
147
+
148
+ def get_moondream_tagger() -> MoondreamTagger:
149
+ """Singleton accessor shared across calls (one model per process)."""
150
+ global _MD_INSTANCE
151
+ if _MD_INSTANCE is None:
152
+ _MD_INSTANCE = MoondreamTagger()
153
+ return _MD_INSTANCE
src/qwen_vl_tagger.py DELETED
@@ -1,131 +0,0 @@
1
- """Qwen3-VL based image captioner for Stable Diffusion prompts.
2
-
3
- Uses the Qwen3-VL-Instruct vision-language model (default: 4B) to produce a
4
- concise natural-language scene description that can be appended to the tagger
5
- output. Loading is opt-in because the model weighs ~4-5 GB in fp16; the class
6
- degrades gracefully when the DEPS or the flag are missing.
7
- """
8
- from __future__ import annotations
9
-
10
- import os
11
- from typing import Optional
12
-
13
- import numpy as np
14
- import torch
15
- from PIL import Image, ImageOps
16
-
17
- _VL_DEPS_OK = True
18
- try:
19
- from transformers import AutoProcessor
20
- # Qwen3-VL needs the generic ImageTextToText class; older transformers
21
- # (<4.55) only had AutoModelForVision2Seq. We try both to stay compatible.
22
- try:
23
- from transformers import AutoModelForImageTextToText as _AutoModel
24
- except ImportError: # pragma: no cover
25
- from transformers import AutoModelForVision2Seq as _AutoModel
26
- except Exception: # pragma: no cover
27
- _VL_DEPS_OK = False
28
- AutoProcessor = _AutoModel = None
29
-
30
- _QWEN_ENABLED = os.getenv("WHYX_ENABLE_QWEN_VL", "").strip().lower() in ("1", "true", "yes", "on")
31
-
32
- _MODEL_ID = os.environ.get("WHYX_QWEN_VL_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
33
- _vl_instance: "QwenVLTagger | None" = None
34
-
35
-
36
-
37
- def _vl_enabled() -> bool:
38
- return os.environ.get("WHYX_ENABLE_QWEN_VL", "1").strip().lower() not in ("0", "false", "no", "off")
39
-
40
-
41
- def _normalize_caption(raw: str) -> str:
42
- """Normalize a VL caption so it joins cleanly with a Stable Diffusion prompt.
43
-
44
- - Collapse whitespace and stray newlines.
45
- - Remove leading caption markers like "This image shows..."."""
46
- text = " ".join(raw.split())
47
- # Strip leading meta framing if present.
48
- for prefix in ("The image", "This image", "The photo", "This photo", "A scene of"):
49
- if text.startswith(prefix):
50
- text = text[len(prefix):].lstrip(" ,:;")
51
- break
52
- # Capitalize-first letter; leave the rest untouched.
53
- if text:
54
- text = text[0].upper() + text[1:]
55
- return text
56
-
57
-
58
- class QwenVLTagger:
59
- """Qwen3-VL wrapper exclusively for the tagger's natural-language caption.
60
-
61
- The model is fully disabled by default (`WHYX_ENABLE_QWEN_VL=1` activates it).
62
- On CPU-only environments the model would otherwise consume more than the
63
- 16 GB limit — hence the off default.
64
- """
65
-
66
- def __init__(self, model_id: str = _MODEL_ID):
67
- self._model_id = model_id
68
- self._processor: Optional[AutoProcessor] = None
69
- self._model: Optional["_AutoModel"] = None
70
- self._loaded = False
71
-
72
- def ensure_loaded(self) -> bool:
73
- if self._loaded:
74
- return True
75
- if not _VL_DEPS_OK:
76
- raise RuntimeError("transformers is not installed")
77
- if not _vl_enabled():
78
- raise RuntimeError("Qwen VL is disabled (WHYX_ENABLE_QWEN_VL=0)")
79
- self._processor = AutoProcessor.from_pretrained(self._model_id)
80
- dtype = torch.float32 # FP32 on CPU — saves 4 bits per parameter over bf16.
81
- self._model = _AutoModel.from_pretrained(
82
- self._model_id,
83
- dtype=dtype,
84
- )
85
- # Keep the model resident on the target device (CPU on our tier).
86
- device = "cpu"
87
- self._model = self._model.to(device)
88
- self._loaded = True
89
- return True
90
-
91
- @staticmethod
92
- def _to_pil(image) -> Image.Image:
93
- img = image if isinstance(image, Image.Image) else Image.fromarray(np.asarray(image))
94
- # EXIF transpose so rotated photo inputs are interpreted upright.
95
- return ImageOps.exif_transpose(img.convert("RGB"))
96
-
97
- def describe(self, image, prompt: str | None = None, max_new_tokens: int = 128) -> str:
98
- if not self.ensure_loaded():
99
- return ""
100
- img = self._to_pil(image)
101
- # Qwen3-VL accepts a chat-style multi-turn format; we only need one-turn
102
- # caption generation. Keep the framing as user-message-only for the best
103
- # instruction-following on the free tier.
104
- system_prompt = prompt or (
105
- "Describe this image in ONE concise sentence suitable as a Stable "
106
- "Diffusion prompt (no preamble, no extra sentences)."
107
- )
108
- messages = [
109
- {
110
- "role": "user",
111
- "content": [
112
- {"type": "text", "text": "Image:"},
113
- {"type": "image"},
114
- {"type": "text", "text": system_prompt},
115
- ],
116
- },
117
- ]
118
- text = self._processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
119
- inputs = self._processor(text=[text], images=[img], return_tensors="pt")
120
- with torch.inference_mode():
121
- gen = self._model.generate(**inputs, max_new_tokens=max_new_tokens)
122
- trimmed = gen[:, inputs["input_ids"].shape[1]:]
123
- caption = self._processor.batch_decode(trimmed, skip_special_tokens=True)[0]
124
- return caption.strip()
125
-
126
-
127
- def get_qwen_tagger() -> QwenVLTagger:
128
- global _vl_instance
129
- if _vl_instance is None:
130
- _vl_instance = QwenVLTagger()
131
- return _vl_instance