| name | comfyui-pose-editing |
| description | Extract, edit, and synthesize OpenPose skeletons for ControlNet conditioning. Use when the user wants a unique/custom pose, wants to modify a pose extracted from a reference image, or wants to escape the "AI default poses" attractor by feeding ControlNet a hand-tuned skeleton. Covers the extract → modify → render → ControlNet pipeline with concrete coordinates and PIL recipes. |
ComfyUI Pose Editing Skill
You are helping the user produce a custom OpenPose skeleton image to feed into a ControlNet OpenPose conditioning pass. This is the most reliable way to break out of the cliché-pose attractor that danbooru-tag fine-tunes (Pony, Illustrious) fall back to — the model can't render dynamic_pose cleanly, but it WILL follow a hand-tuned skeleton from ControlNet.
The workflow is always: extract numerical keypoints → modify them in Python → render the skeleton image → feed it to ControlNet OpenPose in the generation workflow.
Required nodes (verify with search_nodes first)
DWPreprocessor (preferred) or OpenposePreprocessor — outputs IMAGE + POSE_KEYPOINT
SavePoseKpsAsJsonFile — writes keypoints to disk as JSON
LoadImage — for loading the rendered skeleton back into the generation workflow
- A ControlNet OpenPose model (e.g.,
control_v11p_sd15_openpose, noob-sdxl/openpose_pre) loaded via ControlNetLoader + ControlNetApplyAdvanced
If SavePoseKpsAsJsonFile is not installed, the extract_pose_keypoints MCP tool won't work — you'll need to wire the workflow manually and read the JSON via Read tool, or fall back to the keypoint-polyline-mask recipe in comfyui-mask-strategy.
The MCP tools
extract_pose_keypoints(image_filename, preprocessor="DWPreprocessor") — runs the full extract workflow headlessly, returns {keypoints: {...}, skeleton_image: {filename, ...}}. The image must be in ComfyUI's input/ folder; use upload_input or copy_to_input first if needed.
render_pose_skeleton(keypoints, width, height, save_as) — PIL renders a keypoints dict to a colored skeleton image, saves to input/. Auto-detects normalized (0-1) vs pixel-space coords.
The agent's job between those two calls is to modify the keypoints in Python. There's no "edit_pose" tool by design — the modifications are too varied (rotations, mirrors, presets, retargeting). Use Bash + Python.
OpenPose 18-keypoint index reference
The DWPose / OpenPose preprocessor returns pose_keypoints_2d as a flat list [x1, y1, c1, x2, y2, c2, ...] — 3 values per joint, in this order:
| Idx | Joint | | Idx | Joint |
|---|
| 0 | nose | | 9 | r_knee |
| 1 | neck | | 10 | r_ankle |
| 2 | r_shoulder | | 11 | l_hip |
| 3 | r_elbow | | 12 | l_knee |
| 4 | r_wrist | | 13 | l_ankle |
| 5 | l_shoulder | | 14 | r_eye |
| 6 | l_elbow | | 15 | l_eye |
| 7 | l_wrist | | 16 | r_ear |
| 8 | r_hip | | 17 | l_ear |
(r_ = right side of the subject, which is on the viewer's left in a frontal image. Don't get confused.)
Confidence (c) is in [0, 1]. Joints with c < 0.05 are typically considered "not detected" — render_pose_skeleton skips them.
Hands have 21 keypoints each (hand_left_keypoints_2d, hand_right_keypoints_2d); face has 70 (face_keypoints_2d). For ControlNet OpenPose, body keypoints alone are usually enough.
Recipe A — Modify a pose from a reference image
User has a reference photo / existing output and wants the same pose with different content.
1. upload_input(reference_path) [if not already in input/]
└─ or copy_to_input(filename, source_type="output") if it's a previous output
2. extract_pose_keypoints(image_filename)
├─ returns: {keypoints: {people: [{pose_keypoints_2d: [...]}], canvas_width, canvas_height},
│ skeleton_image: {filename, ...}}
└─ view the skeleton_image to verify pose was detected correctly
3. [Python in Bash] modify the keypoints — examples below
4. render_pose_skeleton(modified_keypoints, save_as="custom_pose.png")
└─ saves to ComfyUI/input/custom_pose.png
5. Build/modify the generation workflow:
LoadImage(custom_pose.png) → ControlNetApplyAdvanced.image
ControlNetLoader(openpose_model) → ControlNetApplyAdvanced.control_net
existing CLIPTextEncode → ControlNetApplyAdvanced.positive/negative
route apply.positive/negative into KSampler
6. run_workflow → compare to verify the new generation follows the modified skeleton
Recipe B — Build a pose from scratch
User wants a specific pose with no reference image. Construct the keypoints in Python.
pose_keypoints_2d = [
416, 200, 1.0,
416, 280, 1.0,
340, 290, 1.0,
320, 380, 1.0,
400, 250, 1.0,
492, 290, 1.0,
540, 400, 1.0,
560, 500, 1.0,
370, 520, 1.0,
360, 720, 1.0,
350, 920, 1.0,
462, 520, 1.0,
470, 720, 1.0,
480, 920, 1.0,
400, 192, 1.0,
432, 192, 1.0,
386, 200, 0.5,
446, 200, 0.5,
]
keypoints = {
"people": [{"pose_keypoints_2d": pose_keypoints_2d}],
"canvas_width": 832, "canvas_height": 1216
}
Then render_pose_skeleton(keypoints, save_as="leaning_pose.png").
Common modifications (Python recipes)
All examples assume kps = extract_result["keypoints"] and we're editing the first person.
Lift left arm
body = kps["people"][0]["pose_keypoints_2d"]
for joint_idx in (5, 6, 7):
body[joint_idx*3 + 1] -= 200
Mirror horizontally (flip left/right)
body = kps["people"][0]["pose_keypoints_2d"]
W = kps.get("canvas_width", 1024)
for i in range(0, len(body), 3):
body[i] = W - body[i]
swap_pairs = [(2, 5), (3, 6), (4, 7), (8, 11), (9, 12), (10, 13), (14, 15), (16, 17)]
for a, b in swap_pairs:
for off in (0, 1, 2):
body[a*3 + off], body[b*3 + off] = body[b*3 + off], body[a*3 + off]
Rotate the right arm at the shoulder by 45°
import math
body = kps["people"][0]["pose_keypoints_2d"]
shoulder = (body[2*3], body[2*3 + 1])
angle = math.radians(45)
cos_a, sin_a = math.cos(angle), math.sin(angle)
for joint_idx in (3, 4):
px = body[joint_idx*3] - shoulder[0]
py = body[joint_idx*3 + 1] - shoulder[1]
body[joint_idx*3] = shoulder[0] + px * cos_a - py * sin_a
body[joint_idx*3 + 1] = shoulder[1] + px * sin_a + py * cos_a
Translate the whole figure (shift x, y)
body = kps["people"][0]["pose_keypoints_2d"]
dx, dy = 100, -50
for i in range(0, len(body), 3):
body[i] += dx
body[i + 1] += dy
Scale the figure around its centroid
body = kps["people"][0]["pose_keypoints_2d"]
scale = 0.85
xs = [body[i] for i in range(0, len(body), 3)]
ys = [body[i + 1] for i in range(1, len(body), 3)]
cx = sum(xs) / len(xs)
cy = sum(ys) / len(ys)
for i in range(0, len(body), 3):
body[i] = cx + (body[i] - cx) * scale
body[i + 1] = cy + (body[i + 1] - cy) * scale
Add a second person (combine two extracted poses)
person_a = extract_a["keypoints"]["people"][0]
person_b = extract_b["keypoints"]["people"][0]
combined = {
"people": [person_a, person_b],
"canvas_width": extract_a["keypoints"]["canvas_width"],
"canvas_height": extract_a["keypoints"]["canvas_height"],
}
render_pose_skeleton(combined, save_as="duo_pose.png")
Strip face/hands (use body only)
ControlNet OpenPose with body-only conditioning often gives looser, more creative results than full-body+face+hands. To strip:
for person in kps["people"]:
person.pop("face_keypoints_2d", None)
person.pop("hand_left_keypoints_2d", None)
person.pop("hand_right_keypoints_2d", None)
ControlNet OpenPose connection topology
After you've rendered the skeleton, here's the wiring to add to a generation workflow:
LoadImage(skeleton.png) ────────┐
▼
ControlNetApplyAdvanced
├─ image: skeleton
├─ control_net: ControlNetLoader(openpose_model)
├─ positive: existing CLIPTextEncode (positive)
└─ negative: existing CLIPTextEncode (negative)
│
▼ positive/negative outputs
KSampler.positive / KSampler.negative
(replace the original CLIPTextEncode→KSampler wires)
ControlNetApplyAdvanced has strength (try 0.6–0.8) and start_percent/end_percent (default 0.0/1.0 = full range). For a stronger pose lock, raise strength to 0.9; for more creative interpretation, drop to 0.5.
Pitfalls (from real sessions)
- DWPose returns
people: [] on some inputs. Causes seen:
- Image was masked/clipspaced (alpha channel confuses the body detector). Use a clean, unmasked source.
- Pose is highly stylized (chibi proportions, extreme camera angles). Try
OpenposePreprocessor as a fallback.
- Body is too small in frame (preprocessor downsamples to 512px internally). Crop tighter to the figure first.
- Coordinate system gotcha. OpenPose y increases downward. To "raise an arm," subtract from y, don't add. Subject's
r_ = viewer's left.
- Normalized vs pixel space. Most DWPose configs return pixel-space coordinates, but some setups normalize to 0-1.
render_pose_skeleton auto-detects (max value ≤ 1.5 → treated as normalized). If you're modifying coordinates manually, check the format first.
- Confidence below threshold = not rendered. If you set a joint's coordinates but leave its confidence at 0, it won't draw. Set
c = 1.0 for joints you've manually placed.
- ControlNet strength + tag conflicts. If your prompt has strong pose tags (
from_below, arched_back) AND the skeleton contradicts them, results are muddled. Either match the tags to the skeleton, or drop the explicit pose tags from the prompt.
- Subgraph workflows. If your generation workflow uses subgraphs (like the zimage one), insert the ControlNet inside the subgraph definition or before the subgraph instance, depending on where conditioning enters.
Verification protocol
After rendering:
view_file(skeleton_filename, type="input") — visually confirm the skeleton looks like the pose you intended. The render is fast; iterate here, NOT after a full generation run.
- Once skeleton looks right, run the full generation workflow with ControlNet wired in.
- Compare result to the skeleton with
compare_images([{filename: skeleton, type: "input"}, {filename: gen_output}]) to verify the pose was followed.
- If the result drifts from the skeleton, raise ControlNet strength (or check that the LoadImage actually points at your saved skeleton, not an old cached one).
Skills to layer with this one
comfyui-mask-strategy — pose keypoints can drive limb-mask creation (polyline along the bones). Cross-pollinates with this skill.
comfyui-image-quality — once the pose is right, image-quality recipes apply normally.
prompt-illustrious / prompt-flux — the prompt should match the model's conventions; the pose comes from ControlNet, the style from the prompt.
Pre-flight before starting
search_nodes("DWPreprocessor")
search_nodes("SavePoseKpsAsJsonFile")
list_models("controlnet") # confirm an openpose-compatible ControlNet model exists
list_custom_nodes | grep -i pose # any pose-related custom nodes
If any are missing, fall back to manual workflow wiring (the user can install the missing node via ComfyUI Manager).