ArtShumov commited on
Commit
716aebb
·
1 Parent(s): ef745a5

feat(smart): accurate pose + intelligent whole-image tags

Browse files

- wholebody_pose: aspect-preserving crop (fix distortion), single 25% box expand,
TTA-flip for body keypoints, area-weighted person selection, confidence gate
- tag_categorizer: 0MB new models - categorize WD14 tags into emotion/lighting/
style/concept + k-means palette on full frame (true smart tool)
- ensemble_tagger: integrate categorizer (emotion/lighting/style/concept/palette)
+ pose consensus (geometry+WD14) with boosted scores
- app: new sections Emotion/Lighting/Style/Concept/Palette in tagger panel
- i18n: EN/RU labels for new sections

app.py CHANGED
@@ -536,6 +536,25 @@ def _build_tagger_html(result: dict, lc: str) -> str:
536
  + "</div>"
537
  )
538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  # ---- Rating distribution ---------------------------------------------------
540
  lines.append(f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{t('tagger_ratings', lc)}</div>")
541
  rating_colors = {"general": "#34D399", "sensitive": "#FBBF24", "questionable": "#F97316", "explicit": "#EF4444"}
@@ -1044,6 +1063,11 @@ input[type="range"]::-moz-range-thumb { width: 16px !important; height: 16px !im
1044
  .whyx-tagger-bg-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1045
  .whyx-tagger-seg-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(16,185,129,0.16) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1046
  .whyx-tagger-depth-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(37,99,235,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
 
 
 
 
 
1047
  .whyx-tagger-control-img { border-radius: var(--radius-md) !important; overflow: hidden !important; border: 1px solid var(--glass-border) !important; }
1048
  .whyx-tagger-control-img img { border-radius: var(--radius-md) !important; }
1049
 
 
536
  + "</div>"
537
  )
538
 
539
+ # ---- Categorized tags (emotion / lighting / style / concept / palette) ----
540
+ for key, label_key, emoji, css_class in [
541
+ ("emotion_tags", "tagger_emotion_label", "🎭", "whyx-tagger-emotion-tags"),
542
+ ("lighting_tags", "tagger_lighting_label", "💡", "whyx-tagger-lighting-tags"),
543
+ ("style_tags", "tagger_style_label", "🎨", "whyx-tagger-style-tags"),
544
+ ("concept_tags", "tagger_concept_label", "💭", "whyx-tagger-concept-tags"),
545
+ ("palette_tags", "tagger_palette_label", "🌈", "whyx-tagger-palette-tags"),
546
+ ]:
547
+ cat_tags = result.get(key) or []
548
+ if cat_tags:
549
+ lines.append(
550
+ f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{emoji} {t(label_key, lc)}</div>"
551
+ )
552
+ lines.append(
553
+ f"<div class='{css_class}'>"
554
+ + ", ".join(html.escape(p) for p in cat_tags)
555
+ + "</div>"
556
+ )
557
+
558
  # ---- Rating distribution ---------------------------------------------------
559
  lines.append(f"<div class='whyx-tagger-panel-title' style='margin-top:10px;'>{t('tagger_ratings', lc)}</div>")
560
  rating_colors = {"general": "#34D399", "sensitive": "#FBBF24", "questionable": "#F97316", "explicit": "#EF4444"}
 
1063
  .whyx-tagger-bg-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(79,70,229,0.1) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1064
  .whyx-tagger-seg-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(16,185,129,0.16) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1065
  .whyx-tagger-depth-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(37,99,235,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1066
+ .whyx-tagger-emotion-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(244,63,94,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1067
+ .whyx-tagger-lighting-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(251,191,36,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1068
+ .whyx-tagger-style-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(168,85,247,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1069
+ .whyx-tagger-concept-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(59,130,246,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1070
+ .whyx-tagger-palette-tags { font-size: 12px !important; color: var(--text-dim) !important; line-height: 1.4 !important; background: var(--input-bg) !important; border: 1px solid rgba(16,185,129,0.18) !important; border-radius: var(--radius-sm) !important; padding: 6px 10px !important; margin-top: 4px !important; }
1071
  .whyx-tagger-control-img { border-radius: var(--radius-md) !important; overflow: hidden !important; border: 1px solid var(--glass-border) !important; }
1072
  .whyx-tagger-control-img img { border-radius: var(--radius-md) !important; }
1073
 
scripts/validate_smart_accuracy.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate pose + smart analysis accuracy on real images.
2
+
3
+ Runs the wholebody pose estimator and tag categorizer on images from a
4
+ directory, reports detection rate / confidence / tags, and saves skeleton
5
+ overlays for visual inspection.
6
+
7
+ Usage:
8
+ python scripts/validate_smart_accuracy.py <image_dir> [--limit N] [--out DIR]
9
+ """
10
+ import os
11
+ import sys
12
+ import argparse
13
+ import json
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
17
+
18
+ import numpy as np
19
+ from PIL import Image, ImageDraw, ImageOps
20
+
21
+ _COCO_SKELETON = [
22
+ (0, 1), (0, 2), (1, 3), (2, 4),
23
+ (5, 6), (5, 7), (7, 9),
24
+ (6, 8), (8, 10),
25
+ (5, 11), (6, 12), (11, 12),
26
+ (11, 13), (13, 15),
27
+ (12, 14), (14, 16),
28
+ ]
29
+
30
+
31
+ def draw_skeleton(img, kpts, color=(0, 255, 100), width=2):
32
+ canvas = img.convert("RGB").copy()
33
+ draw = ImageDraw.Draw(canvas)
34
+ kpts = np.asarray(kpts)
35
+ if kpts.ndim != 2 or kpts.shape[0] < 17:
36
+ return canvas
37
+ for a, b in _COCO_SKELETON:
38
+ if kpts[a, 2] >= 0.3 and kpts[b, 2] >= 0.3:
39
+ draw.line([kpts[a, 0], kpts[a, 1], kpts[b, 0], kpts[b, 1]],
40
+ fill=color, width=width)
41
+ for i in range(17):
42
+ if kpts[i, 2] >= 0.3:
43
+ x, y = int(kpts[i, 0]), int(kpts[i, 1])
44
+ draw.ellipse([x - 3, y - 3, x + 3, y + 3], fill=(255, 80, 80))
45
+ return canvas
46
+
47
+
48
+ def main():
49
+ parser = argparse.ArgumentParser()
50
+ parser.add_argument("image_dir")
51
+ parser.add_argument("--limit", type=int, default=0)
52
+ parser.add_argument("--out", default=None)
53
+ args = parser.parse_args()
54
+
55
+ img_dir = Path(args.image_dir)
56
+ files = sorted([f for f in img_dir.iterdir()
57
+ if f.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")])
58
+ if args.limit > 0:
59
+ files = files[:args.limit]
60
+
61
+ out_dir = Path(args.out) if args.out else img_dir / "smart_validation"
62
+ out_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ from src.wholebody_pose import get_wholebody_tagger
65
+ from src.tag_categorizer import categorize_tags
66
+
67
+ tagger = get_wholebody_tagger()
68
+ results = []
69
+ detected = 0
70
+ total_conf = 0.0
71
+
72
+ for i, f in enumerate(files):
73
+ try:
74
+ pil = ImageOps.exif_transpose(Image.open(f).convert("RGB"))
75
+ except Exception as e:
76
+ print(f"[{i+1}/{len(files)}] SKIP {f.name}: {e}")
77
+ continue
78
+
79
+ res = tagger.estimate(pil)
80
+ n_people = res.get("people_count", 0)
81
+ conf = res.get("pose_score", 0.0)
82
+ tags = res.get("pose_tags", [])
83
+ kpts = res.get("keypoints", [])
84
+
85
+ if n_people > 0 and kpts:
86
+ detected += 1
87
+ total_conf += conf
88
+ overlay = draw_skeleton(pil, kpts)
89
+ overlay.save(out_dir / f"pose_{f.stem}.jpg", quality=90)
90
+
91
+ # Categorize (use dummy WD14 tags from pose for demo)
92
+ cat = categorize_tags(tags, np.asarray(pil))
93
+
94
+ entry = {
95
+ "image": f.name,
96
+ "people": n_people,
97
+ "pose_score": round(conf, 4),
98
+ "pose_tags": tags,
99
+ "categories": {k: v for k, v in cat.items() if v},
100
+ }
101
+ results.append(entry)
102
+ status = "OK" if n_people > 0 else "MISS"
103
+ print(f"[{i+1}/{len(files)}] {status} {f.name}: people={n_people} "
104
+ f"conf={conf:.3f} tags={tags[:5]}")
105
+
106
+ summary = {
107
+ "total": len(files),
108
+ "detected": detected,
109
+ "detection_rate": round(detected / max(len(files), 1), 4),
110
+ "avg_confidence": round(total_conf / max(detected, 1), 4),
111
+ "results": results,
112
+ }
113
+ (out_dir / "smart_summary.json").write_text(
114
+ json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
115
+
116
+ print(f"\n=== SUMMARY ===")
117
+ print(f"Total: {len(files)}, Detected: {detected} "
118
+ f"({summary['detection_rate']:.1%})")
119
+ print(f"Avg confidence: {summary['avg_confidence']:.3f}")
120
+ print(f"Output: {out_dir}")
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
src/ensemble_tagger.py CHANGED
@@ -280,13 +280,49 @@ class EnsembleTagger:
280
  {"primary": w_primary, "joy": w_joy},
281
  )
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  @staticmethod
284
  def _fold_pose(result: dict, pose_tags: list[str]) -> dict:
285
- """Fold geometry-derived pose tags into the general tag list."""
 
 
 
 
286
  gen = dict(result.get("general", {}))
 
287
  for pt in pose_tags:
288
  if pt not in gen:
289
- gen[pt] = _POSE_BOOST
 
 
 
 
 
 
290
  result["general"] = dict(sorted(gen.items(), key=lambda kv: -kv[1]))
291
  result["pose_tags"] = pose_tags
292
  result["caption"] = _esc_for_output(", ".join(result["general"].keys()))
@@ -390,6 +426,14 @@ class EnsembleTagger:
390
  except Exception:
391
  seg_tags = []
392
 
 
 
 
 
 
 
 
 
393
  # Fold all smart tags at once
394
  self._fold_smart_tags(result, {
395
  "anatomy": anatomy_tags,
@@ -398,6 +442,8 @@ class EnsembleTagger:
398
  })
399
  self._fold_extra_tags(result, depth_tags, "depth")
400
  self._fold_extra_tags(result, seg_tags, "segmentation")
 
 
401
 
402
  return result
403
 
 
280
  {"primary": w_primary, "joy": w_joy},
281
  )
282
 
283
+ # Geometry pose tag → WD14 equivalents for consensus boosting.
284
+ _POSE_CONSENSUS_MAP = {
285
+ "standing": {"standing"},
286
+ "sitting": {"sitting"},
287
+ "lying": {"lying", "on_back", "on_side", "on_stomach"},
288
+ "jumping": {"jumping"},
289
+ "squatting": {"squatting", "crouching"},
290
+ "crouching": {"crouching", "squatting"},
291
+ "bent_over": {"bent_over"},
292
+ "arms_up": {"arms_up", "arm_up", "arms_raised"},
293
+ "one_arm_up": {"arm_up", "arms_up"},
294
+ "arms_behind_head": {"arms_behind_head", "arm_behind_head"},
295
+ "arms_crossed": {"arms_crossed", "crossed_arms"},
296
+ "arms_at_sides": {"arms_at_sides"},
297
+ "hands_on_hips": {"hands_on_hips", "hand_on_hip"},
298
+ "legs_apart": {"legs_apart", "spread_legs"},
299
+ "crossed_legs": {"crossed_legs", "leg_cross"},
300
+ "splits": {"splits", "split"},
301
+ "tiptoes": {"tiptoes"},
302
+ "looking_left": {"looking_to_the_side"},
303
+ "looking_right": {"looking_to_the_side"},
304
+ "open_mouth": {"open_mouth", "mouth_open"},
305
+ "closed_eyes": {"closed_eyes", "eyes_closed"},
306
+ }
307
+
308
  @staticmethod
309
  def _fold_pose(result: dict, pose_tags: list[str]) -> dict:
310
+ """Fold geometry-derived pose tags into the general tag list.
311
+
312
+ Applies consensus: tags confirmed by both geometry AND WD14 get a
313
+ stronger boost; geometry-only tags get a weaker boost.
314
+ """
315
  gen = dict(result.get("general", {}))
316
+ wd14_lower = {t.lower().replace(" ", "_") for t in gen.keys()}
317
  for pt in pose_tags:
318
  if pt not in gen:
319
+ # Check if WD14 has a matching tag (consensus)
320
+ wd_matches = EnsembleTagger._POSE_CONSENSUS_MAP.get(pt, {pt})
321
+ has_consensus = any(m in wd14_lower for m in wd_matches)
322
+ if has_consensus:
323
+ gen[pt] = min(_POSE_BOOST * 1.4, 0.95)
324
+ else:
325
+ gen[pt] = _POSE_BOOST * 0.7
326
  result["general"] = dict(sorted(gen.items(), key=lambda kv: -kv[1]))
327
  result["pose_tags"] = pose_tags
328
  result["caption"] = _esc_for_output(", ".join(result["general"].keys()))
 
426
  except Exception:
427
  seg_tags = []
428
 
429
+ # Tag categorization (emotion / lighting / style / concept / palette)
430
+ cat_tags: dict[str, list[str]] = {}
431
+ try:
432
+ from src.tag_categorizer import categorize_tags
433
+ cat_tags = categorize_tags(wd14_tags, arr)
434
+ except Exception:
435
+ cat_tags = {}
436
+
437
  # Fold all smart tags at once
438
  self._fold_smart_tags(result, {
439
  "anatomy": anatomy_tags,
 
442
  })
443
  self._fold_extra_tags(result, depth_tags, "depth")
444
  self._fold_extra_tags(result, seg_tags, "segmentation")
445
+ for cat in ("emotion", "lighting", "style", "concept", "palette"):
446
+ self._fold_extra_tags(result, cat_tags.get(cat, []), cat)
447
 
448
  return result
449
 
src/i18n.py CHANGED
@@ -351,6 +351,11 @@ L10N = {
351
  "tagger_background_label": "Background",
352
  "tagger_segmentation_label": "Scene (segmentation)",
353
  "tagger_depth_label": "Depth / composition",
 
 
 
 
 
354
  "tagger_pose_single": "{n} person detected",
355
  "tagger_pose_many": "{n} people detected",
356
  "tagger_caption_label": "Natural-language caption",
@@ -743,6 +748,11 @@ L10N = {
743
  "tagger_background_label": "Фон",
744
  "tagger_segmentation_label": "Сцена (сегментация)",
745
  "tagger_depth_label": "Глубина / композиция",
 
 
 
 
 
746
  "tagger_pose_single": "Обнаружен {n} человек",
747
  "tagger_pose_many": "Обнаружено {n} человек",
748
  "tagger_caption_label": "Языковое описание сцены",
 
351
  "tagger_background_label": "Background",
352
  "tagger_segmentation_label": "Scene (segmentation)",
353
  "tagger_depth_label": "Depth / composition",
354
+ "tagger_emotion_label": "Emotion",
355
+ "tagger_lighting_label": "Lighting",
356
+ "tagger_style_label": "Art style",
357
+ "tagger_concept_label": "Concept / theme",
358
+ "tagger_palette_label": "Color palette",
359
  "tagger_pose_single": "{n} person detected",
360
  "tagger_pose_many": "{n} people detected",
361
  "tagger_caption_label": "Natural-language caption",
 
748
  "tagger_background_label": "Фон",
749
  "tagger_segmentation_label": "Сцена (сегментация)",
750
  "tagger_depth_label": "Глубина / композиция",
751
+ "tagger_emotion_label": "Эмоции",
752
+ "tagger_lighting_label": "Освещение",
753
+ "tagger_style_label": "Стиль рисовки",
754
+ "tagger_concept_label": "Концепция / тема",
755
+ "tagger_palette_label": "Цветовая палитра",
756
  "tagger_pose_single": "Обнаружен {n} человек",
757
  "tagger_pose_many": "Обнаружено {n} человек",
758
  "tagger_caption_label": "Языковое описание сцены",
src/tag_categorizer.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tag categorizer: organizes WD14 tags into semantic categories + image analysis.
2
+
3
+ Extracts structured categories from the WD14 general tag list and the image
4
+ itself, producing Danbooru-format tags for:
5
+
6
+ - **emotion** — facial expression / mood (from WD14 + expression pool)
7
+ - **lighting** — lighting conditions (from WD14 + lighting pool)
8
+ - **style** — art style / medium (from WD14 + style pool)
9
+ - **concept** — theme / genre (rule-based from tag combinations)
10
+ - **palette** — dominant color analysis (k-means on the full frame)
11
+
12
+ Zero new model downloads — everything runs on existing WD14 output + numpy.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ _DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "tag_pools"
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Pool loading (cached)
26
+ # ---------------------------------------------------------------------------
27
+ _pool_cache: dict[str, set[str]] = {}
28
+
29
+
30
+ def _load_pool(name: str) -> set[str]:
31
+ if name in _pool_cache:
32
+ return _pool_cache[name]
33
+ path = _DATA_DIR / f"{name}.json"
34
+ tags: set[str] = set()
35
+ if path.exists():
36
+ try:
37
+ data = json.loads(path.read_text(encoding="utf-8"))
38
+ if isinstance(data, list):
39
+ for item in data:
40
+ if isinstance(item, dict) and "tag" in item:
41
+ tags.add(item["tag"].lower().replace(" ", "_"))
42
+ elif isinstance(item, str):
43
+ tags.add(item.lower().replace(" ", "_"))
44
+ elif isinstance(data, dict):
45
+ for v in data.values():
46
+ if isinstance(v, list):
47
+ for item in v:
48
+ if isinstance(item, str):
49
+ tags.add(item.lower().replace(" ", "_"))
50
+ except Exception:
51
+ pass
52
+ _pool_cache[name] = tags
53
+ return tags
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Category extraction from WD14 tags
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _match_pool(wd14_tags: list[str], pool_name: str) -> list[str]:
61
+ """Return WD14 tags that appear in the given pool."""
62
+ pool = _load_pool(pool_name)
63
+ if not pool:
64
+ return []
65
+ matched = []
66
+ for tag in wd14_tags:
67
+ norm = tag.lower().replace(" ", "_")
68
+ if norm in pool:
69
+ matched.append(tag)
70
+ return matched
71
+
72
+
73
+ def categorize_emotions(wd14_tags: list[str]) -> list[str]:
74
+ """Extract emotion / expression tags from WD14 output."""
75
+ return _match_pool(wd14_tags, "expression")
76
+
77
+
78
+ def categorize_lighting(wd14_tags: list[str]) -> list[str]:
79
+ """Extract lighting tags from WD14 output."""
80
+ return _match_pool(wd14_tags, "lighting")
81
+
82
+
83
+ def categorize_style(wd14_tags: list[str]) -> list[str]:
84
+ """Extract art style / medium tags from WD14 output."""
85
+ return _match_pool(wd14_tags, "style")
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Concept / theme detection (rule-based)
90
+ # ---------------------------------------------------------------------------
91
+
92
+ _CONCEPT_RULES: list[tuple[str, set[str], int]] = [
93
+ # (concept_tag, required_keywords, min_matches)
94
+ ("cyberpunk", {"neon", "neon_lights", "cyberpunk", "hologram", "futuristic",
95
+ "sci-fi", "robot", "android", "mecha", "city", "night"}, 2),
96
+ ("fantasy", {"fantasy", "magic", "dragon", "elf", "fairy", "wizard",
97
+ "castle", "sword", "mythical", "enchanted"}, 2),
98
+ ("sci-fi", {"sci-fi", "space", "spaceship", "alien", "futuristic",
99
+ "robot", "android", "mecha", "planet", "stars"}, 2),
100
+ ("gothic", {"gothic", "dark", "cathedral", "vampire", "bat", "moonlight",
101
+ "cemetery", "cross", "gargoyle"}, 2),
102
+ ("steampunk", {"steampunk", "gears", "clockwork", "brass", "victorian",
103
+ "airship", "steam"}, 2),
104
+ ("post-apocalyptic", {"post-apocalyptic", "ruins", "wasteland", "destroyed",
105
+ "abandoned", "overgrown", "desolate"}, 2),
106
+ ("military", {"military", "soldier", "gun", "tank", "war", "battlefield",
107
+ "uniform", "camouflage"}, 2),
108
+ ("school", {"school", "classroom", "uniform", "blackboard", "desk",
109
+ "school_uniform", "chalkboard"}, 2),
110
+ ("beach", {"beach", "ocean", "sand", "waves", "seashell", "palm_tree",
111
+ "swimsuit", "shore"}, 2),
112
+ ("winter", {"snow", "winter", "ice", "frozen", "snowflake", "cold",
113
+ "scarf", "snowing"}, 2),
114
+ ("autumn", {"autumn", "fall_leaves", "maple", "harvest", "orange_leaves",
115
+ "fallen_leaves", "autumn_leaves"}, 2),
116
+ ("spring", {"spring", "cherry_blossoms", "sakura", "petals", "blossom",
117
+ "flower_field"}, 2),
118
+ ("night_scene", {"night", "moon", "stars", "starry_sky", "moonlight",
119
+ "night_sky", "constellation"}, 2),
120
+ ("underwater", {"underwater", "aquarium", "fish", "coral", "seaweed",
121
+ "bubble", "deep_sea"}, 2),
122
+ ("horror", {"horror", "blood", "gore", "skull", "zombie", "ghost",
123
+ "haunted", "creepy", "demon"}, 2),
124
+ ("romantic", {"romantic", "couple", "kiss", "heart", "rose", "candlelight",
125
+ "embrace", "holding_hands"}, 2),
126
+ ("action", {"action", "fighting", "explosion", "dynamic", "battle",
127
+ "combat", "punch", "kick", "weapon"}, 2),
128
+ ("slice_of_life", {"slice_of_life", "cozy", "cafe", "cooking", "reading",
129
+ "tea", "daily_life", "relaxing"}, 2),
130
+ ]
131
+
132
+
133
+ def categorize_concept(wd14_tags: list[str]) -> list[str]:
134
+ """Detect theme / genre concepts from WD14 tag combinations."""
135
+ tag_set = {t.lower().replace(" ", "_") for t in wd14_tags}
136
+ concepts = []
137
+ for concept, keywords, min_matches in _CONCEPT_RULES:
138
+ matches = sum(1 for kw in keywords if kw in tag_set)
139
+ if matches >= min_matches:
140
+ concepts.append(concept)
141
+ return sorted(concepts)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Color palette analysis (k-means on full frame)
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def _kmeans(pixels: np.ndarray, k: int = 5, max_iter: int = 20) -> np.ndarray:
149
+ """Simple k-means clustering. Returns (k, 3) centroids."""
150
+ n = len(pixels)
151
+ if n == 0:
152
+ return np.zeros((k, 3), dtype=np.float32)
153
+ if n < k:
154
+ k = n
155
+ rng = np.random.default_rng(42)
156
+ centroids = pixels[rng.choice(n, k, replace=False)].astype(np.float32)
157
+ for _ in range(max_iter):
158
+ dists = np.linalg.norm(pixels[:, None, :] - centroids[None, :, :], axis=2)
159
+ labels = np.argmin(dists, axis=1)
160
+ new_centroids = np.zeros_like(centroids)
161
+ for j in range(k):
162
+ mask = labels == j
163
+ if mask.any():
164
+ new_centroids[j] = pixels[mask].mean(axis=0)
165
+ else:
166
+ new_centroids[j] = centroids[j]
167
+ if np.allclose(centroids, new_centroids, atol=1.0):
168
+ break
169
+ centroids = new_centroids
170
+ return centroids
171
+
172
+
173
+ def _rgb_to_hsv(r: float, g: float, b: float) -> tuple[float, float, float]:
174
+ """Convert RGB [0-255] to HSV (h: 0-360, s: 0-1, v: 0-1)."""
175
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
176
+ mx, mn = max(r, g, b), min(r, g, b)
177
+ d = mx - mn
178
+ v = mx
179
+ s = 0.0 if mx == 0 else d / mx
180
+ if d == 0:
181
+ h = 0.0
182
+ elif mx == r:
183
+ h = 60.0 * (((g - b) / d) % 6)
184
+ elif mx == g:
185
+ h = 60.0 * (((b - r) / d) + 2)
186
+ else:
187
+ h = 60.0 * (((r - g) / d) + 4)
188
+ return h, s, v
189
+
190
+
191
+ def analyze_palette(img_arr: np.ndarray) -> list[str]:
192
+ """Analyze dominant colors of the full frame via k-means.
193
+
194
+ Returns Danbooru-style color/mood tags.
195
+ """
196
+ if img_arr is None or img_arr.size == 0 or img_arr.ndim != 3:
197
+ return []
198
+ h, w = img_arr.shape[:2]
199
+ if h < 16 or w < 16:
200
+ return []
201
+
202
+ # Subsample for speed (max 2000 pixels)
203
+ pixels = img_arr.reshape(-1, 3).astype(np.float32)
204
+ if len(pixels) > 2000:
205
+ idx = np.random.default_rng(42).choice(len(pixels), 2000, replace=False)
206
+ pixels = pixels[idx]
207
+
208
+ centroids = _kmeans(pixels, k=5)
209
+ tags: set[str] = set()
210
+
211
+ # Analyze each centroid
212
+ hues = []
213
+ sats = []
214
+ vals = []
215
+ for c in centroids:
216
+ hue, sat, val = _rgb_to_hsv(float(c[0]), float(c[1]), float(c[2]))
217
+ hues.append(hue)
218
+ sats.append(sat)
219
+ vals.append(val)
220
+
221
+ avg_sat = float(np.mean(sats))
222
+ avg_val = float(np.mean(vals))
223
+
224
+ # Saturation-based tags
225
+ if avg_sat < 0.15:
226
+ tags.add("monochrome")
227
+ elif avg_sat < 0.30:
228
+ tags.add("muted_colors")
229
+ elif avg_sat > 0.65:
230
+ tags.add("vibrant_colors")
231
+
232
+ # Brightness-based tags
233
+ if avg_val < 0.25:
234
+ tags.add("dark_colors")
235
+ elif avg_val > 0.80:
236
+ tags.add("bright_colors")
237
+ if 0.55 < avg_val < 0.80 and avg_sat < 0.45:
238
+ tags.add("pastel_colors")
239
+
240
+ # Dominant hue analysis
241
+ warm_count = sum(1 for h_ in hues if (h_ < 60 or h_ > 300))
242
+ cool_count = sum(1 for h_ in hues if 150 < h_ < 270)
243
+ if warm_count >= 3 and warm_count > cool_count:
244
+ tags.add("warm_colors")
245
+ elif cool_count >= 3 and cool_count > warm_count:
246
+ tags.add("cool_colors")
247
+
248
+ # Specific dominant colors
249
+ for c in centroids:
250
+ hue, sat, val = _rgb_to_hsv(float(c[0]), float(c[1]), float(c[2]))
251
+ if sat < 0.2 or val < 0.15:
252
+ continue
253
+ if hue < 15 or hue > 345:
254
+ tags.add("red_theme")
255
+ elif 15 <= hue < 45:
256
+ tags.add("orange_theme")
257
+ elif 45 <= hue < 70:
258
+ tags.add("yellow_theme")
259
+ elif 70 <= hue < 160:
260
+ tags.add("green_theme")
261
+ elif 160 <= hue < 200:
262
+ tags.add("cyan_theme")
263
+ elif 200 <= hue < 260:
264
+ tags.add("blue_theme")
265
+ elif 260 <= hue < 300:
266
+ tags.add("purple_theme")
267
+ elif 300 <= hue < 345:
268
+ tags.add("pink_theme")
269
+
270
+ return sorted(tags)
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Main entry point
275
+ # ---------------------------------------------------------------------------
276
+
277
+ def categorize_tags(wd14_tags: list[str], img_arr: np.ndarray | None = None) -> dict[str, list[str]]:
278
+ """Categorize WD14 tags and analyze image palette.
279
+
280
+ Args:
281
+ wd14_tags: General tags from WD14 ensemble.
282
+ img_arr: RGB image as numpy array (H, W, 3 uint8), optional.
283
+
284
+ Returns:
285
+ Dict with keys: emotion, lighting, style, concept, palette.
286
+ Each maps to a list of Danbooru-style tags.
287
+ """
288
+ result = {
289
+ "emotion": categorize_emotions(wd14_tags),
290
+ "lighting": categorize_lighting(wd14_tags),
291
+ "style": categorize_style(wd14_tags),
292
+ "concept": categorize_concept(wd14_tags),
293
+ "palette": analyze_palette(img_arr) if img_arr is not None else [],
294
+ }
295
+ return result
src/wholebody_pose.py CHANGED
@@ -130,6 +130,24 @@ _DET_CONF = 0.30
130
  _DET_NMS = 0.65
131
  _KPT_CONF = 0.30
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
  def _sigmoid(x: np.ndarray) -> np.ndarray:
135
  return 1.0 / (1.0 + np.exp(-np.clip(x, -15, 15)))
@@ -151,6 +169,76 @@ def _letterbox(im: np.ndarray, size: int = _DET_SIZE) -> tuple[np.ndarray, float
151
  return canvas, scale, float(px), float(py)
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def _yolo_decode(out: np.ndarray, scale: float, pad_x: float, pad_y: float,
155
  conf_thresh: float = _DET_CONF, nms_thresh: float = _DET_NMS) -> np.ndarray:
156
  """Decode YOLOX-L output ``(1, 8400, 85)`` into ``(N, 6)`` boxes.
@@ -651,18 +739,9 @@ class WholebodyPoseEstimator:
651
  y2 = int(min(img_h, cy + h / 2))
652
  if x2 - x1 < 20 or y2 - y1 < 20:
653
  continue
654
- # Expand box by 15% to include hands/face context.
655
- bw, bh = x2 - x1, y2 - y1
656
- mx, my = (x1 + x2) / 2, (y1 + y2) / 2
657
- nw = int(bw * 1.15)
658
- nh = int(bh * 1.15)
659
- cx1 = max(0, int(mx - nw / 2))
660
- cy1 = max(0, int(my - nh / 2))
661
- cx2 = min(img_w, int(mx + nw / 2))
662
- cy2 = min(img_h, int(my + nh / 2))
663
- if cx2 - cx1 < 20 or cy2 - cy1 < 20:
664
- continue
665
- boxes.append([cx1, cy1, cx2, cy2, score, 0.0])
666
 
667
  if not boxes:
668
  return np.zeros((0, 6), dtype=np.float32)
@@ -695,45 +774,64 @@ class WholebodyPoseEstimator:
695
 
696
  persons: list[np.ndarray] = []
697
  confs: list[float] = []
 
698
  for box in boxes:
699
  x1, y1, x2, y2 = (int(round(v)) for v in box[:4])
700
  x1, y1 = max(0, x1), max(0, y1)
701
  x2, y2 = min(img_w, x2), min(img_h, y2)
702
  if x2 - x1 < 10 or y2 - y1 < 10:
703
  continue
704
- # Expand crop by 20% to include hands/face context.
705
- bw, bh = x2 - x1, y2 - y1
706
- mx, my = (x1 + x2) / 2, (y1 + y2) / 2
707
- nw = int(bw * 1.2)
708
- nh = int(bh * 1.2)
709
- cx1 = max(0, int(mx - nw / 2))
710
- cy1 = max(0, int(my - nh / 2))
711
- cx2 = min(img_w, int(mx + nw / 2))
712
- cy2 = min(img_h, int(my + nh / 2))
713
- crop = arr[cy1:cy2, cx1:cx2]
714
- crop_resized = np.array(Image.fromarray(crop).resize((_POSE_W, _POSE_H), Image.LANCZOS))
715
  kpts = self._estimate_crop(crop_resized)
 
 
 
 
 
 
 
 
 
 
716
  # Map keypoints from crop space back to original image coords.
717
- kpts[:, 0] = kpts[:, 0] / _POSE_W * (cx2 - cx1) + cx1
718
- kpts[:, 1] = kpts[:, 1] / _POSE_H * (cy2 - cy1) + cy1
 
719
  persons.append(kpts)
720
- confs.append(float(np.mean(kpts[:, 2])))
 
 
721
 
722
  if not persons:
723
  return {"pose_tags": [], "people_count": 0, "pose_score": 0.0,
724
  "keypoints": [], "body_kpts": [], "face_kpts": [], "hand_kpts": []}
725
 
726
- best = int(np.argmax(confs))
 
 
 
727
  best_kpts = persons[best]
728
- pose_tags = _wholebody_tags(best_kpts, img_w, img_h)
 
 
 
 
 
 
729
 
730
  if _DEBUG:
731
- print(f"[wholebody] persons={len(persons)} score={confs[best]:.3f} tags={pose_tags}")
732
 
733
  return {
734
  "pose_tags": pose_tags,
735
  "people_count": len(persons),
736
- "pose_score": round(confs[best], 4),
737
  "keypoints": best_kpts.tolist(),
738
  "body_kpts": best_kpts[0:17].tolist(),
739
  "face_kpts": best_kpts[23:91].tolist(),
 
130
  _DET_NMS = 0.65
131
  _KPT_CONF = 0.30
132
 
133
+ # TTA (test-time augmentation): flip the crop horizontally, run DWPose again,
134
+ # flip the body keypoints back and average. Improves keypoint stability at the
135
+ # cost of ~2x inference time. Face/hands are NOT flipped (risky index mapping).
136
+ _POSE_TTA = os.environ.get("WHYX_POSE_TTA", "1").strip().lower() in ("1", "true", "yes")
137
+
138
+ # COCO-17 horizontal flip pairs (indices into the 17 body keypoints).
139
+ _COCO_FLIP_PAIRS = [
140
+ (1, 2), (3, 4), (5, 6), (7, 8), (9, 10), (11, 12), (13, 14), (15, 16),
141
+ ]
142
+
143
+ # Minimum mean body-keypoint confidence to emit pose tags. Below this the
144
+ # detection is considered unreliable and tags are suppressed (keypoints kept).
145
+ _POSE_TAG_CONF_GATE = 0.35
146
+
147
+ # Single aspect-aware box expansion applied before cropping (replaces the old
148
+ # double 15%+20% expansion that shrank the person too much).
149
+ _BOX_EXPAND = 1.25
150
+
151
 
152
  def _sigmoid(x: np.ndarray) -> np.ndarray:
153
  return 1.0 / (1.0 + np.exp(-np.clip(x, -15, 15)))
 
169
  return canvas, scale, float(px), float(py)
170
 
171
 
172
+ def _aspect_crop(arr: np.ndarray, x1: int, y1: int, x2: int, y2: int,
173
+ target_w: int = _POSE_W, target_h: int = _POSE_H,
174
+ expand: float = _BOX_EXPAND):
175
+ """Aspect-preserving crop around a person box, letterboxed to target size.
176
+
177
+ Instead of naively resizing the box to ``target_w x target_h`` (which
178
+ distorts the person when the box aspect != target aspect), this expands
179
+ the box to match the target aspect ratio, then letterboxes the crop.
180
+
181
+ Returns ``(crop_resized, inv_scale, inv_pad_x, inv_pad_y, crop_x1, crop_y1)``
182
+ where the inverse values map keypoints from crop space back to original
183
+ image coordinates: ``orig_x = kp_x / inv_scale - inv_pad_x + crop_x1``.
184
+ """
185
+ img_h, img_w = arr.shape[:2]
186
+ bw, bh = x2 - x1, y2 - y1
187
+ if bw < 10 or bh < 10:
188
+ return None
189
+
190
+ # Expand box
191
+ mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
192
+ ebw, ebh = bw * expand, bh * expand
193
+
194
+ # Match target aspect ratio (target_w / target_h)
195
+ target_aspect = target_w / target_h
196
+ box_aspect = ebw / max(ebh, 1e-6)
197
+ if box_aspect > target_aspect:
198
+ # Box is wider than target → expand height
199
+ ebh = ebw / target_aspect
200
+ else:
201
+ # Box is taller than target → expand width
202
+ ebw = ebh * target_aspect
203
+
204
+ # Compute crop region (clamped to image)
205
+ cx1 = max(0, int(mx - ebw / 2))
206
+ cy1 = max(0, int(my - ebh / 2))
207
+ cx2 = min(img_w, int(mx + ebw / 2))
208
+ cy2 = min(img_h, int(my + ebh / 2))
209
+ cw, ch = cx2 - cx1, cy2 - cy1
210
+ if cw < 10 or ch < 10:
211
+ return None
212
+
213
+ crop = arr[cy1:cy2, cx1:cx2]
214
+
215
+ # Letterbox crop into target_w x target_h
216
+ scale = min(target_w / cw, target_h / ch)
217
+ nw, nh = int(round(cw * scale)), int(round(ch * scale))
218
+ resized = np.array(Image.fromarray(crop).resize((nw, nh), Image.LANCZOS))
219
+ canvas = np.full((target_h, target_w, 3), 114, dtype=np.uint8)
220
+ px = (target_w - nw) // 2
221
+ py = (target_h - nh) // 2
222
+ canvas[py:py + nh, px:px + nw] = resized
223
+
224
+ return canvas, scale, float(px), float(py), cx1, cy1
225
+
226
+
227
+ def _flip_body_kpts(kpts: np.ndarray, width: int) -> np.ndarray:
228
+ """Horizontally flip body keypoints (first 17) and swap left/right pairs.
229
+
230
+ ``kpts`` is ``(133, 3)`` — only the first 17 (COCO body) are flipped.
231
+ Face/hands are returned unchanged.
232
+ """
233
+ out = kpts.copy()
234
+ body = out[0:17].copy()
235
+ body[:, 0] = width - 1 - body[:, 0]
236
+ for a, b in _COCO_FLIP_PAIRS:
237
+ body[a], body[b] = body[b].copy(), body[a].copy()
238
+ out[0:17] = body
239
+ return out
240
+
241
+
242
  def _yolo_decode(out: np.ndarray, scale: float, pad_x: float, pad_y: float,
243
  conf_thresh: float = _DET_CONF, nms_thresh: float = _DET_NMS) -> np.ndarray:
244
  """Decode YOLOX-L output ``(1, 8400, 85)`` into ``(N, 6)`` boxes.
 
739
  y2 = int(min(img_h, cy + h / 2))
740
  if x2 - x1 < 20 or y2 - y1 < 20:
741
  continue
742
+ # No expansion here _aspect_crop applies a single aspect-aware
743
+ # expansion (_BOX_EXPAND) before cropping.
744
+ boxes.append([x1, y1, x2, y2, score, 0.0])
 
 
 
 
 
 
 
 
 
745
 
746
  if not boxes:
747
  return np.zeros((0, 6), dtype=np.float32)
 
774
 
775
  persons: list[np.ndarray] = []
776
  confs: list[float] = []
777
+ areas: list[float] = []
778
  for box in boxes:
779
  x1, y1, x2, y2 = (int(round(v)) for v in box[:4])
780
  x1, y1 = max(0, x1), max(0, y1)
781
  x2, y2 = min(img_w, x2), min(img_h, y2)
782
  if x2 - x1 < 10 or y2 - y1 < 10:
783
  continue
784
+
785
+ # Aspect-preserving crop (fixes the old distortion bug).
786
+ crop_data = _aspect_crop(arr, x1, y1, x2, y2)
787
+ if crop_data is None:
788
+ continue
789
+ crop_resized, inv_scale, inv_px, inv_py, crop_x1, crop_y1 = crop_data
790
+
 
 
 
 
791
  kpts = self._estimate_crop(crop_resized)
792
+
793
+ # TTA: flip horizontally, re-run, flip body kpts back, average.
794
+ if _POSE_TTA:
795
+ flipped_img = crop_resized[:, ::-1, :].copy()
796
+ kpts_flip = self._estimate_crop(flipped_img)
797
+ kpts_flip = _flip_body_kpts(kpts_flip, _POSE_W)
798
+ # Average body keypoints (0-16); keep face/hands from original.
799
+ avg_body = (kpts[0:17] + kpts_flip[0:17]) / 2.0
800
+ kpts[0:17] = avg_body
801
+
802
  # Map keypoints from crop space back to original image coords.
803
+ kpts[:, 0] = (kpts[:, 0] - inv_px) / inv_scale + crop_x1
804
+ kpts[:, 1] = (kpts[:, 1] - inv_py) / inv_scale + crop_y1
805
+
806
  persons.append(kpts)
807
+ body_conf = float(np.mean(kpts[0:17, 2]))
808
+ confs.append(body_conf)
809
+ areas.append(float((x2 - x1) * (y2 - y1)))
810
 
811
  if not persons:
812
  return {"pose_tags": [], "people_count": 0, "pose_score": 0.0,
813
  "keypoints": [], "body_kpts": [], "face_kpts": [], "hand_kpts": []}
814
 
815
+ # Select primary person: weighted by box area * body confidence.
816
+ max_area = max(areas) if areas else 1.0
817
+ scores = [c * (0.5 + 0.5 * (a / max_area)) for c, a in zip(confs, areas)]
818
+ best = int(np.argmax(scores))
819
  best_kpts = persons[best]
820
+ best_conf = confs[best]
821
+
822
+ # Confidence gate: suppress pose tags when body keypoints are unreliable.
823
+ if best_conf >= _POSE_TAG_CONF_GATE:
824
+ pose_tags = _wholebody_tags(best_kpts, img_w, img_h)
825
+ else:
826
+ pose_tags = []
827
 
828
  if _DEBUG:
829
+ print(f"[wholebody] persons={len(persons)} conf={best_conf:.3f} tags={pose_tags}")
830
 
831
  return {
832
  "pose_tags": pose_tags,
833
  "people_count": len(persons),
834
+ "pose_score": round(best_conf, 4),
835
  "keypoints": best_kpts.tolist(),
836
  "body_kpts": best_kpts[0:17].tolist(),
837
  "face_kpts": best_kpts[23:91].tolist(),
tests/test_ensemble.py CHANGED
@@ -4,6 +4,7 @@ import sys
4
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
 
6
  import numpy as np
 
7
  from PIL import Image
8
 
9
  from src.ensemble_tagger import (
@@ -84,14 +85,32 @@ def test_merge_wd_joy_none_second_vote():
84
  def test_fold_pose_injects_tags():
85
  result = _primary_result()
86
  EnsembleTagger._fold_pose(result, ["standing", "arms_up"])
87
- assert result["general"]["standing"] == _POSE_BOOST
88
- assert result["general"]["arms_up"] == _POSE_BOOST
 
89
  assert result["pose_tags"] == ["standing", "arms_up"]
90
  # pose tag lands in the caption/taglist
91
  assert "standing" in result["caption"]
92
  assert "standing" in result["taglist"]
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def test_fold_pose_keeps_existing_tag():
96
  result = _primary_result()
97
  EnsembleTagger._fold_pose(result, ["a"])
 
4
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
 
6
  import numpy as np
7
+ import pytest
8
  from PIL import Image
9
 
10
  from src.ensemble_tagger import (
 
85
  def test_fold_pose_injects_tags():
86
  result = _primary_result()
87
  EnsembleTagger._fold_pose(result, ["standing", "arms_up"])
88
+ # No WD14 consensus → weaker boost (0.7 * _POSE_BOOST)
89
+ assert result["general"]["standing"] == pytest.approx(_POSE_BOOST * 0.7)
90
+ assert result["general"]["arms_up"] == pytest.approx(_POSE_BOOST * 0.7)
91
  assert result["pose_tags"] == ["standing", "arms_up"]
92
  # pose tag lands in the caption/taglist
93
  assert "standing" in result["caption"]
94
  assert "standing" in result["taglist"]
95
 
96
 
97
+ def test_fold_pose_consensus_boost():
98
+ """Pose tags confirmed by WD14 get a stronger boost."""
99
+ result = _primary_result()
100
+ # Add a WD14 tag that matches "sitting" consensus
101
+ result["general"]["sitting"] = 0.6
102
+ EnsembleTagger._fold_pose(result, ["sitting"])
103
+ # "sitting" already in general → keeps its own confidence
104
+ assert result["general"]["sitting"] == 0.6
105
+
106
+ # Test consensus with a tag NOT already in general but WD14 has equivalent
107
+ result2 = _primary_result()
108
+ result2["general"]["on_back"] = 0.5 # WD14 equivalent of "lying"
109
+ EnsembleTagger._fold_pose(result2, ["lying"])
110
+ # "lying" not in general, but "on_back" matches consensus → strong boost
111
+ assert result2["general"]["lying"] == pytest.approx(min(_POSE_BOOST * 1.4, 0.95))
112
+
113
+
114
  def test_fold_pose_keeps_existing_tag():
115
  result = _primary_result()
116
  EnsembleTagger._fold_pose(result, ["a"])
tests/test_tag_categorizer.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for tag_categorizer and wholebody_pose geometry fixes."""
2
+ import numpy as np
3
+ import pytest
4
+
5
+ from src.tag_categorizer import (
6
+ categorize_tags, categorize_emotions, categorize_lighting,
7
+ categorize_style, categorize_concept, analyze_palette,
8
+ )
9
+ from src.wholebody_pose import (
10
+ _aspect_crop, _flip_body_kpts, _COCO_FLIP_PAIRS, _POSE_W, _POSE_H,
11
+ )
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Tag categorizer tests
16
+ # ---------------------------------------------------------------------------
17
+
18
+ def test_categorize_emotions():
19
+ tags = ["1girl", "smile", "blush", "blue_hair", "angry"]
20
+ result = categorize_emotions(tags)
21
+ assert "smile" in result
22
+ assert "blush" in result
23
+ assert "angry" in result
24
+ assert "1girl" not in result
25
+ assert "blue_hair" not in result
26
+
27
+
28
+ def test_categorize_lighting():
29
+ tags = ["cinematic lighting", "backlighting", "1girl", "sky"]
30
+ result = categorize_lighting(tags)
31
+ assert "cinematic lighting" in result
32
+ assert "backlighting" in result
33
+ assert "1girl" not in result
34
+
35
+
36
+ def test_categorize_style():
37
+ tags = ["watercolor", "sketch", "1girl", "smile"]
38
+ result = categorize_style(tags)
39
+ assert "watercolor" in result
40
+ assert "sketch" in result
41
+ assert "1girl" not in result
42
+
43
+
44
+ def test_categorize_concept_cyberpunk():
45
+ tags = ["neon", "city", "night", "futuristic", "1girl"]
46
+ result = categorize_concept(tags)
47
+ assert "cyberpunk" in result
48
+
49
+
50
+ def test_categorize_concept_winter():
51
+ tags = ["snow", "winter", "scarf", "1girl"]
52
+ result = categorize_concept(tags)
53
+ assert "winter" in result
54
+
55
+
56
+ def test_categorize_concept_no_match():
57
+ tags = ["1girl", "smile", "blue_hair"]
58
+ result = categorize_concept(tags)
59
+ assert result == []
60
+
61
+
62
+ def test_analyze_palette_warm():
63
+ # Create a warm-toned image (reds/oranges)
64
+ img = np.zeros((100, 100, 3), dtype=np.uint8)
65
+ img[:, :, 0] = 200 # high red
66
+ img[:, :, 1] = 100 # medium green
67
+ img[:, :, 2] = 50 # low blue
68
+ result = analyze_palette(img)
69
+ assert "warm_colors" in result
70
+
71
+
72
+ def test_analyze_palette_cool():
73
+ # Create a cool-toned image (blues)
74
+ img = np.zeros((100, 100, 3), dtype=np.uint8)
75
+ img[:, :, 0] = 50
76
+ img[:, :, 1] = 100
77
+ img[:, :, 2] = 200
78
+ result = analyze_palette(img)
79
+ assert "cool_colors" in result
80
+
81
+
82
+ def test_analyze_palette_monochrome():
83
+ # Grayscale image
84
+ img = np.full((100, 100, 3), 128, dtype=np.uint8)
85
+ result = analyze_palette(img)
86
+ assert "monochrome" in result
87
+
88
+
89
+ def test_analyze_palette_empty():
90
+ assert analyze_palette(None) == []
91
+ assert analyze_palette(np.zeros((5, 5, 3), dtype=np.uint8)) == []
92
+
93
+
94
+ def test_categorize_tags_full():
95
+ tags = ["1girl", "smile", "cinematic lighting", "watercolor", "neon", "city"]
96
+ img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
97
+ result = categorize_tags(tags, img)
98
+ assert "emotion" in result
99
+ assert "lighting" in result
100
+ assert "style" in result
101
+ assert "concept" in result
102
+ assert "palette" in result
103
+ assert "smile" in result["emotion"]
104
+ assert "cinematic lighting" in result["lighting"]
105
+ assert "watercolor" in result["style"]
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Geometry tests (aspect crop + flip)
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def test_aspect_crop_square_box():
113
+ """Square box should produce a valid crop at target size."""
114
+ arr = np.random.randint(0, 255, (400, 400, 3), dtype=np.uint8)
115
+ result = _aspect_crop(arr, 100, 100, 300, 300)
116
+ assert result is not None
117
+ crop, scale, px, py, cx1, cy1 = result
118
+ assert crop.shape == (_POSE_H, _POSE_W, 3)
119
+
120
+
121
+ def test_aspect_crop_wide_box():
122
+ """Wide box should be letterboxed vertically, not distorted."""
123
+ arr = np.random.randint(0, 255, (400, 600, 3), dtype=np.uint8)
124
+ result = _aspect_crop(arr, 50, 150, 550, 250) # 500x100 wide box
125
+ assert result is not None
126
+ crop, scale, px, py, cx1, cy1 = result
127
+ assert crop.shape == (_POSE_H, _POSE_W, 3)
128
+ # Wide box → letterboxed top/bottom → py > 0
129
+ assert py > 0
130
+
131
+
132
+ def test_aspect_crop_tall_box():
133
+ """Tall box should be letterboxed horizontally, not distorted."""
134
+ arr = np.random.randint(0, 255, (600, 400, 3), dtype=np.uint8)
135
+ result = _aspect_crop(arr, 150, 50, 250, 550) # 100x500 tall box
136
+ assert result is not None
137
+ crop, scale, px, py, cx1, cy1 = result
138
+ assert crop.shape == (_POSE_H, _POSE_W, 3)
139
+ # Tall box → letterboxed left/right → px > 0
140
+ assert px > 0
141
+
142
+
143
+ def test_aspect_crop_too_small():
144
+ """Tiny box should return None."""
145
+ arr = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
146
+ result = _aspect_crop(arr, 10, 10, 15, 15)
147
+ assert result is None
148
+
149
+
150
+ def test_flip_body_kpts_symmetry():
151
+ """Flipping twice should return to original (for body keypoints)."""
152
+ kpts = np.random.rand(133, 3).astype(np.float32)
153
+ kpts[:, 0] *= _POSE_W # x in crop space
154
+ kpts[:, 1] *= _POSE_H # y in crop space
155
+ kpts[:, 2] = 0.9 # confidence
156
+
157
+ flipped = _flip_body_kpts(kpts, _POSE_W)
158
+ double_flipped = _flip_body_kpts(flipped, _POSE_W)
159
+
160
+ # Body keypoints should be back to original
161
+ np.testing.assert_allclose(double_flipped[0:17], kpts[0:17], atol=1.0)
162
+ # Face/hands should be unchanged after both flips
163
+ np.testing.assert_allclose(double_flipped[17:], kpts[17:], atol=1e-5)
164
+
165
+
166
+ def test_flip_body_kpts_swaps_pairs():
167
+ """Flip should swap left/right keypoint pairs."""
168
+ kpts = np.zeros((133, 3), dtype=np.float32)
169
+ # Set left shoulder (index 5) at x=100, right shoulder (index 6) at x=200
170
+ kpts[5] = [100, 50, 0.9]
171
+ kpts[6] = [200, 50, 0.9]
172
+
173
+ flipped = _flip_body_kpts(kpts, _POSE_W)
174
+
175
+ # After flip: left shoulder should be at (POSE_W-1-200), right at (POSE_W-1-100)
176
+ # But they're also swapped, so index 5 gets old index 6's flipped position
177
+ expected_x5 = _POSE_W - 1 - 200
178
+ expected_x6 = _POSE_W - 1 - 100
179
+ assert abs(flipped[5, 0] - expected_x5) < 1.0
180
+ assert abs(flipped[6, 0] - expected_x6) < 1.0
181
+
182
+
183
+ def test_flip_pairs_cover_all_limbs():
184
+ """All COCO flip pairs should be valid indices in 0-16."""
185
+ for a, b in _COCO_FLIP_PAIRS:
186
+ assert 0 <= a <= 16
187
+ assert 0 <= b <= 16
188
+ assert a != b