Loading...
Searching...
No Matches
HumanoidRetarget.hpp
1#pragma once
2
3// Threedim::HumanoidRetarget: live scene filter that drives a rigged model's
4// skeleton from a humanoid_pose stream.
5//
6// Inputs are scene_in, an ossia::scene_spec carrying at least one
7// skeleton_component, and pose_in, an optional humanoid_pose from a source
8// adapter. Controls select the target rig's bone-name convention (Mixamo, VRM,
9// Unreal Mannequin), capture the rest pose -- required before any motion
10// transfers -- and optionally drive Hips translation from the source's
11// hip_position delta.
12//
13// scene_out is the incoming scene_spec with only the mapped joints' rotations,
14// and optionally Hips translation, replaced; everything else passes through.
15//
16// Offset / delta-from-rest math, the correct choice when source and target rigs
17// have different axis conventions:
18//
19// q_tgt_cur = q_tgt_rest * ( inverse(q_src_rest) * q_src_cur )
20//
21// Calibration captures q_src_rest per canonical bone and q_tgt_rest per resolved
22// target joint, so the delta is a parent-relative quaternion that transfers even
23// between a BlazePose landmark graph and a Mixamo FBX, as long as the adapter
24// produces parent-relative rotations. Per-bone axis correction matrices, needed
25// for some exotic rigs, are a follow-up.
26//
27// No smoothing -- that belongs in the adapter, pre-pose_spec. No IK -- chain
28// InverseKinematics after this process; the two compose on scene_spec.
29
30#include <Threedim/HumanoidPose.hpp>
31#include <Threedim/HumanoidPresets.hpp>
32#include <Threedim/HumanoidSourceAdapters.hpp>
33#include <Threedim/HumanoidSourceMaps.hpp>
34
35#include <halp/controls.hpp>
36#include <halp/controls.buttons.hpp>
37#include <halp/meta.hpp>
38
39#include <ossia/dataflow/geometry_port.hpp>
40
41#include <array>
42#include <cstdint>
43#include <cstring>
44#include <memory>
45#include <optional>
46
47namespace Threedim
48{
49
50// Picks which input shape the retargeter consumes this frame. The
51// matching input ports are always present on the process (halp doesn't
52// hide ports conditionally); the combobox just tells the dispatch which
53// one to translate into humanoid_pose.
54enum class HumanoidSourceType : uint8_t
55{
56 Off = 0, // Passthrough (no motion applied)
57 BlazePose, // keypoints_in, BlazePose 33-landmark ordering
58 Coco17, // keypoints_in, COCO-17 (YOLO-pose / ViTPose / RTMPose_COCO)
59 RTMPoseWhole, // keypoints_in, RTMPose_Whole (body subset of 133)
60 Trackers6, // trackers_in, 6 DOF (head / hips / 2 hands / 2 feet)
61 Count
62};
63
65{
66public:
67 halp_meta(name, "Humanoid Retarget")
68 halp_meta(category, "Visuals/3D/Scene")
69 halp_meta(c_name, "humanoid_retarget")
70 halp_meta(description, "Transfer a humanoid pose stream to a calibrated character skeleton with optional root motion.")
71 halp_meta(authors, "ossia team")
72 halp_meta(
73 manual_url,
74 "https://ossia.io/score-docs/processes/humanoid-retarget.html")
75 halp_meta(uuid, "7e1f4d8a-2c6b-4e7f-9a35-6c4b8d2e0f1a")
76
77 struct ins
78 {
79 struct
80 {
81 halp_meta(name, "Scene In");
82 ossia::scene_spec scene;
83 uint8_t dirty{0};
84 } scene_in;
85
86 // Keypoint input -- populated when Source is a PoseDetector workflow.
87 // Structurally compatible with score-addon-onnx's DetectedPose
88 // (matching halp_field_names), so a DetectedPose port wires directly.
89 struct
90 {
91 halp_meta(name, "Keypoints");
92 std::optional<keypoint_stream> value;
93 } keypoints_in;
94
95 // Tracker input -- populated when Source is Trackers6. The user wires
96 // OSC-emitted xyz+quat streams from a PSN/RTTrP/VRPN device into the
97 // matching tracker_pose slots of the bundle.
98 struct
99 {
100 halp_meta(name, "Trackers");
101 std::optional<tracker_bundle_6> value;
102 } trackers_in;
103
104 struct : halp::combobox_t<"Source", HumanoidSourceType>
105 {
106 struct range
107 {
108 std::string_view values[5]{
109 "Off", "BlazePose", "COCO-17", "RTMPose Whole", "6DOF Trackers"};
110 int init{0};
111 };
112 void update(HumanoidRetarget& self)
113 {
114 // Source-shape change invalidates the captured source rest pose;
115 // the map of landmark→bone (and bone→tracker) differs, so previous
116 // "rest" values aren't meaningful under the new source.
117 self.m_calibrated = false;
118 }
119 } source;
120
121 struct : halp::hslider_f32<"Confidence", halp::range{0.f, 1.f, 0.5f}>
122 {
123 halp_meta(description, "Per-keypoint confidence threshold");
124 } confidence_threshold;
125
126 struct : halp::combobox_t<"Target rig", HumanoidRigPreset>
127 {
128 struct range
129 {
130 std::string_view values[3]{"Mixamo", "VRM", "Unreal Mannequin"};
131 int init{0};
132 };
133 void update(HumanoidRetarget& self)
134 {
135 // Bone-name table change invalidates the cached joint index
136 // lookups and the captured target rest pose; force a fresh
137 // calibration on the next frame that has both inputs.
138 self.m_calibrated = false;
139 }
140 } preset;
141
142 halp::toggle<"Root motion"> root_motion;
143
144 struct : halp::hslider_f32<"Root scale", halp::range{0.01f, 10.f, 1.f}>
145 {
146 } root_scale;
147
148 struct : halp::impulse_button<"Capture rest pose">
149 {
150 void update(HumanoidRetarget& self) { self.m_need_calibrate = true; }
151 } calibrate;
152 } inputs;
153
154 struct outs
155 {
156 struct
157 {
158 halp_meta(name, "Scene Out");
159 ossia::scene_spec scene;
160 uint8_t dirty{0};
161 } scene_out;
162 } outputs;
163
164 void operator()()
165 {
166 const auto& in = inputs.scene_in.scene;
167 if(!in.state || !in.state->roots)
168 {
169 outputs.scene_out.scene.state.reset();
170 outputs.scene_out.dirty = 0;
171 return;
172 }
173
174 // Translate the selected source into a humanoid_pose. Off mode and
175 // "source has no fresh data" both fall through to a clean passthrough
176 // so downstream nodes see the input unchanged until motion starts.
177 std::optional<humanoid_pose> maybe_pose
178 = composeSourcePose(inputs.confidence_threshold.value);
179 if(!maybe_pose)
180 {
181 outputs.scene_out.scene = in;
182 outputs.scene_out.dirty = 0;
183 return;
184 }
185
186 const auto& pose = *maybe_pose;
187
188 // Resolve the skeleton -- first entry in the scene's skeletons list.
189 if(!in.state->skeletons || in.state->skeletons->empty())
190 {
191 outputs.scene_out.scene = in;
192 outputs.scene_out.dirty = 0;
193 return;
194 }
195 const auto& srcSkel = *(*in.state->skeletons)[0];
196 if(srcSkel.joints.empty())
197 {
198 outputs.scene_out.scene = in;
199 outputs.scene_out.dirty = 0;
200 return;
201 }
202
203 // Calibrate on demand. Two triggers:
204 // - user pressed "Capture rest pose"
205 // - preset combobox changed (invalidates previous joint lookups)
206 if(m_need_calibrate || !m_calibrated)
207 {
208 calibrate(srcSkel, pose);
209 m_need_calibrate = false;
210 }
211
212 // Clone the skeleton so other consumers of the input scene don't see
213 // our mutations. This is the same pattern InverseKinematics uses.
214 auto newSkel = std::make_shared<ossia::skeleton_component>(srcSkel);
215
216 // Per-bone offset-mode retarget:
217 // q_tgt_new = q_tgt_rest * ( inverse(q_src_rest) * q_src_cur )
218 for(std::size_t b = 0; b < std::size_t(humanoid_bone_index::Count); ++b)
219 {
220 const int32_t tgt = m_target_joint_indices[b];
221 if(tgt < 0 || tgt >= int32_t(newSkel->joints.size()))
222 continue;
223
224 const auto& src_cur = pose.bones[b];
225 if(src_cur.validity < kValidityThreshold)
226 continue; // trust the target's current rotation (kept from clone)
227
228 const float src_cur_q[4] = {
229 src_cur.qx, src_cur.qy, src_cur.qz, src_cur.qw};
230 float inv_src_rest[4];
231 quat_inv(m_source_rest[b], inv_src_rest);
232
233 float delta[4];
234 quat_mul(inv_src_rest, src_cur_q, delta);
235
236 float out[4];
237 quat_mul(m_target_rest[b], delta, out);
238
239 auto& tgtJoint = newSkel->joints[tgt];
240 tgtJoint.rotation[0] = out[0];
241 tgtJoint.rotation[1] = out[1];
242 tgtJoint.rotation[2] = out[2];
243 tgtJoint.rotation[3] = out[3];
244 }
245
246 // Root motion -- apply source hip delta to target hip translation,
247 // scaled by the user control. Off by default (most live scenes want
248 // animate-in-place; locomotion is a deliberate choice).
249 if(inputs.root_motion.value)
250 {
251 const int32_t hipsIdx
252 = m_target_joint_indices[std::size_t(humanoid_bone_index::Hips)];
253 if(hipsIdx >= 0 && hipsIdx < int32_t(newSkel->joints.size()))
254 {
255 const float s = inputs.root_scale.value;
256 auto& hip = newSkel->joints[hipsIdx];
257 hip.translation[0]
258 = m_target_rest_hip_tr[0] + (pose.hip_x - m_source_rest_hip[0]) * s;
259 hip.translation[1]
260 = m_target_rest_hip_tr[1] + (pose.hip_y - m_source_rest_hip[1]) * s;
261 hip.translation[2]
262 = m_target_rest_hip_tr[2] + (pose.hip_z - m_source_rest_hip[2]) * s;
263 }
264 }
265
266 newSkel->dirty_index++;
267
268 // Emit a fresh scene_state that shares everything with the input
269 // except the skeletons vector.
270 auto state = std::make_shared<ossia::scene_state>(*in.state);
271 auto skels
272 = std::make_shared<std::vector<ossia::skeleton_component_ptr>>();
273 skels->reserve(in.state->skeletons->size());
274 for(std::size_t i = 0; i < in.state->skeletons->size(); ++i)
275 skels->push_back(
276 i == 0 ? ossia::skeleton_component_ptr(newSkel)
277 : (*in.state->skeletons)[i]);
278 state->skeletons = std::move(skels);
279 state->version = ++m_version_counter;
280 state->dirty_index = in.state->dirty_index + 1;
281
282 m_state = std::move(state);
283 outputs.scene_out.scene.state = m_state;
284 outputs.scene_out.dirty = ossia::scene_port::dirty_transform;
285 }
286
287private:
288 // Rotation confidence below which we don't override the target bone.
289 // Adapters default bone validity to 1.0; BlazePose maps landmark
290 // visibility into [0, 1]. 0.5 is a reasonable "believe this" line.
291 static constexpr float kValidityThreshold = 0.5f;
292
293 // Hamilton quaternion multiply. (x, y, z, w) ordering.
294 static void quat_mul(const float a[4], const float b[4], float out[4]) noexcept
295 {
296 const float x = a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1];
297 const float y = a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0];
298 const float z = a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3];
299 const float w = a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2];
300 out[0] = x;
301 out[1] = y;
302 out[2] = z;
303 out[3] = w;
304 }
305
306 // Inverse of a unit quaternion = conjugate. Adapters should be
307 // emitting normalized rotations; if they drift, the math still
308 // produces a stable result but scale factors creep in.
309 static void quat_inv(const float q[4], float out[4]) noexcept
310 {
311 out[0] = -q[0];
312 out[1] = -q[1];
313 out[2] = -q[2];
314 out[3] = q[3];
315 }
316
317 void calibrate(
318 const ossia::skeleton_component& skel,
319 const humanoid_pose& pose) noexcept
320 {
321 const auto& map = humanoidBoneMap(inputs.preset.value);
322
323 for(std::size_t b = 0; b < std::size_t(humanoid_bone_index::Count); ++b)
324 {
325 // Snapshot source rest pose quaternion (identity-ish if adapter
326 // hasn't moved yet; whatever is there is what "neutral" means
327 // for this capture).
328 m_source_rest[b][0] = pose.bones[b].qx;
329 m_source_rest[b][1] = pose.bones[b].qy;
330 m_source_rest[b][2] = pose.bones[b].qz;
331 m_source_rest[b][3] = pose.bones[b].qw;
332
333 m_target_joint_indices[b] = -1;
334 if(map[b].empty())
335 continue; // preset intentionally skips this bone (e.g. UpperChest)
336
337 const int32_t idx = skel.find_joint(map[b]);
338 if(idx < 0)
339 continue;
340 m_target_joint_indices[b] = idx;
341
342 // Snapshot target rest rotation.
343 const auto& j = skel.joints[std::size_t(idx)];
344 m_target_rest[b][0] = j.rotation[0];
345 m_target_rest[b][1] = j.rotation[1];
346 m_target_rest[b][2] = j.rotation[2];
347 m_target_rest[b][3] = j.rotation[3];
348
349 if(b == std::size_t(humanoid_bone_index::Hips))
350 {
351 m_target_rest_hip_tr[0] = j.translation[0];
352 m_target_rest_hip_tr[1] = j.translation[1];
353 m_target_rest_hip_tr[2] = j.translation[2];
354 }
355 }
356
357 m_source_rest_hip[0] = pose.hip_x;
358 m_source_rest_hip[1] = pose.hip_y;
359 m_source_rest_hip[2] = pose.hip_z;
360
361 m_calibrated = true;
362 }
363
364 // Dispatch the selected source toggle into a humanoid_pose. Returns
365 // nullopt when the source is Off or no fresh data is present -- in that
366 // case operator() passes the input scene through unchanged.
367 std::optional<humanoid_pose>
368 composeSourcePose(float confidence_threshold) noexcept
369 {
370 const auto src = inputs.source.value;
371 switch(src)
372 {
373 case HumanoidSourceType::Off:
374 case HumanoidSourceType::Count:
375 return std::nullopt;
376
377 case HumanoidSourceType::BlazePose:
378 if(!inputs.keypoints_in.value
379 || inputs.keypoints_in.value->keypoints.empty())
380 return std::nullopt;
381 return keypoints_to_humanoid_pose(
382 *inputs.keypoints_in.value, kBlazePoseMap, confidence_threshold);
383
384 case HumanoidSourceType::Coco17:
385 if(!inputs.keypoints_in.value
386 || inputs.keypoints_in.value->keypoints.empty())
387 return std::nullopt;
388 return keypoints_to_humanoid_pose(
389 *inputs.keypoints_in.value, kCoco17Map, confidence_threshold);
390
391 case HumanoidSourceType::RTMPoseWhole:
392 if(!inputs.keypoints_in.value
393 || inputs.keypoints_in.value->keypoints.empty())
394 return std::nullopt;
395 return keypoints_to_humanoid_pose(
396 *inputs.keypoints_in.value, kRTMPoseWholeMap,
397 confidence_threshold);
398
399 case HumanoidSourceType::Trackers6:
400 if(!inputs.trackers_in.value)
401 return std::nullopt;
402 return trackers_to_humanoid_pose(*inputs.trackers_in.value);
403 }
404 return std::nullopt;
405 }
406
407public:
408 // Persisted across score-document saves (serialized with process state).
409 bool m_calibrated{false};
410 std::array<float[4], std::size_t(humanoid_bone_index::Count)> m_source_rest{};
411 std::array<float[4], std::size_t(humanoid_bone_index::Count)> m_target_rest{};
412 std::array<int32_t, std::size_t(humanoid_bone_index::Count)>
413 m_target_joint_indices{};
414 float m_target_rest_hip_tr[3]{0.f, 0.f, 0.f};
415 float m_source_rest_hip[3]{0.f, 0.f, 0.f};
416
417 // Ephemeral.
418 bool m_need_calibrate{false};
419 std::shared_ptr<ossia::scene_state> m_state;
420 int64_t m_version_counter{0};
421};
422
423} // namespace Threedim
Definition HumanoidRetarget.hpp:65
Definition HumanoidRetarget.hpp:78
Definition HumanoidRetarget.hpp:155
Definition MIDISync.hpp:128