Add OpenPose_full renderer plugin for Maya
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>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""OpenPose_full renderer plugin for Maya (Python API 2.0)."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""MEL/Python-visible commands: ``openposeCreateCharacter`` (undoable node
|
||||
creation) and ``openposeShowMappingEditor`` (launches the mapping UI).
|
||||
"""
|
||||
|
||||
import maya.api.OpenMaya as om
|
||||
|
||||
from . import mapping_node
|
||||
|
||||
|
||||
class CreateCharacterCommand(om.MPxCommand):
|
||||
|
||||
kCmdName = "openposeCreateCharacter"
|
||||
|
||||
def __init__(self):
|
||||
om.MPxCommand.__init__(self)
|
||||
self._modifiers = []
|
||||
self._created_node_name = None
|
||||
|
||||
@staticmethod
|
||||
def creator():
|
||||
return CreateCharacterCommand()
|
||||
|
||||
@staticmethod
|
||||
def createSyntax():
|
||||
syntax = om.MSyntax()
|
||||
syntax.addFlag("-n", "-name", om.MSyntax.kString)
|
||||
return syntax
|
||||
|
||||
def doIt(self, args):
|
||||
parser = om.MArgParser(self.syntax(), args)
|
||||
want_name = parser.flagArgumentString("-n", 0) if parser.isFlagSet("-n") else None
|
||||
|
||||
self._modifiers = []
|
||||
|
||||
# For a shape type, MDagModifier.createNode() returns the
|
||||
# auto-created *parent transform*, not the shape -- the shape itself
|
||||
# is its only child, and only exists once doIt() has run.
|
||||
create_modifier = om.MDagModifier()
|
||||
transform_obj = create_modifier.createNode(mapping_node.NODE_NAME)
|
||||
create_modifier.doIt()
|
||||
self._modifiers.append(create_modifier)
|
||||
|
||||
shape_obj = om.MFnDagNode(transform_obj).child(0)
|
||||
|
||||
if want_name:
|
||||
rename_modifier = om.MDagModifier()
|
||||
rename_modifier.renameNode(shape_obj, want_name)
|
||||
rename_modifier.doIt()
|
||||
self._modifiers.append(rename_modifier)
|
||||
|
||||
self._created_node_name = om.MFnDagNode(shape_obj).name()
|
||||
self.setResult(self._created_node_name)
|
||||
|
||||
def redoIt(self):
|
||||
for modifier in self._modifiers:
|
||||
modifier.doIt()
|
||||
|
||||
def undoIt(self):
|
||||
for modifier in reversed(self._modifiers):
|
||||
modifier.undoIt()
|
||||
|
||||
def isUndoable(self):
|
||||
return True
|
||||
|
||||
|
||||
class ShowMappingEditorCommand(om.MPxCommand):
|
||||
|
||||
kCmdName = "openposeShowMappingEditor"
|
||||
|
||||
@staticmethod
|
||||
def creator():
|
||||
return ShowMappingEditorCommand()
|
||||
|
||||
def doIt(self, args):
|
||||
from .ui import mapping_editor
|
||||
mapping_editor.show()
|
||||
|
||||
def isUndoable(self):
|
||||
return False
|
||||
|
||||
|
||||
def register(fn_plugin):
|
||||
fn_plugin.registerCommand(
|
||||
CreateCharacterCommand.kCmdName, CreateCharacterCommand.creator, CreateCharacterCommand.createSyntax
|
||||
)
|
||||
fn_plugin.registerCommand(ShowMappingEditorCommand.kCmdName, ShowMappingEditorCommand.creator)
|
||||
|
||||
|
||||
def deregister(fn_plugin):
|
||||
fn_plugin.deregisterCommand(ShowMappingEditorCommand.kCmdName)
|
||||
fn_plugin.deregisterCommand(CreateCharacterCommand.kCmdName)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""OpenPose_full (137-point) keypoint schema.
|
||||
|
||||
Layout: 25 BODY_25 body/foot points + 21 left-hand + 21 right-hand + 70 face
|
||||
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.
|
||||
"""
|
||||
|
||||
import colorsys
|
||||
|
||||
|
||||
# --- Part sizes / offsets into the flat 0..136 keypoint index ---------------
|
||||
|
||||
BODY_COUNT = 25
|
||||
HAND_COUNT = 21
|
||||
FACE_COUNT = 70
|
||||
|
||||
BODY_START = 0
|
||||
HAND_LEFT_START = BODY_START + BODY_COUNT # 25
|
||||
HAND_RIGHT_START = HAND_LEFT_START + HAND_COUNT # 46
|
||||
FACE_START = HAND_RIGHT_START + HAND_COUNT # 67
|
||||
TOTAL_KEYPOINTS = FACE_START + FACE_COUNT # 137
|
||||
|
||||
PART_BODY = "body"
|
||||
PART_HAND_LEFT = "hand_left"
|
||||
PART_HAND_RIGHT = "hand_right"
|
||||
PART_FACE = "face"
|
||||
|
||||
# (part id, start index, count) in draw order
|
||||
PARTS = [
|
||||
(PART_BODY, BODY_START, BODY_COUNT),
|
||||
(PART_HAND_LEFT, HAND_LEFT_START, HAND_COUNT),
|
||||
(PART_HAND_RIGHT, HAND_RIGHT_START, HAND_COUNT),
|
||||
(PART_FACE, FACE_START, FACE_COUNT),
|
||||
]
|
||||
|
||||
|
||||
def _hsv255(hue, saturation=1.0, value=1.0):
|
||||
r, g, b = colorsys.hsv_to_rgb(hue % 1.0, saturation, value)
|
||||
return (int(round(r * 255)), int(round(g * 255)), int(round(b * 255)))
|
||||
|
||||
|
||||
# --- Body: BODY_25 -----------------------------------------------------------
|
||||
|
||||
BODY_25_NAMES = [
|
||||
"Nose", "Neck", "RShoulder", "RElbow", "RWrist", "LShoulder", "LElbow",
|
||||
"LWrist", "MidHip", "RHip", "RKnee", "RAnkle", "LHip", "LKnee", "LAnkle",
|
||||
"REye", "LEye", "REar", "LEar", "LBigToe", "LSmallToe", "LHeel",
|
||||
"RBigToe", "RSmallToe", "RHeel",
|
||||
]
|
||||
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).
|
||||
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),
|
||||
(22, 23), (11, 24),
|
||||
]
|
||||
|
||||
BODY_25_POINT_COLORS = [_hsv255(i / BODY_COUNT) for i in range(BODY_COUNT)]
|
||||
BODY_25_LIMB_COLORS = [
|
||||
_hsv255(i / len(BODY_25_PAIRS_LOCAL)) for i in range(len(BODY_25_PAIRS_LOCAL))
|
||||
]
|
||||
|
||||
BODY_POINT_RADIUS = 4.0
|
||||
BODY_LIMB_THICKNESS = 4.0
|
||||
|
||||
|
||||
# --- Body: COCO-18 (legacy OpenPose COCO model) ------------------------------
|
||||
#
|
||||
# COCO is *not* simply "BODY_25 with some points removed": it has its own
|
||||
# limb connectivity (Neck connects directly to each hip -- there is no
|
||||
# MidHip point at all -- and there are no foot points). Both the point names
|
||||
# and the COCO section of the limb-pair table are verbatim from OpenPose's
|
||||
# own POSE_COCO_BODY_PARTS / POSE_BODY_PART_PAIRS,
|
||||
# src/openpose/pose/poseParameters.cpp.
|
||||
#
|
||||
# COCO mode does not need its own joint-mapping slots: it reuses the exact
|
||||
# same BODY_25-indexed targetJoint mapping every character already has, and
|
||||
# is purely a different "which of those points + which limbs to draw" view
|
||||
# over it (COCO_TO_BODY25_INDEX below is that view).
|
||||
|
||||
COCO_COUNT = 18
|
||||
|
||||
COCO_NAMES = [
|
||||
"Nose", "Neck", "RShoulder", "RElbow", "RWrist", "LShoulder", "LElbow",
|
||||
"LWrist", "RHip", "RKnee", "RAnkle", "LHip", "LKnee", "LAnkle", "REye",
|
||||
"LEye", "REar", "LEar",
|
||||
]
|
||||
assert len(COCO_NAMES) == COCO_COUNT
|
||||
|
||||
# COCO local index -> BODY_25 local index (== global index, since the body
|
||||
# part starts at 0). BODY_25's MidHip (8) and the six foot points (19-24)
|
||||
# have no COCO equivalent and are simply absent from this list.
|
||||
COCO_TO_BODY25_INDEX = [0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
|
||||
assert len(COCO_TO_BODY25_INDEX) == COCO_COUNT
|
||||
assert all(BODY_25_NAMES[COCO_TO_BODY25_INDEX[i]] == COCO_NAMES[i] for i in range(COCO_COUNT))
|
||||
|
||||
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_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
|
||||
]
|
||||
COCO_LIMB_COLORS = [_hsv255(i / len(COCO_PAIRS_LOCAL)) for i in range(len(COCO_PAIRS_LOCAL))]
|
||||
|
||||
BODY_MODEL_BODY_25 = "BODY_25"
|
||||
BODY_MODEL_COCO = "COCO"
|
||||
BODY_MODELS = [BODY_MODEL_BODY_25, BODY_MODEL_COCO]
|
||||
|
||||
|
||||
# --- Hands: 21 points each, standard wrist + 5 x 4-joint fingers ------------
|
||||
|
||||
_FINGER_NAMES = ["Thumb", "Index", "Middle", "Ring", "Pinky"]
|
||||
|
||||
HAND_NAMES = ["Wrist"] + [
|
||||
"{0}{1}".format(finger, joint)
|
||||
for finger in _FINGER_NAMES
|
||||
for joint in range(1, 5)
|
||||
]
|
||||
assert len(HAND_NAMES) == HAND_COUNT
|
||||
|
||||
# (0=wrist, 1-4=thumb, 5-8=index, 9-12=middle, 13-16=ring, 17-20=pinky)
|
||||
HAND_PAIRS_LOCAL = []
|
||||
for _finger_i in range(5):
|
||||
_base = 1 + _finger_i * 4
|
||||
HAND_PAIRS_LOCAL += [
|
||||
(0, _base), (_base, _base + 1), (_base + 1, _base + 2),
|
||||
(_base + 2, _base + 3),
|
||||
]
|
||||
assert len(HAND_PAIRS_LOCAL) == 20
|
||||
|
||||
|
||||
def _hand_point_color(local_index):
|
||||
if local_index == 0:
|
||||
return (255, 255, 255) # wrist
|
||||
finger = (local_index - 1) // 4
|
||||
joint = (local_index - 1) % 4 # 0 (nearest wrist) .. 3 (tip)
|
||||
hue = finger / 5.0
|
||||
value = 0.4 + 0.2 * joint # brighter towards the fingertip
|
||||
return _hsv255(hue, 1.0, value)
|
||||
|
||||
|
||||
HAND_POINT_COLORS = [_hand_point_color(i) for i in range(HAND_COUNT)]
|
||||
HAND_LIMB_COLORS = [
|
||||
_hand_point_color(j) for (_, j) in HAND_PAIRS_LOCAL
|
||||
] # colored by the joint further from the wrist
|
||||
|
||||
HAND_POINT_RADIUS = 3.0
|
||||
HAND_LIMB_THICKNESS = 2.0
|
||||
|
||||
|
||||
# --- Face: 70 points (iBUG 68-point landmarks + 2 pupils) -------------------
|
||||
|
||||
FACE_NAMES = (
|
||||
["Jaw{0}".format(i) for i in range(17)]
|
||||
+ ["REyebrow{0}".format(i) for i in range(5)]
|
||||
+ ["LEyebrow{0}".format(i) for i in range(5)]
|
||||
+ ["Nose{0}".format(i) for i in range(9)]
|
||||
+ ["REye{0}".format(i) for i in range(6)]
|
||||
+ ["LEye{0}".format(i) for i in range(6)]
|
||||
+ ["Mouth{0}".format(i) for i in range(20)]
|
||||
+ ["RPupil", "LPupil"]
|
||||
)
|
||||
assert len(FACE_NAMES) == FACE_COUNT
|
||||
|
||||
# OpenPose's default renderer draws the face as keypoints only (no limb
|
||||
# lines), all white.
|
||||
FACE_PAIRS_LOCAL = []
|
||||
FACE_POINT_COLORS = [(255, 255, 255)] * FACE_COUNT
|
||||
|
||||
FACE_POINT_RADIUS = 2.0
|
||||
FACE_LIMB_THICKNESS = 0.0
|
||||
|
||||
|
||||
# --- Flattened 0..136 global schema ------------------------------------------
|
||||
|
||||
KEYPOINT_NAMES = (
|
||||
["Body_{0}".format(n) for n in BODY_25_NAMES]
|
||||
+ ["HandLeft_{0}".format(n) for n in HAND_NAMES]
|
||||
+ ["HandRight_{0}".format(n) for n in HAND_NAMES]
|
||||
+ ["Face_{0}".format(n) for n in FACE_NAMES]
|
||||
)
|
||||
assert len(KEYPOINT_NAMES) == TOTAL_KEYPOINTS
|
||||
|
||||
NAME_TO_INDEX = {name: i for i, name in enumerate(KEYPOINT_NAMES)}
|
||||
|
||||
KEYPOINT_PART = (
|
||||
[PART_BODY] * BODY_COUNT
|
||||
+ [PART_HAND_LEFT] * HAND_COUNT
|
||||
+ [PART_HAND_RIGHT] * HAND_COUNT
|
||||
+ [PART_FACE] * FACE_COUNT
|
||||
)
|
||||
|
||||
KEYPOINT_POINT_COLORS = (
|
||||
BODY_25_POINT_COLORS + HAND_POINT_COLORS + HAND_POINT_COLORS + FACE_POINT_COLORS
|
||||
)
|
||||
|
||||
KEYPOINT_POINT_RADIUS = (
|
||||
[BODY_POINT_RADIUS] * BODY_COUNT
|
||||
+ [HAND_POINT_RADIUS] * HAND_COUNT
|
||||
+ [HAND_POINT_RADIUS] * HAND_COUNT
|
||||
+ [FACE_POINT_RADIUS] * FACE_COUNT
|
||||
)
|
||||
|
||||
def part_of(global_index):
|
||||
"""Return the part id (PART_BODY / PART_HAND_LEFT / ...) for a keypoint index."""
|
||||
for part_id, start, count in PARTS:
|
||||
if start <= global_index < start + count:
|
||||
return part_id
|
||||
raise IndexError(global_index)
|
||||
|
||||
|
||||
# Hand 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]
|
||||
|
||||
|
||||
def _body_limb_data(body_model):
|
||||
"""(pairs, colors, thickness) for just the body part, global-indexed."""
|
||||
if body_model == BODY_MODEL_COCO:
|
||||
pairs = COCO_PAIRS_LOCAL # already translated to BODY_25-local/global indices
|
||||
colors = COCO_LIMB_COLORS
|
||||
else:
|
||||
pairs = [(a + BODY_START, b + BODY_START) for (a, b) in BODY_25_PAIRS_LOCAL]
|
||||
colors = BODY_25_LIMB_COLORS
|
||||
thickness = [BODY_LIMB_THICKNESS] * len(pairs)
|
||||
return pairs, colors, thickness
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
thickness = (
|
||||
list(body_thickness)
|
||||
+ [HAND_LIMB_THICKNESS] * len(HAND_PAIRS_LOCAL)
|
||||
+ [HAND_LIMB_THICKNESS] * len(HAND_PAIRS_LOCAL)
|
||||
)
|
||||
assert len(pairs) == len(colors) == len(thickness)
|
||||
return pairs, colors, thickness
|
||||
|
||||
|
||||
def active_point_indices(body_model=BODY_MODEL_BODY_25):
|
||||
"""Global keypoint indices to draw as points for the given body model.
|
||||
|
||||
Hands and face are always fully active; the body part is either all 25
|
||||
BODY_25 points or just the 18 COCO points (no MidHip, no feet).
|
||||
"""
|
||||
if body_model == BODY_MODEL_COCO:
|
||||
body_indices = list(COCO_ACTIVE_BODY25_INDICES)
|
||||
else:
|
||||
body_indices = list(range(BODY_START, BODY_START + BODY_COUNT))
|
||||
return (
|
||||
body_indices
|
||||
+ list(range(HAND_LEFT_START, HAND_LEFT_START + HAND_COUNT))
|
||||
+ list(range(HAND_RIGHT_START, HAND_RIGHT_START + HAND_COUNT))
|
||||
+ list(range(FACE_START, FACE_START + FACE_COUNT))
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Viewport 2.0 draw override for ``openposeCharacter``: a live, colored
|
||||
skeleton overlay drawn in world space on top of the mapped joints, so a rig
|
||||
can be visually QA'd against the OpenPose_full mapping without rendering.
|
||||
|
||||
This is independent of the render override (render_override.py) -- it always
|
||||
draws (when the node's enableDisplay attr is on) in every viewport, in 3D
|
||||
world space, and tracks animation live via prepareForDraw() being re-run
|
||||
whenever the node is dirtied.
|
||||
"""
|
||||
|
||||
import maya.api.OpenMaya as om
|
||||
import maya.api.OpenMayaRender as omr
|
||||
|
||||
from . import constants
|
||||
from . import face_blendshapes
|
||||
from . import mapping_node
|
||||
from . import projector
|
||||
|
||||
|
||||
def _mcolor(rgb255):
|
||||
return om.MColor((rgb255[0] / 255.0, rgb255[1] / 255.0, rgb255[2] / 255.0, 1.0))
|
||||
|
||||
|
||||
class OpenPoseUserData(om.MUserData):
|
||||
|
||||
def __init__(self):
|
||||
om.MUserData.__init__(self, False) # Maya should not delete after one draw
|
||||
self.lines = [] # [(p0, p1, MColor, thickness), ...]
|
||||
self.points = [] # [(p, MColor, pointSize), ...]
|
||||
|
||||
|
||||
class OpenPoseDrawOverride(omr.MPxDrawOverride):
|
||||
|
||||
@staticmethod
|
||||
def creator(obj):
|
||||
return OpenPoseDrawOverride(obj)
|
||||
|
||||
def __init__(self, obj):
|
||||
omr.MPxDrawOverride.__init__(self, obj, None, isAlwaysDirty=True)
|
||||
|
||||
def supportedDrawAPIs(self):
|
||||
return omr.MRenderer.kAllDevices
|
||||
|
||||
def isBounded(self, obj_path, camera_path):
|
||||
# Joints being displayed can be anywhere in the scene relative to
|
||||
# this node's own (irrelevant) transform -- don't cull on bbox.
|
||||
return False
|
||||
|
||||
def hasUIDrawables(self):
|
||||
return True
|
||||
|
||||
def prepareForDraw(self, obj_path, camera_path, frame_context, old_data):
|
||||
data = old_data if isinstance(old_data, OpenPoseUserData) else OpenPoseUserData()
|
||||
data.lines = []
|
||||
data.points = []
|
||||
|
||||
dep_fn = om.MFnDependencyNode(obj_path.node())
|
||||
try:
|
||||
enabled = dep_fn.findPlug(mapping_node.aEnableDisplay, False).asBool()
|
||||
except RuntimeError:
|
||||
enabled = True
|
||||
if not enabled:
|
||||
return data
|
||||
|
||||
radius_scale = dep_fn.findPlug(mapping_node.aJointRadiusScale, False).asFloat()
|
||||
thickness_scale = dep_fn.findPlug(mapping_node.aLimbThicknessScale, False).asFloat()
|
||||
body_model = mapping_node.get_body_model(dep_fn)
|
||||
|
||||
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_face_source(dep_fn) == mapping_node.FACE_SOURCE_ARKIT_BLENDSHAPES:
|
||||
nose_index = constants.NAME_TO_INDEX["Body_Nose"]
|
||||
anchor = positions.get(nose_index)
|
||||
if anchor is not None:
|
||||
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)
|
||||
for offset, world_point in enumerate(world_points):
|
||||
positions[constants.FACE_START + offset] = world_point
|
||||
|
||||
active_indices = set(constants.active_point_indices(body_model))
|
||||
pairs, colors, thickness_list = constants.full_limb_data(body_model)
|
||||
|
||||
for pair_i, (a, b) in enumerate(pairs):
|
||||
if a in positions and b in positions:
|
||||
thickness = thickness_list[pair_i] * thickness_scale
|
||||
if thickness <= 0.0:
|
||||
continue
|
||||
data.lines.append((positions[a], positions[b], _mcolor(colors[pair_i]), thickness))
|
||||
|
||||
for index, pos in positions.items():
|
||||
if index not in active_indices:
|
||||
continue
|
||||
radius = constants.KEYPOINT_POINT_RADIUS[index] * radius_scale
|
||||
if radius <= 0.0:
|
||||
continue
|
||||
data.points.append((pos, _mcolor(constants.KEYPOINT_POINT_COLORS[index]), radius * 2.0))
|
||||
|
||||
return data
|
||||
|
||||
def addUIDrawables(self, obj_path, draw_manager, frame_context, data):
|
||||
if not isinstance(data, OpenPoseUserData):
|
||||
return
|
||||
|
||||
draw_manager.beginDrawable()
|
||||
draw_manager.setDepthPriority(omr.MRenderItem.sActiveWireDepthPriority)
|
||||
|
||||
for p0, p1, color, thickness in data.lines:
|
||||
draw_manager.setColor(color)
|
||||
draw_manager.setLineWidth(thickness)
|
||||
draw_manager.line(p0, p1)
|
||||
|
||||
for pos, color, point_size in data.points:
|
||||
draw_manager.setColor(color)
|
||||
draw_manager.setPointSize(point_size)
|
||||
draw_manager.point(pos)
|
||||
|
||||
draw_manager.endDrawable()
|
||||
|
||||
|
||||
def register(fn_plugin):
|
||||
omr.MDrawRegistry.registerDrawOverrideCreator(
|
||||
mapping_node.DRAW_CLASSIFICATION,
|
||||
mapping_node.DRAW_REGISTRANT_ID,
|
||||
OpenPoseDrawOverride.creator,
|
||||
)
|
||||
|
||||
|
||||
def deregister(fn_plugin):
|
||||
omr.MDrawRegistry.deregisterDrawOverrideCreator(
|
||||
mapping_node.DRAW_CLASSIFICATION,
|
||||
mapping_node.DRAW_REGISTRANT_ID,
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Drives the 70-point OpenPose_full face part from ARKit's 52 facial
|
||||
blendshape weights, for rigs that animate the face with blendShape targets
|
||||
(or live ARKit/iPhone TrueDepth mocap) instead of facial joints.
|
||||
|
||||
``ARKIT_BLENDSHAPE_NAMES`` (order, spelling) is verbatim from Apple's own
|
||||
``ARFaceAnchor.BlendShapeLocation`` enumeration.
|
||||
|
||||
There is no published, authoritative "which ARKit weight moves which
|
||||
OpenPose/iBUG landmark, by how much" table anywhere -- unlike BODY_25/COCO,
|
||||
which OpenPose's own source defines precisely, this is a genuinely
|
||||
unstandardized problem. ``NEUTRAL_FACE_TEMPLATE`` and ``BLENDSHAPE_OFFSETS``
|
||||
below are therefore a hand-authored, geometrically-reasoned *approximation*
|
||||
(each shape nudges only the anatomically obvious nearby landmarks, in an
|
||||
intuitive direction) -- good enough to produce a recognizable, responsive
|
||||
OpenPose-style face for ControlNet conditioning, but not a biomechanically
|
||||
accurate face simulation. A few shapes with no representable effect on a
|
||||
frontal 2D landmark set (``jawForward``, ``tongueOut``, ``mouthFunnel`` --
|
||||
visually redundant with ``mouthPucker`` on this coarse a template) are
|
||||
intentionally no-ops.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from . import constants
|
||||
|
||||
# Verbatim from Apple's ARFaceAnchor.BlendShapeLocation.
|
||||
ARKIT_BLENDSHAPE_NAMES = [
|
||||
# Left eye
|
||||
"eyeBlinkLeft", "eyeLookDownLeft", "eyeLookInLeft", "eyeLookOutLeft",
|
||||
"eyeLookUpLeft", "eyeSquintLeft", "eyeWideLeft",
|
||||
# Right eye
|
||||
"eyeBlinkRight", "eyeLookDownRight", "eyeLookInRight", "eyeLookOutRight",
|
||||
"eyeLookUpRight", "eyeSquintRight", "eyeWideRight",
|
||||
# Jaw
|
||||
"jawForward", "jawLeft", "jawRight", "jawOpen",
|
||||
# Mouth
|
||||
"mouthClose", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight",
|
||||
"mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight",
|
||||
"mouthDimpleLeft", "mouthDimpleRight", "mouthStretchLeft", "mouthStretchRight",
|
||||
"mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper",
|
||||
"mouthPressLeft", "mouthPressRight", "mouthLowerDownLeft", "mouthLowerDownRight",
|
||||
"mouthUpperUpLeft", "mouthUpperUpRight",
|
||||
# Brow
|
||||
"browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight",
|
||||
# Cheek
|
||||
"cheekPuff", "cheekSquintLeft", "cheekSquintRight",
|
||||
# Nose
|
||||
"noseSneerLeft", "noseSneerRight",
|
||||
# Tongue
|
||||
"tongueOut",
|
||||
]
|
||||
assert len(ARKIT_BLENDSHAPE_NAMES) == 52
|
||||
BLENDSHAPE_COUNT = 52
|
||||
BLENDSHAPE_NAME_TO_INDEX = {name: i for i, name in enumerate(ARKIT_BLENDSHAPE_NAMES)}
|
||||
|
||||
_FACE_INDEX = {name: i for i, name in enumerate(constants.FACE_NAMES)}
|
||||
|
||||
# Mouth outer contour = Mouth0..11 (12 pts), inner contour = Mouth12..19 (8
|
||||
# pts). Within the outer contour: 0 = left corner, 1-5 = upper lip (left to
|
||||
# right), 6 = right corner, 7-11 = lower lip (right to left).
|
||||
_MOUTH_UPPER_OUTER = [0, 1, 2, 3, 4, 5, 6]
|
||||
_MOUTH_LOWER_OUTER = [6, 7, 8, 9, 10, 11, 0]
|
||||
|
||||
|
||||
def _mouth_outer(i):
|
||||
return "Mouth{0}".format(i)
|
||||
|
||||
|
||||
def _mouth_inner(i):
|
||||
return "Mouth{0}".format(12 + i)
|
||||
|
||||
|
||||
def _arc(cx, cy, rx, ry, start_deg, end_deg, count):
|
||||
points = []
|
||||
for i in range(count):
|
||||
t = i / (count - 1) if count > 1 else 0.0
|
||||
angle = math.radians(start_deg + (end_deg - start_deg) * t)
|
||||
points.append((cx + rx * math.cos(angle), cy + ry * math.sin(angle)))
|
||||
return points
|
||||
|
||||
|
||||
def _build_neutral_template():
|
||||
"""70 (x, y) points in a local, camera-facing "face plane" (roughly a
|
||||
unit square, +X = screen-right, +Y = up, origin ~ between the eyes), in
|
||||
the same order as constants.FACE_NAMES. Proportions are a plausible
|
||||
generic face, not a specific published dataset's exact mean shape.
|
||||
"""
|
||||
template = [None] * constants.FACE_COUNT
|
||||
|
||||
# Jaw0..16: jawline, ear to ear around the chin.
|
||||
for i in range(17):
|
||||
angle = math.radians(180 + 180 * i / 16.0)
|
||||
x = 1.05 * math.cos(angle)
|
||||
y = -0.15 + 0.85 * math.sin(angle)
|
||||
template[_FACE_INDEX["Jaw{0}".format(i)]] = (x, y)
|
||||
|
||||
# Eyebrows: shallow arcs above each eye. Right eyebrow sits at negative
|
||||
# local X, left eyebrow mirrors at positive X (an arbitrary but
|
||||
# internally consistent left/right convention, same idea as BODY_25).
|
||||
for i, (x, y) in enumerate(_arc(-0.5, 0.62, 0.32, 0.10, 200, 340, 5)):
|
||||
template[_FACE_INDEX["REyebrow{0}".format(i)]] = (x, y)
|
||||
for i, (x, y) in enumerate(_arc(0.5, 0.62, 0.32, 0.10, -20, 140, 5)):
|
||||
template[_FACE_INDEX["LEyebrow{0}".format(i)]] = (x, y)
|
||||
|
||||
# Nose: bridge (top to tip) then the nostril-base arc.
|
||||
for i in range(4):
|
||||
t = i / 3.0
|
||||
template[_FACE_INDEX["Nose{0}".format(i)]] = (0.0, 0.42 - 0.38 * t)
|
||||
for i, (x, y) in enumerate(_arc(0.0, -0.02, 0.20, 0.06, 200, 340, 5)):
|
||||
template[_FACE_INDEX["Nose{0}".format(4 + i)]] = (x, y)
|
||||
|
||||
# Eyes: 6 points each (outer corner, 2x upper lid, inner corner, 2x lower lid).
|
||||
for i, (x, y) in enumerate(_arc(-0.5, 0.32, 0.30, 0.14, 0, 300, 6)):
|
||||
template[_FACE_INDEX["REye{0}".format(i)]] = (x, y)
|
||||
for i, (x, y) in enumerate(_arc(0.5, 0.32, 0.30, 0.14, 180, -120, 6)):
|
||||
template[_FACE_INDEX["LEye{0}".format(i)]] = (x, y)
|
||||
|
||||
# Mouth: 12-point outer contour, then 8-point inner contour.
|
||||
for i, (x, y) in enumerate(_arc(0.0, -0.55, 0.42, 0.20, 0, 300, 12)):
|
||||
template[_FACE_INDEX["Mouth{0}".format(i)]] = (x, y)
|
||||
for i, (x, y) in enumerate(_arc(0.0, -0.55, 0.28, 0.11, 0, 315, 8)):
|
||||
template[_FACE_INDEX["Mouth{0}".format(12 + i)]] = (x, y)
|
||||
|
||||
template[_FACE_INDEX["RPupil"]] = (-0.5, 0.32)
|
||||
template[_FACE_INDEX["LPupil"]] = (0.5, 0.32)
|
||||
|
||||
assert all(p is not None for p in template)
|
||||
return template
|
||||
|
||||
|
||||
NEUTRAL_FACE_TEMPLATE = _build_neutral_template()
|
||||
|
||||
|
||||
def _sign(value):
|
||||
return 1.0 if value >= 0 else -1.0
|
||||
|
||||
|
||||
# {blendshape_name: [(face_point_name, dx, dy), ...]} -- displacement applied
|
||||
# at weight 1.0, linearly scaled by the live weight. See module docstring for
|
||||
# the honesty caveat on precision.
|
||||
BLENDSHAPE_OFFSETS = {
|
||||
# --- Eyes: eyelid closure / widening (upper lid moves toward or away
|
||||
# from the lower lid; lower lid nudges slightly the opposite way).
|
||||
"eyeBlinkLeft": [("LEye1", 0.0, -0.10), ("LEye2", 0.0, -0.10)],
|
||||
"eyeBlinkRight": [("REye1", 0.0, -0.10), ("REye2", 0.0, -0.10)],
|
||||
"eyeWideLeft": [("LEye1", 0.0, 0.05), ("LEye2", 0.0, 0.05), ("LEye4", 0.0, -0.03), ("LEye5", 0.0, -0.03)],
|
||||
"eyeWideRight": [("REye1", 0.0, 0.05), ("REye2", 0.0, 0.05), ("REye4", 0.0, -0.03), ("REye5", 0.0, -0.03)],
|
||||
"eyeSquintLeft": [("LEye1", 0.0, -0.04), ("LEye2", 0.0, -0.04), ("LEye4", 0.0, 0.02), ("LEye5", 0.0, 0.02)],
|
||||
"eyeSquintRight": [("REye1", 0.0, -0.04), ("REye2", 0.0, -0.04), ("REye4", 0.0, 0.02), ("REye5", 0.0, 0.02)],
|
||||
# Gaze direction moves the pupil point within the eye socket.
|
||||
"eyeLookDownLeft": [("LPupil", 0.0, -0.06)],
|
||||
"eyeLookUpLeft": [("LPupil", 0.0, 0.06)],
|
||||
"eyeLookInLeft": [("LPupil", -0.08, 0.0)],
|
||||
"eyeLookOutLeft": [("LPupil", 0.08, 0.0)],
|
||||
"eyeLookDownRight": [("RPupil", 0.0, -0.06)],
|
||||
"eyeLookUpRight": [("RPupil", 0.0, 0.06)],
|
||||
"eyeLookInRight": [("RPupil", 0.08, 0.0)],
|
||||
"eyeLookOutRight": [("RPupil", -0.08, 0.0)],
|
||||
|
||||
# --- Brows.
|
||||
"browDownLeft": [("LEyebrow{0}".format(i), 0.0, -0.08) for i in range(5)],
|
||||
"browDownRight": [("REyebrow{0}".format(i), 0.0, -0.08) for i in range(5)],
|
||||
"browInnerUp": [("LEyebrow0", 0.0, 0.09), ("REyebrow4", 0.0, 0.09)],
|
||||
"browOuterUpLeft": [("LEyebrow3", 0.0, 0.08), ("LEyebrow4", 0.0, 0.08)],
|
||||
"browOuterUpRight": [("REyebrow0", 0.0, 0.08), ("REyebrow1", 0.0, 0.08)],
|
||||
|
||||
# --- Jaw. jawForward has no representable 2D effect (frontal view) --
|
||||
# intentionally omitted below.
|
||||
"jawOpen": (
|
||||
[("Jaw{0}".format(i), 0.0, -0.30) for i in range(6, 11)]
|
||||
+ [(_mouth_outer(i), 0.0, -0.22) for i in _MOUTH_LOWER_OUTER]
|
||||
+ [(_mouth_inner(i), 0.0, -0.16) for i in range(4, 7)]
|
||||
),
|
||||
"jawLeft": [("Jaw{0}".format(i), 0.06, 0.0) for i in range(6, 11)],
|
||||
"jawRight": [("Jaw{0}".format(i), -0.06, 0.0) for i in range(6, 11)],
|
||||
|
||||
# --- Mouth shape.
|
||||
"mouthSmileLeft": [(_mouth_outer(0), 0.05, 0.10), (_mouth_outer(1), 0.03, 0.06), (_mouth_outer(11), 0.03, 0.06)],
|
||||
"mouthSmileRight": [(_mouth_outer(6), -0.05, 0.10), (_mouth_outer(5), -0.03, 0.06), (_mouth_outer(7), -0.03, 0.06)],
|
||||
"mouthFrownLeft": [(_mouth_outer(0), 0.02, -0.08)],
|
||||
"mouthFrownRight": [(_mouth_outer(6), -0.02, -0.08)],
|
||||
"mouthLeft": [(_mouth_outer(i), 0.08, 0.0) for i in range(12)],
|
||||
"mouthRight": [(_mouth_outer(i), -0.08, 0.0) for i in range(12)],
|
||||
"mouthDimpleLeft": [(_mouth_outer(0), 0.02, 0.02)],
|
||||
"mouthDimpleRight": [(_mouth_outer(6), -0.02, 0.02)],
|
||||
"mouthStretchLeft": [(_mouth_outer(0), 0.10, 0.0)],
|
||||
"mouthStretchRight": [(_mouth_outer(6), -0.10, 0.0)],
|
||||
"mouthPucker": [
|
||||
(_mouth_outer(i), -0.10 * _sign(NEUTRAL_FACE_TEMPLATE[_FACE_INDEX[_mouth_outer(i)]][0]), 0.0)
|
||||
for i in range(12)
|
||||
],
|
||||
"mouthFunnel": [], # visually redundant with mouthPucker on this coarse template
|
||||
"mouthClose": (
|
||||
[(_mouth_outer(i), 0.0, -0.02) for i in _MOUTH_UPPER_OUTER]
|
||||
+ [(_mouth_outer(i), 0.0, 0.02) for i in _MOUTH_LOWER_OUTER]
|
||||
),
|
||||
"mouthRollLower": [(_mouth_outer(i), 0.0, 0.03) for i in _MOUTH_LOWER_OUTER],
|
||||
"mouthRollUpper": [(_mouth_outer(i), 0.0, -0.03) for i in _MOUTH_UPPER_OUTER],
|
||||
"mouthShrugLower": [(_mouth_outer(i), 0.0, -0.04) for i in _MOUTH_LOWER_OUTER],
|
||||
"mouthShrugUpper": [(_mouth_outer(i), 0.0, 0.04) for i in _MOUTH_UPPER_OUTER],
|
||||
"mouthPressLeft": [(_mouth_outer(0), 0.02, -0.01)],
|
||||
"mouthPressRight": [(_mouth_outer(6), -0.02, -0.01)],
|
||||
"mouthLowerDownLeft": [(_mouth_outer(7), 0.0, -0.06), (_mouth_outer(8), 0.0, -0.06)],
|
||||
"mouthLowerDownRight": [(_mouth_outer(10), 0.0, -0.06), (_mouth_outer(11), 0.0, -0.06)],
|
||||
"mouthUpperUpLeft": [(_mouth_outer(1), 0.0, 0.05), (_mouth_outer(2), 0.0, 0.05)],
|
||||
"mouthUpperUpRight": [(_mouth_outer(4), 0.0, 0.05), (_mouth_outer(5), 0.0, 0.05)],
|
||||
|
||||
# --- Cheeks / nose: subtle.
|
||||
"cheekPuff": [("Jaw{0}".format(i), 0.03, 0.0) for i in (3, 4)] + [("Jaw{0}".format(i), -0.03, 0.0) for i in (12, 13)],
|
||||
"cheekSquintLeft": [("LEye4", 0.0, 0.02), ("LEye5", 0.0, 0.02)],
|
||||
"cheekSquintRight": [("REye4", 0.0, 0.02), ("REye5", 0.0, 0.02)],
|
||||
"noseSneerLeft": [("Nose7", 0.0, 0.03), ("Nose8", 0.0, 0.03)],
|
||||
"noseSneerRight": [("Nose4", 0.0, 0.03), ("Nose5", 0.0, 0.03)],
|
||||
|
||||
# --- No 2D-representable effect on a frontal landmark set.
|
||||
"jawForward": [],
|
||||
"tongueOut": [],
|
||||
}
|
||||
assert set(BLENDSHAPE_OFFSETS.keys()) == set(ARKIT_BLENDSHAPE_NAMES)
|
||||
|
||||
|
||||
def compute_face_local_points(weights):
|
||||
"""``weights``: {blendshape_name: 0..1}. Returns 70 (x, y) local points
|
||||
(constants.FACE_NAMES order), the neutral template plus every active
|
||||
blendshape's weighted displacement.
|
||||
"""
|
||||
points = list(NEUTRAL_FACE_TEMPLATE)
|
||||
for name, weight in weights.items():
|
||||
if not weight:
|
||||
continue
|
||||
for point_name, dx, dy in BLENDSHAPE_OFFSETS.get(name, ()):
|
||||
idx = _FACE_INDEX[point_name]
|
||||
x, y = points[idx]
|
||||
points[idx] = (x + dx * weight, y + dy * weight)
|
||||
return points
|
||||
@@ -0,0 +1,228 @@
|
||||
"""``openposeCharacter`` node: per-character OpenPose_full joint mapping.
|
||||
|
||||
A pure MPxLocatorNode with one connectable message-attribute slot per
|
||||
OpenPose_full keypoint (``targetJoint[0..136]``). The mapping lives entirely
|
||||
as Maya connections (joint.message -> openposeCharacter.targetJoint[i]) so it
|
||||
survives joint renames/reparenting and round-trips through the scene file
|
||||
with no separate config to keep in sync. The node has no compute() output --
|
||||
it is a passive connection target read directly by the draw override
|
||||
(live viewport skeleton) and the render override (OpenPose image output).
|
||||
"""
|
||||
|
||||
import maya.api.OpenMaya as om
|
||||
import maya.api.OpenMayaUI as omui
|
||||
|
||||
from . import constants
|
||||
from . import face_blendshapes
|
||||
|
||||
NODE_NAME = "openposeCharacter"
|
||||
NODE_ID = om.MTypeId(0x0012F5C0)
|
||||
DRAW_CLASSIFICATION = "drawdb/geometry/openposeCharacter"
|
||||
DRAW_REGISTRANT_ID = "openposeRendererPlugin"
|
||||
|
||||
# Attribute handles, filled in by initialize()
|
||||
aTargetJoint = None
|
||||
aEnableDisplay = None
|
||||
aJointRadiusScale = None
|
||||
aLimbThicknessScale = None
|
||||
aCharacterName = None
|
||||
aBodyModel = None
|
||||
aFaceSource = None
|
||||
aBlendshapeWeight = None
|
||||
aFaceScale = None
|
||||
|
||||
# aBodyModel enum field values, in registration order (index 0 = BODY_25).
|
||||
BODY_MODEL_FIELDS = [constants.BODY_MODEL_BODY_25, constants.BODY_MODEL_COCO]
|
||||
|
||||
# aFaceSource enum field values, in registration order (index 0 = Joints).
|
||||
FACE_SOURCE_JOINTS = "Joints"
|
||||
FACE_SOURCE_ARKIT_BLENDSHAPES = "ARKitBlendshapes"
|
||||
FACE_SOURCE_FIELDS = [FACE_SOURCE_JOINTS, FACE_SOURCE_ARKIT_BLENDSHAPES]
|
||||
|
||||
DEFAULT_FACE_SCALE = 15.0
|
||||
|
||||
|
||||
class OpenposeCharacterNode(omui.MPxLocatorNode):
|
||||
|
||||
kTypeName = NODE_NAME
|
||||
kTypeId = NODE_ID
|
||||
|
||||
def __init__(self):
|
||||
omui.MPxLocatorNode.__init__(self)
|
||||
|
||||
def compute(self, plug, data_block):
|
||||
# Pure passive mapping node: nothing to compute.
|
||||
return None
|
||||
|
||||
def isBounded(self):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def creator():
|
||||
return OpenposeCharacterNode()
|
||||
|
||||
@staticmethod
|
||||
def initialize():
|
||||
global aTargetJoint, aEnableDisplay, aJointRadiusScale
|
||||
global aLimbThicknessScale, aCharacterName, aBodyModel
|
||||
global aFaceSource, aBlendshapeWeight, aFaceScale
|
||||
|
||||
msg_attr = om.MFnMessageAttribute()
|
||||
aTargetJoint = msg_attr.create("targetJoint", "tj")
|
||||
msg_attr.array = True
|
||||
# indexMatters already defaults to True (sparse, index-preserving
|
||||
# array) -- explicitly assigning True here throws kInvalidParameter
|
||||
# in Maya's Python binding, so it is deliberately left untouched.
|
||||
msg_attr.storable = True
|
||||
msg_attr.readable = True
|
||||
msg_attr.writable = True
|
||||
om.MPxNode.addAttribute(aTargetJoint)
|
||||
|
||||
num_attr = om.MFnNumericAttribute()
|
||||
|
||||
aEnableDisplay = num_attr.create(
|
||||
"enableDisplay", "ed", om.MFnNumericData.kBoolean, True
|
||||
)
|
||||
num_attr.keyable = True
|
||||
om.MPxNode.addAttribute(aEnableDisplay)
|
||||
|
||||
aJointRadiusScale = num_attr.create(
|
||||
"jointRadiusScale", "jrs", om.MFnNumericData.kFloat, 1.0
|
||||
)
|
||||
num_attr.keyable = True
|
||||
num_attr.setMin(0.0)
|
||||
om.MPxNode.addAttribute(aJointRadiusScale)
|
||||
|
||||
aLimbThicknessScale = num_attr.create(
|
||||
"limbThicknessScale", "lts", om.MFnNumericData.kFloat, 1.0
|
||||
)
|
||||
num_attr.keyable = True
|
||||
num_attr.setMin(0.0)
|
||||
om.MPxNode.addAttribute(aLimbThicknessScale)
|
||||
|
||||
str_data = om.MFnStringData()
|
||||
empty_string_obj = str_data.create("")
|
||||
typed_attr = om.MFnTypedAttribute()
|
||||
aCharacterName = typed_attr.create(
|
||||
"characterName", "cn", om.MFnData.kString, empty_string_obj
|
||||
)
|
||||
typed_attr.storable = True
|
||||
om.MPxNode.addAttribute(aCharacterName)
|
||||
|
||||
enum_attr = om.MFnEnumAttribute()
|
||||
aBodyModel = enum_attr.create("bodyModel", "bmd", 0)
|
||||
for field_index, field_name in enumerate(BODY_MODEL_FIELDS):
|
||||
enum_attr.addField(field_name, field_index)
|
||||
enum_attr.keyable = True
|
||||
om.MPxNode.addAttribute(aBodyModel)
|
||||
|
||||
face_source_attr = om.MFnEnumAttribute()
|
||||
aFaceSource = face_source_attr.create("faceSource", "fsrc", 0)
|
||||
for field_index, field_name in enumerate(FACE_SOURCE_FIELDS):
|
||||
face_source_attr.addField(field_name, field_index)
|
||||
face_source_attr.keyable = True
|
||||
om.MPxNode.addAttribute(aFaceSource)
|
||||
|
||||
weight_attr = om.MFnNumericAttribute()
|
||||
aBlendshapeWeight = weight_attr.create("blendshapeWeight", "bsw", om.MFnNumericData.kFloat, 0.0)
|
||||
weight_attr.array = True
|
||||
weight_attr.keyable = True
|
||||
weight_attr.setMin(0.0)
|
||||
weight_attr.setMax(1.0)
|
||||
om.MPxNode.addAttribute(aBlendshapeWeight)
|
||||
|
||||
face_scale_attr = om.MFnNumericAttribute()
|
||||
aFaceScale = face_scale_attr.create("faceScale", "fsc", om.MFnNumericData.kFloat, DEFAULT_FACE_SCALE)
|
||||
face_scale_attr.keyable = True
|
||||
face_scale_attr.setMin(0.0)
|
||||
om.MPxNode.addAttribute(aFaceScale)
|
||||
|
||||
|
||||
def get_mapped_joint_paths(dep_node_fn):
|
||||
"""Return {keypoint_index: MDagPath} for every connected targetJoint slot.
|
||||
|
||||
``dep_node_fn`` is an MFnDependencyNode wrapping an openposeCharacter.
|
||||
Unmapped keypoint indices are simply absent from the returned dict.
|
||||
"""
|
||||
result = {}
|
||||
plug = dep_node_fn.findPlug(aTargetJoint, False)
|
||||
for logical_index in plug.getExistingArrayAttributeIndices():
|
||||
if logical_index < 0 or logical_index >= constants.TOTAL_KEYPOINTS:
|
||||
continue
|
||||
element_plug = plug.elementByLogicalIndex(logical_index)
|
||||
if not element_plug.isConnected:
|
||||
continue
|
||||
source_plug = element_plug.source()
|
||||
if source_plug.isNull:
|
||||
continue
|
||||
source_node = source_plug.node()
|
||||
if not source_node.hasFn(om.MFn.kDagNode):
|
||||
continue
|
||||
dag_fn = om.MFnDagNode(source_node)
|
||||
result[logical_index] = dag_fn.getPath()
|
||||
return result
|
||||
|
||||
|
||||
def get_body_model(dep_node_fn):
|
||||
"""Return constants.BODY_MODEL_BODY_25 / BODY_MODEL_COCO for this character."""
|
||||
try:
|
||||
field_index = dep_node_fn.findPlug(aBodyModel, False).asShort()
|
||||
except RuntimeError:
|
||||
field_index = 0
|
||||
if 0 <= field_index < len(BODY_MODEL_FIELDS):
|
||||
return BODY_MODEL_FIELDS[field_index]
|
||||
return constants.BODY_MODEL_BODY_25
|
||||
|
||||
|
||||
def get_face_source(dep_node_fn):
|
||||
"""Return FACE_SOURCE_JOINTS / FACE_SOURCE_ARKIT_BLENDSHAPES for this character."""
|
||||
try:
|
||||
field_index = dep_node_fn.findPlug(aFaceSource, False).asShort()
|
||||
except RuntimeError:
|
||||
field_index = 0
|
||||
if 0 <= field_index < len(FACE_SOURCE_FIELDS):
|
||||
return FACE_SOURCE_FIELDS[field_index]
|
||||
return FACE_SOURCE_JOINTS
|
||||
|
||||
|
||||
def get_blendshape_weights(dep_node_fn):
|
||||
"""Return {arkit_blendshape_name: weight} for all 52 slots (0.0 default)."""
|
||||
plug = dep_node_fn.findPlug(aBlendshapeWeight, False)
|
||||
weights = {}
|
||||
for index, name in enumerate(face_blendshapes.ARKIT_BLENDSHAPE_NAMES):
|
||||
element_plug = plug.elementByLogicalIndex(index)
|
||||
weights[name] = element_plug.asFloat()
|
||||
return weights
|
||||
|
||||
|
||||
def get_face_scale(dep_node_fn):
|
||||
try:
|
||||
return dep_node_fn.findPlug(aFaceScale, False).asFloat()
|
||||
except RuntimeError:
|
||||
return DEFAULT_FACE_SCALE
|
||||
|
||||
|
||||
def iter_character_nodes():
|
||||
"""Yield an MFnDependencyNode for every openposeCharacter node in the scene."""
|
||||
it = om.MItDependencyNodes(om.MFn.kPluginLocatorNode)
|
||||
while not it.isDone():
|
||||
obj = it.thisNode()
|
||||
fn = om.MFnDependencyNode(obj)
|
||||
if fn.typeName == NODE_NAME:
|
||||
yield fn
|
||||
it.next()
|
||||
|
||||
|
||||
def register(fn_plugin):
|
||||
fn_plugin.registerNode(
|
||||
NODE_NAME,
|
||||
NODE_ID,
|
||||
OpenposeCharacterNode.creator,
|
||||
OpenposeCharacterNode.initialize,
|
||||
om.MPxNode.kLocatorNode,
|
||||
DRAW_CLASSIFICATION,
|
||||
)
|
||||
|
||||
|
||||
def deregister(fn_plugin):
|
||||
fn_plugin.deregisterNode(NODE_ID)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""World-space joint -> 2D pixel projection for a given camera + resolution.
|
||||
|
||||
Pure math, no drawing. Shared by the viewport draw override (which draws in
|
||||
3D world space directly and does not strictly need this) and the render
|
||||
override (which needs actual 2D pixel coordinates to rasterize the
|
||||
OpenPose-style image). Kept independent of "which camera" / "which
|
||||
character" decisions -- callers supply an explicit camera path and mapping.
|
||||
"""
|
||||
|
||||
import maya.api.OpenMaya as om
|
||||
|
||||
from . import constants
|
||||
|
||||
DEFAULT_RESOLUTION_NODE = "defaultResolution"
|
||||
|
||||
|
||||
def get_render_resolution():
|
||||
"""Return (width, height) in pixels from the scene's defaultResolution node."""
|
||||
sel = om.MSelectionList()
|
||||
sel.add(DEFAULT_RESOLUTION_NODE)
|
||||
fn = om.MFnDependencyNode(sel.getDependNode(0))
|
||||
width = fn.findPlug("width", False).asInt()
|
||||
height = fn.findPlug("height", False).asInt()
|
||||
return max(1, width), max(1, height)
|
||||
|
||||
|
||||
def world_position(dag_path):
|
||||
"""World-space MPoint for any DAG transform (including joints)."""
|
||||
world_matrix = dag_path.inclusiveMatrix()
|
||||
return om.MPoint(om.MTransformationMatrix(world_matrix).translation(om.MSpace.kWorld))
|
||||
|
||||
|
||||
def project_point(world_point, camera_path, width, height):
|
||||
"""Project a world-space MPoint through ``camera_path`` to pixel coordinates.
|
||||
|
||||
Returns (pixel_x, pixel_y, in_front_of_camera).
|
||||
"""
|
||||
camera_space = om.MPoint(world_point) * camera_path.inclusiveMatrixInverse()
|
||||
|
||||
camera_fn = om.MFnCamera(camera_path)
|
||||
# projectionMatrix() returns an MFloatMatrix; MPoint's matrix multiply
|
||||
# operator only supports MMatrix, hence the explicit conversion.
|
||||
projection_matrix = om.MMatrix(camera_fn.projectionMatrix())
|
||||
clip = om.MPoint(camera_space) * projection_matrix
|
||||
if abs(clip.w) < 1e-9:
|
||||
return 0.0, 0.0, False
|
||||
|
||||
ndc_x = clip.x / clip.w
|
||||
ndc_y = clip.y / clip.w
|
||||
pixel_x = (ndc_x * 0.5 + 0.5) * width
|
||||
pixel_y = (1.0 - (ndc_y * 0.5 + 0.5)) * height
|
||||
# The camera looks down its own -Z axis, so a point in front of it has
|
||||
# negative camera-space Z.
|
||||
in_front = camera_space.z < 0.0
|
||||
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()
|
||||
right = (om.MVector(1.0, 0.0, 0.0) * world_matrix).normal()
|
||||
up = (om.MVector(0.0, 1.0, 0.0) * world_matrix).normal()
|
||||
anchor = om.MPoint(anchor_world_point)
|
||||
return [
|
||||
om.MPoint(anchor + right * (x * scale) + up * (y * scale))
|
||||
for x, y in local_points_xy
|
||||
]
|
||||
|
||||
|
||||
def project_character(mapped_joint_paths, camera_path, width, height):
|
||||
"""Project one character's mapping to OpenPose_full pixel keypoints.
|
||||
|
||||
``mapped_joint_paths``: {keypoint_index: MDagPath}, as returned by
|
||||
``mapping_node.get_mapped_joint_paths``.
|
||||
|
||||
Returns a list of length ``constants.TOTAL_KEYPOINTS`` of
|
||||
(pixel_x, pixel_y, confidence) tuples. Unmapped or off-camera keypoints
|
||||
get confidence 0.0 at (0.0, 0.0), matching OpenPose's own convention for
|
||||
undetected keypoints.
|
||||
"""
|
||||
keypoints = [(0.0, 0.0, 0.0)] * constants.TOTAL_KEYPOINTS
|
||||
for index, joint_path in mapped_joint_paths.items():
|
||||
world_point = world_position(joint_path)
|
||||
pixel_x, pixel_y, in_front = project_point(world_point, camera_path, width, height)
|
||||
confidence = 1.0 if in_front else 0.0
|
||||
keypoints[index] = (pixel_x, pixel_y, confidence)
|
||||
return keypoints
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Draws OpenPose_full-style skeleton images (black background, colored
|
||||
limbs/joints) and writes them out via maya.api.OpenMaya.MImage.
|
||||
|
||||
This is a self-contained software rasterizer (no OpenGL/render-target
|
||||
dependency) operating on a flat RGBA bytearray, so it can be driven equally
|
||||
from a batch/offline context or from inside a render override. Confidence-0
|
||||
keypoints (unmapped OpenPose_full slots) are simply not drawn, and any limb
|
||||
whose either endpoint is confidence-0 is skipped -- matching how OpenPose
|
||||
itself omits undetected points/limbs.
|
||||
"""
|
||||
|
||||
import maya.api.OpenMaya as om
|
||||
|
||||
from . import constants
|
||||
|
||||
BYTES_PER_PIXEL = 4 # RGBA
|
||||
|
||||
|
||||
def _new_buffer(width, height):
|
||||
buf = bytearray(width * height * BYTES_PER_PIXEL)
|
||||
for i in range(0, len(buf), BYTES_PER_PIXEL):
|
||||
buf[i + 3] = 255 # opaque black background
|
||||
return buf
|
||||
|
||||
|
||||
def _set_pixel(buffer, width, height, x, y, color):
|
||||
if x < 0 or x >= width or y < 0 or y >= height:
|
||||
return
|
||||
offset = (y * width + x) * BYTES_PER_PIXEL
|
||||
buffer[offset] = color[0]
|
||||
buffer[offset + 1] = color[1]
|
||||
buffer[offset + 2] = color[2]
|
||||
buffer[offset + 3] = 255
|
||||
|
||||
|
||||
def _draw_circle(buffer, width, height, cx, cy, radius, color):
|
||||
if radius <= 0:
|
||||
return
|
||||
x_min = max(0, int(cx - radius))
|
||||
x_max = min(width - 1, int(cx + radius))
|
||||
y_min = max(0, int(cy - radius))
|
||||
y_max = min(height - 1, int(cy + radius))
|
||||
radius_sq = radius * radius
|
||||
for y in range(y_min, y_max + 1):
|
||||
dy = y + 0.5 - cy
|
||||
for x in range(x_min, x_max + 1):
|
||||
dx = x + 0.5 - cx
|
||||
if dx * dx + dy * dy <= radius_sq:
|
||||
_set_pixel(buffer, width, height, x, y, color)
|
||||
|
||||
|
||||
def _draw_line(buffer, width, height, x0, y0, x1, y1, thickness, color):
|
||||
if thickness <= 0:
|
||||
return
|
||||
half = max(thickness * 0.5, 0.5)
|
||||
x_min = max(0, int(min(x0, x1) - half))
|
||||
x_max = min(width - 1, int(max(x0, x1) + half))
|
||||
y_min = max(0, int(min(y0, y1) - half))
|
||||
y_max = min(height - 1, int(max(y0, y1) + half))
|
||||
|
||||
dx = x1 - x0
|
||||
dy = y1 - y0
|
||||
length_sq = dx * dx + dy * dy
|
||||
half_sq = half * half
|
||||
|
||||
if length_sq < 1e-9:
|
||||
_draw_circle(buffer, width, height, x0, y0, half, color)
|
||||
return
|
||||
|
||||
for y in range(y_min, y_max + 1):
|
||||
py = y + 0.5
|
||||
for x in range(x_min, x_max + 1):
|
||||
px = x + 0.5
|
||||
t = ((px - x0) * dx + (py - y0) * dy) / length_sq
|
||||
t = max(0.0, min(1.0, t))
|
||||
proj_x = x0 + t * dx
|
||||
proj_y = y0 + t * dy
|
||||
dist_sq = (px - proj_x) ** 2 + (py - proj_y) ** 2
|
||||
if dist_sq <= half_sq:
|
||||
_set_pixel(buffer, width, height, x, y, color)
|
||||
|
||||
|
||||
def draw_character(buffer, width, height, keypoints, radius_scale=1.0, thickness_scale=1.0,
|
||||
body_model=constants.BODY_MODEL_BODY_25):
|
||||
"""Draw one character's OpenPose skeleton into an existing RGBA buffer.
|
||||
|
||||
``keypoints``: list of length constants.TOTAL_KEYPOINTS of
|
||||
(pixel_x, pixel_y, confidence), as returned by
|
||||
``projector.project_character``. ``body_model`` selects BODY_25 (all 25
|
||||
body/foot points) or COCO (18 points, no MidHip/feet, different limb
|
||||
connectivity) -- hands and face are unaffected either way.
|
||||
"""
|
||||
pairs, colors, thickness_list = constants.full_limb_data(body_model)
|
||||
for pair_i, (a, b) in enumerate(pairs):
|
||||
xa, ya, ca = keypoints[a]
|
||||
xb, yb, cb = keypoints[b]
|
||||
if ca <= 0.0 or cb <= 0.0:
|
||||
continue
|
||||
thickness = thickness_list[pair_i] * thickness_scale
|
||||
_draw_line(buffer, width, height, xa, ya, xb, yb, thickness, colors[pair_i])
|
||||
|
||||
active_indices = constants.active_point_indices(body_model)
|
||||
for index in active_indices:
|
||||
x, y, confidence = keypoints[index]
|
||||
if confidence <= 0.0:
|
||||
continue
|
||||
radius = constants.KEYPOINT_POINT_RADIUS[index] * radius_scale
|
||||
_draw_circle(buffer, width, height, x, y, radius, constants.KEYPOINT_POINT_COLORS[index])
|
||||
|
||||
|
||||
def render_characters_to_buffer(characters, width, height):
|
||||
"""``characters``: iterable of (keypoints, radius_scale, thickness_scale, body_model).
|
||||
|
||||
Returns a flat RGBA bytearray of size width*height*4.
|
||||
"""
|
||||
buffer = _new_buffer(width, height)
|
||||
for keypoints, radius_scale, thickness_scale, body_model in characters:
|
||||
draw_character(buffer, width, height, keypoints, radius_scale, thickness_scale, body_model)
|
||||
return buffer
|
||||
|
||||
|
||||
def _flip_rows(buffer, width, height):
|
||||
"""MImage.setPixels() treats row 0 as the bottom of the image (Maya's
|
||||
classic bottom-up convention); everything else here (and every PNG
|
||||
reader downstream) treats row 0 as the top. Flip row order once, right
|
||||
before handing pixels to MImage, so the written file is a normal
|
||||
top-down image.
|
||||
"""
|
||||
row_bytes = width * BYTES_PER_PIXEL
|
||||
flipped = bytearray(len(buffer))
|
||||
for y in range(height):
|
||||
src = y * row_bytes
|
||||
dst = (height - 1 - y) * row_bytes
|
||||
flipped[dst:dst + row_bytes] = buffer[src:src + row_bytes]
|
||||
return flipped
|
||||
|
||||
|
||||
def render_characters_to_file(characters, width, height, file_path, file_format="png"):
|
||||
"""Render all characters and write the result to ``file_path``."""
|
||||
buffer = render_characters_to_buffer(characters, width, height)
|
||||
image = om.MImage()
|
||||
image.setPixels(_flip_rows(buffer, width, height), width, height)
|
||||
image.writeToFile(file_path, outputFormat=file_format)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Maya renderer integration for OpenPose_full output.
|
||||
|
||||
Two pieces:
|
||||
|
||||
* ``OpenposeRenderOverride`` -- a Viewport 2.0 ``MRenderOverride`` registered
|
||||
as "OpenPose" in the viewport panel's Renderer dropdown, using the same
|
||||
minimal scene/HUD/present operation chain as Maya's own reference render
|
||||
override sample. This makes "OpenPose" a normal, selectable renderer
|
||||
rather than gambling on undocumented GPU render-target pixel readback
|
||||
from Python for the actual image content (see the plan's flagged risk).
|
||||
|
||||
* ``render_frame`` / the ``openposeRenderSequence`` command -- the actual
|
||||
OpenPose image generation: projects every ``openposeCharacter`` in the
|
||||
scene through a given camera and rasterizes them (projector.py +
|
||||
rasterizer.py, pure software, no GPU dependency) to a PNG per frame, and
|
||||
pushes each frame into Maya's Render View via
|
||||
``renderWindowEditor(loadImage=...)`` -- the same mechanism third-party
|
||||
renderers use to display progress there, so the images are visible in the
|
||||
normal render workflow, not just written silently to disk.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import maya.api.OpenMaya as om
|
||||
import maya.api.OpenMayaRender as omr
|
||||
import maya.cmds as cmds
|
||||
|
||||
from . import constants
|
||||
from . import face_blendshapes
|
||||
from . import mapping_node
|
||||
from . import projector
|
||||
from . import rasterizer
|
||||
|
||||
UI_NAME = "OpenPose"
|
||||
RENDER_OVERRIDE_NAME = "openposeRenderOverride"
|
||||
|
||||
_override_instance = None
|
||||
|
||||
|
||||
class OpenposeRenderOverride(omr.MRenderOverride):
|
||||
|
||||
def __init__(self, name):
|
||||
omr.MRenderOverride.__init__(self, name)
|
||||
self._operations = [
|
||||
omr.MSceneRender("openposeSceneRender"),
|
||||
omr.MHUDRender(),
|
||||
omr.MPresentTarget("openposePresentTarget"),
|
||||
]
|
||||
self._current_operation = -1
|
||||
|
||||
def uiName(self):
|
||||
return UI_NAME
|
||||
|
||||
def supportedDrawAPIs(self):
|
||||
return omr.MRenderer.kAllDevices
|
||||
|
||||
def setup(self, destination):
|
||||
return
|
||||
|
||||
def cleanup(self):
|
||||
self._current_operation = -1
|
||||
|
||||
def startOperationIterator(self):
|
||||
self._current_operation = 0
|
||||
return True
|
||||
|
||||
def renderOperation(self):
|
||||
if 0 <= self._current_operation < len(self._operations):
|
||||
return self._operations[self._current_operation]
|
||||
return None
|
||||
|
||||
def nextRenderOperation(self):
|
||||
self._current_operation += 1
|
||||
return self._current_operation < len(self._operations)
|
||||
|
||||
|
||||
def render_frame(camera_path, width, height, output_path):
|
||||
"""Project + rasterize every mapped openposeCharacter to a single PNG."""
|
||||
characters = []
|
||||
for dep_fn in mapping_node.iter_character_nodes():
|
||||
try:
|
||||
enabled = dep_fn.findPlug(mapping_node.aEnableDisplay, False).asBool()
|
||||
except RuntimeError:
|
||||
enabled = True
|
||||
if not enabled:
|
||||
continue
|
||||
radius_scale = dep_fn.findPlug(mapping_node.aJointRadiusScale, False).asFloat()
|
||||
thickness_scale = dep_fn.findPlug(mapping_node.aLimbThicknessScale, False).asFloat()
|
||||
body_model = mapping_node.get_body_model(dep_fn)
|
||||
mapped = mapping_node.get_mapped_joint_paths(dep_fn)
|
||||
keypoints = projector.project_character(mapped, 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]
|
||||
if nose_confidence > 0.0 and nose_index in mapped:
|
||||
anchor = projector.world_position(mapped[nose_index])
|
||||
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)
|
||||
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)
|
||||
|
||||
characters.append((keypoints, radius_scale, thickness_scale, body_model))
|
||||
|
||||
rasterizer.render_characters_to_file(characters, width, height, output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
def _default_output_dir():
|
||||
images_rule = cmds.workspace(fileRuleEntry="images", query=True) or "images"
|
||||
workspace_root = cmds.workspace(query=True, rootDirectory=True)
|
||||
out_dir = os.path.join(workspace_root, images_rule, "openpose")
|
||||
if not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir)
|
||||
return out_dir
|
||||
|
||||
|
||||
def _resolve_camera_path(camera_name):
|
||||
if camera_name:
|
||||
selection = om.MSelectionList()
|
||||
selection.add(camera_name)
|
||||
dag_path = selection.getDagPath(0)
|
||||
if dag_path.apiType() == om.MFn.kTransform:
|
||||
dag_path.extendToShape()
|
||||
return dag_path
|
||||
import maya.api.OpenMayaUI as omui
|
||||
return omui.M3dView.active3dView().getCamera()
|
||||
|
||||
|
||||
class RenderSequenceCommand(om.MPxCommand):
|
||||
|
||||
kCmdName = "openposeRenderSequence"
|
||||
|
||||
@staticmethod
|
||||
def creator():
|
||||
return RenderSequenceCommand()
|
||||
|
||||
@staticmethod
|
||||
def createSyntax():
|
||||
syntax = om.MSyntax()
|
||||
syntax.addFlag("-s", "-startFrame", om.MSyntax.kDouble)
|
||||
syntax.addFlag("-e", "-endFrame", om.MSyntax.kDouble)
|
||||
syntax.addFlag("-cam", "-camera", om.MSyntax.kString)
|
||||
syntax.addFlag("-dir", "-outputDirectory", om.MSyntax.kString)
|
||||
syntax.addFlag("-sw", "-showInRenderView", om.MSyntax.kBoolean)
|
||||
return syntax
|
||||
|
||||
def isUndoable(self):
|
||||
return False
|
||||
|
||||
def doIt(self, args):
|
||||
parser = om.MArgParser(self.syntax(), args)
|
||||
|
||||
start = (
|
||||
parser.flagArgumentDouble("-s", 0)
|
||||
if parser.isFlagSet("-s")
|
||||
else cmds.playbackOptions(query=True, minTime=True)
|
||||
)
|
||||
end = (
|
||||
parser.flagArgumentDouble("-e", 0)
|
||||
if parser.isFlagSet("-e")
|
||||
else cmds.playbackOptions(query=True, maxTime=True)
|
||||
)
|
||||
camera_name = parser.flagArgumentString("-cam", 0) if parser.isFlagSet("-cam") else None
|
||||
out_dir = parser.flagArgumentString("-dir", 0) if parser.isFlagSet("-dir") else _default_output_dir()
|
||||
show_in_render_view = parser.flagArgumentBool("-sw", 0) if parser.isFlagSet("-sw") else True
|
||||
|
||||
if not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir)
|
||||
|
||||
camera_path = _resolve_camera_path(camera_name)
|
||||
width, height = projector.get_render_resolution()
|
||||
|
||||
scene_path = cmds.file(query=True, sceneName=True)
|
||||
scene_name = os.path.splitext(os.path.basename(scene_path))[0] if scene_path else "openpose"
|
||||
scene_name = scene_name or "openpose"
|
||||
|
||||
if show_in_render_view:
|
||||
cmds.RenderViewWindow()
|
||||
|
||||
original_time = cmds.currentTime(query=True)
|
||||
written = []
|
||||
frame = int(start)
|
||||
while frame <= int(end):
|
||||
cmds.currentTime(frame)
|
||||
file_path = os.path.join(out_dir, "{0}.{1:04d}.png".format(scene_name, frame))
|
||||
render_frame(camera_path, width, height, file_path)
|
||||
written.append(file_path)
|
||||
if show_in_render_view:
|
||||
cmds.renderWindowEditor("renderView", edit=True, loadImage=file_path)
|
||||
frame += 1
|
||||
cmds.currentTime(original_time)
|
||||
|
||||
self.setResult(written)
|
||||
|
||||
|
||||
def register(fn_plugin):
|
||||
global _override_instance
|
||||
fn_plugin.registerCommand(
|
||||
RenderSequenceCommand.kCmdName, RenderSequenceCommand.creator, RenderSequenceCommand.createSyntax
|
||||
)
|
||||
_override_instance = OpenposeRenderOverride(RENDER_OVERRIDE_NAME)
|
||||
omr.MRenderer.registerOverride(_override_instance)
|
||||
|
||||
|
||||
def deregister(fn_plugin):
|
||||
global _override_instance
|
||||
if _override_instance is not None:
|
||||
omr.MRenderer.deregisterOverride(_override_instance)
|
||||
_override_instance = None
|
||||
fn_plugin.deregisterCommand(RenderSequenceCommand.kCmdName)
|
||||
@@ -0,0 +1,304 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user