Spaces:
Running
Running
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 +24 -0
- scripts/validate_smart_accuracy.py +124 -0
- src/ensemble_tagger.py +48 -2
- src/i18n.py +10 -0
- src/tag_categorizer.py +295 -0
- src/wholebody_pose.py +128 -30
- tests/test_ensemble.py +21 -2
- tests/test_tag_categorizer.py +188 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 655 |
-
|
| 656 |
-
|
| 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 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 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]
|
| 718 |
-
kpts[:, 1] = kpts[:, 1]
|
|
|
|
| 719 |
persons.append(kpts)
|
| 720 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 727 |
best_kpts = persons[best]
|
| 728 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
|
| 730 |
if _DEBUG:
|
| 731 |
-
print(f"[wholebody] persons={len(persons)}
|
| 732 |
|
| 733 |
return {
|
| 734 |
"pose_tags": pose_tags,
|
| 735 |
"people_count": len(persons),
|
| 736 |
-
"pose_score": round(
|
| 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 |
-
|
| 88 |
-
assert result["general"]["
|
|
|
|
| 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
|