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