Files
PoseRenderer/python/openpose_renderer/ui/mapping_editor.py
T
indigo 67b3deedbd 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>
2026-07-14 10:12:10 +08:00

348 lines
14 KiB
Python

"""PySide2 tool for mapping Maya joints onto the 137 OpenPose_full keypoint
slots of a selected ``openposeCharacter`` node.
Mapping edits go through ``cmds.connectAttr``/``disconnectAttr`` (not raw
API calls) so every edit is a normal, undoable Maya scene operation.
"""
from functools import partial
import maya.cmds as cmds
import maya.OpenMayaUI as omui
try:
from PySide2 import QtCore, QtWidgets
from shiboken2 import wrapInstance
except ImportError:
from PySide6 import QtCore, QtWidgets
from shiboken6 import wrapInstance
from .. import constants
from .. import face_blendshapes
from .. import mapping_node
_PART_LABELS = {
constants.PART_BODY: "Body",
constants.PART_HAND_LEFT: "Left Hand",
constants.PART_HAND_RIGHT: "Right Hand",
constants.PART_FACE: "Face",
}
_window_instance = None
def _maya_main_window():
ptr = omui.MQtUtil.mainWindow()
return wrapInstance(int(ptr), QtWidgets.QWidget)
def _target_plug(character, index):
return "{0}.targetJoint[{1}]".format(character, index)
class MappingEditor(QtWidgets.QDialog):
def __init__(self, parent=None):
super(MappingEditor, self).__init__(parent or _maya_main_window())
self.setWindowTitle("OpenPose Mapping Editor")
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
self.resize(620, 720)
self._row_items = {} # index -> QTreeWidgetItem
self._build_ui()
self.refresh_characters()
def _build_ui(self):
layout = QtWidgets.QVBoxLayout(self)
char_row = QtWidgets.QHBoxLayout()
char_row.addWidget(QtWidgets.QLabel("Character:"))
self.character_combo = QtWidgets.QComboBox()
self.character_combo.currentIndexChanged.connect(self.refresh_mapping)
char_row.addWidget(self.character_combo, 1)
refresh_btn = QtWidgets.QPushButton("Refresh")
refresh_btn.clicked.connect(self.refresh_characters)
char_row.addWidget(refresh_btn)
create_btn = QtWidgets.QPushButton("Create Character")
create_btn.clicked.connect(self._on_create_character)
char_row.addWidget(create_btn)
char_row.addWidget(QtWidgets.QLabel("Body Model:"))
self.body_model_combo = QtWidgets.QComboBox()
self.body_model_combo.addItems(constants.BODY_MODELS)
self.body_model_combo.currentIndexChanged.connect(self._on_body_model_changed)
char_row.addWidget(self.body_model_combo)
layout.addLayout(char_row)
face_row = QtWidgets.QHBoxLayout()
face_row.addWidget(QtWidgets.QLabel("Face Source:"))
self.face_source_combo = QtWidgets.QComboBox()
self.face_source_combo.addItems(
[mapping_node.FACE_SOURCE_JOINTS, mapping_node.FACE_SOURCE_ARKIT_BLENDSHAPES]
)
self.face_source_combo.currentIndexChanged.connect(self._on_face_source_changed)
face_row.addWidget(self.face_source_combo)
face_row.addWidget(QtWidgets.QLabel("Face Scale:"))
self.face_scale_spin = QtWidgets.QDoubleSpinBox()
self.face_scale_spin.setRange(0.0, 100000.0)
self.face_scale_spin.setValue(mapping_node.DEFAULT_FACE_SCALE)
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"
"slots wherever an attribute matching the canonical ARKit name (e.g. jawOpen,\n"
"mouthSmileLeft) exists on it -- typically a blendShape node whose targets are\n"
"named per the ARKit convention, or an ARKit-mocap-driven control."
)
autoconnect_btn.clicked.connect(self._on_autoconnect_blendshapes)
face_row.addWidget(autoconnect_btn)
self.blendshape_status_label = QtWidgets.QLabel("")
face_row.addWidget(self.blendshape_status_label)
face_row.addStretch(1)
layout.addLayout(face_row)
self.tree = QtWidgets.QTreeWidget()
self.tree.setColumnCount(3)
self.tree.setHeaderLabels(["Keypoint", "Mapped Joint", ""])
self.tree.setColumnWidth(0, 220)
self.tree.setColumnWidth(1, 180)
layout.addWidget(self.tree, 1)
self._build_rows()
def _build_rows(self):
self.tree.clear()
self._row_items = {}
part_items = {}
for part_id, _start, _count in constants.PARTS:
part_item = QtWidgets.QTreeWidgetItem([_PART_LABELS[part_id], "", ""])
self.tree.addTopLevelItem(part_item)
part_items[part_id] = part_item
for index in range(constants.TOTAL_KEYPOINTS):
part_id = constants.part_of(index)
row = QtWidgets.QTreeWidgetItem([constants.KEYPOINT_NAMES[index], "<unmapped>", ""])
part_items[part_id].addChild(row)
button_widget = QtWidgets.QWidget()
button_layout = QtWidgets.QHBoxLayout(button_widget)
button_layout.setContentsMargins(0, 0, 0, 0)
set_btn = QtWidgets.QPushButton("Set from Selected")
set_btn.clicked.connect(partial(self._on_set_joint, index))
clear_btn = QtWidgets.QPushButton("Clear")
clear_btn.clicked.connect(partial(self._on_clear_joint, index))
button_layout.addWidget(set_btn)
button_layout.addWidget(clear_btn)
self.tree.setItemWidget(row, 2, button_widget)
self._row_items[index] = row
self.tree.expandAll()
def _current_character(self):
return self.character_combo.currentText() or None
def refresh_characters(self):
current = self._current_character()
self.character_combo.blockSignals(True)
self.character_combo.clear()
characters = sorted(cmds.ls(type="openposeCharacter") or [])
self.character_combo.addItems(characters)
if current in characters:
self.character_combo.setCurrentText(current)
self.character_combo.blockSignals(False)
self.refresh_mapping()
def refresh_mapping(self):
character = self._current_character()
for index, row in self._row_items.items():
label = "<unmapped>"
if character:
plug = _target_plug(character, index)
sources = cmds.listConnections(plug, source=True, destination=False) or []
if sources:
label = sources[0]
row.setText(1, label)
self.body_model_combo.blockSignals(True)
if character:
self.body_model_combo.setEnabled(True)
self.body_model_combo.setCurrentIndex(cmds.getAttr(character + ".bodyModel"))
else:
self.body_model_combo.setEnabled(False)
self.body_model_combo.blockSignals(False)
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:
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)
)
self.blendshape_status_label.setText(
"{0} / {1} blendshapes connected".format(connected, face_blendshapes.BLENDSHAPE_COUNT)
)
else:
for widget in face_widgets:
widget.setEnabled(False)
self.blendshape_status_label.setText("")
for widget in face_widgets:
widget.blockSignals(False)
def _on_body_model_changed(self, index):
character = self._current_character()
if character:
cmds.setAttr(character + ".bodyModel", index)
def _on_face_source_changed(self, index):
character = self._current_character()
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:
QtWidgets.QMessageBox.warning(self, "OpenPose", "Create or select a character first.")
return
selected = cmds.ls(selection=True)
if not selected:
QtWidgets.QMessageBox.warning(
self, "OpenPose", "Select a blendShape node (or control) with ARKit-named attributes first."
)
return
source_node = selected[0]
connected_count = 0
for index, name in enumerate(face_blendshapes.ARKIT_BLENDSHAPE_NAMES):
if not cmds.attributeQuery(name, node=source_node, exists=True):
continue
plug = "{0}.blendshapeWeight[{1}]".format(character, index)
existing = cmds.listConnections(plug, source=True, destination=False, plugs=True) or []
for src in existing:
cmds.disconnectAttr(src, plug)
cmds.connectAttr("{0}.{1}".format(source_node, name), plug, force=True)
connected_count += 1
self.refresh_mapping()
QtWidgets.QMessageBox.information(
self, "OpenPose",
"Connected {0} / {1} ARKit blendshapes from '{2}'.".format(
connected_count, face_blendshapes.BLENDSHAPE_COUNT, source_node
),
)
def _on_create_character(self):
name, ok = QtWidgets.QInputDialog.getText(
self, "Create Character", "Name:", text="openposeCharacter1"
)
if not ok:
return
created = cmds.openposeCreateCharacter(name=name) if name else cmds.openposeCreateCharacter()
if isinstance(created, list): # MPxCommand string results come back as a 1-item list
created = created[0]
self.refresh_characters()
combo_index = self.character_combo.findText(created)
if combo_index >= 0:
self.character_combo.setCurrentIndex(combo_index)
def _on_set_joint(self, index):
character = self._current_character()
if not character:
QtWidgets.QMessageBox.warning(self, "OpenPose", "Create or select a character first.")
return
selected = cmds.ls(selection=True, type="joint")
if not selected:
QtWidgets.QMessageBox.warning(self, "OpenPose", "Select a joint first.")
return
joint = selected[0]
plug = _target_plug(character, index)
existing = cmds.listConnections(plug, source=True, destination=False, plugs=True) or []
for src in existing:
cmds.disconnectAttr(src, plug)
cmds.connectAttr(joint + ".message", plug, force=True)
self.refresh_mapping()
def _on_clear_joint(self, index):
character = self._current_character()
if not character:
return
plug = _target_plug(character, index)
existing = cmds.listConnections(plug, source=True, destination=False, plugs=True) or []
for src in existing:
cmds.disconnectAttr(src, plug)
self.refresh_mapping()
def show():
global _window_instance
if _window_instance is not None:
try:
_window_instance.close()
_window_instance.deleteLater()
except RuntimeError:
pass
_window_instance = MappingEditor()
_window_instance.show()
return _window_instance