added mesh building

This commit is contained in:
PozzettiAndrea
2026-05-27 12:59:35 +02:00
parent 344051f3e2
commit 73ab7b9a68
8 changed files with 607 additions and 33 deletions
+4
View File
@@ -26,6 +26,8 @@ from .predict_gaussian_attrs import NODE_CLASS_MAPPINGS as PREDICT_ATTRS_MAPPING
from .predict_gaussian_attrs import NODE_DISPLAY_NAME_MAPPINGS as PREDICT_ATTRS_DISPLAY_MAPPINGS
from .panorama_icosahedron_split import NODE_CLASS_MAPPINGS as ICOSAHEDRON_SPLIT_MAPPINGS
from .panorama_icosahedron_split import NODE_DISPLAY_NAME_MAPPINGS as ICOSAHEDRON_SPLIT_DISPLAY_MAPPINGS
from .build_mesh import NODE_CLASS_MAPPINGS as BUILD_MESH_MAPPINGS
from .build_mesh import NODE_DISPLAY_NAME_MAPPINGS as BUILD_MESH_DISPLAY_MAPPINGS
# Aggregate all node mappings
NODE_CLASS_MAPPINGS = {}
@@ -44,6 +46,7 @@ NODE_CLASS_MAPPINGS.update(DEPTH_MERGE_MAPPINGS)
NODE_CLASS_MAPPINGS.update(REFINED_DEPTH_MAPPINGS)
NODE_CLASS_MAPPINGS.update(PREDICT_ATTRS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(ICOSAHEDRON_SPLIT_MAPPINGS)
NODE_CLASS_MAPPINGS.update(BUILD_MESH_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(LOAD_MODEL_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_DISPLAY_MAPPINGS)
@@ -58,5 +61,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(DEPTH_MERGE_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(REFINED_DEPTH_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_ATTRS_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(ICOSAHEDRON_SPLIT_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(BUILD_MESH_DISPLAY_MAPPINGS)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
@@ -1,3 +1,10 @@
# PEP 563 lazy annotations — without this, the o3d.geometry.TriangleMesh /
# trimesh.PointCloud return-type annotations further down are evaluated at
# module import, which crashes when those optional libs aren't installed
# (o3d / trimesh = None below). With it, annotations are strings, only
# evaluated by typing tools that explicitly resolve them.
from __future__ import annotations
from collections import defaultdict
from typing import Optional, Literal, List
+363
View File
@@ -0,0 +1,363 @@
"""SharpBuildMesh — equirect depth + panorama → trimesh.Trimesh.
Pure-geometry mirror of WorldNavBuildMesh. Consumes an equirect panorama and
an equirect distance map (e.g. from SharpDepthMerge) and produces a triangle
mesh by deprojecting each panorama pixel along its spherical ray.
Inputs:
- panorama IMAGE — equirect RGB (2:1)
- depth IMAGE — equirect distance map (only first channel used)
- sky_mask MASK — exclude sky pixels (optional)
- valid_mask MASK — exclude LSMR-invalid pixels (optional)
- scene_cap_depth FLOAT — hard clip on far depth (default 8m, 0 disables)
Outputs:
- mesh (TRIMESH) — trimesh.Trimesh with vertex colors from the panorama
- mesh_preview (IMAGE) — 3 axis-plane cross-sections (pyvista wireframe)
- global_median_depth FLOAT — median of valid depth pixels post-clipping
Heavy deps (open3d, pyvista, trimesh, utils3d) are lazy-imported. The
vendored MoGe `convert_rgbd2mesh_panorama` does the actual mesh build —
same code path as WorldNavBuildMesh and HY-World 2.0 upstream.
"""
from __future__ import annotations
import sys
from typing import Optional
import numpy as np
import torch
import torch.nn.functional as F
from comfy_api.latest import io
def _p(msg: str) -> None:
print(f"[SharpBuildMesh] {msg}", file=sys.stderr, flush=True)
def _make_pbar(total: int = 100):
try:
import comfy.utils
return comfy.utils.ProgressBar(total)
except Exception:
class _Noop:
def update_absolute(self, *_a, **_k): pass
return _Noop()
def _normalize_panorama(panorama_pil):
from PIL import Image
full_img = panorama_pil.convert("RGB")
if full_img.size[1] > 1920:
_p(f"panorama {full_img.size} > 1920 tall → resizing to 3840×1920")
full_img = full_img.resize((3840, 1920), resample=Image.Resampling.BICUBIC)
return full_img
def _render_mesh_preview(
vertices: np.ndarray,
triangles: np.ndarray,
*,
width: int = 1536,
height: int = 512,
) -> np.ndarray:
"""Three orthographic wireframe cross-sections (YZ at x=0, XZ at y=0, XY at z=0).
Each cut keeps the half opposite the camera so we see the cut face. Falls
back to a placeholder image if pyvista isn't importable.
"""
from PIL import Image, ImageDraw
try:
import os as _os
_os.environ.setdefault("PYVISTA_OFF_SCREEN", "true")
import pyvista as pv
except Exception as e:
_p(f"pyvista import failed: {e!r} — placeholder preview")
ph = Image.new("RGB", (width, height), (24, 24, 28))
d = ImageDraw.Draw(ph)
d.text((20, 20), f"pyvista not installed: {e}\nRun `cds install` to add it.",
fill=(220, 220, 220))
return np.array(ph, dtype=np.uint8)
F_count = int(triangles.shape[0])
faces_flat = np.concatenate(
[np.full((F_count, 1), 3, dtype=np.int64), triangles.astype(np.int64)],
axis=1,
).flatten()
poly = pv.PolyData(vertices.astype(np.float32), faces_flat)
bounds = poly.bounds # (xmin, xmax, ymin, ymax, zmin, zmax)
span = max(bounds[1] - bounds[0], bounds[3] - bounds[2], bounds[5] - bounds[4], 1e-6)
R = span * 1.8
cuts = [
("YZ slice (x = 0)", ( 1.0, 0.0, 0.0), ( R, 0, 0), (0, 0, 1)),
("XZ slice (y = 0)", ( 0.0, 1.0, 0.0), (0, R, 0), (0, 0, 1)),
("XY slice (z = 0)", ( 0.0, 0.0, 1.0), (0, 0, R), (0, 1, 0)),
]
plotter = pv.Plotter(
off_screen=True,
shape=(1, 3),
window_size=(int(width), int(height)),
border=True,
border_color="gray",
)
plotter.set_background((0.09, 0.09, 0.11))
for i, (title, normal, cam_pos, view_up) in enumerate(cuts):
plotter.subplot(0, i)
clipped = poly.clip(normal=normal, origin=(0.0, 0.0, 0.0), invert=True)
if clipped.n_cells > 0:
plotter.add_mesh(
clipped,
color=(0.55, 0.72, 0.95),
show_edges=True,
edge_color=(0.18, 0.28, 0.45),
line_width=0.3,
lighting=True,
ambient=0.25,
diffuse=0.7,
)
plotter.add_mesh(pv.Sphere(radius=max(span * 0.01, 1e-3)),
color=(1.0, 0.85, 0.3))
plotter.camera_position = [tuple(cam_pos), (0.0, 0.0, 0.0), tuple(view_up)]
plotter.add_text(title, font_size=10, color="white", position="upper_left")
img = plotter.screenshot(return_img=True)
plotter.close()
img = np.asarray(img, dtype=np.uint8)
if img.ndim == 3 and img.shape[2] == 4:
img = img[..., :3]
if img.shape[:2] != (int(height), int(width)):
img = np.array(
Image.fromarray(img).resize((int(width), int(height)), Image.Resampling.BILINEAR),
dtype=np.uint8,
)
return img
class SharpBuildMesh(io.ComfyNode):
"""Equirect panorama + depth → trimesh.Trimesh (+ 3-viewport preview)."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="SharpBuildMesh",
display_name="SHARP Build Mesh",
category="SHARP",
description=(
"Equirect panorama + equirect depth → trimesh.Trimesh. "
"Pure geometry: no AI models loaded. Mirrors WorldNavBuildMesh "
"and HY-World 2.0's traj_generate.py mesh-build stage; calls the "
"vendored MoGe `convert_rgbd2mesh_panorama` under the hood. "
"Bonus mesh_preview output: 3 orthographic wireframe cross-"
"sections so you can sanity-check geometry before downstream ops."
),
inputs=[
io.Image.Input(
"panorama",
tooltip="Equirectangular RGB panorama (2:1 aspect). "
"Native IMAGE input — no panorama wrapping needed."),
io.Image.Input(
"depth",
tooltip="Equirect distance map from SharpDepthMerge "
"(or any 2:1 depth tensor). Only the first "
"channel is used."),
io.Mask.Input(
"sky_mask",
optional=True,
tooltip="Sky pixels to exclude from the mesh. Same H×W as depth."),
io.Mask.Input(
"valid_mask",
optional=True,
tooltip="Valid-pixel mask (e.g. SharpDepthMerge's valid_mask "
"output). Untrusted pixels skipped during mesh build."),
io.Float.Input(
"scene_cap_depth",
default=8.0, min=0.0, max=1000.0, step=0.5,
optional=True,
tooltip="Hard upper cap on depth in meters. Caps far "
"background / sky so the mesh extent stays bounded. "
"0 disables. Default 8m matches HY-World's typical "
"outdoor contract."),
io.Int.Input("seed", default=1024, min=0, max=2**31 - 1),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="mesh"),
io.Image.Output(display_name="mesh_preview"),
io.Float.Output(display_name="global_median_depth"),
],
)
@classmethod
def execute(cls, panorama, depth, sky_mask=None, valid_mask=None,
scene_cap_depth=8.0, seed=1024):
from PIL import Image
from ._vendor.worldgen.src.panorama_utils import (
convert_rgbd2mesh_panorama,
spherical_uv_to_directions,
)
from utils3d.numpy.maps import uv_map as _uv_map, depth_map_edge as _depth_map_edge
import trimesh as _tm
torch.manual_seed(int(seed))
np.random.seed(int(seed) & 0xFFFFFFFF)
# --- panorama → PIL ---
arr = panorama.detach().cpu().numpy() if isinstance(panorama, torch.Tensor) else np.asarray(panorama)
if arr.ndim == 4:
arr = arr[0]
if arr.dtype != np.uint8:
arr = (np.clip(arr, 0, 1) * 255 + 0.5).astype(np.uint8)
full_img = _normalize_panorama(Image.fromarray(arr))
# --- depth → [H, W] float ---
d = depth.detach().cpu().numpy() if isinstance(depth, torch.Tensor) else np.asarray(depth)
if d.ndim == 4:
d = d[0]
if d.ndim == 3:
d = d[..., 0]
depth_np = d.astype(np.float32)
H, W = depth_np.shape[:2]
# --- masks → bool [H, W] ---
sky_mask_np: Optional[np.ndarray] = None
if sky_mask is not None:
sm = sky_mask.detach().cpu().numpy() if isinstance(sky_mask, torch.Tensor) else np.asarray(sky_mask)
if sm.ndim == 3:
sm = sm[0]
sky_mask_np = (sm > 0.5).astype(bool)
valid_mask_np: Optional[np.ndarray] = None
if valid_mask is not None:
vm = valid_mask.detach().cpu().numpy() if isinstance(valid_mask, torch.Tensor) else np.asarray(valid_mask)
if vm.ndim == 3:
vm = vm[0]
valid_mask_np = (vm > 0.5).astype(bool)
dev = torch.device("cuda" if torch.cuda.is_available() else "cpu")
pbar = _make_pbar(100)
def _stage(pct, msg):
_p(f"[{pct:3d}%] {msg}")
pbar.update_absolute(pct, 100)
_stage(2, f"depth {depth_np.shape}, sky={'y' if sky_mask_np is not None else 'n'}, "
f"valid={'y' if valid_mask_np is not None else 'n'}, "
f"scene_cap={float(scene_cap_depth or 0.0):.2f}m")
# --- equirect rays from depth shape ---
_stage(10, f"equirect rays from depth {(H, W)}")
uv = _uv_map(H, W)
rays_np = spherical_uv_to_directions(uv).astype(np.float32)
d_min = float(np.nanmin(depth_np))
d_med = float(np.nanmedian(depth_np))
d_max = float(np.nanmax(depth_np))
_p(f" depth stats: min={d_min:.3f} median={d_med:.3f} max={d_max:.3f}")
full_distance = torch.as_tensor(depth_np, dtype=torch.float32, device=dev)
full_rays = torch.as_tensor(rays_np, dtype=torch.float32, device=dev)
# --- depth post-process: edge mask + sky + quantile-99 clip ---
_stage(25, "post-process: edge + sky + q99 clip")
edge_mask = torch.from_numpy(
_depth_map_edge(full_distance.cpu().numpy(), rtol=0.1)
).bool()
if sky_mask_np is not None:
sky_t = torch.from_numpy(sky_mask_np)
else:
sky_t = torch.zeros((H, W), dtype=torch.bool)
if valid_mask_np is not None:
invalid_init = torch.from_numpy(~valid_mask_np)
_p(f" external valid_mask: {int(valid_mask_np.sum())} / {valid_mask_np.size}")
else:
invalid_init = torch.zeros((H, W), dtype=torch.bool)
sky_for_depth = sky_t
if sky_for_depth.shape != edge_mask.shape:
_p(f" resizing sky mask {tuple(sky_for_depth.shape)} → {tuple(edge_mask.shape)}")
sky_for_depth = F.interpolate(
sky_for_depth[None, None].float(),
size=edge_mask.shape, mode="nearest",
)[0, 0].bool()
full_mask = (sky_for_depth | edge_mask | invalid_init).to(dev)
valid_pix = full_distance[~full_mask]
if valid_pix.numel() == 0:
raise RuntimeError("SharpBuildMesh: all pixels masked out — nothing to mesh")
max_d = torch.quantile(valid_pix, q=0.99).item()
_p(f" q99 depth = {max_d:.3f}, clipping to [0, {max_d:.3f}]")
full_distance = torch.clip(full_distance, 0, max_d)
if scene_cap_depth is not None and scene_cap_depth > 0:
cap = float(scene_cap_depth)
n_capped = int((full_distance > cap).sum().item())
_p(f" scene_cap_depth={cap:.2f}m → clipping {n_capped} pixels")
full_distance = torch.clip(full_distance, 0, cap)
# --- resize to mesh resolution (960×1920) and build mesh ---
_stage(45, "convert_rgbd2mesh_panorama @ 960×1920")
mesh_h, mesh_w = 960, 1920
img_resized = full_img.resize((mesh_w, mesh_h), resample=Image.Resampling.BICUBIC)
distance_resized = F.interpolate(
full_distance[None, None], size=(mesh_h, mesh_w), mode="nearest",
)[0, 0]
rays_resized = F.interpolate(
full_rays.permute(2, 0, 1)[None], size=(mesh_h, mesh_w), mode="bilinear",
)[0].permute(1, 2, 0)
sky_mask_resized = F.interpolate(
sky_t.float()[None, None].to(dev), size=(mesh_h, mesh_w), mode="nearest",
)[0, 0].bool()
_p(f" resized to {mesh_h}×{mesh_w}; sky pixels @ mesh res: {int(sky_mask_resized.sum())}")
rgb_t = torch.as_tensor(np.array(img_resized) / 255.0, dtype=torch.float32)
o3d_mesh = convert_rgbd2mesh_panorama(
rgb=rgb_t,
distance=distance_resized.to(dev),
rays=rays_resized.to(dev),
excluded_region_mask=sky_mask_resized.to(dev),
device="cuda" if dev.type == "cuda" else "cpu",
)
# --- open3d → trimesh.Trimesh (carry vertex colors) ---
_stage(85, "open3d → trimesh.Trimesh")
vertices = np.asarray(o3d_mesh.vertices, dtype=np.float32)
triangles = np.asarray(o3d_mesh.triangles, dtype=np.int32)
global_median_depth = float(torch.median(full_distance[~full_mask]).item())
vertex_colors_rgba = None
if o3d_mesh.has_vertex_colors():
vc = np.asarray(o3d_mesh.vertex_colors)
if vc.shape[0] == vertices.shape[0]:
vc = np.clip(vc, 0.0, 1.0)
vertex_colors_rgba = np.empty((vc.shape[0], 4), dtype=np.uint8)
vertex_colors_rgba[:, :3] = (vc * 255.0 + 0.5).astype(np.uint8)
vertex_colors_rgba[:, 3] = 255
else:
_p(f" WARN: vertex_colors {vc.shape[0]} != verts {vertices.shape[0]} — dropping")
tm_mesh = _tm.Trimesh(
vertices=vertices,
faces=triangles,
vertex_colors=vertex_colors_rgba,
process=False,
)
_p(f" mesh: {len(vertices)} verts, {len(triangles)} faces; "
f"global_median_depth={global_median_depth:.3f}; "
f"vertex_colors={'yes' if vertex_colors_rgba is not None else 'no'}")
# --- preview (3-viewport orthographic wireframe) ---
_stage(92, "rendering mesh_preview wireframe")
preview_np = _render_mesh_preview(vertices, triangles, width=1536, height=512)
preview_t = torch.from_numpy(preview_np.astype(np.float32) / 255.0).unsqueeze(0)
_stage(100, "done")
return io.NodeOutput(tm_mesh, preview_t, global_median_depth)
NODE_CLASS_MAPPINGS = {
"SharpBuildMesh": SharpBuildMesh,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SharpBuildMesh": "SHARP Build Mesh",
}
+3
View File
@@ -14,6 +14,9 @@ scipy = "*"
transformers = "*"
tqdm = "*"
opencv-python = "*"
open3d = "*"
pyvista = "*"
trimesh = "*"
# utils3d: used by the vendored MoGe LSMR depth-merge solver
# (_vendor/worldgen/src/panorama_utils.py + _vendor/moge_panorama.py).
+95 -6
View File
@@ -146,6 +146,32 @@ class SharpDepthMerge(io.ComfyNode):
extr_list = [ex[i].astype(np.float32) for i in range(N)]
intr_list = [intr[i].astype(np.float32) for i in range(N)]
# --- Normalize intrinsics for the MoGe LSMR solver ---
# Sharp's pipeline (panorama_cube_split.py, panorama_icosahedron_split.py,
# SharpPredict, SamplePanorama) emits PIXEL-space K: fx/fy in pixel
# units, cx/cy in pixel coords. That's the OpenCV/pytorch3d/gsplat
# convention.
#
# The vendored MoGe LSMR solver (panorama_utils.py:207-210) expects
# NORMALIZED K (fx/fy as image-fraction, cx/cy ≈ 0.5) — it
# multiplies by (w, h) internally to recover pixel coords. Feeding
# pixel-space K → solver overshoots by ~W and lands every ray
# outside the frustum → zero valid pixels → output = initial 1.0.
#
# Convert at the LSMR boundary so split nodes can stay in pixel-K
# land (which keeps SharpPredict / SamplePanorama wiring unchanged).
intr_norm_list = []
for k_pix in intr_list:
k = k_pix.copy()
k[0, :] /= float(fw) # row 0: fx, skew, cx by image width
k[1, :] /= float(fh) # row 1: skew, fy, cy by image height
intr_norm_list.append(k.astype(np.float32))
_p(f"K normalized for LSMR: face[0] PIXEL "
f"[fx={intr_list[0][0,0]:.2f} cx={intr_list[0][0,2]:.2f}] -> "
f"NORMALIZED [fx={intr_norm_list[0][0,0]:.4f} "
f"cx={intr_norm_list[0][0,2]:.4f}] "
f"(image {fw}x{fh})")
# --- valid_masks: (N, h, w, C) or None → list of (h, w) bool ---
if face_valid_masks is None:
pred_masks = [np.ones((fh, fw), dtype=bool) for _ in range(N)]
@@ -162,6 +188,47 @@ class SharpDepthMerge(io.ComfyNode):
)
pred_masks = [(v[i] > 0.5).astype(bool) for i in range(N)]
# --- Input depth stats (per-face + overall) ---
# Helps diagnose "all-white output" symptoms: if input depths are
# all very large (or very small), the equirect output is uniform.
# Compare INPUT stats vs OUTPUT stats below to see whether LSMR
# actually compressed/expanded the range or just passed it through.
all_in = np.concatenate([d.ravel() for d in distance_maps])
all_in_finite = all_in[np.isfinite(all_in) & (all_in > 0)]
if all_in_finite.size > 0:
in_min = float(all_in_finite.min())
in_med = float(np.median(all_in_finite))
in_max = float(all_in_finite.max())
in_p1 = float(np.quantile(all_in_finite, 0.01))
in_p99 = float(np.quantile(all_in_finite, 0.99))
else:
in_min = in_med = in_max = in_p1 = in_p99 = 0.0
_p(f"INPUT face_depths: {N} faces @ {fh}x{fw}, "
f"all-faces stats over valid+finite pixels: "
f"min={in_min:.3f} p1={in_p1:.3f} median={in_med:.3f} "
f"p99={in_p99:.3f} max={in_max:.3f}")
# Per-face quick scan — flag any face with wildly different median
# (likely the source of seams).
per_face_medians = []
for i, d_i in enumerate(distance_maps):
mask_i = pred_masks[i] & np.isfinite(d_i) & (d_i > 0)
if mask_i.any():
m = float(np.median(d_i[mask_i]))
per_face_medians.append(m)
if N <= 50: # only spam per-face logs at moderate N
_p(f" face {i:2d}: median={m:.3f} valid={int(mask_i.sum())}/{mask_i.size}")
else:
per_face_medians.append(0.0)
_p(f" face {i:2d}: NO VALID PIXELS (entire face masked out)")
pfm = np.array(per_face_medians, dtype=np.float32)
pfm_finite = pfm[pfm > 0]
if pfm_finite.size > 0:
_p(f"per-face medians: min={float(pfm_finite.min()):.3f} "
f"median={float(np.median(pfm_finite)):.3f} "
f"max={float(pfm_finite.max()):.3f} "
f"(spread = {float(pfm_finite.max() / max(pfm_finite.min(), 1e-6)):.2f}x — "
f"large spread = monocular scale ambiguity → seams expected)")
_p(f"merging {N} faces @ {fh}x{fw} -> equirect {out_height}x{out_width} "
f"(use_gpu={use_gpu})")
@@ -193,7 +260,7 @@ class SharpDepthMerge(io.ComfyNode):
depth_np, mask_np = merge_panorama_depth_gpu(
out_w, out_h,
distance_maps, pred_masks,
extr_list, intr_list,
extr_list, intr_norm_list,
)
elif resolved == "vcycle":
# V-cycle: solve the recursive pyramid up to 1920x960 (the
@@ -203,7 +270,7 @@ class SharpDepthMerge(io.ComfyNode):
depth_low, mask_low = merge_panorama_depth_gpu(
1920, 960,
distance_maps, pred_masks,
extr_list, intr_list,
extr_list, intr_norm_list,
)
if (out_w, out_h) == (1920, 960):
depth_np, mask_np = depth_low, mask_low
@@ -225,7 +292,7 @@ class SharpDepthMerge(io.ComfyNode):
depth_np, mask_np = merge_panorama_depth(
out_w, out_h,
distance_maps, pred_masks,
extr_list, intr_list,
extr_list, intr_norm_list,
pbar=pbar,
)
else:
@@ -250,9 +317,31 @@ class SharpDepthMerge(io.ComfyNode):
depth_np = smooth_south_pole_depth(depth_np, smooth_height_ratio=0.05)
# Belt-and-braces: trap any residual non-finite values that escaped.
depth_np = np.nan_to_num(depth_np, nan=0.0, posinf=0.0, neginf=0.0)
_p(f"merged depth: shape {depth_np.shape}, min={float(depth_np.min()):.3f}, "
f"median={float(np.median(depth_np)):.3f}, max={float(depth_np.max()):.3f}; "
f"valid pixels: {int(mask_np.sum())} / {mask_np.size}")
out_min = float(depth_np.min())
out_med = float(np.median(depth_np))
out_mean = float(np.mean(depth_np))
out_max = float(depth_np.max())
out_p1 = float(np.quantile(depth_np, 0.01))
out_p99 = float(np.quantile(depth_np, 0.99))
_p(f"OUTPUT equirect depth: shape {depth_np.shape}, "
f"min={out_min:.3f} p1={out_p1:.3f} median={out_med:.3f} "
f"mean={out_mean:.3f} p99={out_p99:.3f} max={out_max:.3f}; "
f"valid: {int(mask_np.sum())}/{mask_np.size} "
f"({100*int(mask_np.sum())/mask_np.size:.1f}%)")
# Side-by-side INPUT vs OUTPUT comparison.
in_med_val = in_med if 'in_med' in dir() else 0.0
_p(f"INPUT median={in_med_val:.3f} → OUTPUT median={out_med:.3f} "
f"(ratio {out_med/max(in_med_val, 1e-6):.3f})")
# "All-white image" hint — ComfyUI's IMAGE socket clips values
# to [0, 1] on preview. If your depths are in meters (typically
# 0.5 - 50), the preview will be saturated white. Need to
# normalize for viewing: divide by p99 / max, or apply a colormap.
if out_max > 1.5:
_p(f"NOTE: output depth max={out_max:.3f} > 1.0 — ComfyUI's "
f"IMAGE preview clips to [0,1] so will look WHITE. Wire "
f"through a depth-viz / normalize node before previewing "
f"(or divide by ~{out_p99:.2f} = 99th-percentile for an "
f"auto-normalized view).")
# IMAGE convention: (B, H, W, C). Broadcast depth across 3 channels so
# it composes with regular depth-viz nodes.
+105 -25
View File
@@ -119,7 +119,12 @@ def _build_extrinsics_with_pole_fix(targets: torch.Tensor) -> torch.Tensor:
`targets`: [N, 3] unit-sphere vectors (look-at targets from world
origin). World up default = +Z. For vertices within ~2.5° of ±Z,
swap up to +Y to avoid the cross-product singularity.
Always computes on CPU regardless of input device — 42 small
look-at matrices, the device round-trip dominates any GPU win.
Caller .to(device) on the return.
"""
targets = targets.detach().cpu().float()
N = targets.shape[0]
eye = torch.zeros(3, dtype=torch.float32)
up_default = torch.tensor([0.0, 0.0, 1.0], dtype=torch.float32)
@@ -134,33 +139,100 @@ def _build_extrinsics_with_pole_fix(targets: torch.Tensor) -> torch.Tensor:
return out
def _make_debug_grid(face_images: torch.Tensor, tile_size: int = 96) -> torch.Tensor:
"""Tile N face crops into a single [H, W, 3] preview image.
def _hsv_color_bgr(idx: int, total: int) -> tuple[int, int, int]:
"""Distinct BGR color per face index via HSV color wheel.
Output is a roughly-square grid (cols = ceil(sqrt(N)) by default).
Each tile is `tile_size`x`tile_size`, downsampled via bilinear. Used
to satisfy WorldNavPanoramaSplit's `debug_image` output convention.
Mirrors WorldNavPanoramaSplit's `_hsv_color`. cv2 expects BGR.
Hue in OpenCV is uint8 in [0, 179]; cycle through with full
saturation + value for maximum contrast.
"""
N, H, W, _ = face_images.shape
cols = int(math.ceil(math.sqrt(N)))
rows = int(math.ceil(N / cols))
import cv2
hue = int(round(180.0 * idx / max(total, 1))) % 180
hsv = np.array([[[hue, 255, 255]]], dtype=np.uint8)
bgr = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)[0, 0]
return int(bgr[0]), int(bgr[1]), int(bgr[2])
# Bilinear-resize each face to (tile_size, tile_size).
nchw = face_images.permute(0, 3, 1, 2) # [N, 3, H, W]
tiled = F.interpolate(
nchw, size=(tile_size, tile_size), mode="bilinear", align_corners=False,
) # [N, 3, T, T]
tiled = tiled.permute(0, 2, 3, 1) # [N, T, T, 3]
grid = torch.zeros(rows * tile_size, cols * tile_size, 3, dtype=face_images.dtype)
def _make_debug_overlay(
panorama: np.ndarray,
extrinsics: torch.Tensor,
) -> np.ndarray:
"""Draw each face's frustum edges on the panorama.
For each face: sample N=64 points along each of the 4 edges of the
90° camera image plane in camera space (corners at z=1: (±1, ±1, 1)),
transform to world via R_c2w = R_w2c^T, normalize to the unit
sphere, convert to (yaw, pitch), then to ERP pixel coords. Draw a
polyline per edge with a distinct HSV-wheel hue. Handle azimuth
wraparound by splitting any edge that crosses the ±π seam into
separate segments.
Sharp's coordinate convention (matches `_sample_perspective_from_equirect`):
world up = +Z
yaw = atan2(ry, rx) around Z
pitch = asin(rz) from XY plane
u_erp = ((yaw / π) * 0.5 + 0.5) * W
v_erp = (0.5 - pitch / π) * H
Args:
panorama: (H, W, 3) uint8 RGB.
extrinsics: (N, 4, 4) world-to-camera, CPU tensor.
Returns:
(H, W, 3) uint8 RGB with N face boundaries drawn on it.
"""
import cv2
H, W = panorama.shape[:2]
debug_img = panorama.copy()
N = int(extrinsics.shape[0])
ext_np = extrinsics.detach().cpu().numpy().astype(np.float32)
S = 64
edge_corners = [
([-1.0, -1.0], [+1.0, -1.0]), # top
([+1.0, -1.0], [+1.0, +1.0]), # right
([+1.0, +1.0], [-1.0, +1.0]), # bottom
([-1.0, +1.0], [-1.0, -1.0]), # left
]
for i in range(N):
r, c = divmod(i, cols)
grid[
r * tile_size:(r + 1) * tile_size,
c * tile_size:(c + 1) * tile_size,
:,
] = tiled[i]
return grid
R_w2c = ext_np[i, :3, :3]
R_c2w = R_w2c.T # camera -> world (orthonormal rotation)
color = _hsv_color_bgr(i, N)
for (p0, p1) in edge_corners:
t = np.linspace(0.0, 1.0, S, dtype=np.float32)
xs = p0[0] + t * (p1[0] - p0[0])
ys = p0[1] + t * (p1[1] - p0[1])
cam_dirs = np.stack(
[xs, ys, np.ones_like(xs)], axis=-1,
) # (S, 3)
# row * R_c2w^T = world-direction row (same as cam_col → R_c2w @ cam_col)
world_dirs = cam_dirs @ R_c2w.T
world_dirs /= np.maximum(
np.linalg.norm(world_dirs, axis=-1, keepdims=True), 1e-12,
)
# Sharp convention: world up = +Z, yaw around Z, pitch from XY.
yaw = np.arctan2(world_dirs[:, 1], world_dirs[:, 0]) # [-π, π]
pitch = np.arcsin(np.clip(world_dirs[:, 2], -1.0, 1.0)) # [-π/2, π/2]
u = ((yaw / np.pi) * 0.5 + 0.5) * W
v = (0.5 - pitch / np.pi) * H
pts = np.stack([u, v], axis=-1).astype(np.float32)
# Split at azimuth wraparound: any consecutive pair with
# |Δu| > W/2 wrapped the ±π seam.
du = np.abs(np.diff(pts[:, 0]))
breaks = np.where(du > W * 0.5)[0]
segments = np.split(pts, breaks + 1) if len(breaks) else [pts]
for seg in segments:
if len(seg) < 2:
continue
seg_int = seg.astype(np.int32).reshape(-1, 1, 2)
cv2.polylines(
debug_img, [seg_int], isClosed=False,
color=color, thickness=2, lineType=cv2.LINE_AA,
)
return debug_img
class SharpPanoramaIcosahedronSplit(io.ComfyNode):
@@ -272,9 +344,17 @@ class SharpPanoramaIcosahedronSplit(io.ComfyNode):
intrinsics_t = K.unsqueeze(0).expand(N, -1, -1).contiguous() # [N, 3, 3]
extrinsics_t = extrinsics.contiguous() # [N, 4, 4]
# Debug grid — small thumbnails so the preview node renders fast.
debug_grid = _make_debug_grid(face_images_t.cpu(), tile_size=96)
debug_image = debug_grid.unsqueeze(0) # [1, H, W, 3]
# Debug overlay: panorama with each face's frustum edges drawn on it.
# Matches WorldNavPanoramaSplit's debug_image convention. uint8
# round-trip for cv2.polylines, then back to float [0,1] for the
# IMAGE socket.
pano_uint8 = (
pano.detach().cpu().clamp(0.0, 1.0).mul(255.0).to(torch.uint8).numpy()
)
debug_np = _make_debug_overlay(pano_uint8, extrinsics) # (H, W, 3) uint8
debug_image = (
torch.from_numpy(debug_np.astype(np.float32) / 255.0).unsqueeze(0)
) # [1, H, W, 3]
log.info(
f"[SharpPanoramaIcosahedronSplit] {N} faces "
+29 -1
View File
@@ -366,6 +366,34 @@ class SharpPredictGaussianAttrs(io.ComfyNode):
l0_mb = _build_multiband(attrs0_batch)
l1_mb = _build_multiband(attrs1_batch)
# Rescale intrinsics so their pixel-space matches the emitted depth grid
# (H_grid x W_grid), not the input face shape. Without this the
# downstream merger normalizes K by the wrong W and ends up with the
# wrong effective FOV per face -> visible seams in the merged equirect.
if intrinsics is not None:
sx = float(W_grid) / float(width)
sy = float(H_grid) / float(height)
intr = intrinsics.detach().clone().float() if isinstance(intrinsics, torch.Tensor) \
else torch.as_tensor(np.asarray(intrinsics)).float().clone()
if intr.dim() == 2:
intr[0, :] *= sx
intr[1, :] *= sy
k_dbg = intr
elif intr.dim() == 3:
intr[:, 0, :] *= sx
intr[:, 1, :] *= sy
k_dbg = intr[0]
else:
raise ValueError(f"intrinsics must be (3,3) or (N,3,3), got {tuple(intr.shape)}")
intrinsics_out = intr
log.info(
f"[SharpPredictGaussianAttrs] intrinsics rescaled "
f"({width}x{height}) -> ({W_grid}x{H_grid}); sx={sx:.3f} sy={sy:.3f}; "
f"face[0] fx={float(k_dbg[0,0]):.2f} cx={float(k_dbg[0,2]):.2f}"
)
else:
intrinsics_out = None
n_valid_l0 = int(d0_batch.gt(0).sum())
n_valid_l1 = int(d1_batch.gt(0).sum())
log.info(
@@ -381,7 +409,7 @@ class SharpPredictGaussianAttrs(io.ComfyNode):
return io.NodeOutput(
d0_img, l0_mb, d1_img, l1_mb,
extrinsics, intrinsics,
extrinsics, intrinsics_out,
)
+1 -1
View File
File diff suppressed because one or more lines are too long