From 67b3deedbde45fd7114ab9612d60dad55f2e93d1 Mon Sep 17 00:00:00 2001 From: indigo Date: Tue, 14 Jul 2026 10:12:10 +0800 Subject: [PATCH] Add faceFollow, fix face contour rendering, add default eyes/ears Three changesets bundled together: - faceFollow (Camera/Neck) for the ARKit-blendshape face: it can now rotate with the mapped Body_Neck joint's own local axes instead of always billboarding toward the camera. - Fixed a real correctness bug: the face was rendering as isolated dots. Verified against OpenPose's own published keypoint diagrams and source (FACE_PAIRS_RENDER_GPU, 63 pairs) that it should render connected jaw/eyebrow/nose/eye/mouth contour lines; full_limb_data() was also silently dropping face limbs entirely. Both fixed. - Default eyes/ears: most rigs (HumanIK included) have no REye/LEye/REar/LEar joints, only Head/Nose, leaving those 4 slots permanently invisible. They now get a synthetic head-relative position when unmapped but Body_Nose is mapped, anchored at and rotating with the Nose joint's own orientation (not the camera) -- toggleable via useDefaultEyesEars, always overridden by an explicit joint mapping. Also drops the RShoulder->REar / LShoulder->LEar lines from BODY_25 and COCO connectivity by request (a detection-robustness quirk of OpenPose's original network, not real anatomy) so ears stay leaf points -- a disclosed, deliberate deviation from upstream's otherwise-verbatim connectivity. All verified against real Maya (2022/2023/2024), not just logic: rotation math checked exact, explicit-mapping-overrides-fallback checked, shoulder-ear removal checked against full_limb_data() output, rendered test scenes visually confirmed against OpenPose's own reference diagrams. Co-Authored-By: Claude Sonnet 5 --- README.md | 75 ++++++-- python/openpose_renderer/constants.py | 112 ++++++++---- python/openpose_renderer/draw_override.py | 37 +++- python/openpose_renderer/mapping_node.py | 42 ++++- python/openpose_renderer/projector.py | 67 +++++-- python/openpose_renderer/render_override.py | 39 +++- python/openpose_renderer/ui/mapping_editor.py | 55 +++++- test/openpose_arkit_face_test.ma | 54 +++--- test/openpose_arkit_face_test_expression.png | Bin 3187 -> 4632 bytes test/openpose_arkit_face_test_neck_follow.png | Bin 0 -> 4809 bytes test/openpose_arkit_face_test_neutral.png | Bin 3130 -> 4420 bytes test/openpose_humanik_test.ma | 168 +++++++++--------- test/openpose_humanik_test_preview.png | Bin 6043 -> 6417 bytes test/openpose_humanik_test_preview_coco.png | Bin 6176 -> 6626 bytes 14 files changed, 471 insertions(+), 178 deletions(-) create mode 100644 test/openpose_arkit_face_test_neck_follow.png diff --git a/README.md b/README.md index 6d35c1e..6424582 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,29 @@ rendered output for that character: pelvis point. Slots you mapped for MidHip/feet are simply not drawn in this mode; nothing needs to be remapped to switch. -Hands and face are unaffected by this setting either way. Rendered from the +Hands and face are unaffected by this setting either way. + +### Default eyes/ears + +Most rigs (including HumanIK) have no `REye`/`LEye`/`REar`/`LEar` joints -- +only a Head/Nose. By default (`useDefaultEyesEars`, on unless turned off), +whenever any of those 4 slots is unmapped but `Body_Nose` *is* mapped, it +gets a synthetic head-relative position instead of being invisible -- +anchored at and rotating with the joint mapped to `Body_Nose` itself (sized +by `faceScale`), so they turn with the head's actual rotation rather than +billboarding toward the camera. An explicit joint mapped to any of the 4 +slots always overrides this fallback. The offsets themselves are a +reasonable approximation of human proportions, not from a published +source. + +Note also: BODY_25/COCO's `RShoulder->REar` / `LShoulder->LEar` limb lines +(present in OpenPose's own spec -- a detection-robustness feature of the +original network, not a real anatomical link) are intentionally **not** +drawn here; ears connect only via eye->ear, staying leaf points. This is a +deliberate deviation from upstream, by request -- see the note in +`constants.py`. + +Rendered from the same mapped HumanIK-named test rig (`test/openpose_humanik_test.ma`): | BODY_25 | COCO | @@ -108,9 +130,22 @@ keypoints come from: ARKit Blendshapes from Selected* in the mapping editor -- it connects every one of the 52 canonical names that exists as an attribute on the selected node. The resulting face is anchored at the character's mapped - `Body_Nose` joint and billboards to always face whichever camera is - drawing (viewport or render camera), sized by the `faceScale` attribute - (world units; default 15 -- tune to your scene's unit scale). + `Body_Nose` joint, sized by the `faceScale` attribute (world units; + default 15 -- tune to your scene's unit scale). + + A `faceFollow` enum (mapping editor's *Face Follow* dropdown, or + `cmds.setAttr("myCharacter.faceFollow", 1)`; 0 = `Camera`, 1 = `Neck`) + controls the face plane's orientation: + - **Camera** (default): always faces whichever camera is drawing + (viewport or render camera) -- works regardless of rig conventions, + since it never has to guess which way a joint's local axes point. + - **Neck**: rotates *with* the character's mapped `Body_Neck` joint + instead, using that joint's own local X/Y axes as the face plane's + right/up -- so the face turns with the head. This is rig-dependent (it + assumes local X = right, local Y = up, which is common but not + universal); if the face looks rotated relative to the head, adjust the + neck joint's rotate axis, or use `Camera` instead. Falls back to + `Camera` if no joint is mapped to `Body_Neck`. **Accuracy caveat:** there is no published, authoritative "which ARKit weight moves which OpenPose landmark, by how much" table anywhere (unlike @@ -126,9 +161,9 @@ representable effect on a frontal 2D landmark set and are no-ops. Rendered from `test/openpose_arkit_face_test.ma` (a control node with all 52 ARKit-named attributes, auto-connected -- see that scene for the pattern): -| Neutral | `jawOpen` + smile + raised brows | -| --- | --- | -| ![Neutral face](test/openpose_arkit_face_test_neutral.png) | ![Expression face](test/openpose_arkit_face_test_expression.png) | +| Neutral | `jawOpen` + smile + raised brows | `faceFollow=Neck`, neck tilted 25° | +| --- | --- | --- | +| ![Neutral face](test/openpose_arkit_face_test_neutral.png) | ![Expression face](test/openpose_arkit_face_test_expression.png) | ![Neck-follow face](test/openpose_arkit_face_test_neck_follow.png) | ### 3. Render OpenPose images @@ -147,9 +182,9 @@ ARKit-named attributes, auto-connected -- see that scene for the pattern): Each frame is projected through the given camera at the scene's render resolution (`defaultResolution` node), rasterized as a black-background, OpenPose-colored image (limbs under joints, canonical BODY_25 palette, - per-finger rainbow hand colors, white face dots), written to disk, and - pushed into Maya's Render View so you can watch it render like any other - renderer. + per-finger rainbow hand colors, white face contour lines), written to + disk, and pushed into Maya's Render View so you can watch it render like + any other renderer. ## Debugging (VS Code) @@ -172,14 +207,20 @@ ARKit-named attributes, auto-connected -- see that scene for the pattern): ## Notes / known limitations - Colors/connectivity: BODY_25 and COCO keypoint layout and limb - connectivity are taken verbatim from OpenPose's own + connectivity are taken from OpenPose's own `POSE_BODY_25_BODY_PART_PAIRS` / COCO section of `POSE_BODY_PART_PAIRS` - (`src/openpose/pose/poseParameters.cpp`). BODY_25's colors are likewise - verbatim from `POSE_BODY_25_COLORS_RENDER_GPU`. COCO limb colors, and all - hand/face colors, are generated from an HSV hue wheel (rainbow per limb - or finger, white face dots) reproducing OpenPose's own visual scheme - rather than a hardcoded transcription of its longer, generated gradient - tables. + (`src/openpose/pose/poseParameters.cpp`), with BODY_25's colors likewise + verbatim from `POSE_BODY_25_COLORS_RENDER_GPU` -- with one deliberate, + by-request deviation: the `RShoulder->REar` / `LShoulder->LEar` lines + upstream includes are dropped, so ears stay leaf points connected only + via eye->ear (see "Default eyes/ears" above). The face's 63 contour + lines (jaw/eyebrows/nose/eyes/mouth) and solid-white color are equally + verbatim, from `FACE_PAIRS_RENDER_GPU` / `FACE_COLORS_RENDER_GPU` + (`include/openpose/face/faceParameters.hpp`) -- independently confirmed + against OpenPose's own published keypoint diagrams. COCO limb colors and + all hand colors are generated from an HSV hue wheel (rainbow per limb or + finger) reproducing OpenPose's own visual scheme rather than a hardcoded + transcription of its longer, generated gradient tables. - Projection does not attempt to replicate Maya's film-gate/overscan/fit reconciliation pixel-for-pixel -- for correct results, set the render camera's film aspect ratio to match the scene's render resolution, as you diff --git a/python/openpose_renderer/constants.py b/python/openpose_renderer/constants.py index b89b473..53cf2b4 100644 --- a/python/openpose_renderer/constants.py +++ b/python/openpose_renderer/constants.py @@ -5,21 +5,23 @@ points, in that order, giving a flat 0..136 global keypoint index used everywhere else in this plugin (mapping node attribute slots, draw override, projector, rasterizer). -Body part names, indices and limb pairs are verbatim from OpenPose's own -``POSE_BODY_25_BODY_PARTS`` / ``POSE_BODY_25_BODY_PART_PAIRS`` (see -CMU-Perceptual-Computing-Lab/openpose, include/openpose/pose/ -poseParametersRender.hpp). Hand and face keypoint layouts follow the -standard, widely-interoperable 21-point hand (wrist + 5x4-joint fingers, -identical ordering to OpenPose/MediaPipe hand models) and 70-point face -(iBUG 68-point facial landmark scheme + 2 pupils, OpenPose's own face model) -conventions. - -Colors are generated from an HSV hue wheel per part rather than -hardcoded from upstream source: OpenPose's own hand/face color tables are -long generated gradients (not simple literals) that could not be reliably -transcribed byte-for-byte here, so we reproduce the same *visual* scheme -(rainbow limbs, distinct hue per finger, white face dots) programmatically, -which is verifiably correct instead of a guess. +Body part names, indices, limb pairs and colors are verbatim from +OpenPose's own ``POSE_BODY_25_BODY_PARTS`` / ``POSE_BODY_25_BODY_PART_PAIRS`` +/ ``POSE_BODY_25_COLORS_RENDER_GPU`` (CMU-Perceptual-Computing-Lab/openpose, +include/openpose/pose/poseParametersRender.hpp). Face keypoint layout, limb +pairs, and color are likewise verbatim from OpenPose's own +``FACE_PAIRS_RENDER_GPU`` / ``FACE_COLORS_RENDER_GPU`` +(include/openpose/face/faceParameters.hpp) -- 70 points (iBUG 68-point +facial landmark scheme + 2 pupils) connected into jaw/eyebrow/nose/eye/mouth +contour lines, all solid white. Hand keypoint layout follows the standard, +widely-interoperable 21-point convention (wrist + 5x4-joint fingers, +identical ordering to OpenPose/MediaPipe hand models); unlike body and face, +hand and COCO-limb colors are generated from an HSV hue wheel rather than +transcribed from upstream, since OpenPose's own hand color table is a long +generated gradient (not simple literals) that could not be reliably +transcribed byte-for-byte here -- this reproduces the same *visual* scheme +(rainbow limbs, distinct hue per finger) programmatically, which is +verifiably correct instead of a guess. """ import colorsys @@ -66,12 +68,16 @@ BODY_25_NAMES = [ ] assert len(BODY_25_NAMES) == BODY_COUNT -# Verbatim from OpenPose's POSE_BODY_25_BODY_PART_PAIRS_RENDER_GPU (local -# indices 0..24, i.e. relative to BODY_START). +# From OpenPose's POSE_BODY_25_BODY_PART_PAIRS_RENDER_GPU (local indices +# 0..24, i.e. relative to BODY_START), minus (2,17) RShoulder-REar and +# (5,18) LShoulder-LEar -- upstream includes these, but they're a +# detection-robustness quirk of the original network (a long cross-body +# line), not a real anatomical link; ears are otherwise leaf points +# (connected only via eye-ear) and are kept that way here by request. BODY_25_PAIRS_LOCAL = [ (1, 8), (1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (8, 9), (9, 10), (10, 11), (8, 12), (12, 13), (13, 14), (1, 0), (0, 15), (15, 17), (0, 16), - (16, 18), (2, 17), (5, 18), (14, 19), (19, 20), (14, 21), (11, 22), + (16, 18), (14, 19), (19, 20), (14, 21), (11, 22), (22, 23), (11, 24), ] @@ -83,6 +89,22 @@ BODY_25_LIMB_COLORS = [ BODY_POINT_RADIUS = 4.0 BODY_LIMB_THICKNESS = 4.0 +# Approximate head-relative fallback offsets (local plane: +X = one side, +# +Y = up -- same convention as face_blendshapes' neutral template, and +# scaled by the same faceScale attribute) for REye/LEye/REar/LEar. Used only +# when a rig has no explicit eye/ear joints -- true of most Maya rigs, +# including HumanIK, which typically only goes as far as a Head joint -- +# but does have Nose mapped. An explicit joint mapping for any of these +# always takes priority over this fallback. Not derived from any published +# proportion table -- a reasonable approximation, documented as such (same +# honesty standard as face_blendshapes.py). +DEFAULT_EYE_EAR_LOCAL_OFFSETS = { + BODY_25_NAMES.index("REye"): (-0.5, 0.3), + BODY_25_NAMES.index("LEye"): (0.5, 0.3), + BODY_25_NAMES.index("REar"): (-1.0, 0.05), + BODY_25_NAMES.index("LEar"): (1.0, 0.05), +} + # --- Body: COCO-18 (legacy OpenPose COCO model) ------------------------------ # @@ -116,13 +138,14 @@ assert all(BODY_25_NAMES[COCO_TO_BODY25_INDEX[i]] == COCO_NAMES[i] for i in rang COCO_ACTIVE_BODY25_INDICES = sorted(COCO_TO_BODY25_INDEX) -# Verbatim (COCO section of POSE_BODY_PART_PAIRS), expressed in COCO-local -# 0..17 indices, then translated to BODY_25-local/global indices so it can -# be used directly against the same joint mapping as BODY_25 mode. +# COCO section of POSE_BODY_PART_PAIRS, expressed in COCO-local 0..17 +# indices, then translated to BODY_25-local/global indices so it can be +# used directly against the same joint mapping as BODY_25 mode. Minus +# (2,16) RShoulder-REar and (5,17) LShoulder-LEar for the same reason as +# BODY_25's equivalent pairs -- see BODY_25_PAIRS_LOCAL above. _COCO_PAIRS_COCO_SPACE = [ (1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10), (1, 11), (11, 12), (12, 13), (1, 0), (0, 14), (14, 16), (0, 15), (15, 17), - (2, 16), (5, 17), ] COCO_PAIRS_LOCAL = [ (COCO_TO_BODY25_INDEX[a], COCO_TO_BODY25_INDEX[b]) for (a, b) in _COCO_PAIRS_COCO_SPACE @@ -189,13 +212,34 @@ FACE_NAMES = ( ) assert len(FACE_NAMES) == FACE_COUNT -# OpenPose's default renderer draws the face as keypoints only (no limb -# lines), all white. -FACE_PAIRS_LOCAL = [] +# Verbatim from OpenPose's FACE_PAIRS_RENDER_GPU +# (include/openpose/face/faceParameters.hpp): jaw is an open chain, both +# eyebrows are open chains, the nose bridge/nostril-arc are open chains, and +# both eyes + both mouth contours (outer, inner) are closed loops. All face +# limbs render in solid white (FACE_COLORS_RENDER_GPU), same as the points. +FACE_PAIRS_LOCAL = [ + (0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7), (7, 8), (8, 9), + (9, 10), (10, 11), (11, 12), (12, 13), (13, 14), (14, 15), (15, 16), + (17, 18), (18, 19), (19, 20), (20, 21), + (22, 23), (23, 24), (24, 25), (25, 26), + (27, 28), (28, 29), (29, 30), + (31, 32), (32, 33), (33, 34), (34, 35), + (36, 37), (37, 38), (38, 39), (39, 40), (40, 41), (41, 36), + (42, 43), (43, 44), (44, 45), (45, 46), (46, 47), (47, 42), + (48, 49), (49, 50), (50, 51), (51, 52), (52, 53), (53, 54), (54, 55), + (55, 56), (56, 57), (57, 58), (58, 59), (59, 48), + (60, 61), (61, 62), (62, 63), (63, 64), (64, 65), (65, 66), (66, 67), (67, 60), +] +assert len(FACE_PAIRS_LOCAL) == 63 + FACE_POINT_COLORS = [(255, 255, 255)] * FACE_COUNT +FACE_LIMB_COLORS = [(255, 255, 255)] * len(FACE_PAIRS_LOCAL) FACE_POINT_RADIUS = 2.0 -FACE_LIMB_THICKNESS = 0.0 +# OpenPose's source does not specify a face line thickness (only color); +# this is a reasonable, thinner-than-body/hand choice for a fine facial +# wireframe, not a value taken from upstream. +FACE_LIMB_THICKNESS = 1.5 # --- Flattened 0..136 global schema ------------------------------------------ @@ -236,10 +280,11 @@ def part_of(global_index): raise IndexError(global_index) -# Hand limb pairs, offset into the flat 0..136 range -- unaffected by body -# model choice, so precomputed once. +# Hand/face limb pairs, offset into the flat 0..136 range -- unaffected by +# body model choice, so precomputed once. _HAND_LEFT_PAIRS_GLOBAL = [(a + HAND_LEFT_START, b + HAND_LEFT_START) for (a, b) in HAND_PAIRS_LOCAL] _HAND_RIGHT_PAIRS_GLOBAL = [(a + HAND_RIGHT_START, b + HAND_RIGHT_START) for (a, b) in HAND_PAIRS_LOCAL] +_FACE_PAIRS_GLOBAL = [(a + FACE_START, b + FACE_START) for (a, b) in FACE_PAIRS_LOCAL] def _body_limb_data(body_model): @@ -255,17 +300,18 @@ def _body_limb_data(body_model): def full_limb_data(body_model=BODY_MODEL_BODY_25): - """(pairs, colors, thickness) for the whole rig: the chosen body model - plus both full hands, global-indexed into the flat 0..136 range. Faces - render as dots only and contribute no limbs. + """(pairs, colors, thickness) for the whole rig: the chosen body model, + both full hands, and the face contour, global-indexed into the flat + 0..136 range. """ body_pairs, body_colors, body_thickness = _body_limb_data(body_model) - pairs = list(body_pairs) + _HAND_LEFT_PAIRS_GLOBAL + _HAND_RIGHT_PAIRS_GLOBAL - colors = list(body_colors) + HAND_LIMB_COLORS + HAND_LIMB_COLORS + pairs = list(body_pairs) + _HAND_LEFT_PAIRS_GLOBAL + _HAND_RIGHT_PAIRS_GLOBAL + _FACE_PAIRS_GLOBAL + colors = list(body_colors) + HAND_LIMB_COLORS + HAND_LIMB_COLORS + FACE_LIMB_COLORS thickness = ( list(body_thickness) + [HAND_LIMB_THICKNESS] * len(HAND_PAIRS_LOCAL) + [HAND_LIMB_THICKNESS] * len(HAND_PAIRS_LOCAL) + + [FACE_LIMB_THICKNESS] * len(FACE_PAIRS_LOCAL) ) assert len(pairs) == len(colors) == len(thickness) return pairs, colors, thickness diff --git a/python/openpose_renderer/draw_override.py b/python/openpose_renderer/draw_override.py index 46e873e..55884c6 100644 --- a/python/openpose_renderer/draw_override.py +++ b/python/openpose_renderer/draw_override.py @@ -21,6 +21,30 @@ def _mcolor(rgb255): return om.MColor((rgb255[0] / 255.0, rgb255[1] / 255.0, rgb255[2] / 255.0, 1.0)) +def _fill_default_eye_ear_positions(dep_fn, mapped, positions): + """Fill in synthetic REye/LEye/REar/LEar world positions (see + constants.DEFAULT_EYE_EAR_LOCAL_OFFSETS) for whichever of those slots + aren't already explicitly mapped, anchored at and oriented by the + joint mapped to Nose (the head) -- so they turn with the head's actual + rotation rather than facing the camera. No-op if Nose isn't mapped. + """ + missing_offsets = { + index: offset + for index, offset in constants.DEFAULT_EYE_EAR_LOCAL_OFFSETS.items() + if index not in positions + } + if not missing_offsets: + return + nose_index = constants.NAME_TO_INDEX["Body_Nose"] + if nose_index not in mapped: + return + anchor = positions[nose_index] + + face_scale = mapping_node.get_face_scale(dep_fn) + computed = projector.default_head_relative_points(anchor, mapped[nose_index], face_scale, missing_offsets) + positions.update(computed) + + class OpenPoseUserData(om.MUserData): def __init__(self): @@ -69,6 +93,9 @@ class OpenPoseDrawOverride(omr.MPxDrawOverride): mapped = mapping_node.get_mapped_joint_paths(dep_fn) positions = {index: projector.world_position(path) for index, path in mapped.items()} + if mapping_node.get_use_default_eyes_ears(dep_fn): + _fill_default_eye_ear_positions(dep_fn, mapped, positions) + if mapping_node.get_face_source(dep_fn) == mapping_node.FACE_SOURCE_ARKIT_BLENDSHAPES: nose_index = constants.NAME_TO_INDEX["Body_Nose"] anchor = positions.get(nose_index) @@ -76,7 +103,15 @@ class OpenPoseDrawOverride(omr.MPxDrawOverride): weights = mapping_node.get_blendshape_weights(dep_fn) face_scale = mapping_node.get_face_scale(dep_fn) local_points = face_blendshapes.compute_face_local_points(weights) - world_points = projector.billboard_world_points(anchor, camera_path, local_points, face_scale) + + neck_index = constants.NAME_TO_INDEX["Body_Neck"] + if mapping_node.get_face_follow(dep_fn) == mapping_node.FACE_FOLLOW_NECK and neck_index in mapped: + world_points = projector.oriented_world_points( + anchor, mapped[neck_index], local_points, face_scale + ) + else: + world_points = projector.billboard_world_points(anchor, camera_path, local_points, face_scale) + for offset, world_point in enumerate(world_points): positions[constants.FACE_START + offset] = world_point diff --git a/python/openpose_renderer/mapping_node.py b/python/openpose_renderer/mapping_node.py index 009c6a6..b08e711 100644 --- a/python/openpose_renderer/mapping_node.py +++ b/python/openpose_renderer/mapping_node.py @@ -30,6 +30,8 @@ aBodyModel = None aFaceSource = None aBlendshapeWeight = None aFaceScale = None +aFaceFollow = None +aUseDefaultEyesEars = None # aBodyModel enum field values, in registration order (index 0 = BODY_25). BODY_MODEL_FIELDS = [constants.BODY_MODEL_BODY_25, constants.BODY_MODEL_COCO] @@ -39,6 +41,11 @@ FACE_SOURCE_JOINTS = "Joints" FACE_SOURCE_ARKIT_BLENDSHAPES = "ARKitBlendshapes" FACE_SOURCE_FIELDS = [FACE_SOURCE_JOINTS, FACE_SOURCE_ARKIT_BLENDSHAPES] +# aFaceFollow enum field values, in registration order (index 0 = Camera). +FACE_FOLLOW_CAMERA = "Camera" +FACE_FOLLOW_NECK = "Neck" +FACE_FOLLOW_FIELDS = [FACE_FOLLOW_CAMERA, FACE_FOLLOW_NECK] + DEFAULT_FACE_SCALE = 15.0 @@ -65,7 +72,8 @@ class OpenposeCharacterNode(omui.MPxLocatorNode): def initialize(): global aTargetJoint, aEnableDisplay, aJointRadiusScale global aLimbThicknessScale, aCharacterName, aBodyModel - global aFaceSource, aBlendshapeWeight, aFaceScale + global aFaceSource, aBlendshapeWeight, aFaceScale, aFaceFollow + global aUseDefaultEyesEars msg_attr = om.MFnMessageAttribute() aTargetJoint = msg_attr.create("targetJoint", "tj") @@ -137,6 +145,20 @@ class OpenposeCharacterNode(omui.MPxLocatorNode): face_scale_attr.setMin(0.0) om.MPxNode.addAttribute(aFaceScale) + face_follow_attr = om.MFnEnumAttribute() + aFaceFollow = face_follow_attr.create("faceFollow", "ffol", 0) + for field_index, field_name in enumerate(FACE_FOLLOW_FIELDS): + face_follow_attr.addField(field_name, field_index) + face_follow_attr.keyable = True + om.MPxNode.addAttribute(aFaceFollow) + + default_eyes_ears_attr = om.MFnNumericAttribute() + aUseDefaultEyesEars = default_eyes_ears_attr.create( + "useDefaultEyesEars", "udee", om.MFnNumericData.kBoolean, True + ) + default_eyes_ears_attr.keyable = True + om.MPxNode.addAttribute(aUseDefaultEyesEars) + def get_mapped_joint_paths(dep_node_fn): """Return {keypoint_index: MDagPath} for every connected targetJoint slot. @@ -202,6 +224,24 @@ def get_face_scale(dep_node_fn): return DEFAULT_FACE_SCALE +def get_face_follow(dep_node_fn): + """Return FACE_FOLLOW_CAMERA / FACE_FOLLOW_NECK for this character.""" + try: + field_index = dep_node_fn.findPlug(aFaceFollow, False).asShort() + except RuntimeError: + field_index = 0 + if 0 <= field_index < len(FACE_FOLLOW_FIELDS): + return FACE_FOLLOW_FIELDS[field_index] + return FACE_FOLLOW_CAMERA + + +def get_use_default_eyes_ears(dep_node_fn): + try: + return dep_node_fn.findPlug(aUseDefaultEyesEars, False).asBool() + except RuntimeError: + return True + + def iter_character_nodes(): """Yield an MFnDependencyNode for every openposeCharacter node in the scene.""" it = om.MItDependencyNodes(om.MFn.kPluginLocatorNode) diff --git a/python/openpose_renderer/projector.py b/python/openpose_renderer/projector.py index 20dc761..458cb5b 100644 --- a/python/openpose_renderer/projector.py +++ b/python/openpose_renderer/projector.py @@ -55,20 +55,18 @@ def project_point(world_point, camera_path, width, height): return pixel_x, pixel_y, in_front -def billboard_world_points(anchor_world_point, camera_path, local_points_xy, scale): - """Place 2D local (x, y) points on a plane centered at ``anchor_world_point`` - that always faces ``camera_path`` (camera-space X/Y used as the plane's - right/up axes), scaled by ``scale``. - - Used to draw/render the procedural ARKit-blendshape face without needing - any facial joint orientation -- it billboards toward whichever camera is - currently drawing (the active viewport camera for the live overlay, the - render camera for output), sidestepping the rig-specific, unanswerable - question of "which way does this head joint's local frame face". - """ - world_matrix = camera_path.inclusiveMatrix() +def local_axes_world(dag_path): + """World-space (right, up) unit vectors from a DAG node's local X/Y axes.""" + world_matrix = dag_path.inclusiveMatrix() right = (om.MVector(1.0, 0.0, 0.0) * world_matrix).normal() up = (om.MVector(0.0, 1.0, 0.0) * world_matrix).normal() + return right, up + + +def plane_world_points(anchor_world_point, right, up, local_points_xy, scale): + """Place 2D local (x, y) points on a plane centered at ``anchor_world_point`` + using the given world-space ``right``/``up`` axes, scaled by ``scale``. + """ anchor = om.MPoint(anchor_world_point) return [ om.MPoint(anchor + right * (x * scale) + up * (y * scale)) @@ -76,6 +74,51 @@ def billboard_world_points(anchor_world_point, camera_path, local_points_xy, sca ] +def billboard_world_points(anchor_world_point, camera_path, local_points_xy, scale): + """``plane_world_points`` using ``camera_path``'s own local X/Y as + right/up, so the plane always faces whichever camera is drawing. + + Used to draw/render the procedural ARKit-blendshape face without needing + any facial joint orientation -- it billboards toward whichever camera is + currently drawing (the active viewport camera for the live overlay, the + render camera for output), sidestepping the rig-specific, unanswerable + question of "which way does this head joint's local frame face". This is + the "Camera" faceFollow mode. + """ + right, up = local_axes_world(camera_path) + return plane_world_points(anchor_world_point, right, up, local_points_xy, scale) + + +def oriented_world_points(anchor_world_point, orientation_dag_path, local_points_xy, scale): + """``plane_world_points`` using ``orientation_dag_path``'s own local X/Y + as right/up, so the plane rotates *with* that joint (e.g. the mapped + Neck) instead of always facing the camera. This is the "Neck" faceFollow + mode -- unlike "Camera", it is rig-dependent: it assumes the joint's + local X is the character's right and local Y is up, which matches + common convention but is not guaranteed for every rig. If the face + appears rotated relative to the head, adjust the neck joint's rotate + axis/orient, or use "Camera" mode instead. + """ + right, up = local_axes_world(orientation_dag_path) + return plane_world_points(anchor_world_point, right, up, local_points_xy, scale) + + +def default_head_relative_points(anchor_world_point, orientation_dag_path, scale, local_offsets): + """Compute head-relative fallback world points (e.g. the default + REye/LEye/REar/LEar positions in constants.DEFAULT_EYE_EAR_LOCAL_OFFSETS) + anchored at ``anchor_world_point`` (the mapped Nose), oriented by + ``orientation_dag_path``'s own local X/Y axes -- always the joint + mapped to Nose itself (the head), so these points turn with the head's + actual rotation rather than billboarding toward the camera. + + ``local_offsets``: {index: (x, y)}. Returns {index: MPoint}. + """ + indices = list(local_offsets.keys()) + offsets = [local_offsets[i] for i in indices] + world_points = oriented_world_points(anchor_world_point, orientation_dag_path, offsets, scale) + return dict(zip(indices, world_points)) + + def project_character(mapped_joint_paths, camera_path, width, height): """Project one character's mapping to OpenPose_full pixel keypoints. diff --git a/python/openpose_renderer/render_override.py b/python/openpose_renderer/render_override.py index b0d4e7a..4b47e81 100644 --- a/python/openpose_renderer/render_override.py +++ b/python/openpose_renderer/render_override.py @@ -74,6 +74,32 @@ class OpenposeRenderOverride(omr.MRenderOverride): return self._current_operation < len(self._operations) +def _fill_default_eye_ear_keypoints(dep_fn, mapped, keypoints, camera_path, width, height): + """Fill in synthetic REye/LEye/REar/LEar pixel keypoints (see + constants.DEFAULT_EYE_EAR_LOCAL_OFFSETS) for whichever of those slots + aren't already explicitly mapped, anchored at and oriented by the + joint mapped to Nose (the head) -- so they turn with the head's actual + rotation rather than facing the camera. No-op if Nose isn't mapped. + """ + missing_offsets = { + index: offset + for index, offset in constants.DEFAULT_EYE_EAR_LOCAL_OFFSETS.items() + if index not in mapped + } + if not missing_offsets: + return + nose_index = constants.NAME_TO_INDEX["Body_Nose"] + if nose_index not in mapped: + return + anchor = projector.world_position(mapped[nose_index]) + + face_scale = mapping_node.get_face_scale(dep_fn) + computed = projector.default_head_relative_points(anchor, mapped[nose_index], face_scale, missing_offsets) + for index, world_point in computed.items(): + px, py, in_front = projector.project_point(world_point, camera_path, width, height) + keypoints[index] = (px, py, 1.0 if in_front else 0.0) + + def render_frame(camera_path, width, height, output_path): """Project + rasterize every mapped openposeCharacter to a single PNG.""" characters = [] @@ -90,6 +116,9 @@ def render_frame(camera_path, width, height, output_path): mapped = mapping_node.get_mapped_joint_paths(dep_fn) keypoints = projector.project_character(mapped, camera_path, width, height) + if mapping_node.get_use_default_eyes_ears(dep_fn): + _fill_default_eye_ear_keypoints(dep_fn, mapped, keypoints, camera_path, width, height) + if mapping_node.get_face_source(dep_fn) == mapping_node.FACE_SOURCE_ARKIT_BLENDSHAPES: nose_index = constants.NAME_TO_INDEX["Body_Nose"] nose_x, nose_y, nose_confidence = keypoints[nose_index] @@ -98,7 +127,15 @@ def render_frame(camera_path, width, height, output_path): weights = mapping_node.get_blendshape_weights(dep_fn) face_scale = mapping_node.get_face_scale(dep_fn) local_points = face_blendshapes.compute_face_local_points(weights) - world_points = projector.billboard_world_points(anchor, camera_path, local_points, face_scale) + + neck_index = constants.NAME_TO_INDEX["Body_Neck"] + if mapping_node.get_face_follow(dep_fn) == mapping_node.FACE_FOLLOW_NECK and neck_index in mapped: + world_points = projector.oriented_world_points( + anchor, mapped[neck_index], local_points, face_scale + ) + else: + world_points = projector.billboard_world_points(anchor, camera_path, local_points, face_scale) + for offset, world_point in enumerate(world_points): px, py, in_front = projector.project_point(world_point, camera_path, width, height) keypoints[constants.FACE_START + offset] = (px, py, 1.0 if in_front else 0.0) diff --git a/python/openpose_renderer/ui/mapping_editor.py b/python/openpose_renderer/ui/mapping_editor.py index 2694ad6..f531691 100644 --- a/python/openpose_renderer/ui/mapping_editor.py +++ b/python/openpose_renderer/ui/mapping_editor.py @@ -93,6 +93,34 @@ class MappingEditor(QtWidgets.QDialog): self.face_scale_spin.valueChanged.connect(self._on_face_scale_changed) face_row.addWidget(self.face_scale_spin) + face_row.addWidget(QtWidgets.QLabel("Face Follow:")) + self.face_follow_combo = QtWidgets.QComboBox() + self.face_follow_combo.addItems( + [mapping_node.FACE_FOLLOW_CAMERA, mapping_node.FACE_FOLLOW_NECK] + ) + self.face_follow_combo.setToolTip( + "Camera: the ARKit-blendshape face always faces whichever camera is drawing\n" + "(viewport or render camera) -- works regardless of rig conventions.\n" + "Neck: the face plane rotates with the mapped Body_Neck joint's own local\n" + "X/Y axes instead -- turns with the character's head, but is rig-dependent\n" + "(assumes local X = right, local Y = up; adjust the neck joint's rotate axis\n" + "if the face looks rotated). Falls back to Camera if no Neck is mapped." + ) + self.face_follow_combo.currentIndexChanged.connect(self._on_face_follow_changed) + face_row.addWidget(self.face_follow_combo) + + self.default_eyes_ears_check = QtWidgets.QCheckBox("Default Eyes/Ears") + self.default_eyes_ears_check.setToolTip( + "Most rigs (including HumanIK) have no REye/LEye/REar/LEar joints. When on\n" + "(default), those 4 OpenPose_full slots get a synthetic head-relative position\n" + "instead of being invisible, whenever they're unmapped but Body_Nose is mapped\n" + "-- anchored at and rotating with the joint mapped to Nose (sized by Face Scale\n" + "above). An explicit joint mapped to any of the 4 slots always takes priority\n" + "over this fallback." + ) + self.default_eyes_ears_check.toggled.connect(self._on_use_default_eyes_ears_changed) + face_row.addWidget(self.default_eyes_ears_check) + autoconnect_btn = QtWidgets.QPushButton("Auto-Connect ARKit Blendshapes from Selected") autoconnect_btn.setToolTip( "Connects the selected node's attributes to this character's 52 blendshapeWeight\n" @@ -181,13 +209,18 @@ class MappingEditor(QtWidgets.QDialog): self.body_model_combo.setEnabled(False) self.body_model_combo.blockSignals(False) - for widget in (self.face_source_combo, self.face_scale_spin): + face_widgets = ( + self.face_source_combo, self.face_scale_spin, self.face_follow_combo, self.default_eyes_ears_check, + ) + for widget in face_widgets: widget.blockSignals(True) if character: - self.face_source_combo.setEnabled(True) - self.face_scale_spin.setEnabled(True) + for widget in face_widgets: + widget.setEnabled(True) self.face_source_combo.setCurrentIndex(cmds.getAttr(character + ".faceSource")) self.face_scale_spin.setValue(cmds.getAttr(character + ".faceScale")) + self.face_follow_combo.setCurrentIndex(cmds.getAttr(character + ".faceFollow")) + self.default_eyes_ears_check.setChecked(cmds.getAttr(character + ".useDefaultEyesEars")) connected = sum( 1 for i in range(face_blendshapes.BLENDSHAPE_COUNT) if cmds.listConnections("{0}.blendshapeWeight[{1}]".format(character, i), source=True, destination=False) @@ -196,10 +229,10 @@ class MappingEditor(QtWidgets.QDialog): "{0} / {1} blendshapes connected".format(connected, face_blendshapes.BLENDSHAPE_COUNT) ) else: - self.face_source_combo.setEnabled(False) - self.face_scale_spin.setEnabled(False) + for widget in face_widgets: + widget.setEnabled(False) self.blendshape_status_label.setText("") - for widget in (self.face_source_combo, self.face_scale_spin): + for widget in face_widgets: widget.blockSignals(False) def _on_body_model_changed(self, index): @@ -212,11 +245,21 @@ class MappingEditor(QtWidgets.QDialog): if character: cmds.setAttr(character + ".faceSource", index) + def _on_face_follow_changed(self, index): + character = self._current_character() + if character: + cmds.setAttr(character + ".faceFollow", index) + def _on_face_scale_changed(self, value): character = self._current_character() if character: cmds.setAttr(character + ".faceScale", value) + def _on_use_default_eyes_ears_changed(self, checked): + character = self._current_character() + if character: + cmds.setAttr(character + ".useDefaultEyesEars", checked) + def _on_autoconnect_blendshapes(self): character = self._current_character() if not character: diff --git a/test/openpose_arkit_face_test.ma b/test/openpose_arkit_face_test.ma index 1c3e8b8..c40ca87 100644 --- a/test/openpose_arkit_face_test.ma +++ b/test/openpose_arkit_face_test.ma @@ -1,6 +1,6 @@ //Maya ASCII 2024 scene //Name: openpose_arkit_face_test.ma -//Last modified: Tue, Jul 14, 2026 08:25:25 AM +//Last modified: Tue, Jul 14, 2026 10:10:17 AM //Codeset: 950 requires maya "2024"; requires -nodeType "openposeCharacter" "openposeRenderer.py" "1.0.0"; @@ -10,14 +10,14 @@ fileInfo "product" "Maya 2024"; fileInfo "version" "2024"; fileInfo "cutIdentifier" "202310181224-69282f2959"; fileInfo "osv" "Windows 10 Pro v2009 (Build: 19045)"; -fileInfo "UUID" "93C6E059-4B38-C365-F7D5-0FAD491D0DEB"; +fileInfo "UUID" "85DD57C5-4B97-1CDE-6FD5-BFA49225A98F"; createNode transform -s -n "persp"; - rename -uid "D6BB12A4-4481-B70D-81F9-22BC50DBF6A2"; + rename -uid "E3491364-4B97-48C1-5E9B-578A13404F49"; setAttr ".v" no; setAttr ".t" -type "double3" 28 21 28 ; setAttr ".r" -type "double3" -27.938352729602379 44.999999999999972 -5.172681101354183e-14 ; createNode camera -s -n "perspShape" -p "persp"; - rename -uid "3B79812D-4CE2-EF69-FD29-D3819FB20809"; + rename -uid "DD6D6127-4073-909D-6E02-1997E2D93C26"; setAttr -k off ".v" no; setAttr ".fl" 34.999999999999993; setAttr ".coi" 44.82186966202994; @@ -26,12 +26,12 @@ createNode camera -s -n "perspShape" -p "persp"; setAttr ".man" -type "string" "persp_mask"; setAttr ".hc" -type "string" "viewSet -p %camera"; createNode transform -s -n "top"; - rename -uid "7D9B1797-4CCF-180D-3705-9C9C4F956631"; + rename -uid "035B1CE2-4DC2-F418-C617-688208D299F4"; setAttr ".v" no; setAttr ".t" -type "double3" 0 1000.1 0 ; setAttr ".r" -type "double3" -90 0 0 ; createNode camera -s -n "topShape" -p "top"; - rename -uid "02429A50-43C6-9AEF-C9A9-0EA56778BABE"; + rename -uid "6E8EE682-497D-652E-715F-158DADC66B84"; setAttr -k off ".v" no; setAttr ".rnd" no; setAttr ".coi" 1000.1; @@ -42,11 +42,11 @@ createNode camera -s -n "topShape" -p "top"; setAttr ".hc" -type "string" "viewSet -t %camera"; setAttr ".o" yes; createNode transform -s -n "front"; - rename -uid "E313E787-411D-6315-E6D5-878346B7C7C7"; + rename -uid "53AD066A-4DDC-4765-95C1-9AB82429A5A2"; setAttr ".v" no; setAttr ".t" -type "double3" 0 0 1000.1 ; createNode camera -s -n "frontShape" -p "front"; - rename -uid "CEB81F55-4481-E767-A275-09ACF3D57563"; + rename -uid "2E5CFEDD-476C-0A25-F6B8-38A03B0877F8"; setAttr -k off ".v" no; setAttr ".rnd" no; setAttr ".coi" 1000.1; @@ -57,12 +57,12 @@ createNode camera -s -n "frontShape" -p "front"; setAttr ".hc" -type "string" "viewSet -f %camera"; setAttr ".o" yes; createNode transform -s -n "side"; - rename -uid "23726E62-4236-BA09-5DDB-A8B4AB6467DE"; + rename -uid "A10C8B41-4A98-FACA-5E06-7B8508956AA9"; setAttr ".v" no; setAttr ".t" -type "double3" 1000.1 0 0 ; setAttr ".r" -type "double3" 0 90 0 ; createNode camera -s -n "sideShape" -p "side"; - rename -uid "0EF9B535-4F25-E296-6FE9-17897B0CD5F8"; + rename -uid "72EA7ED6-4C2B-4DE3-569A-D2B1A7BFABE4"; setAttr -k off ".v" no; setAttr ".rnd" no; setAttr ".coi" 1000.1; @@ -72,21 +72,27 @@ createNode camera -s -n "sideShape" -p "side"; setAttr ".man" -type "string" "side_mask"; setAttr ".hc" -type "string" "viewSet -s %camera"; setAttr ".o" yes; -createNode joint -n "Head"; - rename -uid "10AD57B3-4A53-D241-702A-E4B87D617D75"; - setAttr ".t" -type "double3" 0 150 0 ; +createNode joint -n "Neck"; + rename -uid "98591D1D-4B4C-6895-14DE-4AB34B1EA03A"; + setAttr ".t" -type "double3" 0 140 0 ; + setAttr ".mnrl" -type "double3" -360 -360 -360 ; + setAttr ".mxrl" -type "double3" 360 360 360 ; +createNode joint -n "Head" -p "Neck"; + rename -uid "9A0315AF-4863-056E-3EDE-E493010F290C"; + setAttr ".t" -type "double3" 0 10 0 ; setAttr ".mnrl" -type "double3" -360 -360 -360 ; setAttr ".mxrl" -type "double3" 360 360 360 ; createNode transform -n "transform1"; - rename -uid "AC0B13DC-4C96-222F-1C95-D881328B821A"; + rename -uid "64FBC6C8-4532-92E7-B69C-14806ED14FDA"; createNode openposeCharacter -n "openposeCharacter_ARKitFace" -p "transform1"; - rename -uid "91BF3ED3-4092-9E34-128B-1C98376C9C10"; + rename -uid "99770FC8-4339-FCCF-4911-D5A1DE12F05B"; setAttr -k off ".v"; + setAttr -s 2 ".tj"; setAttr ".fsrc" 1; setAttr -s 52 ".bsw"; setAttr -s 52 ".bsw"; createNode transform -n "faceControl"; - rename -uid "BEF04EC7-4C59-6B22-48FA-208B994FD89B"; + rename -uid "6B4BD06A-44E7-A162-C911-A6860437F938"; addAttr -ci true -sn "eyeBlinkLeft" -ln "eyeBlinkLeft" -min 0 -max 1 -at "float"; addAttr -ci true -sn "eyeLookDownLeft" -ln "eyeLookDownLeft" -min 0 -max 1 -at "float"; addAttr -ci true -sn "eyeLookInLeft" -ln "eyeLookInLeft" -min 0 -max 1 -at "float"; @@ -194,25 +200,25 @@ createNode transform -n "faceControl"; setAttr -k on ".noseSneerRight"; setAttr -k on ".tongueOut"; createNode lightLinker -s -n "lightLinker1"; - rename -uid "6F41ABE6-4FC9-CBA9-3A4A-3C88AFFEDA05"; + rename -uid "6D455AD6-40A9-F99D-5089-B0A48DE96155"; setAttr -s 2 ".lnk"; setAttr -s 2 ".slnk"; createNode displayLayerManager -n "layerManager"; - rename -uid "C5E3AE68-4041-3C21-D33D-2DBCA8E53402"; + rename -uid "89D07F4B-4D2B-38E3-1982-E49382568D7B"; createNode displayLayer -n "defaultLayer"; - rename -uid "7E1F3A70-4A01-B94C-04B6-24ABE836AECA"; + rename -uid "FE3D0295-4D3E-B21D-61C6-E48A8F983B9A"; setAttr ".ufem" -type "stringArray" 0 ; createNode renderLayerManager -n "renderLayerManager"; - rename -uid "E2FC1CAE-4BC5-8835-9145-E2AFED84D0D6"; + rename -uid "D3652996-4893-A8CF-67F1-B39C47121B22"; createNode renderLayer -n "defaultRenderLayer"; - rename -uid "711E2B13-42DB-052A-1562-1C99F88A0E30"; + rename -uid "7B692699-4A43-99A7-033B-D8AB3ECF5B0E"; setAttr ".g" yes; createNode script -n "uiConfigurationScriptNode"; - rename -uid "F0132003-4E9D-D0A6-A39C-F6958ABF910E"; + rename -uid "C885E9DA-4CD9-2E2D-D858-D7960EC8BBFF"; setAttr ".b" -type "string" "// Maya Mel UI Configuration File.\n// No UI generated in batch mode.\n"; setAttr ".st" 3; createNode script -n "sceneConfigurationScriptNode"; - rename -uid "5E8354DD-4768-B99B-F133-489A3680AEB2"; + rename -uid "D790ABD8-4269-6467-7F7B-0B99D3F4D4A4"; setAttr ".b" -type "string" "playbackOptions -min 1 -max 120 -ast 1 -aet 200 "; setAttr ".st" 6; select -ne :time1; @@ -259,7 +265,9 @@ select -ne :defaultColorMgtGlobals; select -ne :hardwareRenderGlobals; setAttr ".ctrs" 256; setAttr ".btrs" 512; +connectAttr "Neck.s" "Head.is"; connectAttr "Head.msg" "openposeCharacter_ARKitFace.tj[0]"; +connectAttr "Neck.msg" "openposeCharacter_ARKitFace.tj[1]"; connectAttr "faceControl.eyeBlinkLeft" "openposeCharacter_ARKitFace.bsw[0]"; connectAttr "faceControl.eyeLookDownLeft" "openposeCharacter_ARKitFace.bsw[1]"; connectAttr "faceControl.eyeLookInLeft" "openposeCharacter_ARKitFace.bsw[2]"; diff --git a/test/openpose_arkit_face_test_expression.png b/test/openpose_arkit_face_test_expression.png index 206a82698d782010b566949c0df764b30d99683e..8db1ec261db72430239faef49421588102054971 100644 GIT binary patch literal 4632 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 Jm8{|(`zKS!&h!8P literal 0 HcmV?d00001 diff --git a/test/openpose_arkit_face_test_neutral.png b/test/openpose_arkit_face_test_neutral.png index e510546f007900463cdb624934bd551c9762557e..6040b90018b7388fd358cd5ebd4aeb5f14759a3d 100644 GIT binary patch literal 4420 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 literal 3130 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 Dbrxfi literal 6043 zcmeHLX;f3$mOdHCz$m0dR0JG=vK#;b6&V6D>0@wYQUak|%oTzV1ZBDkf+Yf4Re}}> z4uw7h5)dT;B#9uPVgN-6Q%C>}4`LWZ#t_oi>eYX$yMMgby?XTz|Lk+u-uHZG@9*w= z&OZC+!=A2c%IlN?0BQ#hI2{E5#RUMVi^ISgD^hDBJg5iz9t`pD_yK+dxUT|$yiukf zJm9G=F0p?qzKXsdYT5x%uH`6WV#iB#| zl-vgLtT(ULrAOE6v⋙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 diff --git a/test/openpose_humanik_test_preview_coco.png b/test/openpose_humanik_test_preview_coco.png index be1f7f73ca51a55a0b1f2827e30f5f5b30965169..99d6811adfc25dd71cd6a7584b8f18cb10ae1a54 100644 GIT binary patch literal 6626 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 Fe*xO{nbH6N literal 6176 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