bunch of useful nodes
This commit is contained in:
+2
-1
@@ -1,2 +1,3 @@
|
||||
[node_reqs]
|
||||
ComfyUI-GeometryPack = "PozzettiAndrea/ComfyUI-GeometryPack"
|
||||
ComfyUI-GeometryPack = "PozzettiAndrea/ComfyUI-GeometryPack"
|
||||
ComfyUI-GaussianPack = "PozzettiAndrea/ComfyUI-GaussianPack"
|
||||
@@ -28,6 +28,14 @@ from .panorama_icosahedron_split import NODE_CLASS_MAPPINGS as ICOSAHEDRON_SPLIT
|
||||
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
|
||||
from .predict_metric_depth import NODE_CLASS_MAPPINGS as PREDICT_MDEPTH_MAPPINGS
|
||||
from .predict_metric_depth import NODE_DISPLAY_NAME_MAPPINGS as PREDICT_MDEPTH_DISPLAY_MAPPINGS
|
||||
from .predict_gaussians_from_depth import NODE_CLASS_MAPPINGS as PREDICT_GFD_MAPPINGS
|
||||
from .predict_gaussians_from_depth import NODE_DISPLAY_NAME_MAPPINGS as PREDICT_GFD_DISPLAY_MAPPINGS
|
||||
from .predict_foreground import NODE_CLASS_MAPPINGS as PREDICT_FG_MAPPINGS
|
||||
from .predict_foreground import NODE_DISPLAY_NAME_MAPPINGS as PREDICT_FG_DISPLAY_MAPPINGS
|
||||
from .image_attrs_to_ply import NODE_CLASS_MAPPINGS as IMAGE_ATTRS_MAPPINGS
|
||||
from .image_attrs_to_ply import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_ATTRS_DISPLAY_MAPPINGS
|
||||
|
||||
# Aggregate all node mappings
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
@@ -47,6 +55,10 @@ 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_CLASS_MAPPINGS.update(PREDICT_MDEPTH_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(PREDICT_GFD_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(PREDICT_FG_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(IMAGE_ATTRS_MAPPINGS)
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(LOAD_MODEL_DISPLAY_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_DISPLAY_MAPPINGS)
|
||||
@@ -62,5 +74,9 @@ 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)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_MDEPTH_DISPLAY_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_GFD_DISPLAY_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_FG_DISPLAY_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_ATTRS_DISPLAY_MAPPINGS)
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""SharpImageAttrsToPLY — write a PLY from a MULTIBAND_IMAGE of gaussian attrs.
|
||||
|
||||
Consumes the `first_layer_gaussian_attrs` / `second_layer_gaussian_attrs`
|
||||
output of `SharpPredictGaussianAttrs` (or any 14-channel MULTIBAND_IMAGE
|
||||
in SHARP's gaussian schema) and emits a PLY file in world coordinates.
|
||||
|
||||
Channel order is fixed by `SharpPredictGaussianAttrs.GAUSS_ATTR_CHANNEL_NAMES`:
|
||||
|
||||
0: position_x (NDC)
|
||||
1: position_y (NDC)
|
||||
2: position_z (NDC / camera-space z)
|
||||
3: scale_x (Σ-singular value, world units after unprojection)
|
||||
4: scale_y
|
||||
5: scale_z
|
||||
6: quaternion_w
|
||||
7: quaternion_x
|
||||
8: quaternion_y
|
||||
9: quaternion_z
|
||||
10: color_r (linear or SH-DC)
|
||||
11: color_g
|
||||
12: color_b
|
||||
13: opacity (post-activation, in [0, 1])
|
||||
|
||||
We rebuild a `Gaussians3D` namedtuple in NDC, then call SHARP's
|
||||
`unproject_gaussians` to transform to world coordinates using the
|
||||
per-face extrinsics + intrinsics. Output convention matches SharpPredict:
|
||||
single batch → one .ply; N>1 batch → folder of NNN.ply.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from comfy_api.latest import io
|
||||
|
||||
log = logging.getLogger("sharp")
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
OUTPUT_DIR = folder_paths.get_output_directory()
|
||||
except ImportError:
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "output")
|
||||
|
||||
|
||||
def _p(msg: str) -> None:
|
||||
print(f"[SharpImageAttrsToPLY] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# Channel offsets within the 14-channel multiband — must match
|
||||
# `predict_gaussian_attrs.GAUSS_ATTR_CHANNEL_NAMES`.
|
||||
POS = slice(0, 3)
|
||||
SCALE = slice(3, 6)
|
||||
QUAT = slice(6, 10)
|
||||
COLOR = slice(10, 13)
|
||||
OPACITY = 13
|
||||
|
||||
|
||||
class SharpImageAttrsToPLY(io.ComfyNode):
|
||||
"""MULTIBAND_IMAGE of gaussian attrs + camera → PLY file."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SharpImageAttrsToPLY",
|
||||
display_name="SHARP Image + Attrs to PLY",
|
||||
category="SHARP",
|
||||
description=(
|
||||
"Take a 14-channel MULTIBAND_IMAGE of per-pixel gaussian "
|
||||
"attributes (e.g. from SharpPredictGaussianAttrs's "
|
||||
"first_layer_gaussian_attrs output) + the matching camera "
|
||||
"extrinsics/intrinsics and write a 3D Gaussian Splatting "
|
||||
"PLY file. The multiband stores positions in NDC; this "
|
||||
"node unprojects to world coords via SHARP's "
|
||||
"unproject_gaussians before saving."
|
||||
),
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
io.Custom("MULTIBAND_IMAGE").Input(
|
||||
"gaussian_attrs",
|
||||
tooltip="14-channel multiband (samples [B, 14, H, W]) "
|
||||
"with channel order position_xyz, scale_xyz, "
|
||||
"quat_wxyz, color_rgb, opacity. Typically the "
|
||||
"first_layer_gaussian_attrs or "
|
||||
"second_layer_gaussian_attrs output of "
|
||||
"SharpPredictGaussianAttrs."),
|
||||
io.Custom("EXTRINSICS").Input(
|
||||
"extrinsics",
|
||||
tooltip="Per-batch extrinsics matching the multiband "
|
||||
"batch (4×4 each). From "
|
||||
"SharpPredictGaussianAttrs.extrinsics."),
|
||||
io.Custom("INTRINSICS").Input(
|
||||
"intrinsics",
|
||||
tooltip="Per-batch intrinsics rescaled to the gaussian "
|
||||
"grid (3×3 or 4×4 pixel-K for H×W). From "
|
||||
"SharpPredictGaussianAttrs.intrinsics."),
|
||||
io.String.Input(
|
||||
"output_prefix", default="sharp_attrs",
|
||||
optional=True,
|
||||
tooltip="Basename for the output PLY (single) or batch "
|
||||
"folder (N>1)."),
|
||||
io.Float.Input(
|
||||
"focal_length_px", default=0.0, min=0.0, max=8192.0,
|
||||
step=1.0, optional=True,
|
||||
tooltip="Override focal length in pixels saved into the "
|
||||
"PLY metadata. 0 = use intrinsics[0, 0] directly."),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(display_name="ply_path"),
|
||||
io.Custom("EXTRINSICS").Output(display_name="extrinsics"),
|
||||
io.Custom("INTRINSICS").Output(display_name="intrinsics"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@torch.no_grad()
|
||||
def execute(
|
||||
cls,
|
||||
gaussian_attrs: dict,
|
||||
extrinsics: torch.Tensor,
|
||||
intrinsics: torch.Tensor,
|
||||
output_prefix: str = "sharp_attrs",
|
||||
focal_length_px: float = 0.0,
|
||||
):
|
||||
import comfy.utils
|
||||
from .sharp.gaussians import Gaussians3D, save_ply, unproject_gaussians
|
||||
|
||||
# ---- unpack the multiband ---------------------------------------
|
||||
if not isinstance(gaussian_attrs, dict) or "samples" not in gaussian_attrs:
|
||||
raise ValueError(
|
||||
f"SharpImageAttrsToPLY: gaussian_attrs must be a MULTIBAND_IMAGE "
|
||||
f"dict with key 'samples', got {type(gaussian_attrs).__name__}"
|
||||
)
|
||||
samples = gaussian_attrs["samples"] # [B, C, H, W]
|
||||
if samples.dim() != 4 or samples.shape[1] < 14:
|
||||
raise ValueError(
|
||||
f"SharpImageAttrsToPLY: samples must be [B, 14+, H, W], "
|
||||
f"got {tuple(samples.shape)}"
|
||||
)
|
||||
B, C, H, W = samples.shape
|
||||
if C < 14:
|
||||
raise ValueError(f"need at least 14 channels, got {C}")
|
||||
|
||||
# ---- normalize camera inputs ------------------------------------
|
||||
ext = extrinsics.detach().float() if isinstance(extrinsics, torch.Tensor) \
|
||||
else torch.as_tensor(np.asarray(extrinsics)).float()
|
||||
intr = intrinsics.detach().float() if isinstance(intrinsics, torch.Tensor) \
|
||||
else torch.as_tensor(np.asarray(intrinsics)).float()
|
||||
if ext.dim() == 2:
|
||||
ext = ext.unsqueeze(0)
|
||||
if intr.dim() == 2:
|
||||
intr = intr.unsqueeze(0)
|
||||
if ext.shape[0] != B:
|
||||
raise ValueError(
|
||||
f"extrinsics batch {ext.shape[0]} != multiband batch {B}"
|
||||
)
|
||||
if intr.shape[0] != B:
|
||||
raise ValueError(
|
||||
f"intrinsics batch {intr.shape[0]} != multiband batch {B}"
|
||||
)
|
||||
|
||||
device = samples.device if samples.is_cuda else torch.device(
|
||||
"cuda" if torch.cuda.is_available() else "cpu"
|
||||
)
|
||||
samples = samples.to(device).float()
|
||||
ext = ext.to(device)
|
||||
intr = intr.to(device)
|
||||
|
||||
# ---- output path convention (matches SharpPredict) --------------
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
timestamp = int(time.time() * 1000)
|
||||
if B == 1:
|
||||
output_path = os.path.join(OUTPUT_DIR, f"{output_prefix}_{timestamp}.ply")
|
||||
output_folder = None
|
||||
is_batch = False
|
||||
else:
|
||||
output_folder = os.path.join(OUTPUT_DIR, f"{output_prefix}_{timestamp}")
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
output_path = output_folder
|
||||
is_batch = True
|
||||
|
||||
all_ply_paths = []
|
||||
all_extrinsics = []
|
||||
all_intrinsics = []
|
||||
|
||||
pbar = comfy.utils.ProgressBar(B)
|
||||
t_start = time.time()
|
||||
|
||||
for b in range(B):
|
||||
# ---- extract per-pixel attrs → Gaussians3D in NDC ------------
|
||||
s = samples[b] # [14, H, W]
|
||||
n_pixels = H * W
|
||||
|
||||
# Multiband stores [C, H, W]; flatten spatial dims → [C, N], then
|
||||
# transpose to [N, C] for the per-channel slices below.
|
||||
flat = s.reshape(C, n_pixels).T # [N, C]
|
||||
mean_ndc = flat[:, POS].contiguous() # [N, 3]
|
||||
scales = flat[:, SCALE].contiguous() # [N, 3]
|
||||
quats = flat[:, QUAT].contiguous() # [N, 4]
|
||||
colors = flat[:, COLOR].contiguous() # [N, 3]
|
||||
opacs = flat[:, OPACITY].contiguous() # [N]
|
||||
|
||||
# Gaussians3D expects [B=1, N, C] for mean/scale/quat/color,
|
||||
# [B=1, N] for opacity.
|
||||
gaussians_ndc = Gaussians3D(
|
||||
mean_vectors=mean_ndc.unsqueeze(0),
|
||||
singular_values=scales.unsqueeze(0),
|
||||
quaternions=quats.unsqueeze(0),
|
||||
colors=colors.unsqueeze(0),
|
||||
opacities=opacs.unsqueeze(0),
|
||||
)
|
||||
|
||||
# ---- camera matrices for unprojection ------------------------
|
||||
ext_b = ext[b] # [4, 4]
|
||||
K_b = intr[b].clone() # [3, 3] or [4, 4]
|
||||
if K_b.shape == (3, 3):
|
||||
K4 = torch.eye(4, device=device, dtype=K_b.dtype)
|
||||
K4[:3, :3] = K_b
|
||||
K_b = K4
|
||||
|
||||
# SHARP's internal convention: scale K to internal_shape
|
||||
# (the resolution used during decode). The multiband H/W
|
||||
# already match that internal grid (768 for the gaussian
|
||||
# decoder head), so no further rescale is needed — but
|
||||
# `unproject_gaussians` wants image_shape=(H, W) matching K's
|
||||
# native resolution. The intrinsics input is already pixel-K
|
||||
# for (H, W) (per the rescale done in SharpPredictGaussianAttrs).
|
||||
image_shape = (H, W)
|
||||
|
||||
# ---- NDC → world ---------------------------------------------
|
||||
gaussians_world = unproject_gaussians(
|
||||
gaussians_ndc, ext_b, K_b, image_shape,
|
||||
)
|
||||
|
||||
# ---- save PLY ------------------------------------------------
|
||||
if is_batch:
|
||||
ply_path = os.path.join(output_folder, f"{b+1:03d}.ply")
|
||||
else:
|
||||
ply_path = output_path
|
||||
|
||||
f_px = float(focal_length_px) if focal_length_px > 0 \
|
||||
else float(K_b[0, 0].item())
|
||||
_, metadata = save_ply(
|
||||
gaussians_world, f_px, image_shape, Path(ply_path),
|
||||
)
|
||||
|
||||
all_ply_paths.append(ply_path)
|
||||
all_extrinsics.append(metadata["extrinsic"])
|
||||
all_intrinsics.append(metadata["intrinsic"])
|
||||
pbar.update(1)
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
loc = output_folder if is_batch else output_path
|
||||
total_n = B * H * W
|
||||
_p(
|
||||
f"{B} multiband(s) {H}×{W} → {total_n/1e6:.2f}M gaussians; "
|
||||
f"saved to {loc}; {elapsed:.1f}s"
|
||||
)
|
||||
|
||||
return io.NodeOutput(
|
||||
output_path, all_extrinsics[0], all_intrinsics[0],
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SharpImageAttrsToPLY": SharpImageAttrsToPLY,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SharpImageAttrsToPLY": "SHARP Image + Attrs to PLY",
|
||||
}
|
||||
+58
-22
@@ -5,6 +5,7 @@ Merges multiple PLY files (from panorama samples) into a single unified Gaussian
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,6 +17,11 @@ from comfy_api.latest import io
|
||||
log = logging.getLogger("sharp")
|
||||
|
||||
|
||||
def _p(msg: str) -> None:
|
||||
"""Print to stderr so the line shows up in ComfyUI's worker log."""
|
||||
print(f"[MergeGaussians] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def load_ply_simple(path: str) -> dict:
|
||||
"""Load Gaussian data from PLY file.
|
||||
|
||||
@@ -134,7 +140,18 @@ class MergeGaussians(io.ComfyNode):
|
||||
tooltip="Filter out Gaussians with opacity below this (0 = no filter)"),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(display_name="ply_path"),
|
||||
io.String.Output(
|
||||
display_name="ply_paths",
|
||||
tooltip="Passthrough of the input ply_folder. Wire to "
|
||||
"downstream nodes that want to operate on the "
|
||||
"original per-face PLYs (e.g. SharpDedupGaussiansEquirect "
|
||||
"on the merged_ply_path while still keeping access "
|
||||
"to the originals)."),
|
||||
io.String.Output(
|
||||
display_name="merged_ply_path",
|
||||
tooltip="Path to the merged output PLY file (a single "
|
||||
"PLY containing every gaussian from every input "
|
||||
"PLY, post-filter)."),
|
||||
io.Int.Output(display_name="num_gaussians"),
|
||||
],
|
||||
)
|
||||
@@ -148,72 +165,79 @@ class MergeGaussians(io.ComfyNode):
|
||||
min_opacity: float = 0.0,
|
||||
):
|
||||
"""Merge all PLY files in folder."""
|
||||
import comfy.utils
|
||||
|
||||
# Find all PLY files
|
||||
t_start = time.time()
|
||||
|
||||
ply_folder_in = ply_folder
|
||||
ply_folder = Path(ply_folder)
|
||||
if not ply_folder.exists():
|
||||
raise ValueError(f"PLY folder does not exist: {ply_folder}")
|
||||
raise ValueError(f"PLY folder does not exist: {ply_folder_in!r}")
|
||||
|
||||
ply_files = sorted(ply_folder.glob("*.ply"))
|
||||
if not ply_files:
|
||||
raise ValueError(f"No PLY files found in: {ply_folder}")
|
||||
|
||||
log.info(f"Found {len(ply_files)} PLY files to merge")
|
||||
filt_str = []
|
||||
if max_depth > 0:
|
||||
filt_str.append(f"max_depth={max_depth:.2f}m")
|
||||
if min_opacity > 0:
|
||||
filt_str.append(f"min_opacity={min_opacity:.3f}")
|
||||
filt_msg = (" + filters " + ", ".join(filt_str)) if filt_str else " (no filters)"
|
||||
_p(f"merging {len(ply_files)} PLY file(s) from {ply_folder}{filt_msg}")
|
||||
|
||||
# Load all PLY files
|
||||
all_positions = []
|
||||
all_colors = []
|
||||
all_scales = []
|
||||
all_rotations = []
|
||||
all_opacities = []
|
||||
n_in_total = 0
|
||||
n_filtered_total = 0
|
||||
|
||||
pbar = comfy.utils.ProgressBar(len(ply_files))
|
||||
for i, ply_path in enumerate(ply_files):
|
||||
log.info(f"Loading {ply_path.name} ({i+1}/{len(ply_files)})")
|
||||
data = load_ply_simple(str(ply_path))
|
||||
|
||||
positions = data['positions']
|
||||
colors = data['colors']
|
||||
scales = data['scales']
|
||||
rotations = data['rotations']
|
||||
opacities = data['opacities']
|
||||
|
||||
# Apply filters
|
||||
mask = np.ones(len(positions), dtype=bool)
|
||||
n_before = len(positions)
|
||||
n_in_total += n_before
|
||||
|
||||
mask = np.ones(n_before, dtype=bool)
|
||||
if max_depth > 0:
|
||||
# Filter by depth (distance from origin)
|
||||
depths = np.linalg.norm(positions, axis=-1)
|
||||
mask &= depths <= max_depth
|
||||
|
||||
if min_opacity > 0:
|
||||
mask &= opacities >= min_opacity
|
||||
|
||||
filtered_count = (~mask).sum()
|
||||
if filtered_count > 0:
|
||||
log.info(f" Filtered out {filtered_count:,} Gaussians")
|
||||
filtered_count = int((~mask).sum())
|
||||
n_filtered_total += filtered_count
|
||||
|
||||
all_positions.append(positions[mask])
|
||||
all_colors.append(colors[mask])
|
||||
all_scales.append(scales[mask])
|
||||
all_rotations.append(rotations[mask])
|
||||
all_opacities.append(opacities[mask])
|
||||
pbar.update(1)
|
||||
|
||||
# Concatenate all
|
||||
merged_positions = np.concatenate(all_positions, axis=0)
|
||||
merged_colors = np.concatenate(all_colors, axis=0)
|
||||
merged_scales = np.concatenate(all_scales, axis=0)
|
||||
merged_rotations = np.concatenate(all_rotations, axis=0)
|
||||
merged_opacities = np.concatenate(all_opacities, axis=0)
|
||||
|
||||
num_gaussians = len(merged_positions)
|
||||
log.info(f"Total Gaussians after merge: {num_gaussians:,}")
|
||||
|
||||
# Save merged PLY
|
||||
if n_filtered_total > 0:
|
||||
pct = 100.0 * n_filtered_total / max(n_in_total, 1)
|
||||
_p(f"filtered {n_filtered_total:,} / {n_in_total:,} gaussians ({pct:.1f}%)")
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
output_filename = f"{output_prefix}_{timestamp}.ply"
|
||||
output_path = ply_folder.parent / output_filename
|
||||
|
||||
log.info(f"Saving to {output_path}")
|
||||
t_save = time.time()
|
||||
save_merged_ply(
|
||||
merged_positions,
|
||||
merged_colors,
|
||||
@@ -222,10 +246,22 @@ class MergeGaussians(io.ComfyNode):
|
||||
merged_opacities,
|
||||
str(output_path),
|
||||
)
|
||||
save_elapsed = time.time() - t_save
|
||||
|
||||
log.info(f"Done! Merged {len(ply_files)} files into {num_gaussians:,} Gaussians")
|
||||
try:
|
||||
size_mb = output_path.stat().st_size / (1024 * 1024)
|
||||
size_str = f", {size_mb:.1f} MB"
|
||||
except Exception:
|
||||
size_str = ""
|
||||
|
||||
return io.NodeOutput(str(output_path), num_gaussians)
|
||||
elapsed = time.time() - t_start
|
||||
_p(
|
||||
f"merged {len(ply_files)} file(s) → {num_gaussians/1e6:.2f}M gaussians "
|
||||
f"→ {output_path}{size_str}; "
|
||||
f"save {save_elapsed:.1f}s, total {elapsed:.1f}s"
|
||||
)
|
||||
|
||||
return io.NodeOutput(str(ply_folder_in), str(output_path), num_gaussians)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""SharpPredictForeground node for ComfyUI-Sharp.
|
||||
|
||||
Same as SharpPredict, but emits ONLY layer 0 (visible/front surface) — drops
|
||||
layer 1 (back/occluded surface). Halves the per-image gaussian count
|
||||
(768²×1 instead of 768²×2). Use when you don't need backside detail and want
|
||||
smaller PLYs / faster downstream rendering.
|
||||
|
||||
The gaussian_composer's flatten order is (layer, h, w), so the first
|
||||
H_grid × W_grid entries of the returned Gaussians3D are layer 0; we slice
|
||||
to that range before save_ply.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
from comfy_api.latest import io
|
||||
|
||||
from .predict import _predict_image_cached
|
||||
from .utils.image import comfy_to_numpy_rgb, convert_focallength
|
||||
|
||||
log = logging.getLogger("sharp")
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
OUTPUT_DIR = folder_paths.get_output_directory()
|
||||
except ImportError:
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "output")
|
||||
|
||||
|
||||
class SharpPredictForeground(io.ComfyNode):
|
||||
"""Run SHARP inference and save PLY containing ONLY layer-0 gaussians.
|
||||
|
||||
Identical wiring to SharpPredict (same inputs, same outputs). The only
|
||||
difference is the foreground-only slice applied before save_ply.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SharpPredictForeground",
|
||||
display_name="SHARP Predict Foreground (Image to PLY)",
|
||||
category="SHARP",
|
||||
description=(
|
||||
"Generate 3D Gaussian Splatting PLY file(s) from image(s) "
|
||||
"using SHARP, keeping only layer-0 (visible/front surface) "
|
||||
"gaussians and dropping layer-1 (back/occluded). Halves the "
|
||||
"per-image gaussian count vs SharpPredict. Batch input creates "
|
||||
"a folder with numbered PLY files."
|
||||
),
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
io.Custom("SHARP_MODEL_CONFIG").Input("model"),
|
||||
io.Image.Input("image"),
|
||||
io.Float.Input(
|
||||
"focal_length_mm", default=30.0, min=0.0, max=500.0,
|
||||
step=0.1, optional=True,
|
||||
tooltip="Focal length in mm (35mm equiv). 0 = auto (30mm). "
|
||||
"Ignored if intrinsics provided."),
|
||||
io.String.Input(
|
||||
"output_prefix", default="sharp_fg", optional=True,
|
||||
tooltip="Prefix for output PLY filename or batch folder."),
|
||||
io.Custom("EXTRINSICS").Input(
|
||||
"extrinsics", optional=True,
|
||||
tooltip="Camera extrinsics (from SamplePanorama). If "
|
||||
"batched, must match image batch size."),
|
||||
io.Custom("INTRINSICS").Input(
|
||||
"intrinsics", optional=True,
|
||||
tooltip="Camera intrinsics. Overrides focal_length_mm."),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(display_name="ply_path"),
|
||||
io.Custom("EXTRINSICS").Output(display_name="extrinsics"),
|
||||
io.Custom("INTRINSICS").Output(display_name="intrinsics"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@torch.no_grad()
|
||||
def execute(
|
||||
cls,
|
||||
model,
|
||||
image: torch.Tensor,
|
||||
focal_length_mm: float = 0.0,
|
||||
output_prefix: str = "sharp_fg",
|
||||
extrinsics: torch.Tensor = None,
|
||||
intrinsics: torch.Tensor = None,
|
||||
):
|
||||
import comfy.model_management
|
||||
import comfy.utils
|
||||
from .load_model import _load_sharp_model
|
||||
from .sharp.gaussians import save_ply
|
||||
|
||||
patcher = _load_sharp_model(model)
|
||||
predictor = patcher.model
|
||||
device = patcher.load_device
|
||||
|
||||
if image.dim() == 3:
|
||||
image = image.unsqueeze(0)
|
||||
batch_size = image.shape[0]
|
||||
|
||||
# Layer-0 count = output_resolution².
|
||||
H_grid = W_grid = int(predictor.output_resolution)
|
||||
n_layer0 = H_grid * W_grid
|
||||
|
||||
has_camera_params = extrinsics is not None and intrinsics is not None
|
||||
if has_camera_params:
|
||||
if extrinsics.dim() == 2:
|
||||
extrinsics = extrinsics.unsqueeze(0)
|
||||
if extrinsics.shape[0] != batch_size:
|
||||
raise ValueError(
|
||||
f"Extrinsics batch size ({extrinsics.shape[0]}) must match "
|
||||
f"image batch size ({batch_size})"
|
||||
)
|
||||
log.info(
|
||||
f"Processing {batch_size} image(s) with provided camera "
|
||||
f"parameters (foreground-only, layer 0 = {n_layer0} gaussians/face)"
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
f"Processing {batch_size} image(s) (foreground-only, "
|
||||
f"layer 0 = {n_layer0} gaussians/face)"
|
||||
)
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
if batch_size == 1:
|
||||
output_filename = f"{output_prefix}_{timestamp}.ply"
|
||||
output_path = os.path.join(OUTPUT_DIR, output_filename)
|
||||
output_folder = None
|
||||
is_batch = False
|
||||
else:
|
||||
folder_name = f"{output_prefix}_{timestamp}"
|
||||
output_folder = os.path.join(OUTPUT_DIR, folder_name)
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
output_path = output_folder
|
||||
is_batch = True
|
||||
|
||||
all_ply_paths = []
|
||||
all_extrinsics = []
|
||||
all_intrinsics = []
|
||||
|
||||
inference_start = time.time()
|
||||
pbar = comfy.utils.ProgressBar(batch_size)
|
||||
|
||||
for i in range(batch_size):
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
single_image = image[i:i+1]
|
||||
image_np = comfy_to_numpy_rgb(single_image)
|
||||
height, width = image_np.shape[:2]
|
||||
|
||||
if i == 0:
|
||||
log.info(f"Image size: {width}x{height}")
|
||||
|
||||
if has_camera_params:
|
||||
img_intrinsics = intrinsics.to(device)
|
||||
img_extrinsics = extrinsics[i].to(device)
|
||||
f_px = img_intrinsics[0, 0].item()
|
||||
else:
|
||||
if focal_length_mm > 0:
|
||||
f_px = convert_focallength(width, height, focal_length_mm)
|
||||
else:
|
||||
f_px = convert_focallength(width, height, 30.0)
|
||||
img_extrinsics = None
|
||||
img_intrinsics = None
|
||||
|
||||
log.info(f"Running inference on image {i+1}/{batch_size}...")
|
||||
gaussians = _predict_image_cached(
|
||||
patcher, predictor, image_np, f_px, device,
|
||||
extrinsics=img_extrinsics,
|
||||
intrinsics=img_intrinsics,
|
||||
)
|
||||
|
||||
# Foreground-only slice: drop layer 1 (occluded surface).
|
||||
# Composer flatten order is (layer, h, w), so [0:n_layer0] = layer 0.
|
||||
gaussians = gaussians._replace(
|
||||
mean_vectors=gaussians.mean_vectors[:, :n_layer0],
|
||||
singular_values=gaussians.singular_values[:, :n_layer0],
|
||||
quaternions=gaussians.quaternions[:, :n_layer0],
|
||||
colors=gaussians.colors[:, :n_layer0],
|
||||
opacities=gaussians.opacities[:, :n_layer0],
|
||||
)
|
||||
|
||||
if is_batch:
|
||||
ply_filename = f"{i+1:03d}.ply"
|
||||
ply_path = os.path.join(output_folder, ply_filename)
|
||||
else:
|
||||
ply_path = output_path
|
||||
|
||||
_, metadata = save_ply(gaussians, f_px, (height, width), Path(ply_path))
|
||||
|
||||
all_ply_paths.append(ply_path)
|
||||
all_extrinsics.append(metadata["extrinsic"])
|
||||
all_intrinsics.append(metadata["intrinsic"])
|
||||
|
||||
log.info(f"Saved: {ply_path} ({metadata['num_gaussians']:,} gaussians, layer 0)")
|
||||
pbar.update(1)
|
||||
|
||||
inference_time = time.time() - inference_start
|
||||
log.info(
|
||||
f"Total inference time: {inference_time:.2f}s "
|
||||
f"({inference_time/batch_size:.2f}s per image)"
|
||||
)
|
||||
|
||||
return io.NodeOutput(output_path, all_extrinsics[0], all_intrinsics[0])
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SharpPredictForeground": SharpPredictForeground,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SharpPredictForeground": "SHARP Predict Foreground (Image to PLY)",
|
||||
}
|
||||
+100
-26
@@ -57,6 +57,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
@@ -65,6 +66,13 @@ import torch.nn.functional as F
|
||||
|
||||
from comfy_api.latest import io
|
||||
|
||||
|
||||
def _p(msg: str) -> None:
|
||||
"""Print to stderr so the line shows up in ComfyUI's worker log
|
||||
(`log.info`/`log.debug` on the 'sharp' logger don't always get routed
|
||||
through the comfy-env worker stderr pipe — direct print does)."""
|
||||
print(f"[SharpPredictGaussianAttrs] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
log = logging.getLogger("sharp")
|
||||
|
||||
|
||||
@@ -224,10 +232,37 @@ class SharpPredictGaussianAttrs(io.ComfyNode):
|
||||
"output."),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(
|
||||
display_name="metric_depth",
|
||||
tooltip="[B, 1536, 1536, 3] per-pixel metric depth from "
|
||||
"SHARP's disparity head at NATIVE 1536² resolution "
|
||||
"(`disparity_factor / monodepth_output.disparity`). "
|
||||
"Unlike the layer_depth_refined outputs (gaussian z "
|
||||
"with sub-pixel xy drift), this is a true per-pixel "
|
||||
"depth map — wire with `extrinsics_mdepth + "
|
||||
"intrinsics_mdepth` into SharpDepthMerge for a "
|
||||
"seam-free LSMR merge that matches MoGe2-style "
|
||||
"smoothness. Memory: 42 faces × 1536² fp32 ≈ 395 MB."),
|
||||
io.Custom("EXTRINSICS").Output(
|
||||
display_name="extrinsics_mdepth",
|
||||
tooltip="Pass-through of input extrinsics. Extrinsics are "
|
||||
"resolution-independent (world-to-camera transform); "
|
||||
"this is just the same matrix paired with metric_depth "
|
||||
"so workflows can wire the metric-depth path as a "
|
||||
"self-contained triplet."),
|
||||
io.Custom("INTRINSICS").Output(
|
||||
display_name="intrinsics_mdepth",
|
||||
tooltip="Intrinsics rescaled to the 1536² metric_depth grid "
|
||||
"(pixel-K). Pairs with `metric_depth` so the "
|
||||
"downstream SharpDepthMerge's K/depth-shape "
|
||||
"invariant holds without conversion."),
|
||||
io.Image.Output(
|
||||
display_name="first_layer_depth_refined",
|
||||
tooltip="[B, H, W, 3] depth broadcast across 3 channels. "
|
||||
"Layer-0 (visible surface) post-decoder z."),
|
||||
"Layer-0 (visible surface) post-decoder z. NOTE: "
|
||||
"this is the gaussian's z position; the gaussian's "
|
||||
"xy may have drifted from the source pixel grid. "
|
||||
"Use `metric_depth` instead for clean per-pixel depth."),
|
||||
io.Custom("MULTIBAND_IMAGE").Output(
|
||||
display_name="first_layer_gaussian_attrs",
|
||||
tooltip="[B, 14, H, W] all layer-0 gaussian attributes. "
|
||||
@@ -279,6 +314,7 @@ class SharpPredictGaussianAttrs(io.ComfyNode):
|
||||
first_attrs = []
|
||||
second_depths = []
|
||||
second_attrs = []
|
||||
metric_depths = [] # disparity-head depth, downsampled to gaussian grid
|
||||
|
||||
# Load model to GPU with the budget the existing predict path uses.
|
||||
internal_shape = (1536, 1536)
|
||||
@@ -334,6 +370,20 @@ class SharpPredictGaussianAttrs(io.ComfyNode):
|
||||
f_px = (width / 36.0) * max(0.1, float(focal_length_mm or 30.0))
|
||||
|
||||
disparity_factor = torch.tensor([f_px / width]).float().to(device)
|
||||
|
||||
# Disparity-head metric depth at SHARP's native 1536². Matches
|
||||
# predict_depth.py's convention: `disparity_factor /
|
||||
# monodepth_disparity`. Emitted at native res rather than
|
||||
# downsampled to the gaussian grid (768) so callers get the
|
||||
# full disparity-head detail; the paired `intrinsics_mdepth`
|
||||
# output below carries the matching pixel-K for 1536.
|
||||
monodepth_disparity = monodepth_output.disparity # [1, 1, 1536, 1536]
|
||||
metric_depth_full = (
|
||||
disparity_factor.view(1, 1, 1, 1)
|
||||
/ monodepth_disparity.clamp(min=1e-4)
|
||||
) # [1, 1, 1536, 1536]
|
||||
metric_depths.append(metric_depth_full[0, 0].cpu()) # [1536, 1536]
|
||||
|
||||
t1 = time.time()
|
||||
gaussians_ndc = predictor.decode(monodepth_output, image_resized_pt, disparity_factor)
|
||||
log.debug(f" [{b}] decode time: {time.time() - t1:.2f}s")
|
||||
@@ -358,54 +408,78 @@ class SharpPredictGaussianAttrs(io.ComfyNode):
|
||||
d1_batch = torch.cat(second_depths, dim=0)
|
||||
attrs0_batch = torch.cat(first_attrs, dim=0) # [B, H, W, 14]
|
||||
attrs1_batch = torch.cat(second_attrs, dim=0)
|
||||
metric_batch = torch.stack(metric_depths, dim=0) # [B, H, W]
|
||||
|
||||
# IMAGE format: [B, H, W, 3] depth broadcast over channels.
|
||||
d0_img = d0_batch.unsqueeze(-1).expand(-1, -1, -1, 3).contiguous()
|
||||
d1_img = d1_batch.unsqueeze(-1).expand(-1, -1, -1, 3).contiguous()
|
||||
metric_img = metric_batch.unsqueeze(-1).expand(-1, -1, -1, 3).contiguous()
|
||||
|
||||
# MULTIBAND_IMAGE: dict with samples [B, 14, H, W] + channel_names.
|
||||
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]
|
||||
# Rescale intrinsics so pixel-K matches the emitted depth grid for
|
||||
# each output pair. The downstream merger normalizes K by the depth
|
||||
# tensor's W; if K's native res doesn't match that W, the effective
|
||||
# FOV is wrong → visible seams. We emit TWO K versions:
|
||||
# - intrinsics : K rescaled to (H_grid, W_grid) = 768²,
|
||||
# pairs with gaussian-z outputs.
|
||||
# - intrinsics_mdepth: K rescaled to (1536, 1536),
|
||||
# pairs with metric_depth.
|
||||
# extrinsics are resolution-independent — same tensor for both pairs.
|
||||
def _rescale_K(intr_in, target_w, target_h, src_w, src_h):
|
||||
sx = float(target_w) / float(src_w)
|
||||
sy = float(target_h) / float(src_h)
|
||||
k = intr_in.detach().clone().float() if isinstance(intr_in, torch.Tensor) \
|
||||
else torch.as_tensor(np.asarray(intr_in)).float().clone()
|
||||
if k.dim() == 2:
|
||||
k[0, :] *= sx
|
||||
k[1, :] *= sy
|
||||
k_dbg = k
|
||||
elif k.dim() == 3:
|
||||
k[:, 0, :] *= sx
|
||||
k[:, 1, :] *= sy
|
||||
k_dbg = k[0]
|
||||
else:
|
||||
raise ValueError(f"intrinsics must be (3,3) or (N,3,3), got {tuple(intr.shape)}")
|
||||
intrinsics_out = intr
|
||||
k_fx_dbg = float(k_dbg[0, 0])
|
||||
raise ValueError(f"intrinsics must be (3,3) or (N,3,3), got {tuple(k.shape)}")
|
||||
return k, float(k_dbg[0, 0])
|
||||
|
||||
if intrinsics is not None:
|
||||
# Gaussian-pair K (768²)
|
||||
intrinsics_out, k_fx_dbg = _rescale_K(
|
||||
intrinsics, W_grid, H_grid, width, height,
|
||||
)
|
||||
# Metric-depth-pair K (1536²)
|
||||
intrinsics_mdepth_out, k_fx_md_dbg = _rescale_K(
|
||||
intrinsics, internal_shape[1], internal_shape[0], width, height,
|
||||
)
|
||||
else:
|
||||
intrinsics_out = None
|
||||
intrinsics_mdepth_out = None
|
||||
k_fx_dbg = None
|
||||
k_fx_md_dbg = None
|
||||
extrinsics_mdepth_out = extrinsics # resolution-independent pass-through
|
||||
|
||||
n_gaussians_total = B * num_layers * H_grid * W_grid
|
||||
elapsed = time.time() - t_start
|
||||
d0_med = float(d0_batch.median())
|
||||
d1_med = float(d1_batch.median())
|
||||
k_str = f", K fx→{k_fx_dbg:.1f}" if k_fx_dbg is not None else ""
|
||||
log.info(
|
||||
f"[SharpPredictGaussianAttrs] {B} face(s) → "
|
||||
f"{n_gaussians_total/1e6:.2f}M gaussians "
|
||||
m_med = float(metric_batch.median())
|
||||
k_str = (
|
||||
f", K_{H_grid} fx→{k_fx_dbg:.1f} K_{internal_shape[0]} fx→{k_fx_md_dbg:.1f}"
|
||||
if k_fx_dbg is not None else ""
|
||||
)
|
||||
_p(
|
||||
f"{B} face(s) → {n_gaussians_total/1e6:.2f}M gaussians "
|
||||
f"({B}×{num_layers}×{H_grid}²); "
|
||||
f"layer0/1 depth median={d0_med:.2f}m/{d1_med:.2f}m"
|
||||
f"metric/layer0/layer1 depth median={m_med:.2f}m/{d0_med:.2f}m/{d1_med:.2f}m"
|
||||
f"{k_str}; {elapsed:.1f}s"
|
||||
)
|
||||
|
||||
return io.NodeOutput(
|
||||
metric_img,
|
||||
extrinsics_mdepth_out, intrinsics_mdepth_out,
|
||||
d0_img, l0_mb, d1_img, l1_mb,
|
||||
extrinsics, intrinsics_out,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""SharpPredictGaussiansFromMetricDepth — image + external depth → PLY paths.
|
||||
|
||||
Like SharpPredict, but feeds an externally-provided metric depth into
|
||||
`predictor.decode(..., depth=...)`. The decoder's depth_alignment module
|
||||
(`model.py:2023`) blends the raw monodepth with the external depth before
|
||||
init_model initializes gaussian base values, so the resulting gaussians
|
||||
share a consistent geometric scaffold across multiple views — fixes the
|
||||
per-face seams caused by per-view monocular scale ambiguity.
|
||||
|
||||
Output convention matches SharpPredict:
|
||||
- Single image → single PLY file at OUTPUT_DIR/{prefix}_{ts}.ply
|
||||
- Batch (>1) → folder OUTPUT_DIR/{prefix}_{ts}/ with NNN.ply files
|
||||
|
||||
Wire the batch folder straight into `MergeGaussians (PLY Files)` for a
|
||||
single merged scene PLY.
|
||||
|
||||
Typical pipeline:
|
||||
1. SharpPanoramaIcosahedronSplit → face images
|
||||
2. SharpPredictMetricDepth (per face) → raw per-face metric depths
|
||||
3. SharpDepthMerge → seam-free equirect depth (LSMR)
|
||||
4. SharpPanoramaIcosahedronSplit (on the merged depth IMAGE)
|
||||
→ re-cropped CONSISTENT face depths
|
||||
5. SharpPredictGaussiansFromMetricDepth(face_images, consistent_face_depths)
|
||||
→ folder of seam-aligned PLYs
|
||||
6. MergeGaussians → final unified PLY
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from comfy_api.latest import io
|
||||
|
||||
from .predict_gaussian_attrs import (
|
||||
_compute_image_hash, _monodepth_to, _encode_cache,
|
||||
)
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
OUTPUT_DIR = folder_paths.get_output_directory()
|
||||
except ImportError:
|
||||
OUTPUT_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "output",
|
||||
)
|
||||
|
||||
|
||||
def _p(msg: str) -> None:
|
||||
print(f"[SharpPredictGaussiansFromMetricDepth] {msg}",
|
||||
file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
class SharpPredictGaussiansFromMetricDepth(io.ComfyNode):
|
||||
"""SHARP gaussian decoder driven by external depth → PLY paths."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SharpPredictGaussiansFromMetricDepth",
|
||||
display_name="SHARP Predict Gaussians (Image + Metric Depth)",
|
||||
category="SHARP",
|
||||
description=(
|
||||
"Run SHARP's gaussian decoder using an externally-provided "
|
||||
"metric depth as the alignment target. The external depth "
|
||||
"becomes the geometric scaffold for `init_model`'s gaussian "
|
||||
"base values, so gaussians share a consistent surface across "
|
||||
"multiple views — fixes per-face seams.\n\n"
|
||||
"Saves PLYs (single image → one .ply; batch → folder of "
|
||||
"NNN.ply). Pipe into MergeGaussians to combine into one PLY."
|
||||
),
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
io.Custom("SHARP_MODEL_CONFIG").Input("model"),
|
||||
io.Image.Input("image"),
|
||||
io.Image.Input(
|
||||
"metric_depth",
|
||||
tooltip="External per-face metric depth (any resolution; "
|
||||
"auto-resized to SHARP's internal 1536² before "
|
||||
"alignment). Typically: SharpPredictMetricDepth "
|
||||
"→ SharpDepthMerge → SharpPanoramaIcosahedronSplit "
|
||||
"(depth as IMAGE)."),
|
||||
io.Float.Input(
|
||||
"focal_length_mm", default=30.0, min=0.0, max=500.0,
|
||||
step=0.1, optional=True,
|
||||
tooltip="Focal length in mm (35mm equiv). Ignored if "
|
||||
"intrinsics provided."),
|
||||
io.String.Input(
|
||||
"output_prefix", default="sharp_aligned", optional=True,
|
||||
tooltip="Prefix for PLY filename or batch folder name."),
|
||||
io.Boolean.Input(
|
||||
"save_background_layer", default=True, optional=True,
|
||||
tooltip=(
|
||||
"SHARP emits 2 gaussian layers per pixel: layer 0 "
|
||||
"(visible/front surface) and layer 1 (back/occluded). "
|
||||
"True (default): save both layers — 2× the gaussian "
|
||||
"count, captures hidden surfaces revealed by parallax. "
|
||||
"False: save only layer 0 — halves the gaussian count "
|
||||
"(768² per face instead of 768²×2). Use False when "
|
||||
"you'll voxel-dedup downstream anyway, or when the "
|
||||
"scene has minimal occlusion (open outdoor)."
|
||||
)),
|
||||
io.Custom("EXTRINSICS").Input(
|
||||
"extrinsics", optional=True,
|
||||
tooltip="Per-face extrinsics from "
|
||||
"SharpPanoramaIcosahedronSplit. Must match "
|
||||
"image batch size if batched."),
|
||||
io.Custom("INTRINSICS").Input(
|
||||
"intrinsics", optional=True,
|
||||
tooltip="Per-face intrinsics from "
|
||||
"SharpPanoramaIcosahedronSplit (pixel-K for the "
|
||||
"ORIGINAL face image resolution, not the merged "
|
||||
"depth resolution). If absent, focal_length_mm "
|
||||
"is used."),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(
|
||||
display_name="ply_path",
|
||||
tooltip="Single image: path to {prefix}_{ts}.ply. "
|
||||
"Batch: path to the folder (identical to "
|
||||
"ply_folder output)."),
|
||||
io.String.Output(
|
||||
display_name="ply_folder",
|
||||
tooltip="Always the containing folder of the PLY(s): "
|
||||
"OUTPUT_DIR/{prefix}_{ts}/ for batch, or the "
|
||||
"parent dir for single-image runs. Wire into "
|
||||
"MergeGaussians.ply_folder regardless of batch size."),
|
||||
io.Custom("EXTRINSICS").Output(display_name="extrinsics"),
|
||||
io.Custom("INTRINSICS").Output(display_name="intrinsics"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@torch.no_grad()
|
||||
def execute(
|
||||
cls,
|
||||
model,
|
||||
image: torch.Tensor,
|
||||
metric_depth: torch.Tensor,
|
||||
focal_length_mm: float = 30.0,
|
||||
output_prefix: str = "sharp_aligned",
|
||||
save_background_layer: bool = True,
|
||||
extrinsics: torch.Tensor | None = None,
|
||||
intrinsics: torch.Tensor | None = None,
|
||||
):
|
||||
global _encode_cache
|
||||
import comfy.model_management
|
||||
import comfy.utils
|
||||
from .load_model import _load_sharp_model
|
||||
from .sharp.gaussians import save_ply, unproject_gaussians
|
||||
|
||||
patcher = _load_sharp_model(model)
|
||||
predictor = patcher.model
|
||||
device = patcher.load_device
|
||||
|
||||
if image.dim() == 3:
|
||||
image = image.unsqueeze(0)
|
||||
B = image.shape[0]
|
||||
|
||||
if metric_depth.dim() == 3:
|
||||
metric_depth = metric_depth.unsqueeze(0)
|
||||
if metric_depth.shape[0] != B:
|
||||
raise ValueError(
|
||||
f"metric_depth batch {metric_depth.shape[0]} != image batch {B}"
|
||||
)
|
||||
|
||||
# Sanity-check the alignment is wired through the model.
|
||||
scale_map_estimator = predictor.depth_alignment.scale_map_estimator
|
||||
if scale_map_estimator is None:
|
||||
_p("WARN: scale_map_estimator not in model; depth_alignment may "
|
||||
"no-op and gaussians may not benefit from the external depth.")
|
||||
|
||||
internal_shape = (1536, 1536)
|
||||
H_grid = W_grid = int(predictor.output_resolution) # typ. 768
|
||||
input_shape = [1, 3, internal_shape[0], internal_shape[1]]
|
||||
memory_required = patcher.memory_required(input_shape)
|
||||
comfy.model_management.load_models_gpu(
|
||||
[patcher], memory_required=memory_required,
|
||||
)
|
||||
|
||||
# Output path(s) — same convention as SharpPredict.
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
timestamp = int(time.time() * 1000)
|
||||
if B == 1:
|
||||
output_path = os.path.join(OUTPUT_DIR, f"{output_prefix}_{timestamp}.ply")
|
||||
output_folder = None
|
||||
is_batch = False
|
||||
else:
|
||||
output_folder = os.path.join(OUTPUT_DIR, f"{output_prefix}_{timestamp}")
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
output_path = output_folder
|
||||
is_batch = True
|
||||
|
||||
all_ply_paths = []
|
||||
all_extrinsics = []
|
||||
all_intrinsics = []
|
||||
|
||||
pbar = comfy.utils.ProgressBar(B)
|
||||
t_start = time.time()
|
||||
n_gaussians_total = 0
|
||||
|
||||
for b in range(B):
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
|
||||
img_np = image[b].cpu().numpy() if isinstance(image, torch.Tensor) else np.asarray(image[b])
|
||||
if img_np.dtype != np.uint8:
|
||||
img_np = (np.clip(img_np, 0, 1) * 255 + 0.5).astype(np.uint8)
|
||||
height, width = img_np.shape[:2]
|
||||
image_hash = _compute_image_hash(img_np)
|
||||
|
||||
# Encode (shared cache).
|
||||
if _encode_cache["image_hash"] == image_hash:
|
||||
monodepth_output = _monodepth_to(_encode_cache["monodepth_output"], device)
|
||||
image_resized_pt = _encode_cache["image_resized"].to(device)
|
||||
else:
|
||||
_encode_cache["image_hash"] = None
|
||||
image_pt = (
|
||||
torch.from_numpy(img_np.copy()).float().to(device).permute(2, 0, 1) / 255.0
|
||||
)
|
||||
image_resized_pt = F.interpolate(
|
||||
image_pt[None],
|
||||
size=(internal_shape[1], internal_shape[0]),
|
||||
mode="bilinear", align_corners=True,
|
||||
)
|
||||
monodepth_output, _ = predictor.encode(image_resized_pt)
|
||||
_encode_cache["image_hash"] = image_hash
|
||||
_encode_cache["monodepth_output"] = _monodepth_to(monodepth_output, "cpu")
|
||||
_encode_cache["image_resized"] = image_resized_pt.cpu()
|
||||
_encode_cache["original_shape"] = (height, width)
|
||||
comfy.model_management.soft_empty_cache()
|
||||
|
||||
# Focal-length resolution. intrinsics is pixel-K for `width` (the
|
||||
# face image resolution). disparity_factor matches SharpPredict.
|
||||
if intrinsics is not None:
|
||||
intr_b = intrinsics[b] if intrinsics.dim() == 3 else intrinsics
|
||||
f_px = float(intr_b[0, 0]) * (internal_shape[0] / width)
|
||||
else:
|
||||
f_px = (width / 36.0) * max(0.1, float(focal_length_mm or 30.0))
|
||||
disparity_factor = torch.tensor([f_px / width]).float().to(device)
|
||||
|
||||
# External depth → 1536² (alignment target).
|
||||
md_b = metric_depth[b]
|
||||
if md_b.dim() == 3:
|
||||
md_b = md_b[..., 0]
|
||||
md_b = md_b.to(device).float().unsqueeze(0).unsqueeze(0)
|
||||
external_depth = F.interpolate(
|
||||
md_b, size=internal_shape, mode="bilinear", align_corners=True,
|
||||
)
|
||||
|
||||
# Decode WITH depth alignment.
|
||||
gaussians_ndc = predictor.decode(
|
||||
monodepth_output, image_resized_pt, disparity_factor,
|
||||
depth=external_depth,
|
||||
)
|
||||
|
||||
# Build extrinsics + intrinsics_resized for unprojection.
|
||||
if extrinsics is not None:
|
||||
ext_b = extrinsics[b] if extrinsics.dim() == 3 else extrinsics
|
||||
unproj_extrinsics = ext_b.to(device).float()
|
||||
else:
|
||||
unproj_extrinsics = torch.eye(4, device=device)
|
||||
|
||||
if intrinsics is not None:
|
||||
intr_b = intrinsics[b] if intrinsics.dim() == 3 else intrinsics
|
||||
K = intr_b.to(device).float().clone()
|
||||
# Promote to 4x4 if it came as 3x3 (unproject_gaussians wants 4x4).
|
||||
if K.shape == (3, 3):
|
||||
K4 = torch.eye(4, device=device, dtype=K.dtype)
|
||||
K4[:3, :3] = K
|
||||
K = K4
|
||||
else:
|
||||
K = torch.tensor(
|
||||
[
|
||||
[f_px, 0, width / 2, 0],
|
||||
[0, f_px, height / 2, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1],
|
||||
],
|
||||
dtype=torch.float32, device=device,
|
||||
)
|
||||
intrinsics_resized = K.clone()
|
||||
intrinsics_resized[0] *= internal_shape[0] / width
|
||||
intrinsics_resized[1] *= internal_shape[1] / height
|
||||
|
||||
gaussians = unproject_gaussians(
|
||||
gaussians_ndc, unproj_extrinsics, intrinsics_resized, internal_shape,
|
||||
)
|
||||
|
||||
# Optionally drop layer 1 (back/occluded surface).
|
||||
# The gaussian_composer's flatten order is (layer, h, w), so the
|
||||
# first H_grid*W_grid entries are layer 0, the second are layer 1.
|
||||
if not save_background_layer:
|
||||
n_layer0 = H_grid * W_grid
|
||||
gaussians = gaussians._replace(
|
||||
mean_vectors=gaussians.mean_vectors[:, :n_layer0],
|
||||
singular_values=gaussians.singular_values[:, :n_layer0],
|
||||
quaternions=gaussians.quaternions[:, :n_layer0],
|
||||
colors=gaussians.colors[:, :n_layer0],
|
||||
opacities=gaussians.opacities[:, :n_layer0],
|
||||
)
|
||||
|
||||
# Save PLY.
|
||||
if is_batch:
|
||||
ply_path = os.path.join(output_folder, f"{b+1:03d}.ply")
|
||||
else:
|
||||
ply_path = output_path
|
||||
_, metadata = save_ply(gaussians, f_px, (height, width), Path(ply_path))
|
||||
|
||||
all_ply_paths.append(ply_path)
|
||||
all_extrinsics.append(metadata["extrinsic"])
|
||||
all_intrinsics.append(metadata["intrinsic"])
|
||||
n_gaussians_total += int(metadata["num_gaussians"])
|
||||
pbar.update(1)
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
loc = output_folder if is_batch else output_path
|
||||
layer_str = "layer0 only" if not save_background_layer else "both layers"
|
||||
_p(
|
||||
f"{B} face(s) → {n_gaussians_total/1e6:.2f}M gaussians total "
|
||||
f"({layer_str}, depth-aligned); saved to {loc}; {elapsed:.1f}s"
|
||||
)
|
||||
|
||||
# ply_folder is always the directory holding the PLYs (regardless
|
||||
# of single/batch), so downstream MergeGaussians can wire to it
|
||||
# without caring about batch size.
|
||||
ply_folder_out = output_folder if is_batch else os.path.dirname(output_path)
|
||||
|
||||
return io.NodeOutput(
|
||||
output_path, ply_folder_out, all_extrinsics[0], all_intrinsics[0],
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SharpPredictGaussiansFromMetricDepth": SharpPredictGaussiansFromMetricDepth,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SharpPredictGaussiansFromMetricDepth": "SHARP Predict Gaussians (Image + Metric Depth)",
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""SharpPredictMetricDepth — image → disparity-head metric depth only.
|
||||
|
||||
Tap SHARP's encoder + disparity head; skip the gaussian decoder entirely.
|
||||
Fastest path to a clean per-pixel depth map at SHARP's native 1536²
|
||||
resolution, paired with extrinsics_mdepth (pass-through) and
|
||||
intrinsics_mdepth (pixel-K rescaled to 1536). The triplet wires straight
|
||||
into SharpDepthMerge with no K/depth-shape mismatch.
|
||||
|
||||
Why separate from SharpPredictGaussianAttrs:
|
||||
- ~2× faster per face (no init_model + feature_model + prediction_head
|
||||
forward passes).
|
||||
- Lower VRAM (skip decoder allocs).
|
||||
- Cleaner workflows where you only want depth (mesh build, navmesh,
|
||||
downstream depth-aligned gaussian prediction, etc.).
|
||||
|
||||
Shares `_encode_cache` with SharpPredictGaussianAttrs so encoding the
|
||||
same image across both nodes is a single forward pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from comfy_api.latest import io
|
||||
|
||||
from .predict_gaussian_attrs import (
|
||||
_compute_image_hash, _monodepth_to, _encode_cache,
|
||||
)
|
||||
|
||||
|
||||
def _p(msg: str) -> None:
|
||||
print(f"[SharpPredictMetricDepth] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _rescale_K(intr_in, target_w, target_h, src_w, src_h):
|
||||
sx = float(target_w) / float(src_w)
|
||||
sy = float(target_h) / float(src_h)
|
||||
k = intr_in.detach().clone().float() if isinstance(intr_in, torch.Tensor) \
|
||||
else torch.as_tensor(np.asarray(intr_in)).float().clone()
|
||||
if k.dim() == 2:
|
||||
k[0, :] *= sx
|
||||
k[1, :] *= sy
|
||||
k_dbg = k
|
||||
elif k.dim() == 3:
|
||||
k[:, 0, :] *= sx
|
||||
k[:, 1, :] *= sy
|
||||
k_dbg = k[0]
|
||||
else:
|
||||
raise ValueError(f"intrinsics must be (3,3) or (N,3,3), got {tuple(k.shape)}")
|
||||
return k, float(k_dbg[0, 0])
|
||||
|
||||
|
||||
class SharpPredictMetricDepth(io.ComfyNode):
|
||||
"""SHARP encoder + disparity head only → per-pixel metric depth at 1536²."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SharpPredictMetricDepth",
|
||||
display_name="SHARP Predict Metric Depth",
|
||||
category="SHARP",
|
||||
description=(
|
||||
"Run only SHARP's encoder + disparity head (skip the gaussian "
|
||||
"decoder). Outputs per-pixel metric depth at native 1536² + "
|
||||
"paired extrinsics/intrinsics. ~2× faster than "
|
||||
"SharpPredictGaussianAttrs when you only want depth. Wire "
|
||||
"into SharpDepthMerge for clean equirect depth merge."
|
||||
),
|
||||
inputs=[
|
||||
io.Custom("SHARP_MODEL_CONFIG").Input("model"),
|
||||
io.Image.Input("image"),
|
||||
io.Float.Input(
|
||||
"focal_length_mm", default=30.0, min=0.0, max=500.0,
|
||||
step=0.1, optional=True,
|
||||
tooltip="Focal length in mm (35mm equiv). Ignored if "
|
||||
"intrinsics provided."),
|
||||
io.Custom("EXTRINSICS").Input(
|
||||
"extrinsics", optional=True,
|
||||
tooltip="Pass-through to extrinsics_mdepth output."),
|
||||
io.Custom("INTRINSICS").Input(
|
||||
"intrinsics", optional=True,
|
||||
tooltip="If provided, overrides focal_length_mm. "
|
||||
"Re-emitted on intrinsics_mdepth rescaled to 1536²."),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(
|
||||
display_name="metric_depth",
|
||||
tooltip="[B, 1536, 1536, 3] disparity-head metric depth "
|
||||
"at SHARP's native resolution."),
|
||||
io.Custom("EXTRINSICS").Output(
|
||||
display_name="extrinsics_mdepth",
|
||||
tooltip="Pass-through of input extrinsics (resolution-"
|
||||
"independent)."),
|
||||
io.Custom("INTRINSICS").Output(
|
||||
display_name="intrinsics_mdepth",
|
||||
tooltip="Intrinsics rescaled to the 1536² metric_depth grid."),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@torch.no_grad()
|
||||
def execute(
|
||||
cls,
|
||||
model,
|
||||
image: torch.Tensor,
|
||||
focal_length_mm: float = 30.0,
|
||||
extrinsics: torch.Tensor | None = None,
|
||||
intrinsics: torch.Tensor | None = None,
|
||||
):
|
||||
global _encode_cache
|
||||
import comfy.model_management
|
||||
from .load_model import _load_sharp_model
|
||||
|
||||
patcher = _load_sharp_model(model)
|
||||
predictor = patcher.model
|
||||
device = patcher.load_device
|
||||
|
||||
if image.dim() == 3:
|
||||
image = image.unsqueeze(0)
|
||||
B = image.shape[0]
|
||||
t_start = time.time()
|
||||
|
||||
internal_shape = (1536, 1536)
|
||||
input_shape = [1, 3, internal_shape[0], internal_shape[1]]
|
||||
memory_required = patcher.memory_required(input_shape)
|
||||
comfy.model_management.load_models_gpu(
|
||||
[patcher], memory_required=memory_required,
|
||||
)
|
||||
|
||||
metric_depths = []
|
||||
last_width = last_height = None
|
||||
for b in range(B):
|
||||
img_np = image[b].cpu().numpy() if isinstance(image, torch.Tensor) else np.asarray(image[b])
|
||||
if img_np.dtype != np.uint8:
|
||||
img_np = (np.clip(img_np, 0, 1) * 255 + 0.5).astype(np.uint8)
|
||||
height, width = img_np.shape[:2]
|
||||
last_width, last_height = width, height
|
||||
image_hash = _compute_image_hash(img_np)
|
||||
|
||||
if _encode_cache["image_hash"] == image_hash:
|
||||
monodepth_output = _monodepth_to(_encode_cache["monodepth_output"], device)
|
||||
else:
|
||||
_encode_cache["image_hash"] = None
|
||||
image_pt = (
|
||||
torch.from_numpy(img_np.copy()).float().to(device).permute(2, 0, 1) / 255.0
|
||||
)
|
||||
image_resized_pt = F.interpolate(
|
||||
image_pt[None],
|
||||
size=(internal_shape[1], internal_shape[0]),
|
||||
mode="bilinear", align_corners=True,
|
||||
)
|
||||
monodepth_output, _ = predictor.encode(image_resized_pt)
|
||||
_encode_cache["image_hash"] = image_hash
|
||||
_encode_cache["monodepth_output"] = _monodepth_to(monodepth_output, "cpu")
|
||||
_encode_cache["image_resized"] = image_resized_pt.cpu()
|
||||
_encode_cache["original_shape"] = (height, width)
|
||||
comfy.model_management.soft_empty_cache()
|
||||
|
||||
if intrinsics is not None:
|
||||
intr_b = intrinsics[b] if intrinsics.dim() == 3 else intrinsics
|
||||
f_px = float(intr_b[0, 0]) * (internal_shape[0] / width)
|
||||
else:
|
||||
f_px = (width / 36.0) * max(0.1, float(focal_length_mm or 30.0))
|
||||
|
||||
disparity_factor_scalar = f_px / width
|
||||
monodepth_disparity = monodepth_output.disparity # [1, 1, 1536, 1536]
|
||||
metric_depth_full = (
|
||||
disparity_factor_scalar / monodepth_disparity.clamp(min=1e-4)
|
||||
) # [1, 1, 1536, 1536]
|
||||
metric_depths.append(metric_depth_full[0, 0].cpu()) # [1536, 1536]
|
||||
|
||||
metric_batch = torch.stack(metric_depths, dim=0) # [B, 1536, 1536]
|
||||
metric_img = metric_batch.unsqueeze(-1).expand(-1, -1, -1, 3).contiguous()
|
||||
|
||||
if intrinsics is not None:
|
||||
intrinsics_mdepth_out, k_fx_md_dbg = _rescale_K(
|
||||
intrinsics, internal_shape[1], internal_shape[0],
|
||||
last_width, last_height,
|
||||
)
|
||||
else:
|
||||
intrinsics_mdepth_out = None
|
||||
k_fx_md_dbg = None
|
||||
extrinsics_mdepth_out = extrinsics
|
||||
|
||||
m_med = float(metric_batch.median())
|
||||
elapsed = time.time() - t_start
|
||||
k_str = f", K_{internal_shape[0]} fx→{k_fx_md_dbg:.1f}" if k_fx_md_dbg is not None else ""
|
||||
_p(
|
||||
f"{B} face(s) @ {internal_shape[0]}²; "
|
||||
f"metric depth median={m_med:.2f}m{k_str}; {elapsed:.1f}s"
|
||||
)
|
||||
|
||||
return io.NodeOutput(
|
||||
metric_img, extrinsics_mdepth_out, intrinsics_mdepth_out,
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SharpPredictMetricDepth": SharpPredictMetricDepth}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SharpPredictMetricDepth": "SHARP Predict Metric Depth"}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user