Files

363 lines
13 KiB
Python

import os
import numpy as np
import torch
from PIL import Image
import comfy.model_management
import comfy.utils
import folder_paths
from .fashn_vton import TryOnPipeline
from .fashn_vton.pose_keypoints import convert_pose_keypoints_to_dwpose
from .fashn_vton.preprocessing import FASHN_LABELS_TO_IDS
MODEL_LIST = ["fashn-ai/fashn-vton-1.5"]
MAX_FASHN_LABEL_ID = max(FASHN_LABELS_TO_IDS.values())
DEFAULT_CATEGORY_LABEL_ID = {
"tops": FASHN_LABELS_TO_IDS["top"],
"bottoms": FASHN_LABELS_TO_IDS["pants"],
"one-pieces": FASHN_LABELS_TO_IDS["dress"],
}
def _tensor_to_pil(image_tensor: torch.Tensor) -> Image.Image:
"""Convert a ComfyUI IMAGE tensor (B,H,W,C in [0,1]) to PIL for pipeline input."""
img = image_tensor[0].detach().cpu().numpy()
img = np.clip(img, 0.0, 1.0)
if img.ndim == 2:
return Image.fromarray((img * 255.0).astype(np.uint8), mode="L")
channels = img.shape[2]
if channels == 1:
return Image.fromarray((img[..., 0] * 255.0).astype(np.uint8), mode="L")
if channels == 2:
return Image.fromarray((img[..., 0] * 255.0).astype(np.uint8), mode="L")
if channels > 3:
img = img[..., :3]
return Image.fromarray((img * 255.0).astype(np.uint8))
class FashnVtonLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": (MODEL_LIST, {"default": "fashn-ai/fashn-vton-1.5"}),
}
}
RETURN_TYPES = ("FASHN_VTON_PIPELINE",)
RETURN_NAMES = ("pipeline",)
FUNCTION = "load_pipeline"
CATEGORY = "FashnAI"
def load_pipeline(self, model):
weights_name = "fashn-vton"
base_weights_dir = os.path.join(folder_paths.models_dir, weights_name)
os.makedirs(base_weights_dir, exist_ok=True)
from huggingface_hub import hf_hub_download
# Download TryOnModel
tryon_path = os.path.join(base_weights_dir, "model.safetensors")
if not os.path.exists(tryon_path):
print(f"FashnVTON: Downloading TryOnModel weights to {tryon_path}...")
hf_hub_download(
repo_id=model,
filename="model.safetensors",
local_dir=base_weights_dir,
)
# Download DWPose
dwpose_dir = os.path.join(base_weights_dir, "dwpose")
os.makedirs(dwpose_dir, exist_ok=True)
for filename in ["yolox_l.onnx", "dw-ll_ucoco_384.onnx"]:
if not os.path.exists(os.path.join(dwpose_dir, filename)):
print(f"FashnVTON: Downloading DWPose/{filename} to {dwpose_dir}...")
hf_hub_download(
repo_id="fashn-ai/DWPose",
filename=filename,
local_dir=dwpose_dir,
)
# Initialize Pipeline
print(f"FashnVTON: Loading pipeline from {base_weights_dir}...")
pipeline = TryOnPipeline(weights_dir=base_weights_dir)
return (pipeline,)
class FashnVtonInference:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pipeline": ("FASHN_VTON_PIPELINE",),
"person_image": ("IMAGE",),
"garment_image": ("IMAGE",),
"garment_photo_type": (
["model", "flat-lay"],
{
"default": "model",
"tooltip": "'model' if garment is worn by a person, 'flat-lay' for product shots on plain backgrounds",
},
),
"category": (
["tops", "bottoms", "one-pieces"],
{"default": "tops", "tooltip": "Garment category - 'tops', 'bottoms', or 'one-pieces'"},
),
"skip_cfg_last_n_steps": (
"INT",
{"default": 1, "tooltip": "Skip CFG for final N steps to prevent color saturation"},
),
"segmentation_free": (
"BOOLEAN",
{
"default": True,
"tooltip": "If True, skip person clothing-agnostic masking. Set to False to use person segmentation for person mask control.",
},
),
"steps": (
"INT",
{
"default": 30,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "Recommended: 20 (fast), 30 (balanced), 50 (quality)",
},
),
"cfg": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 10.0, "step": 0.1}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"keep_model_loaded": ("BOOLEAN", {"default": True}),
"pose_source": (
["auto", "internal_dwpose", "external_pose_keypoints"],
{
"default": "auto",
"tooltip": "auto: use external keypoints when connected, otherwise internal DWPose fallback.",
},
),
"parser_backend": (
["fashn_human_parser", "external_fashn_labelmap"],
{
"default": "fashn_human_parser",
"tooltip": "Use external_fashn_labelmap only when providing segmentation images encoded with FASHN label IDs (0-17).",
},
),
},
"optional": {
"person_pose_keypoints": (
"FASHN_DWPOSE_KEYPOINTS",
{"tooltip": "Optional external keypoints for person pose. Used when pose_source is auto/external_pose_keypoints."},
),
"garment_pose_keypoints": (
"FASHN_DWPOSE_KEYPOINTS",
{"tooltip": "Optional external keypoints for garment pose. Used when pose_source is auto/external_pose_keypoints."},
),
"person_segmentation_image": (
"IMAGE",
{
"tooltip": "Optional external person labelmap. Only affects output when segmentation_free is False and parser_backend is external_fashn_labelmap.",
},
),
"garment_segmentation_image": (
"IMAGE",
{
"tooltip": "Optional external garment labelmap. Used when parser_backend is external_fashn_labelmap.",
},
),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "process"
CATEGORY = "FashnAI"
def process(
self,
pipeline,
person_image,
garment_image,
garment_photo_type,
category,
skip_cfg_last_n_steps,
segmentation_free,
steps,
cfg,
seed,
keep_model_loaded,
pose_source="auto",
parser_backend="fashn_human_parser",
person_pose_keypoints=None,
garment_pose_keypoints=None,
person_segmentation_image=None,
garment_segmentation_image=None,
):
device = comfy.model_management.get_torch_device()
print(f"FashnVTON: Moving models to {device}...")
if hasattr(pipeline, "tryon_model"):
pipeline.tryon_model.to(device)
if hasattr(pipeline, "hp_model"):
if hasattr(pipeline.hp_model, "model"):
pipeline.hp_model.model.to(device)
pipeline.hp_model.device = device
pbar = comfy.utils.ProgressBar(steps)
def progress_callback(step, total_steps):
pbar.update_absolute(step + 1, total_steps)
person_pil = _tensor_to_pil(person_image)
garment_pil = _tensor_to_pil(garment_image)
person_segmentation_pil = (
_tensor_to_pil(person_segmentation_image) if person_segmentation_image is not None else None
)
garment_segmentation_pil = (
_tensor_to_pil(garment_segmentation_image) if garment_segmentation_image is not None else None
)
seed = seed % (2**32)
try:
result = pipeline(
person_image=person_pil,
garment_image=garment_pil,
category=category,
garment_photo_type=garment_photo_type,
segmentation_free=segmentation_free,
skip_cfg_last_n_steps=skip_cfg_last_n_steps,
num_timesteps=steps,
guidance_scale=cfg,
seed=seed,
pose_source=pose_source,
person_pose_keypoints=person_pose_keypoints,
garment_pose_keypoints=garment_pose_keypoints,
parser_backend=parser_backend,
person_segmentation_image=person_segmentation_pil,
garment_segmentation_image=garment_segmentation_pil,
callback=progress_callback,
)
finally:
# Handle Offloading
if not keep_model_loaded:
print("FashnVTON: Unloading models from VRAM...")
if hasattr(pipeline, "tryon_model"):
pipeline.tryon_model.to("cpu")
if hasattr(pipeline, "hp_model"):
if hasattr(pipeline.hp_model, "model"):
pipeline.hp_model.model.to("cpu")
pipeline.hp_model.device = torch.device("cpu")
if hasattr(pipeline, "pose_model"):
del pipeline.pose_model
if hasattr(pipeline, "pose_provider"):
pipeline.pose_provider = None
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
comfy.model_management.soft_empty_cache()
# Convert back to ComfyUI format (B, H, W, C)
output_img = np.array(result.images[0]).astype(np.float32) / 255.0
output_tensor = torch.from_numpy(output_img).unsqueeze(0)
return (output_tensor,)
class FashnPoseKeypointsAdapter:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pose_keypoints": ("POSE_KEYPOINT",),
"single_person": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("FASHN_DWPOSE_KEYPOINTS",)
FUNCTION = "adapt"
CATEGORY = "FashnAI/Adapters"
def adapt(self, pose_keypoints, single_person):
converted = convert_pose_keypoints_to_dwpose(pose_keypoints, single_person=single_person)
if converted is None:
raise ValueError(
"Unsupported POSE_KEYPOINT payload for Fashn adapter. "
"Expected OpenPose-like `people` data or DWPose-style bodies."
)
return (converted,)
class FashnMaskToLabelmap:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask_image": ("IMAGE",),
"category": (["tops", "bottoms", "one-pieces"], {"default": "tops"}),
"mask_threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"label_id_override": (
"INT",
{
"default": -1,
"min": -1,
"max": MAX_FASHN_LABEL_ID,
"step": 1,
"tooltip": "-1 uses category default label id.",
},
),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "convert"
CATEGORY = "FashnAI/Adapters"
def convert(self, mask_image, category, mask_threshold, label_id_override):
arr = mask_image.detach().cpu().numpy().astype(np.float32)
if arr.shape[-1] == 1:
mask = arr[..., 0]
elif arr.shape[-1] >= 3:
mask = 0.299 * arr[..., 0] + 0.587 * arr[..., 1] + 0.114 * arr[..., 2]
else:
mask = np.mean(arr, axis=-1)
mask = np.clip(mask, 0.0, 1.0)
fg = mask >= float(mask_threshold)
label_id = int(label_id_override)
if label_id < 0:
label_id = DEFAULT_CATEGORY_LABEL_ID[category]
label_map = np.zeros(mask.shape, dtype=np.uint8)
label_map[fg] = label_id
out = np.repeat(label_map[..., None], 3, axis=-1).astype(np.float32) / 255.0
return (torch.from_numpy(out),)
NODE_CLASS_MAPPINGS = {
"FashnVtonLoader": FashnVtonLoader,
"FashnVtonInference": FashnVtonInference,
"FashnPoseKeypointsAdapter": FashnPoseKeypointsAdapter,
"FashnMaskToLabelmap": FashnMaskToLabelmap,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FashnVtonLoader": "(Down)load Fashn VTON",
"FashnVtonInference": "Fashn VTON Inference",
"FashnPoseKeypointsAdapter": "Fashn Pose Keypoints Adapter",
"FashnMaskToLabelmap": "Fashn Mask to Labelmap",
}