Whyx-PROmpTea / src /pose_tagger.py
ArtShumov's picture
fix: pose tag naming + swap DWPose detector to YOLOv8m-pose for anime
4fb75d4
Raw
History Blame Contribute Delete
24.2 kB
"""Pose estimation from an image via YOLO-pose ONNX Runtime.
Runs a YOLO-pose ONNX model for person detection + 17-keypoint pose
estimation. The 17 COCO keypoints are mapped to Danbooru-style pose tags
(e.g. ``standing, arms_up``) that blend into the WD14 general-tag list.
Default checkpoint is ``Xenova/yolov8m-pose`` (~83 MB) — letterboxed at
1024x1024 for reliable anime character detection. Override via
``WHYX_POSE_MODEL`` / ``WHYX_POSE_FILENAME`` / ``WHYX_POSE_INPUT``.
"""
from __future__ import annotations
import math
import os
from typing import Optional
import numpy as np
from PIL import Image, ImageOps
try:
import onnxruntime as ort
except Exception: # pragma: no cover
ort = None
# ---------------------------------------------------------------------------
# Keypoint analysis
# ---------------------------------------------------------------------------
_DEBUG_POSE = os.environ.get("WHYX_DEBUG_POSE", "0").strip().lower() in ("1", "true", "yes")
_POSE_REPO = os.environ.get("WHYX_POSE_MODEL", "Xenova/yolov8m-pose")
_POSE_FILENAME = os.environ.get("WHYX_POSE_FILENAME", "onnx/model.onnx")
_POSE_INPUT = int(os.environ.get("WHYX_POSE_INPUT", "1024"))
_COCO_KP = [
"nose", "left eye", "right eye", "left ear", "right ear", "left shoulder",
"right shoulder", "left elbow", "right elbow", "left wrist", "right wrist",
"left hip", "right hip", "left knee", "right knee", "left ankle",
"right ankle",
]
# YOLOv8-pose ONNX model keypoint order is COCO 17. Each detection row is
# [x, y, w, h, conf, kp0_x, kp0_y, kp0_conf, ..., kp16_x, kp16_y, kp16_conf].
_KPT_START = 5 # detection feature index where keypoints begin
_KPT_STRIDE = 3 # (x, y, conf) per keypoint
# Wrist keypoint confidence gate for arm pose heuristics. Wrists are the most
# jittery COCO keypoints; 0.30 produced false "one_arm_up" hits, 0.35 is the
# sweet spot between recall and noise.
_WRIST_MIN = 0.35
# Minimum mean keypoint confidence for a detection to emit pose tags.
# Below this threshold the detection is considered unreliable and pose_tags
# are suppressed (keypoints are still kept for skeleton overlay).
_MIN_POSE_CONF = 0.35
def _yolo_to_keypoints(out: np.ndarray, conf_thresh: float = 0.25, iou_thresh: float = 0.5):
"""out shape: (1, N+4, num_detections) — 84 for det-only, 51+5 for pose."""
# (1, C, D) -> (D, C) so each row is a candidate detection.
preds = out[0].T
conf = preds[:, 4]
keep = conf > conf_thresh
preds = preds[keep]
if not len(preds):
return []
# NMS via greedy diagonal covariance check (good enough at pose level).
def _nms(rows, thresh):
order = conf[keep].argsort()[::-1]
keep_idx = []
suppressed = set()
centers = rows[:, :2]
wh = rows[:, 2:4]
areas = wh[:, 0] * wh[:, 1]
for i in order:
if i in suppressed:
continue
keep_idx.append(i)
cx1, cy1 = centers[i]
w1, h1 = wh[i]
for j in order:
if j == i or j in suppressed:
continue
cx2, cy2 = centers[j]
w2, h2 = wh[j]
# Intersection over smaller area (proxy for pose NMS).
dx = max(0, min(cx1 + w1/2, cx2 + w2/2) - max(cx1 - w1/2, cx2 - w2/2))
dy = max(0, min(cy1 + h1/2, cy2 + h2/2) - max(cy1 - h1/2, cy2 - h2/2))
inter = dx * dy
smaller = min(areas[i], areas[j])
if smaller > 0 and inter / smaller > thresh:
suppressed.add(j)
return rows[keep_idx]
preds = _nms(preds, iou_thresh)
kpts = []
for row in preds:
kp = row[_KPT_START:].reshape(-1, _KPT_STRIDE)
kpts.append(kp)
return kpts
def _vis(kp: np.ndarray, i: int, thresh: float = 0.30) -> bool:
"""Keypoint visibility: confidence gate for COCO-17 row ``i``."""
return kp[i, 2] >= thresh
def _mid(a, b):
"""Midpoint of two keypoint (x, y) pairs; None if both missing."""
if a is None and b is None:
return None
if a is None:
return b
if b is None:
return a
return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
def _hypot(dx: float, dy: float) -> float:
return float((dx * dx + dy * dy) ** 0.5)
def _single_person_tags(kp: np.ndarray) -> list[str]:
"""Map one person's COCO-17 keypoints to Danbooru-style pose tags.
All distances are normalised by shoulder width so the tags stay
pose-invariant across image scales. Returns booru tags with underscores
(e.g. ``arms_up``) so they blend into the WD14 general-tag list. Tag names
are chosen to match the WD14 v3 vocabulary where one exists
(``arms_at_sides``, ``praying``, ``crossed_legs``, ...); a few booru-only
tags (``crouching``, ``on all fours``, ...) are emitted when no valid
WD14 synonym exists.
"""
out: set[str] = set()
ls, rs = kp[5], kp[6]
le_, re_ = kp[7], kp[8] # elbows
lw, rw = kp[9], kp[10] # wrists
lh, rh = kp[11], kp[12]
lk, rk = kp[13], kp[14]
la, ra = kp[15], kp[16]
nose = kp[0]
lea, rea = kp[3], kp[4] # ears
sw = max(abs(ls[0] - rs[0]), 1e-4)
torso_ok = _vis(kp, 5) and _vis(kp, 6) and _vis(kp, 11) and _vis(kp, 12)
sc = _mid(ls[:2], rs[:2])
hc = _mid(lh[:2], rh[:2])
kc = _mid(lk[:2], rk[:2])
ac = _mid(la[:2], ra[:2])
# ------------------------------------------------------------------
# Torso posture
# ------------------------------------------------------------------
# On all fours: torso roughly horizontal, hips ABOVE the knees and both
# wrists planted near the knees (hands and knees on the ground).
all_fours = False
if torso_ok and sc is not None and _vis(kp, 13) and _vis(kp, 14):
if (
abs(lw[1] - lk[1]) < 0.4 * sw and abs(lw[0] - lk[0]) < 0.8 * sw
and abs(rw[1] - rk[1]) < 0.4 * sw and abs(rw[0] - rk[0]) < 0.8 * sw
):
all_fours = True
if torso_ok and sc is not None and hc is not None:
tvx, tvy = sc[0] - hc[0], sc[1] - hc[1]
torso_len = _hypot(tvx, tvy)
theta = abs(tvx) / max(torso_len, 1e-4) # 0=vertical, 1=horizontal
if all_fours and theta > 0.55:
out.add("on_all_fours")
elif theta > 0.72:
out.add("lying")
elif kc is not None:
knee_dy = kc[1] - hc[1] # + = knees below hips
knee_dx = abs(kc[0] - hc[0])
# splits: both legs straight, spread very wide, hips near ground
splits = (
_vis(kp, 13) and _vis(kp, 14) and _vis(kp, 15) and _vis(kp, 16)
and abs(la[1] - lk[1]) < 0.25 * sw
and abs(ra[1] - rk[1]) < 0.25 * sw
and abs(la[0] - ra[0]) > 1.7 * sw
and hc[1] > kc[1] - 0.2 * sw
)
if splits:
out.add("splits")
elif knee_dy < -0.08 * sw:
# knees raised toward the hips → seated
out.add("sitting")
elif (
_vis(kp, 15) and _vis(kp, 16)
and la[1] <= lk[1] + 0.2 * sw
and ra[1] <= rk[1] + 0.2 * sw
and knee_dy < 0.35 * sw
):
# both feet pulled up to knee level with knees raised →
# airborne tuck jump
out.add("jumping")
elif knee_dy < 0.35 * sw and knee_dx < 0.8 * sw:
# hips very close to knees with knees tucked
out.add("squatting")
if knee_dy < 0.15 * sw and knee_dx < 0.6 * sw:
out.add("crouching")
else:
# kneeling: knees planted low, hips above, shins horizontal
knee_planted_l = _vis(kp, 13) and _vis(kp, 15) and abs(lk[1] - la[1]) < 0.25 * sw and abs(lk[0] - la[0]) < 0.5 * sw
knee_planted_r = _vis(kp, 14) and _vis(kp, 16) and abs(rk[1] - ra[1]) < 0.25 * sw and abs(rk[0] - ra[0]) < 0.5 * sw
foot_planted_l = _vis(kp, 13) and _vis(kp, 15) and la[1] > lk[1] + 0.35 * sw
foot_planted_r = _vis(kp, 14) and _vis(kp, 16) and ra[1] > rk[1] + 0.35 * sw
one_knee = (
(knee_planted_l and foot_planted_r and not knee_planted_r)
or (foot_planted_l and knee_planted_r and not knee_planted_l)
)
kneeling = (
knee_planted_l and knee_planted_r
and ac is not None
and abs(ac[1] - kc[1]) < 0.25 * sw
and abs(ac[0] - kc[0]) < 0.5 * sw
)
if one_knee:
out.add("kneeling_on_one_knee")
elif kneeling:
out.add("kneeling")
else:
# legs scissor → walking / running (mutually exclusive
# with standing — no contradictory tags in the output).
if _vis(kp, 15) and _vis(kp, 16) and _vis(kp, 13) and _vis(kp, 14):
stride = abs(la[0] - ra[0])
if stride > 2.0 * sw:
out.add("running")
elif stride > 1.5 * sw:
out.add("walking")
else:
out.add("standing")
else:
out.add("standing")
else:
out.add("standing")
# torso lean / bent over (must not conflict with lying / all fours)
if "lying" not in out and "on_all_fours" not in out and theta <= 0.72:
if theta > 0.55:
out.add("bent_over")
elif theta > 0.3:
out.add("leaning_forward" if tvx > 0 else "leaning_back")
elif kc is not None and hc is not None:
# partial visibility: infer sitting/standing from hips+knees only
if kc[1] < hc[1] - 0.08 * sw:
out.add("sitting")
else:
out.add("standing")
# ------------------------------------------------------------------
# Arms (require both shoulders visible)
# ------------------------------------------------------------------
if _vis(kp, 5) and _vis(kp, 6):
sides = []
for w, e, s, h in ((lw, le_, ls, lh), (rw, re_, rs, rh)):
if w[2] < _WRIST_MIN or s[2] < 0.3:
continue
side = {"w": w, "e": e, "s": s, "h": h}
side["up"] = w[1] < s[1] - 0.15 * sw
side["out"] = abs(w[0] - s[0]) > 0.4 * sw
side["on_hip"] = abs(w[0] - h[0]) < 0.35 * sw and abs(w[1] - h[1]) < 0.3 * sw
side["in_pocket"] = (
w[1] > h[1] + 0.1 * sw
and w[1] < h[1] + 0.6 * sw
and abs(w[0] - h[0]) < 0.3 * sw
)
side["covers_face"] = (
_vis(kp, 0)
and abs(w[0] - nose[0]) < 0.35 * sw
and abs(w[1] - nose[1]) < 0.45 * sw
)
side["hand_face"] = (
not side["covers_face"]
and _vis(kp, 0)
and abs(w[0] - nose[0]) < 0.5 * sw
and nose[1] - 0.2 * sw < w[1] < nose[1] + 0.5 * sw
)
# arm hanging straight down beside the body
side["down"] = (
e[2] >= 0.3
and w[1] > e[1] + 0.05 * sw
and w[1] > s[1] + 0.2 * sw
and abs(w[0] - s[0]) < 0.5 * sw
)
# wrist on the chest / belly (torso midline, guard sc/hc)
side["on_chest"] = (
sc is not None and hc is not None
and abs(w[0] - sc[0]) < 0.45 * sw
and sc[1] + 0.25 * sw < w[1] < hc[1] + 0.05 * sw
)
side["on_stomach"] = (
sc is not None and hc is not None
and abs(w[0] - sc[0]) < 0.45 * sw
and hc[1] - 0.15 * sw < w[1] < hc[1] + 0.45 * sw
)
sides.append(side)
for sd in sides:
if sd["covers_face"]:
out.add("covering_face")
elif sd["hand_face"]:
out.add("hand_on_own_face")
hip_sides = [sd for sd in sides if sd["on_hip"] and not sd["up"]]
pocket_sides = [sd for sd in sides if sd["in_pocket"] and not sd["up"]]
n_up = sum(1 for sd in sides if sd["up"])
if n_up == 2:
elbows_up = all(sd["e"][2] >= 0.3 and sd["e"][1] < sd["s"][1] + 0.05 * sw for sd in sides)
near_head = _vis(kp, 0) and all(abs(sd["w"][0] - nose[0]) < 0.7 * sw for sd in sides)
out.add("arms_behind_head" if (elbows_up and near_head) else "arms_up")
elif n_up == 1:
out.add("one_arm_up")
# one arm straight overhead + the other hanging down → stretch
up_side = next(sd for sd in sides if sd["up"])
others_down = all(sd["down"] for sd in sides if not sd["up"])
if (
others_down
and up_side["w"][1] < up_side["s"][1] - 0.5 * sw
and up_side["e"][2] >= 0.3
and up_side["w"][1] < up_side["e"][1] - 0.15 * sw
):
out.add("stretching")
# wrists close together: clasped hands → praying (chest) or arms
# around neck (nape height)
hands_together = (
len(sides) == 2
and abs(lw[0] - rw[0]) < 0.3 * sw
and abs(lw[1] - rw[1]) < 0.3 * sw
)
if hands_together and sc is not None and hc is not None:
wmid_y = (lw[1] + rw[1]) / 2.0
if wmid_y < sc[1] + 0.35 * sw:
out.add("arms_around_neck")
elif wmid_y < hc[1] + 0.1 * sw:
out.add("praying")
# hands clasped at the small of the back
arms_behind_back = (
len(sides) == 2 and sc is not None and hc is not None
and abs(lw[0] - rw[0]) < 0.5 * sw
and abs(lw[1] - rw[1]) < 0.35 * sw
and lw[1] > hc[1] + 0.2 * sw and rw[1] > hc[1] + 0.2 * sw
and abs(lw[0] - sc[0]) < 0.45 * sw and abs(rw[0] - sc[0]) < 0.45 * sw
)
if arms_behind_back:
out.add("arms_behind_back")
# single hand on chest / belly (not when hands are clasped)
if not hands_together:
chest_sides = [
sd for sd in sides
if sd["on_chest"] and not sd["covers_face"] and not sd["hand_face"]
and not sd["on_hip"] and not sd["in_pocket"] and not sd["up"]
]
if len(chest_sides) == 1:
out.add("hand_on_own_chest")
stomach_sides = [
sd for sd in sides
if sd["on_stomach"] and not sd["covers_face"] and not sd["hand_face"]
and not sd["on_hip"] and not sd["in_pocket"] and not sd["up"]
]
if len(stomach_sides) == 1 and "arms_behind_back" not in out:
out.add("hand_on_own_stomach")
# both arms hanging straight down at the sides
if (
len(sides) == 2
and all(sd["down"] for sd in sides)
and not hands_together
and not arms_behind_back
and not any(sd["on_hip"] or sd["in_pocket"] for sd in sides)
and "on_all_fours" not in out
):
out.add("arms_at_sides")
if len(sides) == 2:
s0, s1 = sides
# crossed arms: each wrist lands on the opposite side of the torso
if (
s0["w"][0] > rs[0] and s1["w"][0] < ls[0]
and all(sd["s"][1] < sd["w"][1] < sd["h"][1] for sd in sides)
):
out.add("arms_crossed")
elif len(hip_sides) == 2:
out.add("hands_on_hips")
elif len(pocket_sides) == 2:
out.add("hands_in_pockets")
elif all(sd["out"] and not sd["up"] for sd in sides):
out.add("arms_out")
if len(hip_sides) == 1:
out.add("hand_on_hip")
if len(pocket_sides) == 1:
out.add("hand_in_pocket")
# ------------------------------------------------------------------
# Head tilt (ears asymmetry)
# ------------------------------------------------------------------
if _vis(kp, 3) and _vis(kp, 4) and abs(lea[1] - rea[1]) > 0.3 * sw:
out.add("head_tilt")
# ------------------------------------------------------------------
# Face orientation: frontal face (nose + both ears spread) → viewer
# ------------------------------------------------------------------
if (
sc is not None
and _vis(kp, 0) and _vis(kp, 3) and _vis(kp, 4)
and abs(lea[0] - rea[0]) >= 0.35 * sw
and nose[1] < sc[1]
):
out.add("looking_at_viewer")
# ------------------------------------------------------------------
# Legs detail (needs both ankles)
# ------------------------------------------------------------------
if _vis(kp, 15) and _vis(kp, 16):
stride = abs(la[0] - ra[0])
dy = abs(la[1] - ra[1])
# legs apart while standing
if "standing" in out and stride > 1.2 * sw and "splits" not in out:
out.add("legs_apart")
# crossed legs while seated
if "sitting" in out and stride < 0.3 * sw and dy < 0.25 * sw:
out.add("crossed_legs")
# ankles crossed while standing (one foot hooked past the other)
elif (
"standing" in out and stride < 0.35 * sw and dy < 0.3 * sw
and (la[0] > ra[0] + 0.1 * sw or ra[0] > la[0] + 0.1 * sw)
):
out.add("crossed_legs")
# one leg raised (foot clearly above the other, above its knee)
if _vis(kp, 13) and _vis(kp, 14):
hi, lo = (la, ra) if la[1] < ra[1] else (ra, la)
hi_knee = lk if hi is la else rk
if lo[1] - hi[1] > 0.5 * sw and hi[1] < hi_knee[1] - 0.1 * sw:
out.add("one_leg_up")
if "standing" in out:
out.add("standing_on_one_leg")
return list(out)
def _keypoints_to_pose_tags(kpts: list[np.ndarray]) -> list[str]:
"""Heuristic mapping of COCO-17 coordinates to Danbooru-style pose tags.
Returns tags with booru underscores (``arms_up``, ``standing``, ...) so
they can be folded into the WD14 general-tag list. All checks are
normalised by shoulder width and gated on keypoint confidence.
For multi-person images only the primary person (highest mean keypoint
confidence) is used for tag generation. Other detections are kept only
for the people_count and skeleton overlay.
"""
if not kpts:
return []
# Pick the primary person: highest mean keypoint confidence.
confs = [float(np.mean(kp[:, 2])) for kp in kpts if kp.size]
best = int(np.argmax(confs))
return _single_person_tags(kpts[best])
# ---------------------------------------------------------------------------
# ONNX pose runner
# ---------------------------------------------------------------------------
def _providers() -> list[str]:
avail = ort.get_available_providers()
return ["CUDAExecutionProvider", "CPUExecutionProvider"] if "CUDAExecutionProvider" in avail else ["CPUExecutionProvider"]
def _letterbox(im: Image.Image, size: int = 1024) -> tuple[Image.Image, float, float, float]:
"""Letterbox *im* to ``size × size`` with grey padding (114, 114, 114).
YOLO-native preprocessing: scale so the image fits inside the box,
then pad the remaining space with neutral grey. This preserves the
full image content (no cropping).
Returns ``(canvas, scale, pad_x, pad_y)`` so the caller can invert
the mapping back to original coordinates.
"""
iw, ih = im.size
s = min(size / float(iw), size / float(ih))
nw, nh = int(round(iw * s)), int(round(ih * s))
resized = im.resize((nw, nh), Image.LANCZOS)
canvas = Image.new("RGB", (size, size), (114, 114, 114))
px = (size - nw) // 2
py = (size - nh) // 2
canvas.paste(resized, (px, py))
return canvas, s, float(px), float(py)
def _inverse_letterbox(kp: np.ndarray, orig_size, pad_size: int,
scale: float, pad_x: float, pad_y: float) -> np.ndarray:
"""Map keypoints from letterbox space back into original image coordinates.
Inverts the letterbox transformation: ``(kp_x - pad_x) / scale`` for x,
``(kp_y - pad_y) / scale`` for y. Row layout is preserved: ``(N, 3)``
with the confidence column untouched.
"""
xs = (kp[:, 0] - pad_x) / scale
ys = (kp[:, 1] - pad_y) / scale
return np.stack([xs, ys, kp[:, 2]], axis=1)
class PoseEstimator:
"""YOLO-pose wrapper with lazy model download."""
def __init__(self, repo_id: str = _POSE_REPO, filename: str = _POSE_FILENAME):
self._repo = repo_id
self._filename = filename
self._session: Optional["ort.InferenceSession"] = None
self._input_shape: tuple[int, int] = (_POSE_INPUT, _POSE_INPUT)
self._loaded = False
def ensure_loaded(self) -> bool:
if self._loaded:
return True
if ort is None:
return False
from huggingface_hub import hf_hub_download
try:
model_path = hf_hub_download(repo_id=self._repo, filename=self._filename)
except Exception:
return False
sess = ort.InferenceSession(model_path, providers=_providers())
inp = sess.get_inputs()[0]
shape = inp.shape
if len(shape) == 4:
self._input_shape = (int(shape[2]), int(shape[3]))
self._session = sess
self._loaded = True
return True
def estimate(self, image) -> dict:
"""Return {pose_tags, pose_score, people_count, keypoints}.
``keypoints`` is a list of ``(N, 3)`` float arrays (COCO-17 order,
columns x/y/confidence) mapped back into the coordinates of the
EXIF-transposed input image, so callers can overlay skeletons or
feed a ControlNet pipeline directly.
"""
if not self.ensure_loaded():
return {"pose_tags": [], "people_count": 0, "keypoints": []}
pil = image if isinstance(image, Image.Image) else Image.fromarray(np.asarray(image))
# Transpose first so the fitted space and the display orientation
# match; the inverse-letterbox mapping then needs no EXIF bookkeeping.
pil = ImageOps.exif_transpose(pil)
orig_size = pil.size
size = self._input_shape[0]
canvas, scale, pad_x, pad_y = _letterbox(pil, size)
arr = np.asarray(canvas, dtype=np.float32) / 255.0
arr = arr.transpose(2, 0, 1)[None, ...] # NCHW
inp_name = self._session.get_inputs()[0].name
out = self._session.run(None, {inp_name: arr})[0]
kpts = _yolo_to_keypoints(out)
if _DEBUG_POSE:
print(f"[pose] raw_out={out.shape}, n_kpts_above_thresh={len(kpts)}")
# Compute per-detection mean confidence and map keypoints back.
mapped = [_inverse_letterbox(kp, orig_size, size, scale, pad_x, pad_y)
for kp in kpts]
confs = [float(np.mean(kp[:, 2])) for kp in kpts if kp.size]
pose_score = float(np.mean(confs)) if confs else 0.0
# Quality gate: suppress tags for low-confidence detections.
if pose_score >= _MIN_POSE_CONF:
pose_tags = _keypoints_to_pose_tags(kpts)
else:
pose_tags = []
if _DEBUG_POSE:
print(f"[pose] pose_score={pose_score:.3f}, tags={pose_tags}")
return {
"pose_tags": pose_tags,
"people_count": len(kpts),
"pose_score": round(pose_score, 4),
"keypoints": mapped,
}
_pose_instance: PoseEstimator | None = None
def get_pose_tagger() -> PoseEstimator:
global _pose_instance
if _pose_instance is None:
_pose_instance = PoseEstimator()
return _pose_instance