This commit is contained in:
Andrea Pozzetti
2026-02-20 20:24:46 +01:00
parent d34ae336d4
commit 4091f89e29
31 changed files with 2298 additions and 69 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

+2 -1
View File
@@ -1 +1,2 @@
[node_reqs]
[node_reqs]
ComfyUI-GeometryPack = "PozzettiAndrea/ComfyUI-GeometryPack"
+20
View File
@@ -6,6 +6,16 @@ from .predict import NODE_CLASS_MAPPINGS as PREDICT_MAPPINGS
from .predict import NODE_DISPLAY_NAME_MAPPINGS as PREDICT_DISPLAY_MAPPINGS
from .load_image_exif import NODE_CLASS_MAPPINGS as LOAD_IMAGE_EXIF_MAPPINGS
from .load_image_exif import NODE_DISPLAY_NAME_MAPPINGS as LOAD_IMAGE_EXIF_DISPLAY_MAPPINGS
from .sample_panorama import NODE_CLASS_MAPPINGS as SAMPLE_PANORAMA_MAPPINGS
from .sample_panorama import NODE_DISPLAY_NAME_MAPPINGS as SAMPLE_PANORAMA_DISPLAY_MAPPINGS
from .merge_gaussians import NODE_CLASS_MAPPINGS as MERGE_GAUSSIANS_MAPPINGS
from .merge_gaussians import NODE_DISPLAY_NAME_MAPPINGS as MERGE_GAUSSIANS_DISPLAY_MAPPINGS
from .predict_depth import NODE_CLASS_MAPPINGS as PREDICT_DEPTH_MAPPINGS
from .predict_depth import NODE_DISPLAY_NAME_MAPPINGS as PREDICT_DEPTH_DISPLAY_MAPPINGS
from .project_depth_to_panorama import NODE_CLASS_MAPPINGS as PROJECT_DEPTH_MAPPINGS
from .project_depth_to_panorama import NODE_DISPLAY_NAME_MAPPINGS as PROJECT_DEPTH_DISPLAY_MAPPINGS
from .align_depth_maps import NODE_CLASS_MAPPINGS as ALIGN_DEPTH_MAPPINGS
from .align_depth_maps import NODE_DISPLAY_NAME_MAPPINGS as ALIGN_DEPTH_DISPLAY_MAPPINGS
# Aggregate all node mappings
NODE_CLASS_MAPPINGS = {}
@@ -14,9 +24,19 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(LOAD_MODEL_MAPPINGS)
NODE_CLASS_MAPPINGS.update(PREDICT_MAPPINGS)
NODE_CLASS_MAPPINGS.update(LOAD_IMAGE_EXIF_MAPPINGS)
NODE_CLASS_MAPPINGS.update(SAMPLE_PANORAMA_MAPPINGS)
NODE_CLASS_MAPPINGS.update(MERGE_GAUSSIANS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(PREDICT_DEPTH_MAPPINGS)
NODE_CLASS_MAPPINGS.update(PROJECT_DEPTH_MAPPINGS)
NODE_CLASS_MAPPINGS.update(ALIGN_DEPTH_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(LOAD_MODEL_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(LOAD_IMAGE_EXIF_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(SAMPLE_PANORAMA_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(MERGE_GAUSSIANS_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PREDICT_DEPTH_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PROJECT_DEPTH_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(ALIGN_DEPTH_DISPLAY_MAPPINGS)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+664
View File
@@ -0,0 +1,664 @@
"""AlignDepthMaps node for ComfyUI-Sharp.
RANSAC-based scale-shift alignment to make depth maps globally consistent
across multiple perspective views before panorama projection.
"""
import json
import math
from collections import deque
import torch
import torch.nn.functional as F
def extrinsics_to_direction(extrinsics: torch.Tensor) -> torch.Tensor:
"""Get the viewing direction (forward vector) from extrinsics.
Args:
extrinsics: [4, 4] world-to-camera matrix
Returns:
[3] normalized direction vector in world space
"""
# Camera-to-world is inverse of world-to-camera
# For pure rotation, inverse = transpose
R_c2w = extrinsics[:3, :3].T
# Camera looks along +Z in camera space
forward = R_c2w @ torch.tensor([0., 0., 1.], device=extrinsics.device)
return forward
def compute_view_overlap(
ext_i: torch.Tensor,
ext_j: torch.Tensor,
half_fov: float,
) -> float:
"""Compute approximate overlap fraction between two views.
Uses angular distance between view centers as a proxy for overlap.
Args:
ext_i, ext_j: [4, 4] extrinsics matrices
half_fov: Half field of view in radians
Returns:
Overlap fraction [0, 1], where 1 = complete overlap
"""
dir_i = extrinsics_to_direction(ext_i)
dir_j = extrinsics_to_direction(ext_j)
# Angular distance between view centers
dot = torch.clamp(torch.dot(dir_i, dir_j), -1, 1)
angle = torch.acos(dot).item()
# If angle < 2*half_fov, there's overlap
# Overlap fraction decreases linearly from 1 (same direction) to 0 (2*half_fov apart)
max_angle = 2 * half_fov
if angle >= max_angle:
return 0.0
return 1.0 - angle / max_angle
def compute_overlap_mask(
depth_h: int,
depth_w: int,
ext_i: torch.Tensor,
ext_j: torch.Tensor,
intrinsics: torch.Tensor,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Compute which pixels in view i overlap with view j and vice versa.
Returns corresponding pixel coordinates for RANSAC matching.
Args:
depth_h, depth_w: Depth map dimensions
ext_i, ext_j: [4, 4] extrinsics matrices
intrinsics: [4, 4] intrinsics matrix
device: Torch device
Returns:
Tuple of (mask_i, mask_j, coords_i, coords_j) where:
- mask_i: [H, W] bool mask of pixels in i that overlap with j
- mask_j: [H, W] bool mask of pixels in j that overlap with i
- coords_i: [N, 2] pixel coordinates in view i
- coords_j: [N, 2] corresponding pixel coordinates in view j
"""
f_px = intrinsics[0, 0].item()
cx = intrinsics[0, 2].item()
cy = intrinsics[1, 2].item()
# Create pixel grid for view i
u = torch.arange(depth_w, dtype=torch.float32, device=device)
v = torch.arange(depth_h, dtype=torch.float32, device=device)
uu, vv = torch.meshgrid(u, v, indexing='xy')
# Convert to camera-space ray directions
dx = (uu - cx) / f_px
dy = (vv - cy) / f_px
dz = torch.ones_like(dx)
rays_cam = torch.stack([dx, dy, dz], dim=-1)
rays_cam = F.normalize(rays_cam, dim=-1)
# Rotate to world space using view i's camera-to-world
R_i_c2w = ext_i[:3, :3].T
rays_world = torch.einsum('ij,hwj->hwi', R_i_c2w, rays_cam)
# Transform world rays to view j's camera space
R_j_w2c = ext_j[:3, :3] # World-to-camera for view j
rays_j_cam = torch.einsum('ij,hwj->hwi', R_j_w2c, rays_world)
# Project to view j's image plane
# Only valid if z > 0 (in front of camera)
z_j = rays_j_cam[..., 2]
valid_z = z_j > 0.01
# Perspective projection
x_j = rays_j_cam[..., 0] / (z_j + 1e-6)
y_j = rays_j_cam[..., 1] / (z_j + 1e-6)
# Convert to pixel coordinates
u_j = x_j * f_px + cx
v_j = y_j * f_px + cy
# Check if projected point is within view j's image bounds
in_bounds = (u_j >= 0) & (u_j < depth_w) & (v_j >= 0) & (v_j < depth_h)
# Combined mask for view i
mask_i = valid_z & in_bounds
# Get pixel coordinates for matching
coords_i_y, coords_i_x = torch.where(mask_i)
coords_i = torch.stack([coords_i_x, coords_i_y], dim=1) # [N, 2] as (x, y)
# Corresponding coordinates in view j
u_j_valid = u_j[mask_i]
v_j_valid = v_j[mask_i]
coords_j = torch.stack([u_j_valid, v_j_valid], dim=1) # [N, 2] as (x, y)
# Create mask_j (approximate - mark pixels that have correspondences)
mask_j = torch.zeros(depth_h, depth_w, dtype=torch.bool, device=device)
u_j_int = coords_j[:, 0].long().clamp(0, depth_w - 1)
v_j_int = coords_j[:, 1].long().clamp(0, depth_h - 1)
mask_j[v_j_int, u_j_int] = True
return mask_i, mask_j, coords_i, coords_j
def median_scale_alignment(
depth1: torch.Tensor,
depth2: torch.Tensor,
coords1: torch.Tensor,
coords2: torch.Tensor,
debug: bool = True,
) -> tuple[float, float, int, dict]:
"""Find scale to align depth2 to depth1 using median ratio.
Solves: depth1 = scale * depth2 (NO SHIFT - prevents negative depths)
Args:
depth1: [H, W] reference depth map
depth2: [H, W] depth map to align
coords1: [N, 2] pixel coordinates in depth1 (x, y)
coords2: [N, 2] corresponding pixel coordinates in depth2 (x, y)
debug: Whether to print debug info
Returns:
Tuple of (scale, shift=0.0, num_valid, debug_info)
"""
n_points = coords1.shape[0]
debug_info = {"overlap_pixels": n_points}
if n_points < 10:
return 1.0, 0.0, 0, debug_info
# Sample depth values at corresponding locations
x1, y1 = coords1[:, 0].long(), coords1[:, 1].long()
x2, y2 = coords2[:, 0].long().clamp(0, depth2.shape[1] - 1), coords2[:, 1].long().clamp(0, depth2.shape[0] - 1)
d1 = depth1[y1, x1]
d2 = depth2[y2, x2]
# Filter out invalid depths (must be positive and finite)
valid = (d1 > 0.1) & (d2 > 0.1) & torch.isfinite(d1) & torch.isfinite(d2)
d1_valid = d1[valid]
d2_valid = d2[valid]
n_valid = len(d1_valid)
debug_info["valid_pixels"] = n_valid
debug_info["d1_range"] = (d1_valid.min().item(), d1_valid.max().item()) if n_valid > 0 else (0, 0)
debug_info["d2_range"] = (d2_valid.min().item(), d2_valid.max().item()) if n_valid > 0 else (0, 0)
if n_valid < 10:
return 1.0, 0.0, n_valid, debug_info
# Compute per-pixel scale ratios
ratios = d1_valid / d2_valid
# Debug: ratio statistics
debug_info["ratio_range"] = (ratios.min().item(), ratios.max().item())
debug_info["ratio_median"] = ratios.median().item()
debug_info["ratio_std"] = ratios.std().item()
# Use median ratio (robust to outliers)
scale = ratios.median().item()
# Constraint: keep scale in reasonable range [0.5, 2.0]
# If scale is outside this range, there's likely a problem
original_scale = scale
scale = max(0.5, min(2.0, scale))
debug_info["scale_clamped"] = (original_scale != scale)
debug_info["original_scale"] = original_scale
# Count "inliers" - pixels where aligned depth is within 10% of reference
aligned = scale * d2_valid
relative_error = torch.abs(d1_valid - aligned) / (d1_valid + 1e-6)
inliers = (relative_error < 0.1).sum().item()
debug_info["inlier_ratio"] = inliers / n_valid if n_valid > 0 else 0
return scale, 0.0, n_valid, debug_info # shift is always 0!
def build_adjacency_graph(
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
min_overlap: float = 0.1,
) -> dict[int, list[int]]:
"""Build a graph of which views overlap with each other.
Args:
extrinsics: [N, 4, 4] batch of extrinsics
intrinsics: [4, 4] shared intrinsics
min_overlap: Minimum overlap fraction to consider views adjacent
Returns:
Dictionary mapping view index to list of adjacent view indices
"""
num_views = extrinsics.shape[0]
f_px = intrinsics[0, 0].item()
cx = intrinsics[0, 2].item()
depth_size = int(cx * 2) # Derive from principal point (cx = width/2)
half_fov = math.atan((depth_size / 2) / f_px)
adjacency = {i: [] for i in range(num_views)}
for i in range(num_views):
for j in range(i + 1, num_views):
overlap = compute_view_overlap(extrinsics[i], extrinsics[j], half_fov)
if overlap >= min_overlap:
adjacency[i].append(j)
adjacency[j].append(i)
return adjacency
def compute_global_alignments(
depth_maps: torch.Tensor,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
reference_view: int,
num_iterations: int,
inlier_threshold: float,
) -> dict[int, tuple[float, float]]:
"""Compute alignment parameters for all views relative to reference.
Uses BFS to chain pairwise alignments through the view graph.
DEPRECATED: Use compute_global_alignments_optimized instead.
Args:
depth_maps: [N, H, W, C] depth maps
extrinsics: [N, 4, 4] extrinsics
intrinsics: [4, 4] intrinsics
reference_view: Index of reference view
num_iterations: RANSAC iterations
inlier_threshold: RANSAC inlier threshold
Returns:
Dictionary mapping view index to (scale, shift) tuple
"""
num_views = depth_maps.shape[0]
depth_h, depth_w = depth_maps.shape[1:3]
device = depth_maps.device
# Build adjacency graph
adjacency = build_adjacency_graph(extrinsics, intrinsics)
# BFS from reference view
alignments = {reference_view: (1.0, 0.0)} # Reference is unchanged
visited = {reference_view}
queue = deque([reference_view])
pairwise_results = {} # Cache pairwise alignments
while queue:
current = queue.popleft()
current_scale, current_shift = alignments[current]
for neighbor in adjacency[current]:
if neighbor in visited:
continue
# Compute overlap mask and coordinates
mask_c, mask_n, coords_c, coords_n = compute_overlap_mask(
depth_h, depth_w,
extrinsics[current], extrinsics[neighbor],
intrinsics, device
)
if coords_c.shape[0] < 10:
# Not enough overlap, skip this edge
continue
# Get depth maps (single channel)
depth_c = depth_maps[current, :, :, 0]
depth_n = depth_maps[neighbor, :, :, 0]
# Apply current view's alignment to get aligned reference depth
aligned_depth_c = current_scale * depth_c + current_shift
# Find alignment from neighbor to aligned current
# aligned_depth_c = scale * depth_n (NO SHIFT!)
scale, shift, n_valid, debug_info = median_scale_alignment(
aligned_depth_c, depth_n,
coords_c, coords_n,
debug=True
)
# Detailed debug output
print(f"[AlignDepthMaps] View {neighbor} -> {current}:")
print(f" Overlap: {debug_info['overlap_pixels']} pixels, {debug_info['valid_pixels']} valid")
print(f" Depth1 (ref): {debug_info['d1_range'][0]:.2f} - {debug_info['d1_range'][1]:.2f}")
print(f" Depth2 (src): {debug_info['d2_range'][0]:.2f} - {debug_info['d2_range'][1]:.2f}")
print(f" Ratio range: {debug_info['ratio_range'][0]:.3f} - {debug_info['ratio_range'][1]:.3f}")
print(f" Scale: {scale:.4f} (median={debug_info['ratio_median']:.4f}, std={debug_info['ratio_std']:.4f})")
if debug_info.get('scale_clamped'):
print(f" WARNING: Scale clamped from {debug_info['original_scale']:.4f} to {scale:.4f}")
print(f" Inlier ratio: {debug_info['inlier_ratio']:.1%}")
alignments[neighbor] = (scale, shift)
visited.add(neighbor)
queue.append(neighbor)
# Handle disconnected views (no overlap with any aligned view)
for i in range(num_views):
if i not in alignments:
print(f"[AlignDepthMaps] Warning: View {i} has no overlap with aligned views, using identity")
alignments[i] = (1.0, 0.0)
return alignments
def compute_global_misalignment(
scales: list[float],
pairwise_ratios: dict[tuple[int, int], float],
) -> tuple[float, float, dict]:
"""Compute global misalignment loss given scales and measured ratios.
The loss measures how well the solved scales satisfy the pairwise constraints.
Args:
scales: List of scale values [s_0, s_1, ..., s_N]
pairwise_ratios: Dict mapping (i,j) to measured ratio d_i/d_j
Returns:
Tuple of (total_loss, mean_loss, per_pair_losses)
"""
per_pair_losses = {}
total_loss = 0.0
for (i, j), measured_ratio in pairwise_ratios.items():
# We want: s_i * d_i = s_j * d_j in overlap
# So: s_i / s_j should equal d_j / d_i = 1/measured_ratio
target_ratio = 1.0 / measured_ratio
actual_ratio = scales[i] / scales[j] if scales[j] > 0 else float('inf')
# Loss in log-space (scale-invariant)
if actual_ratio > 0 and target_ratio > 0:
log_error = abs(math.log(actual_ratio) - math.log(target_ratio))
per_pair_losses[(i, j)] = log_error
total_loss += log_error ** 2
else:
per_pair_losses[(i, j)] = float('inf')
num_pairs = len(pairwise_ratios)
mean_loss = math.sqrt(total_loss / num_pairs) if num_pairs > 0 else 0.0
return total_loss, mean_loss, per_pair_losses
def compute_global_alignments_optimized(
depth_maps: torch.Tensor,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
) -> dict[int, tuple[float, float]]:
"""Compute globally optimal scale alignments using least squares.
Instead of BFS chaining (which causes cumulative drift), solve for all
scales jointly by minimizing total pairwise inconsistency.
The formulation:
- For each pair (i,j) we measure ratio r_ij = median(d_i / d_j)
- We want scales s_i such that s_i / s_j ≈ r_ij
- In log-space: x_i - x_j = log(r_ij) where x_i = log(s_i)
- This is a linear system A @ x = b, solved with least squares
Args:
depth_maps: [N, H, W, C] depth maps
extrinsics: [N, 4, 4] extrinsics
intrinsics: [4, 4] intrinsics
Returns:
Dictionary mapping view index to (scale, shift=0.0) tuple
"""
num_views = depth_maps.shape[0]
depth_h, depth_w = depth_maps.shape[1:3]
device = depth_maps.device
print(f"[GlobalOpt] Computing globally optimal alignment for {num_views} views")
# Step 1: Compute ALL pairwise scale ratios
adjacency = build_adjacency_graph(extrinsics, intrinsics)
pairwise_ratios = {} # (i, j) -> ratio where i < j
print(f"[GlobalOpt] Computing pairwise ratios...")
for i in range(num_views):
for j in adjacency[i]:
if i < j: # Only compute once per pair
# Compute overlap
mask_i, mask_j, coords_i, coords_j = compute_overlap_mask(
depth_h, depth_w, extrinsics[i], extrinsics[j],
intrinsics, device
)
if coords_i.shape[0] < 10:
print(f"[GlobalOpt] Pair ({i},{j}): insufficient overlap ({coords_i.shape[0]} pixels)")
continue
depth_i = depth_maps[i, :, :, 0]
depth_j = depth_maps[j, :, :, 0]
# Get median ratio: d_i / d_j
scale, _, n_valid, debug_info = median_scale_alignment(
depth_i, depth_j, coords_i, coords_j, debug=True
)
if n_valid > 100:
pairwise_ratios[(i, j)] = scale
print(f"[GlobalOpt] Pair ({i},{j}): ratio={scale:.4f}, "
f"valid={n_valid}, inlier_ratio={debug_info['inlier_ratio']:.1%}")
else:
print(f"[GlobalOpt] Pair ({i},{j}): insufficient valid pixels ({n_valid})")
# Compute initial loss (with identity scales)
identity_scales = [1.0] * num_views
initial_loss, initial_rmse, _ = compute_global_misalignment(identity_scales, pairwise_ratios)
print(f"[GlobalOpt] Initial misalignment (identity scales): loss={initial_loss:.4f}, RMSE={initial_rmse:.4f}")
# Step 2: Build linear system for log-scale optimization
# x_i - x_j = log(r_ij) for each pair
# Fix x_0 = 0 (reference)
num_pairs = len(pairwise_ratios)
print(f"[GlobalOpt] Found {num_pairs} valid pairs")
if num_pairs == 0:
print("[GlobalOpt] No valid pairs, using identity scales")
return {i: (1.0, 0.0) for i in range(num_views)}
# Build A matrix and b vector
# For N views with reference fixed, we have N-1 unknowns
# A is [num_pairs, num_views-1], b is [num_pairs]
A = torch.zeros(num_pairs, num_views - 1, device=device)
b = torch.zeros(num_pairs, device=device)
for k, ((i, j), ratio) in enumerate(pairwise_ratios.items()):
# Equation: x_i - x_j = log(ratio)
# ratio = d_i / d_j, so we want s_i * d_i = s_j * d_j
# which means s_i / s_j = d_j / d_i = 1/ratio
# So: x_i - x_j = -log(ratio) = log(1/ratio)
# Actually, let's think about this more carefully:
# We measured: ratio = median(d_i / d_j)
# After alignment: s_i * d_i should equal s_j * d_j in overlap
# So: s_i / s_j = d_j / d_i = 1/ratio
# In log: x_i - x_j = log(1/ratio) = -log(ratio)
log_ratio = -math.log(max(ratio, 1e-6)) # s_i / s_j = 1/ratio
if i == 0:
# x_0 = 0, so: -x_j = log_ratio → x_j = -log_ratio
A[k, j - 1] = -1.0
elif j == 0:
# x_0 = 0, so: x_i = log_ratio
A[k, i - 1] = 1.0
else:
# x_i - x_j = log_ratio
A[k, i - 1] = 1.0
A[k, j - 1] = -1.0
b[k] = log_ratio
# Step 3: Solve least squares: x = (A^T A)^{-1} A^T b
print(f"[GlobalOpt] Solving least squares system ({num_pairs} equations, {num_views-1} unknowns)")
try:
# Use torch.linalg.lstsq for numerical stability
solution = torch.linalg.lstsq(A, b).solution # [num_views-1]
# Convert back from log-space
scales = [1.0] # x_0 = 0 → s_0 = 1
for i in range(num_views - 1):
scales.append(math.exp(solution[i].item()))
print(f"[GlobalOpt] Raw scales: {[f'{s:.4f}' for s in scales]}")
# Compute loss after optimization (before normalization)
opt_loss, opt_rmse, per_pair = compute_global_misalignment(scales, pairwise_ratios)
print(f"[GlobalOpt] Optimized misalignment: loss={opt_loss:.4f}, RMSE={opt_rmse:.4f}")
except Exception as e:
print(f"[GlobalOpt] Least squares failed: {e}, using identity scales")
scales = [1.0] * num_views
# Step 4: Normalize so median scale = 1.0 (prevents overall drift)
median_scale = sorted(scales)[len(scales) // 2]
if median_scale > 0:
scales = [s / median_scale for s in scales]
print(f"[GlobalOpt] Normalized scales: {[f'{s:.4f}' for s in scales]}")
# Compute final loss after normalization
final_loss, final_rmse, per_pair = compute_global_misalignment(scales, pairwise_ratios)
print(f"[GlobalOpt] Final misalignment: loss={final_loss:.4f}, RMSE={final_rmse:.4f}")
# Print per-pair residuals
print(f"[GlobalOpt] Per-pair log-errors:")
for (i, j), err in sorted(per_pair.items()):
status = "OK" if err < 0.1 else "WARN" if err < 0.2 else "BAD"
print(f" Pair ({i},{j}): {err:.4f} [{status}]")
# Summary
min_scale, max_scale = min(scales), max(scales)
variation = max_scale / min_scale if min_scale > 0 else float('inf')
print(f"[GlobalOpt] Scale range: {min_scale:.4f} - {max_scale:.4f} ({variation:.2f}x variation)")
improvement = (initial_rmse - final_rmse) / initial_rmse * 100 if initial_rmse > 0 else 0
print(f"[GlobalOpt] Improvement: {improvement:.1f}% reduction in RMSE")
# Warn if variation is still high
if variation > 2.0:
print(f"[GlobalOpt] WARNING: High scale variation ({variation:.2f}x) - alignment may be unreliable")
return {i: (scales[i], 0.0) for i in range(num_views)}
class AlignDepthMaps:
"""Global optimization-based scale alignment for multi-view depth maps.
Aligns depth maps to remove seams caused by scale ambiguity in
monocular depth estimation. Uses least-squares optimization in log-space
to find globally consistent scales.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"depth_maps": ("IMAGE",),
"extrinsics": ("EXTRINSICS",),
"intrinsics": ("INTRINSICS",),
},
"optional": {
"method": (["global_optimization", "bfs_chain"], {
"default": "global_optimization",
"tooltip": "global_optimization: solve all scales jointly (recommended). bfs_chain: chain pairwise alignments (can drift)."
}),
"reference_view": ("INT", {
"default": 0,
"min": 0,
"max": 999,
"step": 1,
"tooltip": "Index of the reference view (scale=1.0)"
}),
}
}
RETURN_TYPES = ("IMAGE", "EXTRINSICS", "INTRINSICS", "STRING",)
RETURN_NAMES = ("aligned_depth_maps", "extrinsics", "intrinsics", "alignment_info",)
FUNCTION = "align"
CATEGORY = "SHARP"
DESCRIPTION = "Align depth maps using global optimization to remove seams from monocular depth estimation."
def align(
self,
depth_maps: torch.Tensor,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
method: str = "global_optimization",
reference_view: int = 0,
):
"""Align depth maps using global optimization or BFS chain."""
num_views = depth_maps.shape[0]
print(f"[AlignDepthMaps] Aligning {num_views} depth maps")
print(f"[AlignDepthMaps] Method: {method}")
# Clamp reference view to valid range
reference_view = min(reference_view, num_views - 1)
# Compute alignments using selected method
if method == "global_optimization":
alignments = compute_global_alignments_optimized(
depth_maps, extrinsics, intrinsics
)
else:
# Legacy BFS method (kept for comparison)
print(f"[AlignDepthMaps] Using BFS chain (reference view: {reference_view})")
alignments = compute_global_alignments(
depth_maps, extrinsics, intrinsics,
reference_view, num_iterations=1000, inlier_threshold=0.05
)
# Apply alignments
aligned_depth_maps = []
alignment_info = {"method": method}
for i in range(num_views):
scale, shift = alignments[i]
depth = depth_maps[i] # [H, W, C]
# Apply alignment (scale only, shift is always 0)
aligned = scale * depth + shift
aligned_depth_maps.append(aligned)
alignment_info[f"view_{i}"] = {"scale": scale, "shift": shift}
# Stack result
aligned_batch = torch.stack(aligned_depth_maps, dim=0)
# Convert alignment info to JSON string
info_str = json.dumps(alignment_info, indent=2)
scales = [alignments[i][0] for i in range(num_views)]
min_scale, max_scale = min(scales), max(scales)
variation = max_scale / min_scale if min_scale > 0 else float('inf')
print(f"[AlignDepthMaps] Alignment complete")
print(f"[AlignDepthMaps] Final scale range: {min_scale:.4f} - {max_scale:.4f} ({variation:.2f}x variation)")
# Check for problematic alignments
if variation > 2.0:
print(f"[AlignDepthMaps] WARNING: Large scale variation ({variation:.2f}x) - alignment may be unreliable.")
return (aligned_batch, extrinsics, intrinsics, info_str,)
NODE_CLASS_MAPPINGS = {
"AlignDepthMaps": AlignDepthMaps,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AlignDepthMaps": "Align Depth Maps (Global Optimization)",
}
+1 -1
View File
@@ -49,7 +49,7 @@ class LoadSharpModel:
def load_model(self, device: str, checkpoint_path: str = ""):
"""Load and cache the SHARP model."""
from nodes.sharp.models import PredictorParams, create_predictor
from .sharp.models import PredictorParams, create_predictor
# Resolve device
if device == "auto":
+241
View File
@@ -0,0 +1,241 @@
"""MergeGaussians node for ComfyUI-Sharp.
Merges multiple PLY files (from panorama samples) into a single unified Gaussian scene.
"""
import os
import time
from pathlib import Path
import numpy as np
import torch
from plyfile import PlyData, PlyElement
def load_ply_simple(path: str) -> dict:
"""Load Gaussian data from PLY file.
Returns dict with arrays: positions, colors, scales, rotations, opacities
"""
plydata = PlyData.read(path)
vertex = plydata['vertex']
positions = np.stack([
vertex['x'],
vertex['y'],
vertex['z']
], axis=-1)
# Colors (SH coefficients - we take DC term)
colors = np.stack([
vertex['f_dc_0'],
vertex['f_dc_1'],
vertex['f_dc_2']
], axis=-1)
# Scales
scales = np.stack([
vertex['scale_0'],
vertex['scale_1'],
vertex['scale_2']
], axis=-1)
# Rotations (quaternion)
rotations = np.stack([
vertex['rot_0'],
vertex['rot_1'],
vertex['rot_2'],
vertex['rot_3']
], axis=-1)
# Opacity
opacities = vertex['opacity']
return {
'positions': positions,
'colors': colors,
'scales': scales,
'rotations': rotations,
'opacities': opacities,
}
def save_merged_ply(
positions: np.ndarray,
colors: np.ndarray,
scales: np.ndarray,
rotations: np.ndarray,
opacities: np.ndarray,
output_path: str,
):
"""Save merged Gaussians to PLY file."""
num_gaussians = len(positions)
# Create structured array
dtype = [
('x', 'f4'), ('y', 'f4'), ('z', 'f4'),
('f_dc_0', 'f4'), ('f_dc_1', 'f4'), ('f_dc_2', 'f4'),
('opacity', 'f4'),
('scale_0', 'f4'), ('scale_1', 'f4'), ('scale_2', 'f4'),
('rot_0', 'f4'), ('rot_1', 'f4'), ('rot_2', 'f4'), ('rot_3', 'f4'),
]
elements = np.empty(num_gaussians, dtype=dtype)
elements['x'] = positions[:, 0]
elements['y'] = positions[:, 1]
elements['z'] = positions[:, 2]
elements['f_dc_0'] = colors[:, 0]
elements['f_dc_1'] = colors[:, 1]
elements['f_dc_2'] = colors[:, 2]
elements['opacity'] = opacities
elements['scale_0'] = scales[:, 0]
elements['scale_1'] = scales[:, 1]
elements['scale_2'] = scales[:, 2]
elements['rot_0'] = rotations[:, 0]
elements['rot_1'] = rotations[:, 1]
elements['rot_2'] = rotations[:, 2]
elements['rot_3'] = rotations[:, 3]
el = PlyElement.describe(elements, 'vertex')
PlyData([el]).write(output_path)
class MergeGaussians:
"""Merge multiple Gaussian PLY files into a single scene.
Used after running SHARP on panorama samples to combine all views.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ply_folder": ("STRING", {
"tooltip": "Path to folder containing PLY files to merge"
}),
},
"optional": {
"output_prefix": ("STRING", {
"default": "merged",
"tooltip": "Prefix for output merged PLY file"
}),
"max_depth": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 1000.0,
"step": 0.1,
"tooltip": "Filter out Gaussians beyond this depth (0 = no filter)"
}),
"min_opacity": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Filter out Gaussians with opacity below this (0 = no filter)"
}),
}
}
RETURN_TYPES = ("STRING", "INT",)
RETURN_NAMES = ("ply_path", "num_gaussians",)
FUNCTION = "merge"
CATEGORY = "SHARP"
OUTPUT_NODE = True
DESCRIPTION = "Merge multiple Gaussian PLY files into a single unified scene."
def merge(
self,
ply_folder: str,
output_prefix: str = "merged",
max_depth: float = 0.0,
min_opacity: float = 0.0,
):
"""Merge all PLY files in folder."""
# Find all PLY files
ply_folder = Path(ply_folder)
if not ply_folder.exists():
raise ValueError(f"PLY folder does not exist: {ply_folder}")
ply_files = sorted(ply_folder.glob("*.ply"))
if not ply_files:
raise ValueError(f"No PLY files found in: {ply_folder}")
print(f"[MergeGaussians] Found {len(ply_files)} PLY files to merge")
# Load all PLY files
all_positions = []
all_colors = []
all_scales = []
all_rotations = []
all_opacities = []
for i, ply_path in enumerate(ply_files):
print(f"[MergeGaussians] 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)
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:
print(f"[MergeGaussians] Filtered out {filtered_count:,} Gaussians")
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])
# 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)
print(f"[MergeGaussians] Total Gaussians after merge: {num_gaussians:,}")
# Save merged PLY
timestamp = int(time.time() * 1000)
output_filename = f"{output_prefix}_{timestamp}.ply"
output_path = ply_folder.parent / output_filename
print(f"[MergeGaussians] Saving to {output_path}")
save_merged_ply(
merged_positions,
merged_colors,
merged_scales,
merged_rotations,
merged_opacities,
str(output_path),
)
print(f"[MergeGaussians] Done! Merged {len(ply_files)} files into {num_gaussians:,} Gaussians")
return (str(output_path), num_gaussians,)
NODE_CLASS_MAPPINGS = {
"MergeGaussians": MergeGaussians,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MergeGaussians": "Merge Gaussians (PLY Files)",
}
+79 -24
View File
@@ -49,12 +49,18 @@ class SharpPredict:
"min": 0.0,
"max": 500.0,
"step": 0.1,
"tooltip": "Focal length in mm (35mm equivalent). 0 = auto (defaults to 30mm)."
"tooltip": "Focal length in mm (35mm equivalent). 0 = auto (defaults to 30mm). Ignored if intrinsics provided."
}),
"output_prefix": ("STRING", {
"default": "sharp",
"tooltip": "Prefix for output PLY filename or folder name for batches."
}),
"extrinsics": ("EXTRINSICS", {
"tooltip": "Camera extrinsics (from SamplePanorama). If batched, must match image batch size."
}),
"intrinsics": ("INTRINSICS", {
"tooltip": "Camera intrinsics (from SamplePanorama). Overrides focal_length_mm if provided."
}),
}
}
@@ -72,6 +78,8 @@ class SharpPredict:
image: torch.Tensor,
focal_length_mm: float = 0.0,
output_prefix: str = "sharp",
extrinsics: torch.Tensor = None,
intrinsics: torch.Tensor = None,
):
"""Run SHARP inference and save PLY file(s).
@@ -79,8 +87,11 @@ class SharpPredict:
For batch: creates folder {prefix}_{timestamp}/ with 001.ply, 002.ply, etc.
Features are cached per image - changing focal_length with same image is instant.
If extrinsics/intrinsics are provided (from SamplePanorama), Gaussians are
unprojected into world coordinates using those camera parameters.
"""
from nodes.sharp.utils.gaussians import save_ply, unproject_gaussians
from .sharp.utils.gaussians import save_ply, unproject_gaussians
predictor = model["predictor"]
device = torch.device(model["device"])
@@ -90,7 +101,17 @@ class SharpPredict:
image = image.unsqueeze(0)
batch_size = image.shape[0]
print(f"[SHARP] Processing {batch_size} image(s)")
# Validate extrinsics batch size if provided
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 image batch size ({batch_size})")
print(f"[SHARP] Processing {batch_size} image(s) with provided camera parameters (panorama mode)")
else:
print(f"[SHARP] Processing {batch_size} image(s)")
# Ensure output directory exists
os.makedirs(OUTPUT_DIR, exist_ok=True)
@@ -125,15 +146,28 @@ class SharpPredict:
if i == 0:
print(f"[SHARP] Image size: {width}x{height}")
# Determine focal length in pixels
if focal_length_mm > 0:
f_px = convert_focallength(width, height, focal_length_mm)
# Get camera parameters for this image
if has_camera_params:
# Use provided intrinsics (extract focal length)
img_intrinsics = intrinsics.to(device)
img_extrinsics = extrinsics[i].to(device)
f_px = img_intrinsics[0, 0].item() # fx from intrinsics matrix
else:
f_px = convert_focallength(width, height, 30.0)
# Use focal_length_mm parameter
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
# Run inference with caching
print(f"[SHARP] Running inference on image {i+1}/{batch_size}...")
gaussians = self._predict_image_cached(predictor, image_np, f_px, device)
gaussians = self._predict_image_cached(
predictor, image_np, f_px, device,
extrinsics=img_extrinsics,
intrinsics=img_intrinsics,
)
# Determine output filename
if is_batch:
@@ -169,14 +203,24 @@ class SharpPredict:
image: np.ndarray,
f_px: float,
device: torch.device,
extrinsics: torch.Tensor = None,
intrinsics: torch.Tensor = None,
):
"""Predict Gaussians with caching of encoded features.
The expensive encode step is cached per image.
Changing focal_length reuses cached features (instant).
Args:
predictor: SHARP predictor model
image: Input image as numpy array [H, W, 3]
f_px: Focal length in pixels
device: Torch device
extrinsics: Optional 4x4 camera extrinsics (world-to-camera)
intrinsics: Optional 4x4 camera intrinsics
"""
global _encode_cache
from nodes.sharp.utils.gaussians import unproject_gaussians
from .sharp.utils.gaussians import unproject_gaussians
internal_shape = (1536, 1536)
height, width = image.shape[:2]
@@ -231,26 +275,37 @@ class SharpPredict:
decode_time = time.time() - decode_start
print(f"[SHARP] Decode time: {decode_time:.2f}s")
# Build intrinsics for unprojection
intrinsics = (
torch.tensor(
[
[f_px, 0, width / 2, 0],
[0, f_px, height / 2, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
# Build intrinsics for unprojection (use provided or construct from f_px)
if intrinsics is not None:
unproj_intrinsics = intrinsics.clone()
else:
unproj_intrinsics = (
torch.tensor(
[
[f_px, 0, width / 2, 0],
[0, f_px, height / 2, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
)
.float()
.to(device)
)
.float()
.to(device)
)
intrinsics_resized = intrinsics.clone()
# Scale intrinsics to internal resolution
intrinsics_resized = unproj_intrinsics.clone()
intrinsics_resized[0] *= internal_shape[0] / width
intrinsics_resized[1] *= internal_shape[1] / height
# Convert Gaussians to metric space
# Use provided extrinsics or identity
if extrinsics is not None:
unproj_extrinsics = extrinsics
else:
unproj_extrinsics = torch.eye(4, device=device)
# Convert Gaussians to world/metric space
gaussians = unproject_gaussians(
gaussians_ndc, torch.eye(4).to(device), intrinsics_resized, internal_shape
gaussians_ndc, unproj_extrinsics, intrinsics_resized, internal_shape
)
return gaussians
+255
View File
@@ -0,0 +1,255 @@
"""SharpPredictDepth node for ComfyUI-Sharp.
Runs SHARP inference but outputs depth maps instead of PLY files.
Used for panorama depth visualization and verification before Gaussian creation.
"""
import time
import numpy as np
import torch
import torch.nn.functional as F
from .utils.image import comfy_to_numpy_rgb
class SharpPredictDepth:
"""Run SHARP inference to generate depth maps from images.
Unlike SharpPredict which outputs PLY files, this node outputs
depth maps as images for visualization and further processing.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("SHARP_MODEL",),
"image": ("IMAGE",),
},
"optional": {
"extrinsics": ("EXTRINSICS", {
"tooltip": "Camera extrinsics (from SamplePanorama). Passed through for pipeline."
}),
"intrinsics": ("INTRINSICS", {
"tooltip": "Camera intrinsics (from SamplePanorama). Used for depth scale."
}),
"reference_depth": ("IMAGE", {
"tooltip": "Reference depth maps (e.g., from DepthAnythingV3) for dense alignment. Must match image batch size."
}),
}
}
RETURN_TYPES = ("IMAGE", "EXTRINSICS", "INTRINSICS", "IMAGE",)
RETURN_NAMES = ("depth_maps", "extrinsics", "intrinsics", "alignment_maps",)
FUNCTION = "predict_depth"
CATEGORY = "SHARP"
DESCRIPTION = "Generate depth maps from images using SHARP. Optionally align to reference depth (e.g., DepthAnythingV3) using learned dense alignment."
@torch.no_grad()
def predict_depth(
self,
model: dict,
image: torch.Tensor,
extrinsics: torch.Tensor = None,
intrinsics: torch.Tensor = None,
reference_depth: torch.Tensor = None,
):
"""Run SHARP inference and extract depth maps.
Returns depth maps as [N, H, W, 1] tensor (grayscale images).
If reference_depth is provided, uses SHARP's learned dense alignment.
"""
predictor = model["predictor"]
device = torch.device(model["device"])
# Handle batch dimension
if image.dim() == 3:
image = image.unsqueeze(0)
batch_size = image.shape[0]
print(f"[SharpPredictDepth] Processing {batch_size} image(s)")
# Check if we have reference depth for alignment
use_alignment = reference_depth is not None
if use_alignment:
if reference_depth.dim() == 3:
reference_depth = reference_depth.unsqueeze(0)
if reference_depth.shape[0] != batch_size:
print(f"[SharpPredictDepth] WARNING: reference_depth batch size ({reference_depth.shape[0]}) "
f"doesn't match image batch size ({batch_size}). Disabling alignment.")
use_alignment = False
else:
# Check if scale_map_estimator is available
scale_map_estimator = predictor.depth_alignment.scale_map_estimator
if scale_map_estimator is None:
print(f"[SharpPredictDepth] WARNING: scale_map_estimator not available in model. Disabling alignment.")
use_alignment = False
else:
print(f"[SharpPredictDepth] Using learned dense alignment with reference depth")
# SHARP processes at 1536x1536 internally
# We output depth at native disparity resolution (1536x1536)
internal_shape = (1536, 1536)
all_depth_maps = []
all_alignment_maps = []
inference_start = time.time()
for i in range(batch_size):
# Extract single image from batch
single_image = image[i:i+1]
image_np = comfy_to_numpy_rgb(single_image)
height, width = image_np.shape[:2]
if i == 0:
print(f"[SharpPredictDepth] Image size: {width}x{height}")
# Get focal length from intrinsics or use default
if intrinsics is not None:
f_px = intrinsics[0, 0].item()
else:
# Default: assume 65° FOV for the input image size
import math
fov_rad = math.radians(65)
f_px = (width / 2) / math.tan(fov_rad / 2)
# Convert to tensor and normalize
image_pt = torch.from_numpy(image_np.copy()).float().to(device).permute(2, 0, 1) / 255.0
# Resize to internal resolution
image_resized_pt = F.interpolate(
image_pt[None],
size=(internal_shape[1], internal_shape[0]),
mode="bilinear",
align_corners=True,
)
# Run SHARP encode/decode
print(f"[SharpPredictDepth] Running inference on image {i+1}/{batch_size}...")
encode_start = time.time()
monodepth_output, depth_decoder_features = predictor.encode(image_resized_pt)
encode_time = time.time() - encode_start
# Compute disparity factor (consistent across all views with same FOV)
disparity_factor = f_px / width
print(f"[SharpPredictDepth] Encode: {encode_time:.2f}s, disparity_factor: {disparity_factor:.4f}")
# Extract depth from RAW DISPARITY (not Gaussians!)
# monodepth_output.disparity is [1, 1, 1536, 1536] at internal resolution
raw_disparity = monodepth_output.disparity # [1, 1, 1536, 1536]
# Apply learned dense alignment if reference depth is provided
if use_alignment:
# Get reference depth for this image
ref_depth_single = reference_depth[i] # [H, W, C]
# Convert to [1, 1, H, W] and resize to internal resolution
ref_depth_gray = ref_depth_single[:, :, 0] # Take first channel
ref_depth_tensor = ref_depth_gray.unsqueeze(0).unsqueeze(0).to(device) # [1, 1, H, W]
ref_depth_resized = F.interpolate(
ref_depth_tensor,
size=internal_shape,
mode="bilinear",
align_corners=True,
)
# Convert reference depth to same scale as SHARP disparity
# Reference depth is likely in [0, 1] range, convert to depth-like values
# Assuming reference is normalized: 1 = close, 0 = far (like our output)
# Convert to actual depth: larger value = farther
ref_depth_scaled = 1.0 / (ref_depth_resized.clamp(min=0.01) + 1e-4)
# SHARP's alignment expects depth (not disparity), so convert
sharp_depth = disparity_factor / raw_disparity.clamp(min=1e-4)
# Run scale_map_estimator
# It expects: tensor_src (SHARP depth), tensor_tgt (reference depth)
alignment_map = scale_map_estimator(
sharp_depth,
ref_depth_scaled,
depth_decoder_features if predictor.depth_alignment.scale_map_estimator.depth_decoder_features else None,
)
# Apply alignment: aligned_depth = alignment_map * sharp_depth
aligned_depth = alignment_map * sharp_depth
# Convert back to disparity for consistent output
aligned_disparity = disparity_factor / aligned_depth.clamp(min=1e-4)
print(f"[SharpPredictDepth] Alignment map range: {alignment_map.min():.3f} - {alignment_map.max():.3f}")
# Store alignment map for visualization
alignment_map_vis = alignment_map[0, 0].cpu() # [H, W]
# Normalize for visualization
align_min, align_max = alignment_map_vis.min(), alignment_map_vis.max()
if align_max > align_min:
alignment_map_vis = (alignment_map_vis - align_min) / (align_max - align_min)
alignment_map_rgb = alignment_map_vis.unsqueeze(-1).repeat(1, 1, 3)
all_alignment_maps.append(alignment_map_rgb)
# Use aligned disparity
disparity_resized = aligned_disparity[0, 0] # [1536, 1536]
else:
# No alignment - use raw disparity
disparity_resized = raw_disparity[0, 0] # [1536, 1536]
# Create dummy alignment map (all ones)
alignment_map_rgb = torch.ones(internal_shape[0], internal_shape[1], 3)
all_alignment_maps.append(alignment_map_rgb)
# Convert disparity to depth: depth = disparity_factor / disparity
depth_map_raw = disparity_factor / disparity_resized.clamp(min=1e-4) # [H, W]
if i == 0:
print(f"[SharpPredictDepth] Depth range: {depth_map_raw.min():.3f} - {depth_map_raw.max():.3f}")
# Convert to [H, W, 1] format for ComfyUI
depth_map = depth_map_raw.unsqueeze(-1).cpu() # [H, W, 1]
# Store as single channel (will be converted to RGB later if needed)
depth_map_rgb = depth_map.repeat(1, 1, 3) # [H, W, 3]
all_depth_maps.append(depth_map_rgb)
inference_time = time.time() - inference_start
print(f"[SharpPredictDepth] Total inference time: {inference_time:.2f}s")
# Stack all depth maps
depth_maps_batch = torch.stack(all_depth_maps, dim=0) # [N, H, W, 3]
alignment_maps_batch = torch.stack(all_alignment_maps, dim=0) # [N, H, W, 3]
print(f"[SharpPredictDepth] Output shape: {depth_maps_batch.shape}")
# Scale intrinsics to match output depth map size (1536x1536 internal resolution)
# Input intrinsics are for the original image size
if intrinsics is not None:
# Get scale factor: output_size / input_size
input_size = width # Original image width
output_size = internal_shape[0] # 1536 (native disparity resolution)
scale_factor = output_size / input_size
if abs(scale_factor - 1.0) > 0.01: # Only scale if significantly different
print(f"[SharpPredictDepth] Scaling intrinsics by {scale_factor:.3f} "
f"(input {input_size} -> output {output_size})")
# Scale f_px, f_py, cx, cy
intrinsics_scaled = intrinsics.clone()
intrinsics_scaled[0, 0] *= scale_factor # f_px
intrinsics_scaled[1, 1] *= scale_factor # f_py
intrinsics_scaled[0, 2] *= scale_factor # cx
intrinsics_scaled[1, 2] *= scale_factor # cy
intrinsics = intrinsics_scaled
return (depth_maps_batch, extrinsics, intrinsics, alignment_maps_batch,)
NODE_CLASS_MAPPINGS = {
"SharpPredictDepth": SharpPredictDepth,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SharpPredictDepth": "SHARP Predict Depth",
}
+713
View File
@@ -0,0 +1,713 @@
"""ProjectDepthToPanorama node for ComfyUI-Sharp.
Projects depth maps from perspective views back to equirectangular panorama
with blending and debug visualization.
"""
import math
import numpy as np
import torch
import torch.nn.functional as F
def extrinsics_to_yaw_pitch(extrinsics: torch.Tensor) -> tuple[float, float]:
"""Extract yaw and pitch from extrinsics matrix.
The extrinsics is world-to-camera, so we need the inverse (camera-to-world)
to get the camera's viewing direction.
"""
# Camera-to-world is inverse of world-to-camera
# For pure rotation, inverse = transpose
R_c2w = extrinsics[:3, :3].T
# Camera looks along +Z in camera space
# Transform to world space
forward = R_c2w @ torch.tensor([0., 0., 1.], device=extrinsics.device)
# Extract yaw (rotation around Y) and pitch (rotation around X)
yaw = math.atan2(forward[0].item(), forward[2].item())
pitch = math.asin(torch.clamp(forward[1], -1, 1).item())
return yaw, pitch
def compute_blend_weight(
uu: torch.Tensor,
vv: torch.Tensor,
depth_w: int,
depth_h: int,
half_fov: float,
blend_mode: str,
) -> torch.Tensor:
"""Compute blending weights based on distance from view center.
Args:
uu, vv: Pixel coordinate grids
depth_w, depth_h: Depth map dimensions
half_fov: Half field of view in radians
blend_mode: Blending algorithm to use
Returns:
Weight tensor [H, W]
"""
# Normalized distance from center (0 at center, 1 at edge)
dist_from_center = torch.sqrt(
((uu - depth_w / 2) / (depth_w / 2)) ** 2 +
((vv - depth_h / 2) / (depth_h / 2)) ** 2
)
if blend_mode == "cosine":
# Cosine falloff (original)
angle_from_center = dist_from_center * half_fov
weight = torch.cos(torch.clamp(angle_from_center, 0, half_fov))
elif blend_mode == "gaussian":
# Gaussian falloff (smoother)
sigma = 0.5 # Standard deviation in normalized units
weight = torch.exp(-0.5 * (dist_from_center / sigma) ** 2)
elif blend_mode == "linear":
# Linear falloff
weight = 1.0 - torch.clamp(dist_from_center, 0, 1)
elif blend_mode == "quadratic":
# Quadratic falloff (smoother than linear)
weight = 1.0 - torch.clamp(dist_from_center, 0, 1) ** 2
elif blend_mode == "feather":
# Hard center with feathered edges (feather starts at 70% from center)
feather_start = 0.7
feather_width = 1.0 - feather_start
weight = torch.where(
dist_from_center < feather_start,
torch.ones_like(dist_from_center),
1.0 - (dist_from_center - feather_start) / feather_width
)
else: # "none" or fallback
weight = torch.ones_like(dist_from_center)
return torch.clamp(weight, 0.01, 1.0)
def project_depth_maps_to_panorama_with_disagreement(
depth_maps: torch.Tensor,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
output_width: int,
blend_mode: str = "gaussian",
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Project depth maps back to equirectangular panorama with disagreement tracking.
Args:
depth_maps: [N, H, W, C] depth maps from each view
extrinsics: [N, 4, 4] camera extrinsics for each view
intrinsics: [4, 4] shared camera intrinsics
output_width: Width of output panorama (height = width/2)
blend_mode: Blending algorithm ("none", "cosine", "gaussian", "linear", "quadratic", "feather")
Returns:
Tuple of:
- Panoramic depth map [output_height, output_width, 1]
- Disagreement map [output_height, output_width, 1] (std dev of depths)
- Overlap count [output_height, output_width] (number of views per pixel)
"""
device = depth_maps.device
num_views = depth_maps.shape[0]
depth_h, depth_w = depth_maps.shape[1:3]
output_height = output_width // 2
# Get FOV from intrinsics
f_px = intrinsics[0, 0].item()
cx = intrinsics[0, 2].item()
half_fov = math.atan((depth_w / 2) / f_px)
# Create output panorama accumulators
# For Welford's online variance algorithm
panorama_depth = torch.zeros(output_height, output_width, device=device) # weighted mean
panorama_weight = torch.zeros(output_height, output_width, device=device)
panorama_m2 = torch.zeros(output_height, output_width, device=device) # sum of squared differences
panorama_count = torch.zeros(output_height, output_width, device=device) # view count
# Store per-view contributions for disagreement calculation
# We'll use a simpler approach: track sum, sum of squares, and count
panorama_sum = torch.zeros(output_height, output_width, device=device)
panorama_sum_sq = torch.zeros(output_height, output_width, device=device)
# For each view, project its depth to panorama
for view_idx in range(num_views):
depth_map = depth_maps[view_idx, :, :, 0] # [H, W]
ext = extrinsics[view_idx]
# Get view direction
view_yaw, view_pitch = extrinsics_to_yaw_pitch(ext)
# Create pixel grid for depth map
u = torch.arange(depth_w, dtype=torch.float32, device=device)
v = torch.arange(depth_h, dtype=torch.float32, device=device)
uu, vv = torch.meshgrid(u, v, indexing='xy')
# Convert to camera-space ray directions
dx = (uu - cx) / f_px
dy = (vv - cx) / f_px # Assuming square pixels, cy = cx
dz = torch.ones_like(dx)
rays_cam = torch.stack([dx, dy, dz], dim=-1)
rays_cam = F.normalize(rays_cam, dim=-1)
# Rotate to world space (camera-to-world)
R_c2w = ext[:3, :3].T
rays_world = torch.einsum('ij,hwj->hwi', R_c2w, rays_cam)
# Convert world rays to spherical (yaw, pitch)
rx, ry, rz = rays_world[..., 0], rays_world[..., 1], rays_world[..., 2]
ray_yaw = torch.atan2(rx, rz)
ray_pitch = torch.asin(torch.clamp(ry, -1, 1))
# Map to panorama pixel coordinates
pano_x = ((ray_yaw / math.pi + 1) * (output_width - 1) / 2).long()
pano_y = ((0.5 - ray_pitch / math.pi) * (output_height - 1)).long()
# Clamp to valid range
pano_x = torch.clamp(pano_x, 0, output_width - 1)
pano_y = torch.clamp(pano_y, 0, output_height - 1)
# Compute weight for blending
weight = compute_blend_weight(uu, vv, depth_w, depth_h, half_fov, blend_mode)
# Scatter depth values to panorama
flat_pano_idx = pano_y * output_width + pano_x
flat_depth = depth_map.flatten()
flat_weight = weight.flatten()
# Accumulate weighted depth
panorama_depth.view(-1).scatter_add_(0, flat_pano_idx.flatten(), flat_depth * flat_weight)
panorama_weight.view(-1).scatter_add_(0, flat_pano_idx.flatten(), flat_weight.flatten())
# Accumulate for variance calculation (unweighted for simplicity)
panorama_sum.view(-1).scatter_add_(0, flat_pano_idx.flatten(), flat_depth)
panorama_sum_sq.view(-1).scatter_add_(0, flat_pano_idx.flatten(), flat_depth ** 2)
panorama_count.view(-1).scatter_add_(0, flat_pano_idx.flatten(), torch.ones_like(flat_depth))
# Normalize by accumulated weights
panorama_depth = panorama_depth / (panorama_weight + 1e-8)
# Compute variance: var = E[X^2] - E[X]^2
# Only meaningful where count >= 2
mean_depth = panorama_sum / (panorama_count + 1e-8)
mean_sq_depth = panorama_sum_sq / (panorama_count + 1e-8)
variance = mean_sq_depth - mean_depth ** 2
variance = torch.clamp(variance, min=0) # Numerical stability
std_dev = torch.sqrt(variance)
# Compute relative disagreement (normalized by mean depth)
relative_disagreement = std_dev / (mean_depth + 1e-8)
# Print statistics
overlap_mask = panorama_count >= 2
if overlap_mask.any():
overlap_std = std_dev[overlap_mask]
overlap_rel = relative_disagreement[overlap_mask]
print(f"[ProjectDepthToPanorama] Overlap regions ({overlap_mask.sum().item()} pixels):")
print(f" Absolute disagreement: mean={overlap_std.mean():.4f}, max={overlap_std.max():.4f}")
print(f" Relative disagreement: mean={overlap_rel.mean():.2%}, max={overlap_rel.max():.2%}")
# Count pixels with high disagreement (>10% relative)
high_disagreement = (relative_disagreement > 0.1) & overlap_mask
print(f" High disagreement (>10%): {high_disagreement.sum().item()} pixels "
f"({high_disagreement.sum().item() / overlap_mask.sum().item() * 100:.1f}% of overlap)")
# Global normalization for visualization
valid_mask = panorama_weight > 0.01
depth_min, depth_max = 0.0, 1.0
if valid_mask.any():
valid_depths = panorama_depth[valid_mask]
depth_min = valid_depths.min()
depth_max = valid_depths.max()
print(f"[ProjectDepthToPanorama] Depth range: {depth_min:.4f} to {depth_max:.4f}")
if depth_max > depth_min:
panorama_depth = torch.where(
valid_mask,
1.0 - (panorama_depth - depth_min) / (depth_max - depth_min),
torch.zeros_like(panorama_depth)
)
else:
panorama_depth = torch.where(
valid_mask,
torch.full_like(panorama_depth, 0.5),
torch.zeros_like(panorama_depth)
)
return (
panorama_depth.unsqueeze(-1),
relative_disagreement.unsqueeze(-1),
panorama_count,
)
def project_depth_maps_to_panorama(
depth_maps: torch.Tensor,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
output_width: int,
blend_mode: str = "gaussian",
) -> torch.Tensor:
"""Project depth maps back to equirectangular panorama.
Args:
depth_maps: [N, H, W, C] depth maps from each view
extrinsics: [N, 4, 4] camera extrinsics for each view
intrinsics: [4, 4] shared camera intrinsics
output_width: Width of output panorama (height = width/2)
blend_mode: Blending algorithm ("none", "cosine", "gaussian", "linear", "quadratic", "feather")
Returns:
Panoramic depth map [output_height, output_width, 1]
"""
depth, _, _ = project_depth_maps_to_panorama_with_disagreement(
depth_maps, extrinsics, intrinsics, output_width, blend_mode
)
return depth
def get_disagreement_color(d: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Convert normalized disagreement [0,1] to RGB color.
Color gradient: blue (0) -> cyan (0.25) -> green (0.5) -> yellow (0.75) -> red (1.0)
"""
# Red channel: ramps up from 0.5 to 1.0
r = torch.where(d < 0.5, 2 * d, torch.ones_like(d))
# Green channel: ramps up from 0 to 0.5, then down from 0.5 to 1.0
g = torch.where(d < 0.5, 2 * d, 2 * (1 - d))
# Blue channel: ramps down from 0 to 0.5
b = torch.where(d < 0.5, 1 - 2 * d, torch.zeros_like(d))
return r, g, b
def draw_legend(
heatmap: torch.Tensor,
max_disagreement: float,
legend_width: int = 30,
margin: int = 10,
) -> torch.Tensor:
"""Draw a color legend bar on the right side of the heatmap.
Args:
heatmap: [H, W, 3] input heatmap
max_disagreement: Maximum disagreement value for scale
legend_width: Width of the legend bar in pixels
margin: Margin around the legend
Returns:
[H, W + legend_width + margin*2, 3] heatmap with legend
"""
device = heatmap.device
H, W = heatmap.shape[:2]
# Create extended canvas
total_width = W + legend_width + margin * 3
canvas = torch.zeros(H, total_width, 3, device=device)
# Copy original heatmap
canvas[:, :W, :] = heatmap
# Legend area
legend_x_start = W + margin
legend_x_end = legend_x_start + legend_width
# Color bar region (leave space for labels at top and bottom)
label_height = 40
bar_y_start = margin + label_height
bar_y_end = H - margin - label_height
bar_height = bar_y_end - bar_y_start
if bar_height > 0:
# Draw gradient color bar
for y in range(bar_y_start, bar_y_end):
# Map y to disagreement value (top = max, bottom = 0)
t = 1.0 - (y - bar_y_start) / bar_height
d = torch.tensor([t], device=device)
r, g, b = get_disagreement_color(d)
canvas[y, legend_x_start:legend_x_end, 0] = r.item()
canvas[y, legend_x_start:legend_x_end, 1] = g.item()
canvas[y, legend_x_start:legend_x_end, 2] = b.item()
# Draw border around color bar
canvas[bar_y_start:bar_y_end, legend_x_start, :] = 1.0 # Left
canvas[bar_y_start:bar_y_end, legend_x_end-1, :] = 1.0 # Right
canvas[bar_y_start, legend_x_start:legend_x_end, :] = 1.0 # Top
canvas[bar_y_end-1, legend_x_start:legend_x_end, :] = 1.0 # Bottom
# Draw tick marks and approximate label positions
# We'll draw simple markers since we can't render text
tick_positions = [0.0, 0.25, 0.5, 0.75, 1.0]
tick_width = 5
for t in tick_positions:
y = int(bar_y_start + (1 - t) * (bar_height - 1))
# Draw tick mark
canvas[y, legend_x_end:legend_x_end + tick_width, :] = 1.0
# Draw "single view" indicator (dark gray square)
indicator_y = H - margin - 20
indicator_size = 15
canvas[indicator_y:indicator_y+indicator_size,
legend_x_start:legend_x_start+indicator_size, :] = 0.2
# Border
canvas[indicator_y, legend_x_start:legend_x_start+indicator_size, :] = 0.5
canvas[indicator_y+indicator_size-1, legend_x_start:legend_x_start+indicator_size, :] = 0.5
canvas[indicator_y:indicator_y+indicator_size, legend_x_start, :] = 0.5
canvas[indicator_y:indicator_y+indicator_size, legend_x_start+indicator_size-1, :] = 0.5
# Add text labels using simple block letters
# Top label: max value (e.g., "20%")
pct = int(max_disagreement * 100)
draw_text_simple(canvas, f"{pct}%", legend_x_start, margin + 5, device)
draw_text_simple(canvas, "BAD", legend_x_start, margin + 20, device)
# Bottom label
draw_text_simple(canvas, "0%", legend_x_start, bar_y_end + 5, device)
draw_text_simple(canvas, "GOOD", legend_x_start, bar_y_end + 20, device)
# Single view label
draw_text_simple(canvas, "1 VIEW", legend_x_start + 18, indicator_y + 3, device)
return canvas
def draw_text_simple(canvas: torch.Tensor, text: str, x: int, y: int, device: torch.device):
"""Draw simple blocky text on canvas. Very basic 3x5 pixel font."""
# Simple 3x5 pixel font for basic characters
font = {
'0': [[1,1,1], [1,0,1], [1,0,1], [1,0,1], [1,1,1]],
'1': [[0,1,0], [1,1,0], [0,1,0], [0,1,0], [1,1,1]],
'2': [[1,1,1], [0,0,1], [1,1,1], [1,0,0], [1,1,1]],
'3': [[1,1,1], [0,0,1], [1,1,1], [0,0,1], [1,1,1]],
'4': [[1,0,1], [1,0,1], [1,1,1], [0,0,1], [0,0,1]],
'5': [[1,1,1], [1,0,0], [1,1,1], [0,0,1], [1,1,1]],
'6': [[1,1,1], [1,0,0], [1,1,1], [1,0,1], [1,1,1]],
'7': [[1,1,1], [0,0,1], [0,0,1], [0,0,1], [0,0,1]],
'8': [[1,1,1], [1,0,1], [1,1,1], [1,0,1], [1,1,1]],
'9': [[1,1,1], [1,0,1], [1,1,1], [0,0,1], [1,1,1]],
'%': [[1,0,1], [0,0,1], [0,1,0], [1,0,0], [1,0,1]],
'B': [[1,1,0], [1,0,1], [1,1,0], [1,0,1], [1,1,0]],
'A': [[0,1,0], [1,0,1], [1,1,1], [1,0,1], [1,0,1]],
'D': [[1,1,0], [1,0,1], [1,0,1], [1,0,1], [1,1,0]],
'G': [[1,1,1], [1,0,0], [1,0,1], [1,0,1], [1,1,1]],
'O': [[1,1,1], [1,0,1], [1,0,1], [1,0,1], [1,1,1]],
'V': [[1,0,1], [1,0,1], [1,0,1], [0,1,0], [0,1,0]],
'I': [[1,1,1], [0,1,0], [0,1,0], [0,1,0], [1,1,1]],
'E': [[1,1,1], [1,0,0], [1,1,0], [1,0,0], [1,1,1]],
'W': [[1,0,1], [1,0,1], [1,1,1], [1,1,1], [1,0,1]],
' ': [[0,0,0], [0,0,0], [0,0,0], [0,0,0], [0,0,0]],
}
H, W = canvas.shape[:2]
cursor_x = x
scale = 2 # Scale up the tiny font
for char in text.upper():
if char in font:
glyph = font[char]
for row_idx, row in enumerate(glyph):
for col_idx, pixel in enumerate(row):
if pixel:
px = cursor_x + col_idx * scale
py = y + row_idx * scale
# Draw scaled pixel
for dy in range(scale):
for dx in range(scale):
if 0 <= py + dy < H and 0 <= px + dx < W:
canvas[py + dy, px + dx, :] = 1.0
cursor_x += (len(glyph[0]) + 1) * scale
def create_disagreement_heatmap(
disagreement: torch.Tensor,
overlap_count: torch.Tensor,
max_disagreement: float = 0.2,
add_legend: bool = True,
) -> torch.Tensor:
"""Create a color heatmap showing depth disagreement in overlap regions.
Args:
disagreement: [H, W, 1] relative disagreement (std/mean)
overlap_count: [H, W] number of views per pixel
max_disagreement: Maximum disagreement for color scale (clips above)
add_legend: Whether to add a color legend bar
Returns:
[H, W, 3] or [H, W+legend_width, 3] RGB heatmap where:
- Black = no overlap (single view or no coverage)
- Blue = low disagreement (good alignment)
- Green = medium disagreement
- Yellow = high disagreement
- Red = very high disagreement (bad alignment)
"""
device = disagreement.device
H, W = disagreement.shape[:2]
# Normalize disagreement to [0, 1]
disagreement_norm = (disagreement[:, :, 0] / max_disagreement).clamp(0, 1)
# Create RGB output
heatmap = torch.zeros(H, W, 3, device=device)
# Mask: only show overlap regions (2+ views)
overlap_mask = overlap_count >= 2
# Get colors
r, g, b = get_disagreement_color(disagreement_norm)
# Apply mask - only show in overlap regions
heatmap[:, :, 0] = torch.where(overlap_mask, r, torch.zeros_like(r))
heatmap[:, :, 1] = torch.where(overlap_mask, g, torch.zeros_like(g))
heatmap[:, :, 2] = torch.where(overlap_mask, b, torch.zeros_like(b))
# Make non-overlap regions dark gray so you can see coverage
non_overlap = (overlap_count == 1)
heatmap[:, :, 0] = torch.where(non_overlap, torch.full_like(r, 0.2), heatmap[:, :, 0])
heatmap[:, :, 1] = torch.where(non_overlap, torch.full_like(g, 0.2), heatmap[:, :, 1])
heatmap[:, :, 2] = torch.where(non_overlap, torch.full_like(b, 0.2), heatmap[:, :, 2])
# Add legend
if add_legend:
heatmap = draw_legend(heatmap, max_disagreement)
return heatmap
def draw_sample_borders(
panorama: torch.Tensor,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
border_color: tuple = (1.0, 0.0, 0.0),
border_width: int = 2,
) -> torch.Tensor:
"""Draw borders around sample regions on the panorama.
Args:
panorama: [H, W, C] panorama image
extrinsics: [N, 4, 4] camera extrinsics for each view
intrinsics: [4, 4] shared camera intrinsics
border_color: RGB color for borders
border_width: Width of border lines in pixels
Returns:
Panorama with borders drawn [H, W, 3]
"""
device = panorama.device
H, W = panorama.shape[:2]
num_views = extrinsics.shape[0]
# Convert grayscale to RGB if needed
if panorama.shape[-1] == 1:
panorama_rgb = panorama.repeat(1, 1, 3)
else:
panorama_rgb = panorama.clone()
# Get FOV from intrinsics
# The intrinsics encode the image size via principal point (cx = width/2)
f_px = intrinsics[0, 0].item()
cx = intrinsics[0, 2].item()
depth_size = int(cx * 2) # Derive from principal point (cx = width/2)
half_fov = math.atan((depth_size / 2) / f_px)
border_color_t = torch.tensor(border_color, device=device)
for view_idx in range(num_views):
ext = extrinsics[view_idx]
view_yaw, view_pitch = extrinsics_to_yaw_pitch(ext)
# Draw border of this view's coverage area
# The view covers a square region of angular size 2*half_fov
# Sample points along the border
num_border_points = 100
for edge in ['top', 'bottom', 'left', 'right']:
if edge == 'top':
us = torch.linspace(-1, 1, num_border_points)
vs = torch.ones(num_border_points) * -1
elif edge == 'bottom':
us = torch.linspace(-1, 1, num_border_points)
vs = torch.ones(num_border_points) * 1
elif edge == 'left':
us = torch.ones(num_border_points) * -1
vs = torch.linspace(-1, 1, num_border_points)
else: # right
us = torch.ones(num_border_points) * 1
vs = torch.linspace(-1, 1, num_border_points)
# Convert to camera ray directions
dx = us * math.tan(half_fov)
dy = vs * math.tan(half_fov)
dz = torch.ones_like(dx)
rays_cam = torch.stack([dx, dy, dz], dim=-1).to(device)
rays_cam = F.normalize(rays_cam, dim=-1)
# Rotate to world space
R_c2w = ext[:3, :3].T
rays_world = rays_cam @ R_c2w.T
# Convert to spherical
rx, ry, rz = rays_world[:, 0], rays_world[:, 1], rays_world[:, 2]
ray_yaw = torch.atan2(rx, rz)
ray_pitch = torch.asin(torch.clamp(ry, -1, 1))
# Map to panorama pixels
pano_x = ((ray_yaw / math.pi + 1) * (W - 1) / 2).long()
pano_y = ((0.5 - ray_pitch / math.pi) * (H - 1)).long()
# Draw border pixels
for bw in range(-border_width // 2, border_width // 2 + 1):
px = torch.clamp(pano_x + bw, 0, W - 1)
py = torch.clamp(pano_y, 0, H - 1)
panorama_rgb[py, px] = border_color_t
px = torch.clamp(pano_x, 0, W - 1)
py = torch.clamp(pano_y + bw, 0, H - 1)
panorama_rgb[py, px] = border_color_t
return panorama_rgb
class ProjectDepthToPanorama:
"""Project depth maps from perspective views back to equirectangular panorama.
Combines depth maps with blending and optional debug visualization.
Outputs a disagreement heatmap showing where overlapping views have mismatching depths.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"depth_maps": ("IMAGE",),
"extrinsics": ("EXTRINSICS",),
"intrinsics": ("INTRINSICS",),
},
"optional": {
"panorama_width": ("INT", {
"default": 2048,
"min": 512,
"max": 8192,
"step": 64,
"tooltip": "Output panorama width (height = width/2)"
}),
"blend_mode": (["gaussian", "cosine", "quadratic", "linear", "feather", "none"], {
"default": "gaussian",
"tooltip": "Blending algorithm: gaussian (smoothest), cosine, quadratic, linear, feather (hard center), none"
}),
"show_borders": ("BOOLEAN", {
"default": True,
"tooltip": "Draw borders around sample regions for debugging"
}),
"border_color": (["red", "green", "blue", "white", "yellow"], {
"default": "red",
"tooltip": "Color for sample region borders"
}),
"disagreement_scale": ("FLOAT", {
"default": 0.2,
"min": 0.01,
"max": 1.0,
"step": 0.01,
"tooltip": "Max relative disagreement for heatmap color scale (0.2 = 20%)"
}),
}
}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE",)
RETURN_NAMES = ("panoramic_depth", "debug_overlay", "disagreement_heatmap",)
FUNCTION = "project"
CATEGORY = "SHARP"
DESCRIPTION = "Project depth maps to panorama. Outputs disagreement heatmap showing where views mismatch (blue=good, red=bad)."
def project(
self,
depth_maps: torch.Tensor,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
panorama_width: int = 2048,
blend_mode: str = "gaussian",
show_borders: bool = True,
border_color: str = "red",
disagreement_scale: float = 0.2,
):
"""Project depth maps to panorama with disagreement visualization."""
print(f"[ProjectDepthToPanorama] Input: {depth_maps.shape[0]} depth maps")
print(f"[ProjectDepthToPanorama] Output panorama: {panorama_width} x {panorama_width // 2}")
print(f"[ProjectDepthToPanorama] Blend mode: {blend_mode}")
# Project depth maps with disagreement tracking
panoramic_depth, disagreement, overlap_count = project_depth_maps_to_panorama_with_disagreement(
depth_maps,
extrinsics,
intrinsics,
panorama_width,
blend_mode,
)
# Convert to RGB for output
panoramic_depth_rgb = panoramic_depth.repeat(1, 1, 3)
# Create debug overlay with borders
if show_borders:
color_map = {
"red": (1.0, 0.0, 0.0),
"green": (0.0, 1.0, 0.0),
"blue": (0.0, 0.0, 1.0),
"white": (1.0, 1.0, 1.0),
"yellow": (1.0, 1.0, 0.0),
}
debug_overlay = draw_sample_borders(
panoramic_depth_rgb.clone(),
extrinsics,
intrinsics,
border_color=color_map.get(border_color, (1.0, 0.0, 0.0)),
)
else:
debug_overlay = panoramic_depth_rgb.clone()
# Create disagreement heatmap
disagreement_heatmap = create_disagreement_heatmap(
disagreement,
overlap_count,
max_disagreement=disagreement_scale,
)
# Add borders to heatmap too for reference
if show_borders:
disagreement_heatmap = draw_sample_borders(
disagreement_heatmap,
extrinsics,
intrinsics,
border_color=(1.0, 1.0, 1.0), # White borders on heatmap
border_width=1,
)
# Add batch dimension for ComfyUI
panoramic_depth_out = panoramic_depth_rgb.unsqueeze(0) # [1, H, W, 3]
debug_overlay_out = debug_overlay.unsqueeze(0) # [1, H, W, 3]
disagreement_out = disagreement_heatmap.unsqueeze(0) # [1, H, W, 3]
print(f"[ProjectDepthToPanorama] Output shape: {panoramic_depth_out.shape}")
return (panoramic_depth_out, debug_overlay_out, disagreement_out,)
NODE_CLASS_MAPPINGS = {
"ProjectDepthToPanorama": ProjectDepthToPanorama,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ProjectDepthToPanorama": "Project Depth to Panorama",
}
+279
View File
@@ -0,0 +1,279 @@
"""SamplePanorama node for ComfyUI-Sharp.
Samples perspective cutouts from an equirectangular panorama for use with SHARP.
"""
import math
import numpy as np
import torch
import torch.nn.functional as F
def create_rotation_matrix(yaw: float, pitch: float) -> torch.Tensor:
"""Create a rotation matrix from yaw and pitch angles.
Args:
yaw: Horizontal angle in radians (0 = forward, positive = right)
pitch: Vertical angle in radians (0 = horizon, positive = up)
Returns:
3x3 rotation matrix (camera-to-world)
"""
# Rotation around Y axis (yaw - looking left/right)
cy, sy = math.cos(yaw), math.sin(yaw)
R_yaw = torch.tensor([
[cy, 0, sy],
[0, 1, 0],
[-sy, 0, cy]
], dtype=torch.float32)
# Rotation around X axis (pitch - looking up/down)
cp, sp = math.cos(pitch), math.sin(pitch)
R_pitch = torch.tensor([
[1, 0, 0],
[0, cp, -sp],
[0, sp, cp]
], dtype=torch.float32)
# Combined: first pitch, then yaw
R = R_yaw @ R_pitch
return R
def sample_perspective_from_equirectangular(
panorama: torch.Tensor,
yaw: float,
pitch: float,
fov_radians: float,
output_size: int,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Sample a perspective view from an equirectangular panorama.
Args:
panorama: Equirectangular image [H, W, 3] or [B, H, W, 3]
yaw: Horizontal angle in radians (0 = center, positive = right)
pitch: Vertical angle in radians (0 = horizon, positive = up)
fov_radians: Field of view in radians
output_size: Output image size (square)
Returns:
Tuple of (perspective_image, extrinsics_4x4, intrinsics_4x4)
"""
if panorama.dim() == 4:
panorama = panorama[0] # Take first if batched
H, W, C = panorama.shape
device = panorama.device
# Compute focal length in pixels
f_px = (output_size / 2) / math.tan(fov_radians / 2)
# Principal point at center
cx = (output_size - 1) / 2
cy = (output_size - 1) / 2
# Create pixel grid for output image
u = torch.arange(output_size, dtype=torch.float32, device=device)
v = torch.arange(output_size, dtype=torch.float32, device=device)
uu, vv = torch.meshgrid(u, v, indexing='xy') # [H, W]
# Convert to camera-space ray directions
# d_cam = [(u - cx) / f, (v - cy) / f, 1]
dx = (uu - cx) / f_px
dy = (vv - cy) / f_px
dz = torch.ones_like(dx)
# Stack and normalize
rays_cam = torch.stack([dx, dy, dz], dim=-1) # [H, W, 3]
rays_cam = F.normalize(rays_cam, dim=-1)
# Rotate rays to world space
R = create_rotation_matrix(yaw, pitch).to(device) # [3, 3]
rays_world = torch.einsum('ij,hwj->hwi', R, rays_cam) # [H, W, 3]
# Convert world rays to spherical coordinates
# x = right, y = up, z = forward
rx, ry, rz = rays_world[..., 0], rays_world[..., 1], rays_world[..., 2]
# Yaw: angle around Y axis (horizontal)
ray_yaw = torch.atan2(rx, rz) # [-pi, pi]
# Pitch: angle from horizon (vertical)
ray_pitch = torch.asin(torch.clamp(ry, -1, 1)) # [-pi/2, pi/2]
# Map to equirectangular pixel coordinates
# Yaw: -pi..pi -> 0..W
# Pitch: -pi/2..pi/2 -> H..0 (note: inverted, top of image is up)
eq_x = (ray_yaw / math.pi + 1) * (W - 1) / 2
eq_y = (0.5 - ray_pitch / math.pi) * (H - 1)
# Normalize to [-1, 1] for grid_sample
grid_x = eq_x / (W - 1) * 2 - 1
grid_y = eq_y / (H - 1) * 2 - 1
grid = torch.stack([grid_x, grid_y], dim=-1).unsqueeze(0) # [1, H, W, 2]
# Sample from panorama
panorama_nchw = panorama.permute(2, 0, 1).unsqueeze(0) # [1, C, H, W]
sampled = F.grid_sample(
panorama_nchw,
grid,
mode='bilinear',
padding_mode='border',
align_corners=True
)
perspective = sampled[0].permute(1, 2, 0) # [H, W, C]
# Build intrinsics matrix (4x4)
intrinsics = torch.tensor([
[f_px, 0, cx, 0],
[0, f_px, cy, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
], dtype=torch.float32, device=device)
# Build extrinsics matrix (4x4, world-to-camera)
# World-to-camera is the inverse (transpose for rotation)
R_w2c = R.T
extrinsics = torch.eye(4, dtype=torch.float32, device=device)
extrinsics[:3, :3] = R_w2c
# No translation (camera at origin)
return perspective, extrinsics, intrinsics
class SamplePanorama:
"""Sample perspective cutouts from an equirectangular panorama.
Automatically calculates the number of samples needed to cover the full
360° x 180° panorama based on FOV and overlap settings.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"panorama": ("IMAGE",),
"fov_degrees": ("FLOAT", {
"default": 65.0,
"min": 30.0,
"max": 120.0,
"step": 1.0,
"tooltip": "Field of view in degrees for each perspective cutout"
}),
"overlap_percent": ("FLOAT", {
"default": 10.0,
"min": 0.0,
"max": 50.0,
"step": 1.0,
"tooltip": "Overlap between adjacent samples as percentage of FOV"
}),
"output_size": ("INT", {
"default": 1536,
"min": 256,
"max": 2048,
"step": 64,
"tooltip": "Output resolution for each perspective image (square). 1536 is SHARP's native resolution."
}),
},
"optional": {
"skip_poles": ("BOOLEAN", {
"default": True,
"tooltip": "Skip samples pointing straight up/down (often low quality in panoramas)"
}),
}
}
RETURN_TYPES = ("IMAGE", "EXTRINSICS", "INTRINSICS", "INT", "INT")
RETURN_NAMES = ("images", "extrinsics", "intrinsics", "num_horizontal", "num_vertical")
FUNCTION = "sample"
CATEGORY = "SHARP"
DESCRIPTION = "Sample perspective views from a 360° equirectangular panorama for SHARP processing."
def sample(
self,
panorama: torch.Tensor,
fov_degrees: float = 65.0,
overlap_percent: float = 10.0,
output_size: int = 1536,
skip_poles: bool = True,
):
"""Sample perspective cutouts covering the full panorama."""
fov_radians = math.radians(fov_degrees)
# Calculate step angle based on overlap
step_degrees = fov_degrees * (1 - overlap_percent / 100)
step_radians = math.radians(step_degrees)
# Calculate number of samples needed
num_horizontal = math.ceil(360 / step_degrees)
# For vertical, we cover -90° to +90° (180° total)
# But if skip_poles, we limit to avoid extreme angles
if skip_poles:
# Skip top and bottom 15°
vertical_range = 150 # degrees
vertical_start = -75 # degrees
else:
vertical_range = 180
vertical_start = -90
num_vertical = max(1, math.ceil(vertical_range / step_degrees))
print(f"[SamplePanorama] FOV: {fov_degrees}°, Overlap: {overlap_percent}%")
print(f"[SamplePanorama] Step angle: {step_degrees:.1f}°")
print(f"[SamplePanorama] Samples: {num_horizontal} horizontal × {num_vertical} vertical = {num_horizontal * num_vertical} total")
# Handle batch dimension
if panorama.dim() == 3:
panorama = panorama.unsqueeze(0)
# Take first image if batch
pano = panorama[0] # [H, W, C]
all_images = []
all_extrinsics = []
intrinsics = None
for v_idx in range(num_vertical):
# Calculate pitch angle
pitch_degrees = vertical_start + (v_idx + 0.5) * step_degrees
pitch_radians = math.radians(pitch_degrees)
for h_idx in range(num_horizontal):
# Calculate yaw angle
yaw_degrees = -180 + (h_idx + 0.5) * step_degrees
yaw_radians = math.radians(yaw_degrees)
# Sample perspective view
perspective, extrinsics, intr = sample_perspective_from_equirectangular(
pano,
yaw_radians,
pitch_radians,
fov_radians,
output_size,
)
all_images.append(perspective)
all_extrinsics.append(extrinsics)
if intrinsics is None:
intrinsics = intr
# Stack results
images_batch = torch.stack(all_images, dim=0) # [N, H, W, C]
extrinsics_batch = torch.stack(all_extrinsics, dim=0) # [N, 4, 4]
print(f"[SamplePanorama] Output shape: {images_batch.shape}")
return (images_batch, extrinsics_batch, intrinsics, num_horizontal, num_vertical)
NODE_CLASS_MAPPINGS = {
"SamplePanorama": SamplePanorama,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SamplePanorama": "Sample Panorama (Equirect → Perspective)",
}
+4 -4
View File
@@ -15,14 +15,14 @@ import torch
import torch.nn.functional as F
import torch.utils.data
from nodes.sharp.models import (
from ..models import (
PredictorParams,
RGBGaussianPredictor,
create_predictor,
)
from nodes.sharp.utils import io
from nodes.sharp.utils import logging as logging_utils
from nodes.sharp.utils.gaussians import (
from ..utils import io
from ..utils import logging as logging_utils
from ..utils.gaussians import (
Gaussians3D,
SceneMetaData,
save_ply,
+3 -3
View File
@@ -13,9 +13,9 @@ import click
import torch
import torch.utils.data
from nodes.sharp.utils import camera, gsplat, io
from nodes.sharp.utils import logging as logging_utils
from nodes.sharp.utils.gaussians import Gaussians3D, SceneMetaData, load_ply
from ..utils import camera, gsplat, io
from ..utils import logging as logging_utils
from ..utils.gaussians import Gaussians3D, SceneMetaData, load_ply
LOGGER = logging.getLogger(__name__)
+1 -1
View File
@@ -6,7 +6,7 @@ Copyright (C) 2025 Apple Inc. All Rights Reserved.
from __future__ import annotations
from nodes.sharp.models.monodepth import (
from .monodepth import (
create_monodepth_adaptor,
create_monodepth_dpt,
)
+3 -3
View File
@@ -12,9 +12,9 @@ import torch
import torch.nn.functional as F
from torch import nn
from nodes.sharp.models.decoders import UNetDecoder
from nodes.sharp.models.encoders import UNetEncoder
from nodes.sharp.utils import math as math_utils
from .decoders import UNetDecoder
from .encoders import UNetEncoder
from ..utils import math as math_utils
from .params import AlignmentParams
+4 -4
View File
@@ -10,10 +10,10 @@ import torch
from torch import nn
from torch.nn import functional as F
from nodes.sharp.models.initializer import GaussianBaseValues
from nodes.sharp.utils import math as math_utils
from nodes.sharp.utils.color_space import ColorSpace, sRGB2linearRGB
from nodes.sharp.utils.gaussians import Gaussians3D
from .initializer import GaussianBaseValues
from ..utils import math as math_utils
from ..utils.color_space import ColorSpace, sRGB2linearRGB
from ..utils.gaussians import Gaussians3D
from .params import DeltaFactor
@@ -7,7 +7,7 @@ Copyright (C) 2025 Apple Inc. All Rights Reserved.
from __future__ import annotations
from nodes.sharp.models.presets import (
from ..presets import (
MONODEPTH_ENCODER_DIMS_MAP,
ViTPreset,
)
@@ -13,8 +13,8 @@ from typing import Iterable
import torch
import torch.nn as nn
from nodes.sharp.models.blocks import FeatureFusionBlock2d, UpsamplingMode
from nodes.sharp.utils.training import checkpoint_wrapper
from ..blocks import FeatureFusionBlock2d, UpsamplingMode
from ...utils.training import checkpoint_wrapper
from .base_decoder import BaseDecoder
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import List
import torch
from torch import nn
from nodes.sharp.models.blocks import (
from ..blocks import (
NormLayerName,
norm_layer_2d,
residual_block_2d,
+1 -1
View File
@@ -4,7 +4,7 @@ For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from nodes.sharp.models.encoders.base_encoder import BaseEncoder
from .base_encoder import BaseEncoder
from .monodepth_encoder import (
MonodepthFeatureEncoder,
@@ -11,7 +11,7 @@ from __future__ import annotations
import torch
import torch.nn as nn
from nodes.sharp.models.presets import (
from ..presets import (
MONODEPTH_ENCODER_DIMS_MAP,
MONODEPTH_HOOK_IDS_MAP,
ViTPreset,
+1 -1
View File
@@ -14,7 +14,7 @@ import torch.fx
import torch.nn as nn
import torch.nn.functional as F
from nodes.sharp.utils.training import checkpoint_wrapper
from ...utils.training import checkpoint_wrapper
from .base_encoder import BaseEncoder
from .vit_encoder import TimmViT
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import List
import torch
from torch import nn
from nodes.sharp.models.blocks import (
from ..blocks import (
NormLayerName,
norm_layer_2d,
residual_block_2d,
+1 -1
View File
@@ -11,7 +11,7 @@ import logging
import timm
import torch
from nodes.sharp.models.presets.vit import VIT_CONFIG_DICT, ViTConfig, ViTPreset
from ..presets.vit import VIT_CONFIG_DICT, ViTConfig, ViTPreset
LOGGER = logging.getLogger(__name__)
+3 -3
View File
@@ -13,13 +13,13 @@ from typing import NamedTuple
import torch
import torch.nn as nn
from nodes.sharp.models.blocks import (
from .blocks import (
FeatureFusionBlock2d,
NormLayerName,
residual_block_2d,
)
from nodes.sharp.models.decoders import BaseDecoder, MultiresConvDecoder
from nodes.sharp.models.params import DPTImageEncoderType, GaussianDecoderParams
from .decoders import BaseDecoder, MultiresConvDecoder
from .params import DPTImageEncoderType, GaussianDecoderParams
def create_gaussian_decoder(
+4 -4
View File
@@ -14,13 +14,13 @@ from typing import NamedTuple, Tuple
import torch
import torch.nn as nn
from nodes.sharp.models import normalizers
from nodes.sharp.models.decoders import MultiresConvDecoder, create_monodepth_decoder
from nodes.sharp.models.encoders import (
from . import normalizers
from .decoders import MultiresConvDecoder, create_monodepth_decoder
from .encoders import (
SlidingPyramidNetwork,
create_monodepth_encoder,
)
from nodes.sharp.utils import module_surgery
from ..utils import module_surgery
from .params import MonodepthAdaptorParams, MonodepthParams
+4 -4
View File
@@ -7,10 +7,10 @@ Copyright (C) 2025 Apple Inc. All Rights Reserved.
import dataclasses
from typing import Literal
import nodes.sharp.utils.math as math_utils
from nodes.sharp.models.blocks import NormLayerName, UpsamplingMode
from nodes.sharp.models.presets import ViTPreset
from nodes.sharp.utils.color_space import ColorSpace
from ..utils import math as math_utils
from .blocks import NormLayerName, UpsamplingMode
from .presets import ViTPreset
from ..utils.color_space import ColorSpace
DimsDecoder = tuple[int, int, int, int, int]
DPTImageEncoderType = Literal["skip_conv", "skip_conv_kernel2"]
+2 -2
View File
@@ -11,8 +11,8 @@ import logging
import torch
from torch import nn
from nodes.sharp.models.monodepth import MonodepthWithEncodingAdaptor
from nodes.sharp.utils.gaussians import Gaussians3D
from .monodepth import MonodepthWithEncodingAdaptor
from ..utils.gaussians import Gaussians3D
from .composer import GaussianComposer
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import Literal
import torch
from nodes.sharp.utils.robust import robust_where
from .robust import robust_where
LOGGER = logging.getLogger(__name__)
+2 -2
View File
@@ -14,8 +14,8 @@ import numpy as np
import torch
from plyfile import PlyData, PlyElement
from nodes.sharp.utils import color_space as cs_utils
from nodes.sharp.utils import linalg
from . import color_space as cs_utils
from . import linalg
LOGGER = logging.getLogger(__name__)
+3 -3
View File
@@ -13,9 +13,9 @@ import gsplat
import torch
from torch import nn
from nodes.sharp.utils import color_space as cs_utils
from nodes.sharp.utils import io, vis
from nodes.sharp.utils.gaussians import BackgroundColor, Gaussians3D
from . import color_space as cs_utils
from . import io, vis
from .gaussians import BackgroundColor, Gaussians3D
class RenderingOutputs(NamedTuple):
File diff suppressed because one or more lines are too long