ArtShumov commited on
Commit
2c574fa
·
1 Parent(s): e6404d0

feat(tagger): pose extraction + Qwen3-VL optional NL captioner + WD14x3 ensemble weights recalibration

Browse files
requirements.txt CHANGED
@@ -6,5 +6,6 @@ numpy>=1.26.4
6
  torch>=2.3,<3
7
  torchvision>=0.18,<1
8
  timm>=1.0.12,<2
 
9
  onnxruntime>=1.16
10
 
 
6
  torch>=2.3,<3
7
  torchvision>=0.18,<1
8
  timm>=1.0.12,<2
9
+ transformers>=4.45
10
  onnxruntime>=1.16
11
 
src/ensemble_tagger.py CHANGED
@@ -188,7 +188,12 @@ class EnsembleTagger:
188
  if cached is not None:
189
  return cached
190
 
191
- # DeepDanbooru-only path: no rating distribution (model doesn't emit ratings).
 
 
 
 
 
192
  if mode == "deepdanbooru":
193
  dd = self._dd.predict(pil_img)
194
  gen = {k: round(v, 4) for k, v in dd.items() if v >= gen_threshold}
@@ -199,6 +204,8 @@ class EnsembleTagger:
199
  "ratings": {},
200
  "characters": {},
201
  "general": gen,
 
 
202
  "ensemble_votes": {"deepdanbooru": 1},
203
  }
204
  self._cache[key] = result
@@ -221,7 +228,23 @@ class EnsembleTagger:
221
  ])
222
  result["ensemble_votes"] = {name: 1 for _, _, name in self._wd_taggers}
223
 
224
- # DeepDanbooru boosts general tags in ensemble.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  # Ensemble: DeepDanbooru optional secondary vote on top of merged WD14s.
226
  if mode == "ensemble" and self._dd.ensure_loaded():
227
  dd = self._dd.predict(pil_img)
@@ -233,12 +256,12 @@ class EnsembleTagger:
233
  if tag in gen:
234
  gen[tag] = round(max(gen[tag], score), 4)
235
  else:
236
- gen[tag] = round(score * 0.45, 4)
237
- result["general"] = dict(sorted(gen.items(), key=lambda kv: -kv[1]))
238
- result["caption"] = _esc_for_output(", ".join(result["general"].keys()))
239
- result["taglist"] = _esc_for_output(
240
- ", ".join(result["general"].keys()).replace("_", " ")
241
  )
 
 
242
  result["ensemble_votes"]["deepdanbooru"] = 1
243
 
244
  self._cache[key] = result
 
188
  if cached is not None:
189
  return cached
190
 
191
+ # Pose estimation always runs when available it feeds `pose_tags`
192
+ # and `people_count` used below to enrich DeepDanbooru / NL captions.
193
+ from src.pose_tagger import get_pose_tagger
194
+ pose_info = get_pose_tagger().estimate(pil_img)
195
+
196
+ # DeepDanbooru-only path: no rating distribution, but pose tags are kept.
197
  if mode == "deepdanbooru":
198
  dd = self._dd.predict(pil_img)
199
  gen = {k: round(v, 4) for k, v in dd.items() if v >= gen_threshold}
 
204
  "ratings": {},
205
  "characters": {},
206
  "general": gen,
207
+ "pose_tags": pose_info.get("pose_tags", []),
208
+ "people_count": pose_info.get("people_count", 0),
209
  "ensemble_votes": {"deepdanbooru": 1},
210
  }
211
  self._cache[key] = result
 
228
  ])
229
  result["ensemble_votes"] = {name: 1 for _, _, name in self._wd_taggers}
230
 
231
+ # Merge pose metadata (pose_tags are already validated lists).
232
+ result["pose_tags"] = pose_info.get("pose_tags", [])
233
+ result["people_count"] = pose_info.get("people_count", 0)
234
+
235
+ # Optional Qwen3-VL natural-language caption. Lazy-loaded on first call.
236
+ if mode in ("ensemble", "qwen"):
237
+ try:
238
+ from src.qwen_vl_tagger import get_qwen_tagger
239
+ caption = get_qwen_tagger().describe(pil_img)
240
+ if caption:
241
+ result["nl_caption"] = caption
242
+ except Exception:
243
+ pass # VL unavailable — result stays tag-only
244
+
245
+ # DeepDanbooru boosts general tags in ensemble. Only runs when the optional
246
+ # weights happen to be co-located with the app; the app keeps working when
247
+ # they are absent.
248
  # Ensemble: DeepDanbooru optional secondary vote on top of merged WD14s.
249
  if mode == "ensemble" and self._dd.ensure_loaded():
250
  dd = self._dd.predict(pil_img)
 
256
  if tag in gen:
