File size: 6,221 Bytes
d0eb71c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#!/usr/bin/env python3
"""kimodo motion -> OpenPose control image, for driving the weiner Klein LoRA.

The kimodo motion API (http://HOST:7862) returns, per clip, `posed_joints` [T,22,3]:
world-space 3D positions of the 22 SMPL-X body joints (y-up, metres). This module
projects a chosen frame to 2D and draws a canonical OpenPose (COCO-18) colored
skeleton — the abstract pose anchor that FLUX.2 Klein follows WITHOUT donating any
material (unlike a textured mannequin), so the weiner identity/LoRA supplies looks.

Pipeline:  kimodo /animations/{id}  ->  openpose_png(frame)  ->  klein /generate
           image=[skeleton, weiner_identity_shot], lora=weiner

CLI:
    python kimodo_pose.py <anim_id> <out.png> [--frame N | --peak foot|hand] [--view front|side|auto]
"""
from __future__ import annotations

import argparse
import json
import urllib.request

import numpy as np
from PIL import Image, ImageDraw

KIMODO_URL = "http://127.0.0.1:7862"

# SMPL-X 22-joint order returned by the kimodo API (see /info bone_names).
SMPLX = {
    "pelvis": 0, "left_hip": 1, "right_hip": 2, "spine1": 3, "left_knee": 4,
    "right_knee": 5, "spine2": 6, "left_ankle": 7, "right_ankle": 8, "spine3": 9,
    "left_foot": 10, "right_foot": 11, "neck": 12, "left_collar": 13,
    "right_collar": 14, "head": 15, "left_shoulder": 16, "right_shoulder": 17,
    "left_elbow": 18, "right_elbow": 19, "left_wrist": 20, "right_wrist": 21,
}

# OpenPose COCO-18 keypoint index -> source SMPL-X joint (eyes/ears synthesized below).
OP_FROM_SMPLX = {
    0: "head",            # nose ~ head
    1: "neck", 2: "right_shoulder", 3: "right_elbow", 4: "right_wrist",
    5: "left_shoulder", 6: "left_elbow", 7: "left_wrist",
    8: "right_hip", 9: "right_knee", 10: "right_ankle",
    11: "left_hip", 12: "left_knee", 13: "left_ankle",
}
# OpenPose limb pairs (COCO-18) + canonical colors (R,G,B).
OP_LIMBS = [
    (1, 2, (255, 0, 0)), (1, 5, (255, 85, 0)), (2, 3, (255, 170, 0)),
    (3, 4, (255, 255, 0)), (5, 6, (170, 255, 0)), (6, 7, (85, 255, 0)),
    (1, 8, (0, 255, 0)), (8, 9, (0, 255, 85)), (9, 10, (0, 255, 170)),
    (1, 11, (0, 255, 255)), (11, 12, (0, 170, 255)), (12, 13, (0, 85, 255)),
    (1, 0, (0, 0, 255)), (0, 14, (85, 0, 255)), (14, 16, (170, 0, 255)),
    (0, 15, (255, 0, 255)), (15, 17, (255, 0, 170)),
]
OP_POINT_COLOR = [
    (255, 0, 0), (255, 85, 0), (255, 170, 0), (255, 255, 0), (170, 255, 0),
    (85, 255, 0), (0, 255, 0), (0, 255, 85), (0, 255, 170), (0, 255, 255),
    (0, 170, 255), (0, 85, 255), (0, 0, 255), (85, 0, 255), (170, 0, 255),
    (255, 0, 255), (255, 0, 170), (255, 0, 85),
]


def fetch(anim_id: str, url: str = KIMODO_URL) -> dict:
    return json.load(urllib.request.urlopen(f"{url}/animations/{anim_id}", timeout=30))


def peak_frame(P: np.ndarray, what: str = "foot") -> int:
    """Frame where the action reads clearest: foot/hand at max height (kick/punch peak)."""
    idx = [SMPLX["left_foot"], SMPLX["right_foot"], SMPLX["left_ankle"], SMPLX["right_ankle"]] \
        if what == "foot" else [SMPLX["left_wrist"], SMPLX["right_wrist"]]
    return int(P[:, idx, 1].max(axis=1).argmax())


def _project(J: np.ndarray, view: str):
    """3D world joints [22,3] (y up) -> 2D [22,2] in image space. front: x-y; side: z-y."""
    x, y, z = J[:, 0], J[:, 1], J[:, 2]
    if view == "auto":
        view = "side" if z.std() > x.std() * 1.15 else "front"
    h = x if view == "front" else z
    return np.stack([h, y], axis=1), view  # (horizontal, vertical-up)


def openpose_image(P_frame: np.ndarray, size: int = 1024, view: str = "auto",
                   pad: float = 0.14) -> Image.Image:
    """Draw a COCO-18 OpenPose skeleton for one frame's [22,3] joints on black."""
    pts2d, _ = _project(P_frame, view)
    # build the 18 OpenPose keypoints
    kp = np.full((18, 2), np.nan)
    for op_i, name in OP_FROM_SMPLX.items():
        kp[op_i] = pts2d[SMPLX[name]]
    # synthesize eyes/ears around the head so the face limbs render
    head, neck = pts2d[SMPLX["head"]], pts2d[SMPLX["neck"]]
    up = (head - neck); span = np.linalg.norm(up) + 1e-6; up = up / span
    side = np.array([-up[1], up[0]]) * span * 0.25
    kp[0] = head + up * span * 0.15                 # nose
    kp[14], kp[15] = head + side * 0.6, head - side * 0.6   # R/L eye
    kp[16], kp[17] = head + side, head - side              # R/L ear

    # normalize into the canvas (preserve aspect, feet at bottom, head at top)
    valid = kp[~np.isnan(kp).any(axis=1)]
    hmin, hmax = valid[:, 0].min(), valid[:, 0].max()
    vmin, vmax = valid[:, 1].min(), valid[:, 1].max()
    s = (1 - 2 * pad) * size / max(hmax - hmin, vmax - vmin, 1e-6)
    cu = size / 2 - (hmin + hmax) / 2 * s
    cv = size / 2 + (vmin + vmax) / 2 * s

    def to_px(p):
        return (cu + p[0] * s, cv - p[1] * s)  # flip vertical (y-up -> image-down)

    img = Image.new("RGB", (size, size), (0, 0, 0))
    d = ImageDraw.Draw(img)
    lw = max(4, size // 160)
    for a, b, c in OP_LIMBS:
        if np.isnan(kp[a]).any() or np.isnan(kp[b]).any():
            continue
        d.line([to_px(kp[a]), to_px(kp[b])], fill=c, width=lw)
    r = max(4, size // 200)
    for i, p in enumerate(kp):
        if np.isnan(p).any():
            continue
        x, y = to_px(p)
        d.ellipse([x - r, y - r, x + r, y + r], fill=OP_POINT_COLOR[i])
    return img


def render_anim_frame(anim_id, out, frame=None, peak="foot", view="auto", url=KIMODO_URL):
    rec = fetch(anim_id, url)
    P = np.array(rec["posed_joints"])
    f = peak_frame(P, peak) if frame is None else int(frame)
    img = openpose_image(P[f], view=view)
    img.save(out)
    print(f"saved {out}  anim={anim_id} frame={f}/{P.shape[0]} view={view} prompt={rec.get('prompt','')[:60]!r}")
    return f


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("anim_id")
    ap.add_argument("out")
    ap.add_argument("--frame", type=int, default=None)
    ap.add_argument("--peak", choices=["foot", "hand"], default="foot")
    ap.add_argument("--view", choices=["front", "side", "auto"], default="auto")
    ap.add_argument("--url", default=KIMODO_URL)
    a = ap.parse_args()
    render_anim_frame(a.anim_id, a.out, a.frame, a.peak, a.view, a.url)