Improved projection "fill holes"

This commit is contained in:
Bruno Fargnoli
2026-03-14 21:22:08 +01:00
parent b6732dd0f8
commit 6c3771938a
3 changed files with 313 additions and 247 deletions
@@ -789,54 +789,6 @@
1024
]
},
{
"id": 250,
"type": "Hy3DDiffusersSchedulerConfig",
"pos": [
-314.72770034835025,
2141.9033917044144
],
"size": [
390.5999755859375,
82
],
"flags": {
"collapsed": true
},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "pipeline",
"type": "HY3DDIFFUSERSPIPE",
"link": 450
}
],
"outputs": [
{
"name": "diffusers_scheduler",
"type": "NOISESCHEDULER",
"slot_index": 0,
"links": [
455
]
}
],
"properties": {
"aux_id": "visualbruno/ComfyUI-Hunyuan3DWrapper",
"ver": "6090a9051f109d2774d309cf6a8a22d4a11a1b15",
"Node name for S&R": "Hy3DDiffusersSchedulerConfig",
"widget_ue_connectable": {
"scheduler": true,
"sigmas": true
},
"cnr_id": "comfyui-hunyan3dwrapper"
},
"widgets_values": [
"Euler",
"default"
]
},
{
"id": 248,
"type": "Hy3DRenderMultiView",
@@ -1857,8 +1809,8 @@
"cnr_id": "comfy-core",
"ver": "0.16.4",
"Node name for S&R": "Preview3D",
"Last Time Model File": "C:/Git/ComfyUI/output/3D/Pistol_MV/Pistol_MV_Textured_MV_00001_.glb",
"Resource Folder": "Git/ComfyUI/output/3D/Pistol_MV",
"Last Time Model File": "C:/Git/ComfyUI/output/3D/AnimeGirl/AnimeGirl_Textured_MV_00002_.glb",
"Resource Folder": "Git/ComfyUI/output/3D/AnimeGirl",
"Scene Config": {
"showGrid": true,
"backgroundColor": "#282828",
@@ -1870,13 +1822,13 @@
"fov": 35,
"state": {
"position": {
"x": -9.285925508472754,
"y": 6.135860681339741,
"z": -3.084115266640774
"x": -2.7373252313099936,
"y": 5.5744471940243105,
"z": 7.192391392495912
},
"target": {
"x": 0,
"y": 2.3565700410649804,
"y": 2.5,
"z": 0
},
"zoom": 1,
@@ -1888,10 +1840,221 @@
}
},
"widgets_values": [
"C:/Git/ComfyUI/output/3D/Pistol_MV/Pistol_MV_Textured_MV_00001_.glb",
"C:/Git/ComfyUI/output/3D/AnimeGirl/AnimeGirl_Textured_MV_00002_.glb",
""
]
},
{
"id": 209,
"type": "PrimitiveInt",
"pos": [
-1941.870656950478,
277.2558588777398
],
"size": [
270,
82
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [
400
]
}
],
"title": "Target Face Number",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.16.4",
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [
500000,
"fixed"
]
},
{
"id": 6,
"type": "Trellis2LoadImageWithTransparency",
"pos": [
-1948.5694294982507,
430.5866991705322
],
"size": [
631.9393495485403,
765.9737728955563
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": null
},
{
"name": "mask",
"type": "MASK",
"links": null
},
{
"name": "image_with_alpha",
"type": "IMAGE",
"links": [
344
]
}
],
"title": "Normal Image",
"properties": {
"aux_id": "visualbruno/ComfyUI-Trellis2",
"ver": "e7f9b30df7a09bcedf1c955e176754c73f983254",
"Node name for S&R": "Trellis2LoadImageWithTransparency",
"widget_ue_connectable": {}
},
"widgets_values": [
"AnimeGirl_1024_00001_.png",
"image"
]
},
{
"id": 239,
"type": "Trellis2LoadImageWithTransparency",
"pos": [
-1939.1565291278416,
1247.0590522961702
],
"size": [
627.638044195148,
672.4931923313359
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
483
]
},
{
"name": "mask",
"type": "MASK",
"links": null
},
{
"name": "image_with_alpha",
"type": "IMAGE",
"links": [
440
]
}
],
"title": "Color Image",
"properties": {
"aux_id": "visualbruno/ComfyUI-Trellis2",
"ver": "e8098f5641702e74133c2dea0b45a13a3ab19985",
"Node name for S&R": "Trellis2LoadImageWithTransparency"
},
"widgets_values": [
"AnimeGirl_808_00001_.png",
"image"
]
},
{
"id": 219,
"type": "PrimitiveString",
"pos": [
-1941.3621908316666,
157.6667334107927
],
"size": [
270,
58
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [
464,
465
]
}
],
"title": "Name -> output in 3D folder",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.16.4",
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [
"AnimeGirl"
]
},
{
"id": 250,
"type": "Hy3DDiffusersSchedulerConfig",
"pos": [
-308.8898175615872,
2137.2330213377168
],
"size": [
390.5999755859375,
82
],
"flags": {
"collapsed": true
},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "pipeline",
"type": "HY3DDIFFUSERSPIPE",
"link": 450
}
],
"outputs": [
{
"name": "diffusers_scheduler",
"type": "NOISESCHEDULER",
"slot_index": 0,
"links": [
455
]
}
],
"properties": {
"aux_id": "visualbruno/ComfyUI-Hunyuan3DWrapper",
"ver": "6090a9051f109d2774d309cf6a8a22d4a11a1b15",
"Node name for S&R": "Hy3DDiffusersSchedulerConfig",
"widget_ue_connectable": {
"scheduler": true,
"sigmas": true
},
"cnr_id": "comfyui-hunyan3dwrapper"
},
"widgets_values": [
"Euler",
"default"
]
},
{
"id": 264,
"type": "Trellis2MultiViewTexturing",
@@ -1901,7 +2064,7 @@
],
"size": [
363.3228515625,
386
410
],
"flags": {},
"order": 38,
@@ -1991,173 +2154,11 @@
2,
1.1,
1.15,
"",
true,
10,
"",
""
]
},
{
"id": 209,
"type": "PrimitiveInt",
"pos": [
-1941.870656950478,
277.2558588777398
],
"size": [
270,
82
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [
400
]
}
],
"title": "Target Face Number",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.16.4",
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [
500000,
"fixed"
]
},
{
"id": 6,
"type": "Trellis2LoadImageWithTransparency",
"pos": [
-1948.5694294982507,
430.5866991705322
],
"size": [
631.9393495485403,
765.9737728955563
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": null
},
{
"name": "mask",
"type": "MASK",
"links": null
},
{
"name": "image_with_alpha",
"type": "IMAGE",
"links": [
344
]
}
],
"title": "Normal Image",
"properties": {
"aux_id": "visualbruno/ComfyUI-Trellis2",
"ver": "e7f9b30df7a09bcedf1c955e176754c73f983254",
"Node name for S&R": "Trellis2LoadImageWithTransparency",
"widget_ue_connectable": {}
},
"widgets_values": [
"Image_1024_00081_.png",
"image"
]
},
{
"id": 239,
"type": "Trellis2LoadImageWithTransparency",
"pos": [
-1939.1565291278416,
1247.0590522961702
],
"size": [
627.638044195148,
672.4931923313359
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
483
]
},
{
"name": "mask",
"type": "MASK",
"links": null
},
{
"name": "image_with_alpha",
"type": "IMAGE",
"links": [
440
]
}
],
"title": "Color Image",
"properties": {
"aux_id": "visualbruno/ComfyUI-Trellis2",
"ver": "e8098f5641702e74133c2dea0b45a13a3ab19985",
"Node name for S&R": "Trellis2LoadImageWithTransparency"
},
"widgets_values": [
"Image_1024_00065_.png",
"image"
]
},
{
"id": 219,
"type": "PrimitiveString",
"pos": [
-1941.3621908316666,
157.6667334107927
],
"size": [
270,
58
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [
464,
465
]
}
],
"title": "Name -> output in 3D folder",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.16.4",
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [
"Pistol_MV"
]
}
],
"links": [
@@ -2722,10 +2723,10 @@
"workflowRendererVersion": "LG",
"ue_links": [],
"ds": {
"scale": 0.3899868676879694,
"scale": 0.835971485622887,
"offset": [
2537.916276869781,
-243.0909834796268
1391.1352648291552,
-2311.024721130755
]
},
"links_added_by_ue": [],
+5 -2
View File
@@ -3852,7 +3852,8 @@ class Trellis2MultiViewTexturing:
"blend_exponent": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 8.0, "step": 0.5}),
"ortho_scale": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 10.0, "step": 0.01}),
"norm_size": ("FLOAT",{"default":1.15, "min":0.0, "max":9.99, "step":0.01}),
"fill_holes": ("BOOLEAN",{"default":False}),
"fill_holes": ("BOOLEAN",{"default":True}),
"max_hole_size": ("INT",{"default":10,"min":0,"max":99999,"step":1}),
},
"optional": {
# Standard views
@@ -3885,6 +3886,7 @@ class Trellis2MultiViewTexturing:
ortho_scale,
norm_size,
fill_holes,
max_hole_size,
baseColorTexture = None,
front_image=None,
back_image=None,
@@ -3964,7 +3966,8 @@ class Trellis2MultiViewTexturing:
ortho_scale=ortho_scale,
blend_texture=blend_texture,
fill_holes=fill_holes,
norm_size=norm_size
norm_size=norm_size,
max_hole_size=max_hole_size
)
return (trimesh_obj, pil2tensor(base_color), pil2tensor(mr))
+84 -22
View File
@@ -53,6 +53,7 @@ import math
import cumesh as CuMesh
from tqdm import tqdm
# ---------------------------------------------------------------------------
# Camera helpers
@@ -138,6 +139,7 @@ def texture_mesh_with_multiview(
norm_size: float = 1.15,
fill_holes: bool = False,
blend_texture: bool = True,
max_hole_size: int = 10,
):
if not (len(images) == len(azimuths) == len(elevations)):
raise ValueError("images, azimuths, and elevations must have the same length")
@@ -339,36 +341,96 @@ def texture_mesh_with_multiview(
n_projected = int(composite_mask.sum().item())
print(f" Projected texels: {n_projected} / {texture_size*texture_size}"
f" ({100.0*n_projected/(texture_size*texture_size):.1f}%)")
if fill_holes:
print('Filling holes ...')
hole_mask = (~valid_mask.cpu().numpy()).astype(np.uint8)
n_holes = int(hole_mask.sum())
print(f" Inpainting {n_holes} hole texels ({100.0*n_holes/hole_mask.size:.1f}%)...")
if n_holes > 0:
for c in range(3):
color_np[..., c] = cv2.inpaint(color_np[..., c], hole_mask, 3, cv2.INPAINT_NS)
alpha_np = cv2.inpaint(alpha_np, hole_mask, 3, cv2.INPAINT_NS)
else:
# No existing texture found – fall back to projection-only output
print(" WARNING: No existing PBR baseColorTexture found on mesh, "
"outputting projection-only texture (holes will be transparent).")
color_np = (projected_color.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
alpha_np = composite_mask.cpu().numpy().astype(np.uint8) * 255
if fill_holes:
print('Filling holes ...')
hole_mask = (~valid_mask.cpu().numpy()).astype(np.uint8)
n_holes = int(hole_mask.sum())
print(f" Inpainting {n_holes} hole texels ({100.0*n_holes/hole_mask.size:.1f}%)...")
if n_holes > 0:
for c in range(3):
color_np[..., c] = cv2.inpaint(color_np[..., c], hole_mask, 3, cv2.INPAINT_NS)
alpha_np = cv2.inpaint(alpha_np, hole_mask, 3, cv2.INPAINT_NS)
if fill_holes:
print('Filling holes and padding UV seams ...')
# Get numpy arrays
uv_mask_np = uv_hit_mask.cpu().numpy().astype(np.uint8)
valid_mask_np = valid_mask.cpu().numpy().astype(np.uint8)
# 1. INTERNAL HOLES: Pixels inside the UV map that didn't get colored
internal_hole_mask = cv2.bitwise_and(cv2.bitwise_not(valid_mask_np), uv_mask_np)
if max_hole_size > 0:
# Filter internal holes by size to avoid filling massive intentional gaps
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(internal_hole_mask, connectivity=8)
areas = stats[:, cv2.CC_STAT_AREA]
valid_labels_mask = (areas <= max_hole_size)
valid_labels_mask[0] = False # Ignore background
filtered_internal_holes = valid_labels_mask[labels].astype(np.uint8)
else:
filtered_internal_holes = internal_hole_mask
# 2. UV SEAM PADDING: A small band of pixels just outside the UV islands
# We dilate the UV mask by a few pixels to create a "bleed" area
pad_radius = 5 # You can increase this if the black lines persist at long distances
kernel = np.ones((3, 3), np.uint8)
dilated_uv = cv2.dilate(uv_mask_np, kernel, iterations=pad_radius)
# The padding mask is the dilated area MINUS the original UV area
seam_padding_mask = cv2.bitwise_and(dilated_uv, cv2.bitwise_not(uv_mask_np))
# 3. COMBINE AND INPAINT
# We want to inpaint both the internal dots and the external seams simultaneously
final_inpaint_mask = cv2.bitwise_or(filtered_internal_holes, seam_padding_mask)
n_holes = int(filtered_internal_holes.sum())
n_pad = int(seam_padding_mask.sum())
print(f" Inpainting {n_holes} internal texels and {n_pad} seam padding texels...")
if (n_holes + n_pad) > 0:
for c in range(3):
color_np[..., c] = cv2.inpaint(color_np[..., c], final_inpaint_mask, 3, cv2.INPAINT_NS)
alpha_np = cv2.inpaint(alpha_np, final_inpaint_mask, 3, cv2.INPAINT_NS)
# if fill_holes:
# print('Filling holes ...')
# # 1. Get the raw mask of all holes (1 for hole, 0 for valid)
# raw_hole_mask = (~valid_mask.cpu().numpy()).astype(np.uint8)
# # 2. Filter by size if a limit is set
# if max_hole_size > 0:
# filtered_hole_mask = np.zeros_like(raw_hole_mask)
# # Find connected components (connectivity=8 handles diagonals)
# num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(raw_hole_mask, connectivity=8)
# print(f"Num Labels: {num_labels}")
# # Label 0 is the background (non-holes), so we start checking from Label 1
# if num_labels>0:
# progress_bar = tqdm(total=num_labels, desc="Filling holes")
# for label_id in range(1, num_labels):
# area = stats[label_id, cv2.CC_STAT_AREA]
# if area <= max_hole_size:
# # If the hole is small enough, add it to our filtered mask
# filtered_hole_mask[labels == label_id] = 1
# progress_bar.update(1)
# progress_bar.close()
# print(f"Number of filtered holes: {len(filtered_hole_mask)}")
# hole_mask = filtered_hole_mask
# else:
# hole_mask = raw_hole_mask
# n_holes = int(hole_mask.sum())
# print(f" Inpainting {n_holes} hole texels ({100.0*n_holes/hole_mask.size:.1f}%)...")
# if n_holes > 0:
# for c in range(3):
# color_np[..., c] = cv2.inpaint(color_np[..., c], hole_mask, 3, cv2.INPAINT_NS)
# alpha_np = cv2.inpaint(alpha_np, hole_mask, 3, cv2.INPAINT_NS)
# =========================================================================