67b3deedbd
Three changesets bundled together: - faceFollow (Camera/Neck) for the ARKit-blendshape face: it can now rotate with the mapped Body_Neck joint's own local axes instead of always billboarding toward the camera. - Fixed a real correctness bug: the face was rendering as isolated dots. Verified against OpenPose's own published keypoint diagrams and source (FACE_PAIRS_RENDER_GPU, 63 pairs) that it should render connected jaw/eyebrow/nose/eye/mouth contour lines; full_limb_data() was also silently dropping face limbs entirely. Both fixed. - Default eyes/ears: most rigs (HumanIK included) have no REye/LEye/REar/LEar joints, only Head/Nose, leaving those 4 slots permanently invisible. They now get a synthetic head-relative position when unmapped but Body_Nose is mapped, anchored at and rotating with the Nose joint's own orientation (not the camera) -- toggleable via useDefaultEyesEars, always overridden by an explicit joint mapping. Also drops the RShoulder->REar / LShoulder->LEar lines from BODY_25 and COCO connectivity by request (a detection-robustness quirk of OpenPose's original network, not real anatomy) so ears stay leaf points -- a disclosed, deliberate deviation from upstream's otherwise-verbatim connectivity. All verified against real Maya (2022/2023/2024), not just logic: rotation math checked exact, explicit-mapping-overrides-fallback checked, shoulder-ear removal checked against full_limb_data() output, rendered test scenes visually confirmed against OpenPose's own reference diagrams. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
6.0 KiB
Python
140 lines
6.0 KiB
Python
"""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 local_axes_world(dag_path):
|
|
"""World-space (right, up) unit vectors from a DAG node's local X/Y axes."""
|
|
world_matrix = dag_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()
|
|
return right, up
|
|
|
|
|
|
def plane_world_points(anchor_world_point, right, up, local_points_xy, scale):
|
|
"""Place 2D local (x, y) points on a plane centered at ``anchor_world_point``
|
|
using the given world-space ``right``/``up`` axes, scaled by ``scale``.
|
|
"""
|
|
anchor = om.MPoint(anchor_world_point)
|
|
return [
|
|
om.MPoint(anchor + right * (x * scale) + up * (y * scale))
|
|
for x, y in local_points_xy
|
|
]
|
|
|
|
|
|
def billboard_world_points(anchor_world_point, camera_path, local_points_xy, scale):
|
|
"""``plane_world_points`` using ``camera_path``'s own local X/Y as
|
|
right/up, so the plane always faces whichever camera is drawing.
|
|
|
|
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". This is
|
|
the "Camera" faceFollow mode.
|
|
"""
|
|
right, up = local_axes_world(camera_path)
|
|
return plane_world_points(anchor_world_point, right, up, local_points_xy, scale)
|
|
|
|
|
|
def oriented_world_points(anchor_world_point, orientation_dag_path, local_points_xy, scale):
|
|
"""``plane_world_points`` using ``orientation_dag_path``'s own local X/Y
|
|
as right/up, so the plane rotates *with* that joint (e.g. the mapped
|
|
Neck) instead of always facing the camera. This is the "Neck" faceFollow
|
|
mode -- unlike "Camera", it is rig-dependent: it assumes the joint's
|
|
local X is the character's right and local Y is up, which matches
|
|
common convention but is not guaranteed for every rig. If the face
|
|
appears rotated relative to the head, adjust the neck joint's rotate
|
|
axis/orient, or use "Camera" mode instead.
|
|
"""
|
|
right, up = local_axes_world(orientation_dag_path)
|
|
return plane_world_points(anchor_world_point, right, up, local_points_xy, scale)
|
|
|
|
|
|
def default_head_relative_points(anchor_world_point, orientation_dag_path, scale, local_offsets):
|
|
"""Compute head-relative fallback world points (e.g. the default
|
|
REye/LEye/REar/LEar positions in constants.DEFAULT_EYE_EAR_LOCAL_OFFSETS)
|
|
anchored at ``anchor_world_point`` (the mapped Nose), oriented by
|
|
``orientation_dag_path``'s own local X/Y axes -- always the joint
|
|
mapped to Nose itself (the head), so these points turn with the head's
|
|
actual rotation rather than billboarding toward the camera.
|
|
|
|
``local_offsets``: {index: (x, y)}. Returns {index: MPoint}.
|
|
"""
|
|
indices = list(local_offsets.keys())
|
|
offsets = [local_offsets[i] for i in indices]
|
|
world_points = oriented_world_points(anchor_world_point, orientation_dag_path, offsets, scale)
|
|
return dict(zip(indices, world_points))
|
|
|
|
|
|
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
|