Files
Jacob Combrink 61a949fa29 Fix Hy3DBatchImagesSkippingBlanks to handle mismatched photo resolutions
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.
2026-09-01 15:36:11 +02:00

382 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
})