minimax-h3-inpainting / h3_pose.py
linoyts's picture
linoyts HF Staff
Cache the plan and mask, optional pose conversion, clearer description and reference label
0c99e77 verified
Raw
History Blame
6.1 kB
"""Turn a motion reference into a pose render.
A reference clip drives how the new subject moves, and the ComfyUI workflows fed two kinds: the original footage, and
a pose render of it. The raw clip carries more — Nekodificador's best result used it, and pose renders transferred the
motion but flattened the acting — so this is offered rather than imposed. It earns its place when the reference shows
the wrong *subject*: a skeleton says "move like this" without also saying "look like this".
mediapipe rather than DWPose, for one measured reason: DWPose on CPU onnxruntime takes about 43 seconds per frame
(the yolox bounding-box pass is almost all of it), which is an hour and a half for a five-second clip. mediapipe's
pose landmarker is three orders of magnitude cheaper and needs no GPU.
Everything here fails soft. `available()` reports why it cannot run, and a caller that gets `None` from `to_pose_clip`
should use the reference as it came.
"""
from __future__ import annotations
import os
import tempfile
import numpy as np
MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/1/"
"pose_landmarker_full.task"
)
# The limb pairs of mediapipe's 33-point pose, and a colour per limb group. Drawn by hand rather than with
# `mediapipe.solutions.drawing_utils`, because that module is the legacy API and imports the proto path that a modern
# protobuf breaks.
_EDGES = [
(11, 12), (11, 23), (12, 24), (23, 24), # torso
(11, 13), (13, 15), (12, 14), (14, 16), # arms
(23, 25), (25, 27), (24, 26), (26, 28), # legs
(27, 31), (28, 32), (15, 17), (16, 18), # feet and hands
(0, 11), (0, 12), # neck
]
_COLOURS = [
(0, 255, 0), (0, 200, 120), (0, 200, 120), (0, 160, 200),
(255, 170, 0), (255, 120, 0), (170, 255, 0), (120, 255, 0),
(0, 120, 255), (0, 80, 255), (120, 0, 255), (170, 0, 255),
(255, 0, 170), (255, 0, 120), (255, 220, 0), (220, 255, 0),
(255, 60, 60), (60, 60, 255),
]
_model_path: str | None = None
def available() -> tuple[bool, str]:
"""Whether a pose render can be produced here, and why not when it cannot."""
try:
import mediapipe # noqa: F401
from mediapipe.tasks.python import vision # noqa: F401
except Exception as error:
# The usual cause is a protobuf newer than mediapipe accepts (`protobuf<5`), which breaks its generated code.
return False, f"mediapipe is unusable here ({type(error).__name__}: {error})"
return True, ""
def _fetch_model() -> str:
global _model_path
if _model_path and os.path.exists(_model_path):
return _model_path
import requests
path = os.path.join(tempfile.gettempdir(), "pose_landmarker_full.task")
if not os.path.exists(path):
response = requests.get(MODEL_URL, timeout=120)
response.raise_for_status()
with open(path, "wb") as handle:
handle.write(response.content)
_model_path = path
return path
def to_pose_clip(frames: np.ndarray, fps: int = 24, progress=None) -> str | None:
r"""
Render `frames` as a pose skeleton on black and write it as a clip.
Args:
frames (`np.ndarray` of shape `(num_frames, height, width, 3)`): The reference clip, `uint8` RGB.
fps (`int`, defaults to 24): The rate to write the result at.
progress (`callable`, *optional*): Gradio progress callback.
Returns:
`str` or None: path to the pose clip, or None when no pose was found in any frame.
"""
import av
import mediapipe as mp
from mediapipe.tasks import python as tasks
from mediapipe.tasks.python import vision
landmarker = vision.PoseLandmarker.create_from_options(
vision.PoseLandmarkerOptions(
base_options=tasks.BaseOptions(model_asset_path=_fetch_model()),
running_mode=vision.RunningMode.VIDEO,
)
)
num_frames, height, width = frames.shape[:3]
canvases = np.zeros_like(frames)
found = 0
for index, frame in enumerate(frames):
image = mp.Image(image_format=mp.ImageFormat.SRGB, data=np.ascontiguousarray(frame))
result = landmarker.detect_for_video(image, int(index * 1000 / fps))
if not result.pose_landmarks:
continue
found += 1
_draw(canvases[index], result.pose_landmarks[0], width, height)
if progress is not None:
progress(min(1.0, (index + 1) / num_frames), desc=f"Reading the motion — frame {index + 1}/{num_frames}")
if not found:
return None
path = os.path.join(tempfile.mkdtemp(), "pose.mp4")
container = av.open(path, mode="w")
stream = container.add_stream("libx264", rate=fps)
stream.width, stream.height, stream.pix_fmt = width, height, "yuv420p"
stream.options = {"crf": "18", "preset": "veryfast"}
for canvas in canvases:
container.mux(stream.encode(av.VideoFrame.from_ndarray(canvas, format="rgb24")))
for packet in stream.encode():
container.mux(packet)
container.close()
print(f"[pose] {found}/{num_frames} frames had a pose", flush=True)
return path
def _draw(canvas: np.ndarray, landmarks, width: int, height: int) -> None:
"""One skeleton onto one frame, thick enough to survive the video encoder and the VAE."""
import cv2
points = [(int(point.x * width), int(point.y * height)) for point in landmarks]
visible = [point.visibility > 0.4 for point in landmarks]
thickness = max(2, round(min(width, height) / 160))
for (start, end), colour in zip(_EDGES, _COLOURS):
if start < len(points) and end < len(points) and visible[start] and visible[end]:
cv2.line(canvas, points[start], points[end], colour, thickness, cv2.LINE_AA)
for index, (point, seen) in enumerate(zip(points, visible)):
if seen and any(index in edge for edge in _EDGES):
cv2.circle(canvas, point, thickness + 1, (255, 255, 255), -1, cv2.LINE_AA)