e2f2e6668a
Maya API 2.0 plugin that maps a skeleton to OpenPose_full keypoints (BODY_25 or COCO body, 21+21 hand, 70 face) via an openposeCharacter node, with a live Viewport 2.0 draw override, an "OpenPose" viewport renderer plus openposeRenderSequence for PNG output, and a PySide2 mapping editor. Face can also be driven procedurally from ARKit's 52 blendshape weights instead of facial joints, for rigs with no facial skeleton. Includes VS Code debug configs and Maya test scenes/renders. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
144 lines
5.3 KiB
Python
144 lines
5.3 KiB
Python
"""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)
|