e2f2e6668a
Maya API 2.0 plugin that maps a skeleton to OpenPose_full keypoints (BODY_25 or COCO body, 21+21 hand, 70 face) via an openposeCharacter node, with a live Viewport 2.0 draw override, an "OpenPose" viewport renderer plus openposeRenderSequence for PNG output, and a PySide2 mapping editor. Face can also be driven procedurally from ARKit's 52 blendshape weights instead of facial joints, for rigs with no facial skeleton. Includes VS Code debug configs and Maya test scenes/renders. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
305 lines
12 KiB
Python
305 lines
12 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)
|
|
|
|
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)
|
|
|
|
for widget in (self.face_source_combo, self.face_scale_spin):
|
|
widget.blockSignals(True)
|
|
if character:
|
|
self.face_source_combo.setEnabled(True)
|
|
self.face_scale_spin.setEnabled(True)
|
|
self.face_source_combo.setCurrentIndex(cmds.getAttr(character + ".faceSource"))
|
|
self.face_scale_spin.setValue(cmds.getAttr(character + ".faceScale"))
|
|
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:
|
|
self.face_source_combo.setEnabled(False)
|
|
self.face_scale_spin.setEnabled(False)
|
|
self.blendshape_status_label.setText("")
|
|
for widget in (self.face_source_combo, self.face_scale_spin):
|
|
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_scale_changed(self, value):
|
|
character = self._current_character()
|
|
if character:
|
|
cmds.setAttr(character + ".faceScale", value)
|
|
|
|
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
|