torch.cat requires every frame to share H/W exactly, but real multi-photo scene input (several phone photos of the same scene from different angles) is rarely all identical resolution -- confirmed live: 9 sample photos hit "Expected size 704 but got size 752" here. Scale-to-cover + center-crop each mismatched frame to the first real frame's resolution, not a plain stretch: a straight resize would distort per-photo aspect ratio, which matters since the model reads perspective/ parallax cues from these images to estimate geometry. Cropping the overflow keeps every photo's true proportions intact.
382 lines
14 KiB
Python
382 lines
14 KiB
Python
import os
|
||
import numpy as np
|
||
import torch
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from PIL import Image
|
||
|
||
import folder_paths
|
||
|
||
# Mesh-handling deps — optional. Hy3DLoadImageFromURL works without them.
|
||
_MESH_AVAILABLE = False
|
||
try:
|
||
import trimesh as Trimesh
|
||
from comfy_extras.nodes_hunyuan3d import MESH
|
||
_MESH_AVAILABLE = True
|
||
except ImportError as e:
|
||
print(
|
||
f"[ComfyUI-Hunyuan3D] mesh deps unavailable ({e}); "
|
||
"Hy3DLoadImageFromURL still active."
|
||
)
|
||
|
||
|
||
class Hy3DLoadImageFromURL:
|
||
"""Load an image directly from a URL, bypassing ComfyUI's local-file validation."""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"url": ("STRING", {"default": ""}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "MASK")
|
||
RETURN_NAMES = ("image", "mask")
|
||
FUNCTION = "load"
|
||
CATEGORY = "Hunyuan3D"
|
||
|
||
def load(self, url):
|
||
import requests
|
||
|
||
value = (url or "").strip()
|
||
if not value:
|
||
blank = np.ones((512, 512, 3), dtype=np.float32)
|
||
image = torch.from_numpy(blank).unsqueeze(0)
|
||
mask = torch.zeros((1, 512, 512), dtype=torch.float32)
|
||
return (image, mask)
|
||
|
||
if value.lower().startswith(("http://", "https://")):
|
||
response = requests.get(value, timeout=30)
|
||
response.raise_for_status()
|
||
img_bytes = response.content
|
||
else:
|
||
# Graydient's numbered image1..image9 field slots don't pass the raw
|
||
# URL through the way init_image_url does -- confirmed live
|
||
# (2026-09-01): Graydient downloads the URL server-side itself and
|
||
# hands this node a generated filename instead, e.g.
|
||
# "image6__4aa48107e2f2a30c7ace932d0988c895.png", already staged into
|
||
# ComfyUI's input/ directory. A bare "imageN_url" field name does not
|
||
# exist on Graydient's side (confirmed by the user) -- this local-file
|
||
# fallback is the actual fix, not a different field name.
|
||
input_dir = folder_paths.get_input_directory()
|
||
local_path = os.path.join(input_dir, value)
|
||
if not os.path.isfile(local_path):
|
||
raise FileNotFoundError(
|
||
f"Hy3DLoadImageFromURL: '{value}' is neither an http(s) URL nor "
|
||
f"a file staged in {input_dir}"
|
||
)
|
||
with open(local_path, "rb") as f:
|
||
img_bytes = f.read()
|
||
|
||
img = Image.open(BytesIO(img_bytes)).convert("RGBA")
|
||
arr = np.array(img).astype(np.float32) / 255.0
|
||
|
||
# IMAGE: [1, H, W, 3] RGB float32 0–1
|
||
image = torch.from_numpy(arr[:, :, :3]).unsqueeze(0)
|
||
# MASK: [1, H, W] ComfyUI convention — 0 = opaque, 1 = transparent
|
||
mask = torch.from_numpy(1.0 - arr[:, :, 3]).unsqueeze(0)
|
||
|
||
return (image, mask)
|
||
|
||
|
||
class Hy3DBatchImagesSkippingBlanks:
|
||
"""
|
||
Combine up to 10 optional single images into one batch, automatically
|
||
dropping any that match Hy3DLoadImageFromURL's blank-placeholder signature
|
||
(a constant pure-white frame it returns when its url widget was left
|
||
empty). Built specifically for Graydient's fixed init_image_url/image1..
|
||
image9 field system: "leave this slot unused" is expressed there as an
|
||
empty URL string, not by disconnecting the node from the graph -- so an
|
||
unused slot still arrives as a real, fully-connected IMAGE tensor, and has
|
||
to be filtered by *content*, not by wiring. Feeding blank frames straight
|
||
into a multiview reconstruction model (as if they were real, unrelated
|
||
observations of the same scene) measurably hurts results, especially when
|
||
only 1-2 of the 10 slots are actually filled in.
|
||
|
||
Requires at least one non-blank image; raises if every supplied slot was
|
||
either left disconnected or was blank.
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {},
|
||
"optional": {f"image{i}": ("IMAGE", {"tooltip": f"Slot {i} of up to 10. Leave disconnected, or feed a blank Hy3DLoadImageFromURL placeholder, to skip it."}) for i in range(1, 11)},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
RETURN_NAMES = ("images",)
|
||
FUNCTION = "batch"
|
||
CATEGORY = "Hunyuan3D"
|
||
|
||
def batch(self, **kwargs):
|
||
import torch.nn.functional as F
|
||
|
||
frames = []
|
||
target_hw = None
|
||
for i in range(1, 11):
|
||
img = kwargs.get(f"image{i}")
|
||
if img is None:
|
||
continue
|
||
for frame in img: # img: [B, H, W, C]; usually B=1 per loader
|
||
frame_np = frame.detach().cpu().numpy()
|
||
is_blank = np.allclose(frame_np, 1.0, atol=1e-4)
|
||
if is_blank:
|
||
continue
|
||
|
||
frame_hw = (frame.shape[0], frame.shape[1])
|
||
if target_hw is None:
|
||
target_hw = frame_hw
|
||
elif frame_hw != target_hw:
|
||
# torch.cat requires every frame to share H/W -- real-world
|
||
# multi-photo scene input (e.g. several phone photos of the
|
||
# same scene from different angles) is rarely all identical
|
||
# resolution. Confirmed live: 9 sample photos hit
|
||
# "Expected size 704 but got size 752" here.
|
||
#
|
||
# Scale-to-cover + center-crop (like CSS object-fit: cover),
|
||
# NOT a plain stretch to target_hw -- a straight resize would
|
||
# distort aspect ratio per photo, which matters here since the
|
||
# model reads perspective/parallax cues from these images to
|
||
# estimate geometry; warping one photo's proportions relative
|
||
# to the others would feed it a wrong effective field of view
|
||
# for that view. Scaling to cover and cropping the overflow
|
||
# keeps every photo's true aspect ratio intact and only loses
|
||
# the outer edges, same trade-off framing photos this way
|
||
# always makes.
|
||
th, tw = target_hw
|
||
fh, fw = frame_hw
|
||
scale = max(th / fh, tw / fw)
|
||
scaled_h, scaled_w = round(fh * scale), round(fw * scale)
|
||
|
||
chw = frame.permute(2, 0, 1).unsqueeze(0) # [1, C, H, W]
|
||
chw = F.interpolate(chw, size=(scaled_h, scaled_w), mode="bilinear", align_corners=False)
|
||
|
||
top = (scaled_h - th) // 2
|
||
left = (scaled_w - tw) // 2
|
||
chw = chw[:, :, top:top + th, left:left + tw]
|
||
|
||
frame = chw.squeeze(0).permute(1, 2, 0)
|
||
|
||
frames.append(frame.unsqueeze(0))
|
||
|
||
if not frames:
|
||
raise ValueError(
|
||
"Hy3DBatchImagesSkippingBlanks: every supplied image slot was "
|
||
"either disconnected or blank (empty URL) -- at least one real "
|
||
"image is required."
|
||
)
|
||
|
||
return (torch.cat(frames, dim=0),)
|
||
|
||
|
||
class Hy3DLoadImageBatchFromURLList:
|
||
"""
|
||
Load an arbitrary number of images from a newline-separated list of URLs and
|
||
return them as a single batched IMAGE tensor. Built for scene-reconstruction
|
||
workflows (e.g. HunyuanWorld-Mirror) that take a variable-length image
|
||
sequence rather than a fixed number of named views -- unlike
|
||
Hy3DLoadImageFromURL's single-slot design (built for Hunyuan3D's fixed
|
||
front/left/back/right conditioning), Graydient's field_mapping can only
|
||
expose a fixed number of named fields, so this collapses "however many
|
||
images you have" into one multiline STRING field instead of forcing a
|
||
hardcoded slot count.
|
||
|
||
Images are resized (not cropped) to a common square size before batching,
|
||
since torch.cat requires matching dimensions and source URLs may point to
|
||
differently-sized images. Downstream preprocessing nodes (e.g.
|
||
PreprocessImagesForHWM) still run their own resize/crop afterward -- this
|
||
is just what makes batching possible at all, not a substitute for it.
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"urls": ("STRING", {
|
||
"default": "",
|
||
"multiline": True,
|
||
"tooltip": "One image URL per line. Blank lines are ignored."
|
||
}),
|
||
"resize_to": ("INT", {"default": 518, "min": 64, "max": 4096, "step": 2}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
RETURN_NAMES = ("images",)
|
||
FUNCTION = "load_batch"
|
||
CATEGORY = "Hunyuan3D"
|
||
|
||
def load_batch(self, urls, resize_to):
|
||
import requests
|
||
|
||
url_list = [line.strip() for line in urls.splitlines() if line.strip()]
|
||
if not url_list:
|
||
raise ValueError("Hy3DLoadImageBatchFromURLList: no URLs provided (urls field is empty)")
|
||
|
||
frames = []
|
||
for url in url_list:
|
||
response = requests.get(url, timeout=30)
|
||
response.raise_for_status()
|
||
img = Image.open(BytesIO(response.content)).convert("RGB")
|
||
img = img.resize((resize_to, resize_to), Image.LANCZOS)
|
||
arr = np.array(img).astype(np.float32) / 255.0
|
||
frames.append(arr)
|
||
|
||
batch = np.stack(frames, axis=0) # [N, H, W, 3]
|
||
return (torch.from_numpy(batch),)
|
||
|
||
|
||
class Hy3DLoadMeshFromURL:
|
||
"""Load a mesh (glb/obj/ply/stl/3mf/dae) directly from a URL as a TRIMESH, for chaining
|
||
Graydient jobs together (e.g. shape-stage GLB output -> texture-stage mesh input) without
|
||
a local-file round trip."""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"url": ("STRING", {"default": ""}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("TRIMESH",)
|
||
RETURN_NAMES = ("trimesh",)
|
||
FUNCTION = "load"
|
||
CATEGORY = "Hunyuan3D"
|
||
DESCRIPTION = "Downloads a mesh file from a URL and loads it as a trimesh object."
|
||
|
||
def load(self, url):
|
||
import requests
|
||
|
||
response = requests.get(url.strip(), timeout=60)
|
||
response.raise_for_status()
|
||
|
||
file_type = Path(url.split("?")[0]).suffix.lstrip(".").lower() or "glb"
|
||
loaded = Trimesh.load(BytesIO(response.content), file_type=file_type, force="mesh")
|
||
return (loaded,)
|
||
|
||
|
||
class Hy3DMeshToTrimesh:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"mesh": ("MESH",),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("TRIMESH",)
|
||
OUTPUT_TOOLTIPS = ("TRIMESH object containing vertices and faces as numpy arrays.",)
|
||
FUNCTION = "load"
|
||
CATEGORY = "Hunyuan3D"
|
||
DESCRIPTION = "Converts a ComfyUI MESH object to a trimesh object."
|
||
|
||
def load(self, mesh):
|
||
mesh_output = Trimesh.Trimesh(mesh.vertices[0], mesh.faces[0])
|
||
return (mesh_output,)
|
||
|
||
|
||
class Hy3DUVUnwrapMesh:
|
||
"""UV-unwraps a mesh with xatlas so it has a single chart atlas ready for texture painting/baking."""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"trimesh": ("TRIMESH",),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("TRIMESH",)
|
||
OUTPUT_TOOLTIPS = ("Trimesh with per-vertex UV coordinates from an xatlas chart unwrap.",)
|
||
FUNCTION = "unwrap"
|
||
CATEGORY = "Hunyuan3D"
|
||
DESCRIPTION = "UV-unwraps a mesh with xatlas, producing a single chart atlas so it can be exported with a texture."
|
||
|
||
def unwrap(self, trimesh):
|
||
import xatlas
|
||
|
||
vertices = np.asarray(trimesh.vertices, dtype=np.float32)
|
||
faces = np.asarray(trimesh.faces, dtype=np.uint32)
|
||
|
||
vmapping, indices, uvs = xatlas.parametrize(vertices, faces)
|
||
|
||
out = Trimesh.Trimesh(
|
||
vertices=vertices[vmapping],
|
||
faces=indices,
|
||
process=False,
|
||
)
|
||
out.visual = Trimesh.visual.TextureVisuals(uv=uvs)
|
||
return (out,)
|
||
|
||
|
||
class Hy3DSaveTrimesh:
|
||
_FORMATS = ["glb", "obj", "ply", "stl", "3mf", "dae"]
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"trimesh": ("TRIMESH",),
|
||
"filename_prefix": ("STRING", {"default": "3D/Hunyuan3D"}),
|
||
"file_format": (Hy3DSaveTrimesh._FORMATS,),
|
||
},
|
||
"optional": {
|
||
"also_save": (["none"] + Hy3DSaveTrimesh._FORMATS, {"default": "none"}),
|
||
"save_file": ("BOOLEAN", {"default": True, "label_on": "output", "label_off": "temp"}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING", "STRING")
|
||
RETURN_NAMES = ("file_path", "also_path")
|
||
FUNCTION = "process"
|
||
CATEGORY = "Hunyuan3D"
|
||
OUTPUT_NODE = True
|
||
DESCRIPTION = "Export trimesh object to one or two model files simultaneously"
|
||
|
||
def process(self, trimesh, filename_prefix, file_format, also_save="none", save_file=True):
|
||
save_dir = folder_paths.get_output_directory() if save_file else folder_paths.get_temp_directory()
|
||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||
filename_prefix, save_dir
|
||
)
|
||
|
||
def write(fmt):
|
||
path = Path(full_output_folder, f"{filename}_{counter:05}_.{fmt}")
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
trimesh.export(str(path), file_type=fmt)
|
||
print(f"[Hy3DSaveTrimesh] wrote {fmt.upper()} -> {path} ({path.stat().st_size} bytes)")
|
||
return str(Path(subfolder) / f"{filename}_{counter:05}_.{fmt}")
|
||
|
||
primary_path = write(file_format)
|
||
also_path = write(also_save) if also_save != "none" and also_save != file_format else ""
|
||
|
||
return (primary_path, also_path)
|
||
|
||
|
||
NODE_CLASS_MAPPINGS = {
|
||
"Hy3DLoadImageFromURL": Hy3DLoadImageFromURL,
|
||
"Hy3DBatchImagesSkippingBlanks": Hy3DBatchImagesSkippingBlanks,
|
||
"Hy3DLoadImageBatchFromURLList": Hy3DLoadImageBatchFromURLList,
|
||
}
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"Hy3DLoadImageFromURL": "Load Image From URL (Hunyuan3D)",
|
||
"Hy3DBatchImagesSkippingBlanks": "Batch Images Skipping Blanks (Hunyuan3D)",
|
||
"Hy3DLoadImageBatchFromURLList": "Load Image Batch From URL List (Hunyuan3D)",
|
||
}
|
||
|
||
if _MESH_AVAILABLE:
|
||
NODE_CLASS_MAPPINGS.update({
|
||
"Hy3DLoadMeshFromURL": Hy3DLoadMeshFromURL,
|
||
"Hy3DMeshToTrimesh": Hy3DMeshToTrimesh,
|
||
"Hy3DUVUnwrapMesh": Hy3DUVUnwrapMesh,
|
||
"Hy3DSaveTrimesh": Hy3DSaveTrimesh,
|
||
})
|
||
NODE_DISPLAY_NAME_MAPPINGS.update({
|
||
"Hy3DLoadMeshFromURL": "Load Mesh From URL",
|
||
"Hy3DMeshToTrimesh": "Mesh to Trimesh",
|
||
"Hy3DUVUnwrapMesh": "UV Unwrap Mesh",
|
||
"Hy3DSaveTrimesh": "Save Trimesh",
|
||
})
|