From 73ab7b9a6889fa8d4d826445f09ccbd99203c42c Mon Sep 17 00:00:00 2001 From: PozzettiAndrea Date: Wed, 27 May 2026 12:59:35 +0200 Subject: [PATCH] added mesh building --- nodes/__init__.py | 4 + nodes/_vendor/worldgen/src/panorama_utils.py | 7 + nodes/build_mesh.py | 363 +++++++++++++++++++ nodes/comfy-env.toml | 3 + nodes/depth_merge.py | 101 +++++- nodes/panorama_icosahedron_split.py | 130 +++++-- nodes/predict_gaussian_attrs.py | 30 +- workflows/pano.json | 2 +- 8 files changed, 607 insertions(+), 33 deletions(-) create mode 100644 nodes/build_mesh.py diff --git a/nodes/__init__.py b/nodes/__init__.py index d58e273..4809561 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -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"] diff --git a/nodes/_vendor/worldgen/src/panorama_utils.py b/nodes/_vendor/worldgen/src/panorama_utils.py index a5e9b7c..663bcc2 100644 --- a/nodes/_vendor/worldgen/src/panorama_utils.py +++ b/nodes/_vendor/worldgen/src/panorama_utils.py @@ -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 diff --git a/nodes/build_mesh.py b/nodes/build_mesh.py new file mode 100644 index 0000000..15c43dc --- /dev/null +++ b/nodes/build_mesh.py @@ -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", +} diff --git a/nodes/comfy-env.toml b/nodes/comfy-env.toml index d011a05..e656ac0 100644 --- a/nodes/comfy-env.toml +++ b/nodes/comfy-env.toml @@ -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). diff --git a/nodes/depth_merge.py b/nodes/depth_merge.py index db788b7..d0da272 100644 --- a/nodes/depth_merge.py +++ b/nodes/depth_merge.py @@ -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. diff --git a/nodes/panorama_icosahedron_split.py b/nodes/panorama_icosahedron_split.py index aa1f3a9..7511ea7 100644 --- a/nodes/panorama_icosahedron_split.py +++ b/nodes/panorama_icosahedron_split.py @@ -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 " diff --git a/nodes/predict_gaussian_attrs.py b/nodes/predict_gaussian_attrs.py index a767dc0..23d74bd 100644 --- a/nodes/predict_gaussian_attrs.py +++ b/nodes/predict_gaussian_attrs.py @@ -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, ) diff --git a/workflows/pano.json b/workflows/pano.json index 3e21f50..a073a57 100644 --- a/workflows/pano.json +++ b/workflows/pano.json @@ -1 +1 @@ -{"id":"sharp-basic-workflow","revision":0,"last_node_id":25,"last_link_id":52,"nodes":[{"id":9,"type":"LoadSharpModel","pos":[-177.95877396853095,-1213.308620911364],"size":[270,82],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"device","name":"device","type":"COMBO","widget":{"name":"device"},"link":null},{"localized_name":"checkpoint_path","name":"checkpoint_path","shape":7,"type":"STRING","widget":{"name":"checkpoint_path"},"link":null}],"outputs":[{"localized_name":"model","name":"model","type":"SHARP_MODEL","links":[20]}],"properties":{"cnr_id":"comfyui-sharp","ver":"51939f0d3bdcd9f4965978445ad0fa31a6222ef0","Node name for S&R":"LoadSharpModel"},"widgets_values":["auto",""]},{"id":21,"type":"PreviewImage","pos":[1387.5905422433161,-1871.477198804017],"size":[636,398],"flags":{},"order":12,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":38}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"PreviewImage"},"widgets_values":[]},{"id":20,"type":"PreviewImage","pos":[1394.930870035046,-2300.6935283639473],"size":[627.4232386014363,358.3867594737071],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":37}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"PreviewImage"},"widgets_values":[]},{"id":7,"type":"LoadImage","pos":[-910.0234950238612,-940.0676692572397],"size":[662.2282800153919,367.6329862374266],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[11]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"LoadImage"},"widgets_values":["pano.png","image"]},{"id":11,"type":"SharpPredictDepth","pos":[167.9465603665549,-1182.3544511142193],"size":[181.1509765625,86],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"SHARP_MODEL","link":20},{"localized_name":"image","name":"image","type":"IMAGE","link":14},{"localized_name":"extrinsics","name":"extrinsics","shape":7,"type":"EXTRINSICS","link":18},{"localized_name":"intrinsics","name":"intrinsics","shape":7,"type":"INTRINSICS","link":19}],"outputs":[{"localized_name":"depth_maps","name":"depth_maps","type":"IMAGE","links":[25,46]},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","links":[26,47]},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","links":[27,48]}],"properties":{"cnr_id":"comfyui-sharp","ver":"51939f0d3bdcd9f4965978445ad0fa31a6222ef0","Node name for S&R":"SharpPredictDepth"},"widgets_values":[]},{"id":19,"type":"ProjectDepthToPanorama","pos":[809.5589801635481,-1818.007378585998],"size":[277.4771484375,194],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"depth_maps","name":"depth_maps","type":"IMAGE","link":49},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","link":50},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","link":51},{"localized_name":"panorama_width","name":"panorama_width","shape":7,"type":"INT","widget":{"name":"panorama_width"},"link":null},{"localized_name":"blend_mode","name":"blend_mode","shape":7,"type":"COMBO","widget":{"name":"blend_mode"},"link":null},{"localized_name":"show_borders","name":"show_borders","shape":7,"type":"BOOLEAN","widget":{"name":"show_borders"},"link":null},{"localized_name":"border_color","name":"border_color","shape":7,"type":"COMBO","widget":{"name":"border_color"},"link":null},{"localized_name":"disagreement_scale","name":"disagreement_scale","shape":7,"type":"FLOAT","widget":{"name":"disagreement_scale"},"link":null}],"outputs":[{"localized_name":"panoramic_depth","name":"panoramic_depth","type":"IMAGE","links":[37]},{"localized_name":"debug_overlay","name":"debug_overlay","type":"IMAGE","links":[38]},{"localized_name":"disagreement_heatmap","name":"disagreement_heatmap","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-sharp","ver":"51939f0d3bdcd9f4965978445ad0fa31a6222ef0","Node name for S&R":"ProjectDepthToPanorama"},"widgets_values":[2048,"none",true,"red",0.2]},{"id":24,"type":"AlignDepthMaps","pos":[383.48454847695496,-1605.533120374793],"size":[360.65,142],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"depth_maps","name":"depth_maps","type":"IMAGE","link":46},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","link":47},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","link":48},{"localized_name":"method","name":"method","shape":7,"type":"COMBO","widget":{"name":"method"},"link":null},{"localized_name":"reference_view","name":"reference_view","shape":7,"type":"INT","widget":{"name":"reference_view"},"link":null}],"outputs":[{"localized_name":"aligned_depth_maps","name":"aligned_depth_maps","type":"IMAGE","links":[49]},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","links":[50]},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","links":[51]},{"localized_name":"alignment_info","name":"alignment_info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-sharp","ver":"51939f0d3bdcd9f4965978445ad0fa31a6222ef0","Node name for S&R":"AlignDepthMaps"},"widgets_values":["global_optimization",10]},{"id":16,"type":"ProjectDepthToPanorama","pos":[422.29970963740055,-1189.3127313610928],"size":[277.4771484375,194],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"depth_maps","name":"depth_maps","type":"IMAGE","link":25},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","link":26},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","link":27},{"localized_name":"panorama_width","name":"panorama_width","shape":7,"type":"INT","widget":{"name":"panorama_width"},"link":null},{"localized_name":"blend_mode","name":"blend_mode","shape":7,"type":"COMBO","widget":{"name":"blend_mode"},"link":null},{"localized_name":"show_borders","name":"show_borders","shape":7,"type":"BOOLEAN","widget":{"name":"show_borders"},"link":null},{"localized_name":"border_color","name":"border_color","shape":7,"type":"COMBO","widget":{"name":"border_color"},"link":null},{"localized_name":"disagreement_scale","name":"disagreement_scale","shape":7,"type":"FLOAT","widget":{"name":"disagreement_scale"},"link":null}],"outputs":[{"localized_name":"panoramic_depth","name":"panoramic_depth","type":"IMAGE","links":[29]},{"localized_name":"debug_overlay","name":"debug_overlay","type":"IMAGE","links":[30]},{"localized_name":"disagreement_heatmap","name":"disagreement_heatmap","type":"IMAGE","links":[52]}],"properties":{"cnr_id":"comfyui-sharp","ver":"51939f0d3bdcd9f4965978445ad0fa31a6222ef0","Node name for S&R":"ProjectDepthToPanorama"},"widgets_values":[2048,"gaussian",true,"red",0.2]},{"id":6,"type":"SamplePanorama","pos":[-200.75380822431256,-934.3551031268805],"size":[388.2466796875,210],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"panorama","name":"panorama","type":"IMAGE","link":11},{"localized_name":"fov_degrees","name":"fov_degrees","type":"FLOAT","widget":{"name":"fov_degrees"},"link":null},{"localized_name":"overlap_percent","name":"overlap_percent","type":"FLOAT","widget":{"name":"overlap_percent"},"link":null},{"localized_name":"output_size","name":"output_size","type":"INT","widget":{"name":"output_size"},"link":null},{"localized_name":"skip_poles","name":"skip_poles","shape":7,"type":"BOOLEAN","widget":{"name":"skip_poles"},"link":null}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":[12,14]},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","links":[18]},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","links":[19]},{"localized_name":"num_horizontal","name":"num_horizontal","type":"INT","links":null},{"localized_name":"num_vertical","name":"num_vertical","type":"INT","links":null}],"properties":{"cnr_id":"comfyui-sharp","ver":"51939f0d3bdcd9f4965978445ad0fa31a6222ef0","Node name for S&R":"SamplePanorama","aux_id":"PozzettiAndrea/ComfyUI-Sharp"},"widgets_values":[90,20,1536,true]},{"id":25,"type":"PreviewImage","pos":[837.7108350995264,-452.772224935793],"size":[636,398],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":52}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"PreviewImage"},"widgets_values":[]},{"id":8,"type":"PreviewImage","pos":[294.0726257921507,-862.2110152531175],"size":[456.7585111624088,246],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":12}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"PreviewImage"},"widgets_values":[]},{"id":13,"type":"PreviewImage","pos":[827.8445250599017,-891.9706860455786],"size":[636,398],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":30}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"PreviewImage"},"widgets_values":[]},{"id":12,"type":"PreviewImage","pos":[823.5595711961479,-1305.772046037818],"size":[548.9009283914478,321.74301470904584],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":29}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"PreviewImage"},"widgets_values":[]}],"links":[[11,7,0,6,0,"IMAGE"],[12,6,0,8,0,"IMAGE"],[14,6,0,11,1,"IMAGE"],[18,6,1,11,2,"EXTRINSICS"],[19,6,2,11,3,"INTRINSICS"],[20,9,0,11,0,"SHARP_MODEL"],[25,11,0,16,0,"IMAGE"],[26,11,1,16,1,"EXTRINSICS"],[27,11,2,16,2,"INTRINSICS"],[29,16,0,12,0,"IMAGE"],[30,16,1,13,0,"IMAGE"],[37,19,0,20,0,"IMAGE"],[38,19,1,21,0,"IMAGE"],[46,11,0,24,0,"IMAGE"],[47,11,1,24,1,"EXTRINSICS"],[48,11,2,24,2,"INTRINSICS"],[49,24,0,19,0,"IMAGE"],[50,24,1,19,1,"EXTRINSICS"],[51,24,2,19,2,"INTRINSICS"],[52,16,2,25,0,"IMAGE"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.573085533011683,"offset":[733.1530148998932,1856.7715714261788]},"workflowRendererVersion":"LG","frontendVersion":"1.34.9"},"version":0.4} \ No newline at end of file +{"id":"sharp-basic-workflow","revision":0,"last_node_id":34,"last_link_id":83,"nodes":[{"id":9,"type":"LoadSharpModel","pos":[-333.4312128826269,-989.2139440177309],"size":[270,82],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"precision","name":"precision","shape":7,"type":"COMBO","widget":{"name":"precision"},"link":null}],"outputs":[{"localized_name":"model_config","name":"model_config","type":"SHARP_MODEL_CONFIG","links":[59]}],"properties":{"cnr_id":"comfyui-sharp","ver":"51939f0d3bdcd9f4965978445ad0fa31a6222ef0","Node name for S&R":"LoadSharpModel"},"widgets_values":["auto"]},{"id":25,"type":"PreviewImage","pos":[740.1385628634665,-806.6059769599477],"size":[636,398],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":66}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"PreviewImage"},"widgets_values":[]},{"id":31,"type":"GetImageSize","pos":[-171.20477315771052,-496.670417063211],"size":[140,124],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":82}],"outputs":[{"localized_name":"width","name":"width","type":"INT","links":[69]},{"localized_name":"height","name":"height","type":"INT","links":[70]},{"localized_name":"batch_size","name":"batch_size","type":"INT","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.22.0","Node name for S&R":"GetImageSize"},"widgets_values":[]},{"id":28,"type":"SharpPredictGaussianAttrs","pos":[-11.318504206652932,-796.1153966433557],"size":[364.5328125,158],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"SHARP_MODEL_CONFIG","link":59},{"localized_name":"image","name":"image","type":"IMAGE","link":58},{"localized_name":"extrinsics","name":"extrinsics","shape":7,"type":"EXTRINSICS","link":73},{"localized_name":"intrinsics","name":"intrinsics","shape":7,"type":"INTRINSICS","link":74},{"localized_name":"focal_length_mm","name":"focal_length_mm","shape":7,"type":"FLOAT","widget":{"name":"focal_length_mm"},"link":null}],"outputs":[{"localized_name":"first_layer_depth_refined","name":"first_layer_depth_refined","type":"IMAGE","links":[65]},{"localized_name":"first_layer_gaussian_attrs","name":"first_layer_gaussian_attrs","type":"MULTIBAND_IMAGE","links":null},{"localized_name":"second_layer_depth_refined","name":"second_layer_depth_refined","type":"IMAGE","links":null},{"localized_name":"second_layer_gaussian_attrs","name":"second_layer_gaussian_attrs","type":"MULTIBAND_IMAGE","links":null},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","links":[]},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","links":[]}],"properties":{"cnr_id":"comfyui-sharp","ver":"344051f3e2eb0527d83ba2c2c0746d46bbf8565b","Node name for S&R":"SharpPredictGaussianAttrs"},"widgets_values":[30]},{"id":29,"type":"SharpDepthMerge","pos":[396.9468475836463,-804.7254960264037],"size":[270,190],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"face_depths","name":"face_depths","type":"IMAGE","link":65},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","link":75},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","link":76},{"localized_name":"face_valid_masks","name":"face_valid_masks","shape":7,"type":"MASK","link":null},{"localized_name":"out_width","name":"out_width","type":"INT","widget":{"name":"out_width"},"link":69},{"localized_name":"out_height","name":"out_height","type":"INT","widget":{"name":"out_height"},"link":70},{"localized_name":"use_gpu","name":"use_gpu","type":"BOOLEAN","widget":{"name":"use_gpu"},"link":null},{"localized_name":"strategy","name":"strategy","type":"COMBO","widget":{"name":"strategy"},"link":null}],"outputs":[{"localized_name":"depth","name":"depth","type":"IMAGE","links":[66,78]},{"localized_name":"valid_mask","name":"valid_mask","type":"MASK","links":null}],"properties":{"cnr_id":"comfyui-sharp","ver":"344051f3e2eb0527d83ba2c2c0746d46bbf8565b","Node name for S&R":"SharpDepthMerge"},"widgets_values":[1920,960,true,"auto"]},{"id":32,"type":"SharpBuildMesh","pos":[855.9169305893066,-1094.7384144194202],"size":[287.853515625,166],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"panorama","name":"panorama","type":"IMAGE","link":83},{"localized_name":"depth","name":"depth","type":"IMAGE","link":78},{"localized_name":"sky_mask","name":"sky_mask","shape":7,"type":"MASK","link":null},{"localized_name":"valid_mask","name":"valid_mask","shape":7,"type":"MASK","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"scene_cap_depth","name":"scene_cap_depth","shape":7,"type":"FLOAT","widget":{"name":"scene_cap_depth"},"link":null}],"outputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","links":[79]},{"localized_name":"mesh_preview","name":"mesh_preview","type":"IMAGE","links":null},{"localized_name":"global_median_depth","name":"global_median_depth","type":"FLOAT","links":null}],"properties":{"cnr_id":"comfyui-sharp","ver":"344051f3e2eb0527d83ba2c2c0746d46bbf8565b","Node name for S&R":"SharpBuildMesh"},"widgets_values":[1024,"randomize",8]},{"id":33,"type":"GeomPackRemesh","pos":[1198.7700133013057,-1101.11603401942],"size":[358.068359375,158],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":79},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.gpu_target_face_count","name":"backend.gpu_target_face_count","type":"INT","widget":{"name":"backend.gpu_target_face_count"},"link":null},{"localized_name":"backend.remesh_band","name":"backend.remesh_band","type":"FLOAT","widget":{"name":"backend.remesh_band"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[80]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"c67199de05705642258e727fa118f412877b4ebf","Node name for S&R":"GeomPackRemesh"},"widgets_values":["gpu_cumesh",500000,1,"false"]},{"id":34,"type":"GeomPackPreviewMeshVTK","pos":[1631.2914299017073,-1122.95447277733],"size":[512,714],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","shape":7,"type":"TRIMESH","link":80},{"localized_name":"voxelgrid","name":"voxelgrid","shape":7,"type":"VOXELGRID","link":null},{"localized_name":"mode","name":"mode","type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"c67199de05705642258e727fa118f412877b4ebf","Node name for S&R":"GeomPackPreviewMeshVTK"},"widgets_values":["fields","{\"show_edges\":false,\"camera_state\":\"\",\"selected_field\":\"\",\"selected_channel\":\"magnitude\",\"selected_colormap\":\"erdc_rainbow_bright\"}"]},{"id":26,"type":"SharpPanoramaIcosahedronSplit","pos":[-387.32488905316114,-797.8930522427164],"size":[328.6578125,186],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"panorama","name":"panorama","type":"IMAGE","link":81},{"localized_name":"resolution","name":"resolution","type":"INT","widget":{"name":"resolution"},"link":null},{"localized_name":"subdivision","name":"subdivision","type":"COMBO","widget":{"name":"subdivision"},"link":null},{"localized_name":"use_gpu","name":"use_gpu","shape":7,"type":"BOOLEAN","widget":{"name":"use_gpu"},"link":null}],"outputs":[{"localized_name":"face_images","name":"face_images","type":"IMAGE","links":[58]},{"localized_name":"extrinsics","name":"extrinsics","type":"EXTRINSICS","links":[73,75]},{"localized_name":"intrinsics","name":"intrinsics","type":"INTRINSICS","links":[74,76]},{"localized_name":"fov_x_deg","name":"fov_x_deg","type":"FLOAT","links":null},{"localized_name":"debug_image","name":"debug_image","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-sharp","ver":"344051f3e2eb0527d83ba2c2c0746d46bbf8565b","Node name for S&R":"SharpPanoramaIcosahedronSplit"},"widgets_values":[1536,"icosahedron_42",true]},{"id":7,"type":"LoadImage","pos":[-1163.7622907189734,-982.6675026789543],"size":[662.2282800153919,367.6329862374266],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[81,82,83]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.12.2","Node name for S&R":"LoadImage"},"widgets_values":["pano.png","image"]}],"links":[[58,26,0,28,1,"IMAGE"],[59,9,0,28,0,"SHARP_MODEL_CONFIG"],[65,28,0,29,0,"IMAGE"],[66,29,0,25,0,"IMAGE"],[69,31,0,29,4,"INT"],[70,31,1,29,5,"INT"],[73,26,1,28,2,"EXTRINSICS"],[74,26,2,28,3,"INTRINSICS"],[75,26,1,29,1,"EXTRINSICS"],[76,26,2,29,2,"INTRINSICS"],[78,29,0,32,1,"IMAGE"],[79,32,0,33,0,"TRIMESH"],[80,33,0,34,0,"TRIMESH"],[81,7,0,26,0,"IMAGE"],[82,7,0,31,0,"IMAGE"],[83,7,0,32,0,"IMAGE"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.5644739300537777,"offset":[1080.1378849470495,1307.8547200141966]},"workflowRendererVersion":"LG","frontendVersion":"1.34.9"},"version":0.4} \ No newline at end of file