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
+49 -6
View File
@@ -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: