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:
2026-07-14 08:41:57 +08:00
commit e2f2e6668a
23 changed files with 2999 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""OpenPose_full renderer plugin for Maya (Python API 2.0)."""
+91
View File
@@ -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)
+289
View File
@@ -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))
)
+135
View File
@@ -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
+228
View File
@@ -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)
+96
View File
@@ -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
+143
View File
@@ -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)
+214
View File
@@ -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