Files
ketle-manandClaude Sonnet 4.6 9298e430d0 feat: v1.1.0 - aspect ratio fix, workflow draft save fix, kwargs fix
- Preserve pose image aspect ratio on output (contain scaling with letterbox)
- Fix "Failed to save workflow draft" on tab switch by wrapping app.graph.serialize
  to strip image_data base64 from saved JSON without affecting execution prompts
- Add **kwargs to export_pose and IS_CHANGED to fix unexpected keyword argument error
  when background_image is not connected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:00:58 +09:00

118 lines
4.4 KiB
Python

import torch
import numpy as np
from PIL import Image
import base64
import io
class PoseEditor2DNode:
"""
2D リギングポーズエディタ ノード
- ポーズエディタでポーズを編集し IMAGE として出力
- background_image 入力で背景合成・複数人物の重ね合わせ対応
- image_input_mode=True で画像読込ノードとして動作(ポーズエディタオフ)
- output_size_mode: Standard / Background / Custom
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_data": ("STRING", {"default": ""}),
"output_size_mode": (["Standard", "Background", "Custom"], {"default": "Standard"}),
"custom_width": ("INT", {"default": 600, "min": 64, "max": 4096, "step": 8}),
"custom_height": ("INT", {"default": 600, "min": 64, "max": 4096, "step": 8}),
},
"optional": {
"background_image": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "export_pose"
CATEGORY = "2D Pose"
OUTPUT_NODE = False
def export_pose(self,
image_data: str,
output_size_mode: str = "Standard",
custom_width: int = 600,
custom_height: int = 600,
background_image=None,
**kwargs):
# ---- 背景画像の準備 ----
bg_pil = None
if background_image is not None:
bg_np = (background_image[0].numpy() * 255).clip(0, 255).astype(np.uint8)
bg_pil = Image.fromarray(bg_np).convert("RGBA")
# ---- image_data デコード ----
pose_pil = None
if image_data and image_data.strip():
try:
data = image_data
if "," in data:
data = data.split(",", 1)[1]
pose_pil = Image.open(io.BytesIO(base64.b64decode(data))).convert("RGBA")
except Exception as e:
print(f"[PoseEditor2D] 画像デコードエラー: {e}")
# ---- 出力サイズ決定 ----
if output_size_mode == "Background" and bg_pil is not None:
out_w, out_h = bg_pil.size
elif output_size_mode == "Custom":
out_w, out_h = custom_width, custom_height
else: # Standard(またはBackgroundで背景なし)
if pose_pil is not None:
out_w, out_h = pose_pil.size
elif bg_pil is not None:
out_w, out_h = bg_pil.size
else:
out_w, out_h = 600, 600
def fit_contain(img: Image.Image, w: int, h: int) -> tuple[Image.Image, int, int]:
"""アスペクト比を保ったまま w×h に収め、オフセットも返す"""
iw, ih = img.size
scale = min(w / iw, h / ih)
nw, nh = round(iw * scale), round(ih * scale)
resized = img.resize((nw, nh), Image.LANCZOS)
ox, oy = (w - nw) // 2, (h - nh) // 2
return resized, ox, oy
# ---- 合成 ----
result = Image.new("RGBA", (out_w, out_h), (224, 224, 224, 255))
# 背景を貼る(背景は出力キャンバス全体にフィット)
if bg_pil is not None:
bg_resized = bg_pil.resize((out_w, out_h), Image.LANCZOS) if bg_pil.size != (out_w, out_h) else bg_pil
result.paste(bg_resized, (0, 0))
# ポーズ(前景)を貼る(アスペクト比を保って中央配置)
if pose_pil is not None:
pose_resized, ox, oy = fit_contain(pose_pil, out_w, out_h)
result.paste(pose_resized, (ox, oy), pose_resized)
# ---- テンソル変換 ----
result_rgb = result.convert("RGB")
img_array = np.array(result_rgb).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(img_array).unsqueeze(0) # (1, H, W, C)
return (img_tensor,)
@classmethod
def IS_CHANGED(cls, image_data, output_size_mode="Standard",
custom_width=600, custom_height=600, background_image=None, **kwargs):
import hashlib
key = f"{image_data}|{output_size_mode}|{custom_width}|{custom_height}"
return hashlib.md5(key.encode()).hexdigest()
NODE_CLASS_MAPPINGS = {
"PoseEditor2D": PoseEditor2DNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PoseEditor2D": "2D Pose Editor",
}