257
  gen[tag] = round(max(gen[tag], score), 4)
258
  else:
259
+ gen[tag] = round(score * 0.35, 4)
260
+ result["general"] = dict(
261
+ sorted(gen.items(), key=lambda kv: -kv[1])
 
 
262
  )
263
+ result["caption"] = _esc_for_output(", ".join(result["general"].keys()))
264
+ result["taglist"] = _esc_for_output(", ".join(result["general"].keys()).replace("_", " "))
265
  result["ensemble_votes"]["deepdanbooru"] = 1
266
 
267
  self._cache[key] = result
src/handlers.py CHANGED
@@ -510,6 +510,8 @@ def on_tag_image(image, gen_threshold, char_threshold, fmt, lang, progress=gr.Pr
510
  progress(0.35, desc=t("tagger_ratings_label", lc))
511
  result = ens.tag_image(image, gen_threshold, char_threshold, mode="ensemble")
512
  progress(0.9, desc=t("tagger_results", lc))
 
 
513
  if not result["general"] and not result["characters"]:
514
  html_out = f'<div style="color:#FBBF24;font-size:12px;">{t("tagger_no_tags", lc)}</div>'
515
  return (
 
510
  progress(0.35, desc=t("tagger_ratings_label", lc))
511
  result = ens.tag_image(image, gen_threshold, char_threshold, mode="ensemble")
512
  progress(0.9, desc=t("tagger_results", lc))
513
+ result["pose_tags"] = ens._last_result_pose if hasattr(ens, "_last_result_pose") else result.get("pose_tags", [])
514
+ result["people_count"] = ens._last_people if hasattr(ens, "_last_people") else result.get("people_count", 0)
515
  if not result["general"] and not result["characters"]:
516
  html_out = f'<div style="color:#FBBF24;font-size:12px;">{t("tagger_no_tags", lc)}</div>'
517
  return (
src/pose_tagger.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pose estimation from an image via DWPose/YOLO11n ONNX Runtime.
2
+
3
+ Runs a lightweight YOLO11n-pose ONNX model for person detection + 17-keypoint
4
+ pose estimation. The 17 COCO keypoints are turned into a natural-language
5
+ pose tag string that can be appended to a prompt (e.g. ``standing, arms up``).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+ from PIL import Image, ImageOps
15
+
16
+ try:
17
+ import onnxruntime as ort
18
+ except Exception: # pragma: no cover
19
+ ort = None
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Keypoint analysis
23
+ # ---------------------------------------------------------------------------
24
+
25
+ import os
26
+
27
+ _DEBUG_POSE = os.environ.get("WHYX_DEBUG_POSE", "0").strip().lower() in ("1", "true", "yes")
28
+ _COCO_KP = [
29
+ "nose", "left eye", "right eye", "left ear", "right ear", "left shoulder",
30
+ "right shoulder", "left elbow", "right elbow", "left wrist", "right wrist",
31
+ "left hip", "right hip", "left knee", "right knee", "left ankle",
32
+ "right ankle",
33
+ ]
34
+
35
+ # YOLOv8-pose ONNX model keypoint order is COCO 17. Each detection row is
36
+ # [x, y, w, h, conf, kp0_x, kp0_y, kp0_conf, ..., kp16_x, kp16_y, kp16_conf].
37
+ _KPT_START = 5 # index of first keypoint value in a row of the output vector
38
+ _KPT_STRIDE = 3 # (x, y, conf) per keypoint
39
+
40
+
41
+ def _yolo_to_keypoints(out: np.ndarray, conf_thresh: float = 0.25):
42
+ """out shape: (1, N+5, num_detections). Return list of (K, 3) arrays."""
43
+ # (1, N+5, D) -> (D, N+5)
44
+ preds = out[0].T
45
+ conf = preds[:, 4]
46
+ keep = conf > conf_thresh
47
+ preds = preds[keep]
48
+ kpts = []
49
+ for row in preds:
50
+ kp = row[_KPT_START:].reshape(-1, _KPT_STRIDE) # (K, 3)
51
+ kpts.append(kp)
52
+ return kpts
53
+
54
+
55
+ def _keypoints_to_pose_tags(kpts: list[np.ndarray]) -> list[str]:
56
+ """Heuristic mapping of COCO-17 coordinates to coarse pose tags."""
57
+ tags: list[str] = []
58
+ if not kpts:
59
+ return tags
60
+
61
+ for kp in kpts:
62
+ # Normalise coords by shoulder width so the tags are pose-invariant.
63
+ ls, rs = kp[5], kp[6]
64
+ shoulder_span = max(np.abs(ls[0] - rs[0]), 1e-4)
65
+ lw, rw = kp[9], kp[10]
66
+ hips = kp[11], kp[12]
67
+ lk, rk = kp[13], kp[14]
68
+ la, ra = kp[15], kp[16]
69
+
70
+ # arms up (wrists above shoulders)
71
+ if lw[2] > 0.3 and rw[2] > 0.3 and lw[1] < ls[1] - 0.05 * shoulder_span \
72
+ and rw[1] < rs[1] - 0.05 * shoulder_span:
73
+ tags.append("arms up")
74
+ # one arm up
75
+ elif (lw[2] > 0.3 and lw[1] < ls[1] - 0.05 * shoulder_span) or (
76
+ rw[2] > 0.3 and rw[1] < rs[1] - 0.05 * shoulder_span):
77
+ tags.append("arm up")
78
+
79
+ # sitting vs standing: hip-to-knee vertical distance smaller than
80
+ # hip-to-ankle distance suggests knees bent (sitting)
81
+ if hips[2] > 0.3 and lk[2] > 0.3:
82
+ hip_y = hips[1]
83
+ knee_y = lk[1]
84
+ ankle_y = la[1] if la[2] > 0.3 else knee_y
85
+ # Sitting: knees are raised toward hips.
86
+ if knee_y < hip_y - 0.05 * shoulder_span:
87
+ tags.append("sitting")
88
+ else:
89
+ tags.append("standing")
90
+
91
+ # lying: body's vertical extent is smaller than horizontal
92
+ if hips[2] > 0.3 and ls[2] > 0.3:
93
+ torso_vert = abs(ls[1] - hips[1])
94
+ torso_horiz = abs(ls[0] - hips[0])
95
+ if torso_horiz > torso_vert * 1.5:
96
+ tags.append("lying")
97
+
98
+ # walking: one ankle significantly ahead of the other horizontally
99
+ if la[2] > 0.3 and ra[2] > 0.3:
100
+ stride = abs(la[0] - ra[0])
101
+ if stride > 0.4 * shoulder_span:
102
+ tags.append("walking")
103
+
104
+ # Deduplicate preserving order
105
+ seen: set[str] = set()
106
+ out: list[str] = []
107
+ for t in tags:
108
+ if t not in seen:
109
+ seen.add(t)
110
+ out.append(t)
111
+ return out
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # ONNX pose runner
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ def _providers() -> list[str]:
120
+ avail = ort.get_available_providers()
121
+ return ["CUDAExecutionProvider", "CPUExecutionProvider"] if "CUDAExecutionProvider" in avail else ["CPUExecutionProvider"]
122
+
123
+
124
+ class PoseEstimator:
125
+ """YOLO11n-pose wrapper with lazy model download."""
126
+
127
+ def __init__(self, repo_id: str = "SamTheDev/YOLO11n-pose", filename: str = "yolo11n.onnx"):
128
+ self._repo = repo_id
129
+ self._filename = filename
130
+ self._session: Optional["ort.InferenceSession"] = None
131
+ self._input_shape: tuple[int, int] = (640, 640)
132
+ self._loaded = False
133
+
134
+ def ensure_loaded(self) -> bool:
135
+ if self._loaded:
136
+ return True
137
+ if ort is None:
138
+ return False
139
+ from huggingface_hub import hf_hub_download
140
+ try:
141
+ model_path = hf_hub_download(repo_id=self._repo, filename=self._filename)
142
+ except Exception:
143
+ return False
144
+ sess = ort.InferenceSession(model_path, providers=_providers())
145
+ inp = sess.get_inputs()[0]
146
+ shape = inp.shape
147
+ if len(shape) == 4:
148
+ self._input_shape = (int(shape[2]), int(shape[3]))
149
+ self._session = sess
150
+ self._loaded = True
151
+ return True
152
+
153
+ def estimate(self, image) -> dict:
154
+ """Return {pose_tags, pose_score, people_count, raw_keypoints_count}."""
155
+ if not self.ensure_loaded():
156
+ return {"pose_tags": [], "people_count": 0}
157
+
158
+ pil = image if isinstance(image, Image.Image) else Image.fromarray(np.asarray(image))
159
+ pil = ImageOps.exif_transpose(ImageOps.fit(pil, self._input_shape, Image.LANCZOS))
160
+ arr = np.asarray(pil, dtype=np.float32) / 255.0
161
+ arr = arr.transpose(2, 0, 1)[None, ...] # NCHW
162
+
163
+ inp_name = self._session.get_inputs()[0].name
164
+ out = self._session.run(None, {inp_name: arr})[0]
165
+ kpts = _yolo_to_keypoints(out)
166
+ if _DEBUG_POSE:
167
+ print(f"[pose] raw_out={out.shape}, n_kpts_above_thresh={len(kpts)}")
168
+
169
+ pose_tags = _keypoints_to_pose_tags(kpts)
170
+ confs = [kp[:, 2].mean() for kp in kpts if kp.size]
171
+ pose_score = float(np.mean(confs)) if confs else 0.0
172
+
173
+ return {
174
+ "pose_tags": pose_tags,
175
+ "people_count": len(kpts),
176
+ "pose_score": round(pose_score, 4),
177
+ }
178
+
179
+
180
+ _pose_instance: PoseEstimator | None = None
181
+
182
+
183
+ def get_pose_tagger() -> PoseEstimator:
184
+ global _pose_instance
185
+ if _pose_instance is None:
186
+ _pose_instance = PoseEstimator()
187
+ return _pose_instance
src/qwen_vl_tagger.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ _MODEL_ID = os.environ.get("WHYX_QWEN_VL_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
31
+ _vl_instance: "QwenVLTagger | None" = None
32
+
33
+
34
+ def _vl_enabled() -> bool:
35
+ return os.environ.get("WHYX_ENABLE_QWEN_VL", "1").strip().lower() not in ("0", "false", "no", "off")
36
+
37
+
38
+ def _normalize_caption(raw: str) -> str:
39
+ """Normalize a VL caption so it joins cleanly with a Stable Diffusion prompt.
40
+
41
+ - Collapse whitespace and stray newlines.
42
+ - Remove leading caption markers like "This image shows..."."""
43
+ text = " ".join(raw.split())
44
+ # Strip leading meta framing if present.
45
+ for prefix in ("The image", "This image", "The photo", "This photo", "A scene of"):
46
+ if text.startswith(prefix):
47
+ text = text[len(prefix):].lstrip(" ,:;")
48
+ break
49
+ # Capitalize-first letter; leave the rest untouched.
50
+ if text:
51
+ text = text[0].upper() + text[1:]
52
+ return text
53
+
54
+
55
+ class QwenVLTagger:
56
+ def __init__(self, model_id: str = _MODEL_ID):
57
+ self._model_id = model_id
58
+ self._processor: Optional[AutoProcessor] = None
59
+ self._model: Optional["_AutoModel"] = None
60
+ self._device: Optional[torch.device] = None
61
+ self._loaded = False
62
+
63
+ def ensure_loaded(self) -> bool:
64
+ if self._loaded:
65
+ return True
66
+ if not _VL_DEPS_OK:
67
+ raise RuntimeError("transformers is not installed")
68
+ if not _vl_enabled():
69
+ raise RuntimeError("Qwen VL is disabled (WHYX_ENABLE_QWEN_VL=0)")
70
+ self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
71
+ self._processor = AutoProcessor.from_pretrained(self._model_id)
72
+ dtype = torch.float16 if self._device.type != "cpu" else torch.float32
73
+ self._model = _AutoModel.from_pretrained(
74
+ self._model_id,
75
+ dtype=dtype,
76
+ ).to(self._device)
77
+ self._loaded = True
78
+ return True
79
+
80
+ @staticmethod
81
+ def _to_pil(image) -> Image.Image:
82
+ img = image if isinstance(image, Image.Image) else Image.fromarray(np.asarray(image))
83
+ # EXIF transpose so rotated photo inputs are interpreted upright.
84
+ return ImageOps.exif_transpose(img.convert("RGB"))
85
+
86
+ def describe(self, image, prompt: str | None = None, max_new_tokens: int = 128) -> str:
87
+ if not self.ensure_loaded():
88
+ return ""
89
+ img = self._to_pil(image)
90
+ system_prompt = prompt or (
91
+ "Describe this image in ONE concise sentence suitable as a Stable "
92
+ "Diffusion prompt (no preamble, no extra sentences, no lists)."
93
+ )
94
+ messages = [
95
+ {"role": "system", "content": [{"type": "text", "text": system_prompt}]},
96
+ {"role": "user", "content": [
97
+ {"type": "image"},
98
+ {"type": "text", "text": "Image:"},
99
+ ]},
100
+ ]
101
+ text = self._processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
102
+ inputs = self._processor(text=[text], images=[img], return_tensors="pt")
103
+ inputs = {k: v.to(self._device) for k, v in inputs.items()}
104
+ with torch.inference_mode():
105
+ gen = self._model.generate(**inputs, max_new_tokens=max_new_tokens)
106
+ trimmed = gen[:, inputs["input_ids"].shape[1]:]
107
+ caption = self._processor.batch_decode(trimmed, skip_special_tokens=True)[0]
108
+ return _normalize_caption(caption)
109
+
110
+
111
+ def get_qwen_tagger() -> QwenVLTagger:
112
+ global _vl_instance
113
+ if _vl_instance is None:
114
+ _vl_instance = QwenVLTagger()
115
+ return _vl_instance