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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:12:10 +08:00
parent 417ff72850
commit 67b3deedbd
14 changed files with 471 additions and 178 deletions
+41 -1
View File
@@ -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)