added mesh building
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user