"""Zero-download depth processing, analysis, and 3D export nodes for ComfyUI.""" from __future__ import annotations import json import os import re import struct from datetime import datetime from typing import Any import numpy as np from PIL import Image, ImageDraw CATEGORY = "depth/toolkit" CATEGORY_3D = "depth/3D export" def _numpy_batch(images: Any) -> np.ndarray: array = images.detach().cpu().float().numpy() array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=0.0) if array.ndim == 3: array = array[None, ...] if array.ndim != 4: raise ValueError(f"Expected a BHWC image batch, got {array.shape}.") if array.shape[-1] == 1: array = np.repeat(array, 3, axis=-1) elif array.shape[-1] < 3: raise ValueError(f"Expected one or at least three channels, got {array.shape}.") return np.clip(array[..., :3], 0.0, 1.0).astype(np.float32, copy=False) def _depth(images: Any) -> np.ndarray: rgb = _numpy_batch(images) return (rgb[..., 0] * 0.2126 + rgb[..., 1] * 0.7152 + rgb[..., 2] * 0.0722).astype(np.float32) def _torch(array: np.ndarray): import torch return torch.from_numpy(np.ascontiguousarray(array, dtype=np.float32)) def _depth_image(values: np.ndarray) -> np.ndarray: return np.repeat(values[..., None], 3, axis=-1) def _resize_batch(batch: np.ndarray, height: int, width: int) -> np.ndarray: if batch.shape[1:3] == (height, width): return batch result = [] for image in batch: pil = Image.fromarray((image * 255.0).round().astype(np.uint8), "RGB") result.append(np.asarray(pil.resize((width, height), Image.Resampling.LANCZOS), dtype=np.float32) / 255.0) return np.stack(result) def _safe_output_path(prefix: str, extension: str) -> str: import folder_paths output_root = os.path.realpath(folder_paths.get_output_directory()) pieces = [re.sub(r"[^A-Za-z0-9._-]+", "_", item).strip("._") for item in str(prefix).replace("\\", "/").split("/")] pieces = [item for item in pieces if item] or ["depth_exports", "depth"] directory = os.path.realpath(os.path.join(output_root, *pieces[:-1])) os.makedirs(directory, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") path = os.path.realpath(os.path.join(directory, f"{pieces[-1]}_{stamp}.{extension}")) if os.path.commonpath((output_root, path)) != output_root: raise ValueError("Export path must remain inside the ComfyUI output directory.") return path def _normalize_one(values: np.ndarray, method: str, low: float, high: float) -> tuple[np.ndarray, float, float]: finite = values[np.isfinite(values)] if finite.size == 0: return np.zeros_like(values), 0.0, 1.0 if method == "Percentile": lo, hi = np.percentile(finite, [low, high]) elif method == "Fixed range": lo, hi = float(low), float(high) else: lo, hi = float(finite.min()), float(finite.max()) if hi <= lo + 1e-12: return np.zeros_like(values), float(lo), float(hi) return np.clip((values - lo) / (hi - lo), 0.0, 1.0).astype(np.float32), float(lo), float(hi) def _median_filter(values: np.ndarray, radius: int) -> np.ndarray: """Precision-preserving, bounded-memory 2D median filter for BHW depth.""" radius = int(radius) if radius <= 0: return values size = radius * 2 + 1 filtered = np.empty_like(values, dtype=np.float32) for batch_index, image in enumerate(values): padded = np.pad(image, ((radius, radius), (radius, radius)), mode="edge") for row in range(image.shape[0]): windows = np.lib.stride_tricks.sliding_window_view( padded[row : row + size], (size, size) ) filtered[batch_index, row] = np.median( windows[0], axis=(-2, -1) ).astype(np.float32) return filtered class DepthNormalize: @classmethod def INPUT_TYPES(cls): return { "required": { "depth_map": ("IMAGE",), "method": (["Percentile", "Min-max", "Fixed range"],), "low": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.1}), "high": ("FLOAT", {"default": 99.0, "min": 0.0, "max": 100.0, "step": 0.1}), "invert": ("BOOLEAN", {"default": False}), "gamma": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 8.0, "step": 0.05}), } } RETURN_TYPES = ("IMAGE", "STRING") RETURN_NAMES = ("normalized_depth", "range_report_json") FUNCTION = "normalize" CATEGORY = CATEGORY DESCRIPTION = "Normalizes arbitrary depth ranges per image with percentile clipping, inversion, and gamma." def normalize(self, depth_map, method, low, high, invert, gamma): if float(high) <= float(low): raise ValueError("high must be greater than low.") source = _depth(depth_map) outputs, ranges = [], [] for image in source: normalized, actual_low, actual_high = _normalize_one(image, method, float(low), float(high)) if invert: normalized = 1.0 - normalized normalized = np.power(np.clip(normalized, 0.0, 1.0), 1.0 / float(gamma)).astype(np.float32) outputs.append(normalized) ranges.append({"low": actual_low, "high": actual_high}) return (_torch(_depth_image(np.stack(outputs))), json.dumps({"method": method, "invert": bool(invert), "gamma": float(gamma), "ranges": ranges}, indent=2)) _COLORMAPS = { "viridis": [(0.0, (68, 1, 84)), (0.25, (59, 82, 139)), (0.5, (33, 145, 140)), (0.75, (94, 201, 98)), (1.0, (253, 231, 37))], "magma": [(0.0, (0, 0, 4)), (0.25, (81, 18, 124)), (0.5, (183, 55, 121)), (0.75, (252, 137, 97)), (1.0, (252, 253, 191))], "turbo": [(0.0, (48, 18, 59)), (0.2, (65, 105, 225)), (0.4, (52, 205, 166)), (0.6, (190, 233, 51)), (0.8, (249, 126, 32)), (1.0, (122, 4, 3))], "plasma": [(0.0, (13, 8, 135)), (0.25, (126, 3, 168)), (0.5, (204, 71, 120)), (0.75, (248, 149, 64)), (1.0, (240, 249, 33))], "grayscale": [(0.0, (0, 0, 0)), (1.0, (255, 255, 255))], } def _lut(name: str) -> np.ndarray: controls = _COLORMAPS[name] x = np.linspace(0.0, 1.0, 256) xp = np.array([point for point, _ in controls]) colors = np.array([color for _, color in controls], dtype=np.float32) / 255.0 return np.stack([np.interp(x, xp, colors[:, channel]) for channel in range(3)], axis=-1).astype(np.float32) class DepthColormap: @classmethod def INPUT_TYPES(cls): return {"required": {"depth_map": ("IMAGE",), "colormap": (list(_COLORMAPS),), "invert": ("BOOLEAN", {"default": False}), "show_invalid_magenta": ("BOOLEAN", {"default": True})}} RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("colored_depth",) FUNCTION = "colorize" CATEGORY = CATEGORY DESCRIPTION = "Applies an embedded perceptual depth colormap without matplotlib or network access." def colorize(self, depth_map, colormap, invert, show_invalid_magenta): raw = depth_map.detach().cpu().float().numpy() invalid = ~np.isfinite(raw[..., 0] if raw.ndim == 4 else raw) values = _depth(depth_map) if invert: values = 1.0 - values indices = np.clip(np.rint(values * 255.0), 0, 255).astype(np.uint8) output = _lut(colormap)[indices] if show_invalid_magenta and invalid.shape == output.shape[:3]: output[invalid] = (1.0, 0.0, 1.0) return (_torch(output),) class DepthToNormal: @classmethod def INPUT_TYPES(cls): return {"required": {"depth_map": ("IMAGE",), "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 20.0, "step": 0.05}), "field_of_view": ("FLOAT", {"default": 60.0, "min": 1.0, "max": 179.0, "step": 1.0}), "convention": (["OpenGL (+Y)", "DirectX (-Y)"],), "invert_depth": ("BOOLEAN", {"default": False})}} RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("surface_normal",) FUNCTION = "convert" CATEGORY = CATEGORY DESCRIPTION = "Computes FOV-aware camera-space surface normals from a depth map." def convert(self, depth_map, strength, field_of_view, convention, invert_depth): values = _depth(depth_map) if invert_depth: values = 1.0 - values h, w = values.shape[1:3] focal = 0.5 * w / np.tan(np.deg2rad(float(field_of_view)) * 0.5) dy, dx = np.gradient(values, axis=(1, 2)) scale = float(strength) * focal / max(w, h) ny = -dy * scale if convention.startswith("OpenGL") else dy * scale vectors = np.stack((-dx * scale, ny, np.ones_like(values)), axis=-1) vectors /= np.maximum(np.linalg.norm(vectors, axis=-1, keepdims=True), 1e-8) return (_torch(vectors * 0.5 + 0.5),) class DepthRangeMask: @classmethod def INPUT_TYPES(cls): return {"required": {"depth_map": ("IMAGE",), "near": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), "far": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01}), "feather": ("FLOAT", {"default": 0.02, "min": 0.0, "max": 0.5, "step": 0.005}), "invert_depth": ("BOOLEAN", {"default": False})}} RETURN_TYPES = ("MASK", "MASK", "IMAGE") RETURN_NAMES = ("inside_range", "outside_range", "masked_depth") FUNCTION = "mask" CATEGORY = CATEGORY DESCRIPTION = "Builds soft near/far masks for compositing, relighting, and depth-conditioned generation." def mask(self, depth_map, near, far, feather, invert_depth): if float(far) <= float(near): raise ValueError("far must be greater than near.") values = _depth(depth_map) if invert_depth: values = 1.0 - values f = max(float(feather), 1e-6) enter = np.clip((values - (float(near) - f)) / f, 0.0, 1.0) leave = np.clip(((float(far) + f) - values) / f, 0.0, 1.0) inside = np.minimum(enter, leave).astype(np.float32) return (_torch(inside), _torch(1.0 - inside), _torch(_depth_image(values * inside))) class DepthCleanup: @classmethod def INPUT_TYPES(cls): return {"required": {"depth_map": ("IMAGE",), "hole_threshold": ("FLOAT", {"default": 0.001, "min": 0.0, "max": 1.0, "step": 0.001}), "fill_iterations": ("INT", {"default": 3, "min": 0, "max": 32}), "median_radius": ("INT", {"default": 1, "min": 0, "max": 5}), "preserve_edges": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.05})}} RETURN_TYPES = ("IMAGE", "MASK", "STRING") RETURN_NAMES = ("clean_depth", "repaired_pixels", "report_json") FUNCTION = "clean" CATEGORY = CATEGORY DESCRIPTION = "Fills zero/invalid holes and suppresses speckle with an edge-preserving blend." def clean(self, depth_map, hole_threshold, fill_iterations, median_radius, preserve_edges): original = _depth(depth_map) result = original.copy() holes = result <= float(hole_threshold) for _ in range(int(fill_iterations)): if not holes.any(): break total = np.zeros_like(result) count = np.zeros_like(result) for oy, ox in ((-1, 0), (1, 0), (0, -1), (0, 1)): neighbor = np.roll(result, (oy, ox), axis=(1, 2)) valid = neighbor > float(hole_threshold) total += neighbor * valid count += valid can_fill = holes & (count > 0) result[can_fill] = total[can_fill] / count[can_fill] holes = result <= float(hole_threshold) if int(median_radius) > 0: filtered = _median_filter(result, int(median_radius)) gradients = np.hypot(*np.gradient(original, axis=(1, 2))) edge_weight = np.clip(gradients * 16.0 * float(preserve_edges), 0.0, 1.0) result = filtered * (1.0 - edge_weight) + result * edge_weight repaired = (np.abs(result - original) > 1e-6).astype(np.float32) report = {"input_hole_fraction": float((original <= float(hole_threshold)).mean()), "unfilled_fraction": float(holes.mean()), "changed_fraction": float(repaired.mean())} return (_torch(_depth_image(np.clip(result, 0.0, 1.0))), _torch(repaired), json.dumps(report, indent=2)) class DepthStats: @classmethod def INPUT_TYPES(cls): return {"required": {"depth_map": ("IMAGE",), "bins": ("INT", {"default": 64, "min": 16, "max": 256, "step": 16})}} RETURN_TYPES = ("STRING", "IMAGE") RETURN_NAMES = ("statistics_json", "histogram") FUNCTION = "analyze" CATEGORY = CATEGORY DESCRIPTION = "Reports robust depth statistics and creates a workflow-visible histogram image." def analyze(self, depth_map, bins): values = _depth(depth_map) reports, charts = [], [] for image in values: reports.append({"min": float(image.min()), "max": float(image.max()), "mean": float(image.mean()), "median": float(np.median(image)), "std": float(image.std()), "p01": float(np.percentile(image, 1)), "p99": float(np.percentile(image, 99)), "near_fraction_0_25": float((image <= 0.25).mean()), "far_fraction_0_75": float((image >= 0.75).mean())}) hist, _ = np.histogram(image, bins=int(bins), range=(0.0, 1.0)) canvas = Image.new("RGB", (640, 300), (14, 18, 25)) draw = ImageDraw.Draw(canvas) max_count = max(int(hist.max()), 1) for index, count in enumerate(hist): x0 = 40 + index * 560 / len(hist) x1 = 40 + (index + 1) * 560 / len(hist) y = 260 - int(count / max_count * 220) draw.rectangle((x0, y, x1 + 1, 260), fill=(55, 190, 210)) draw.line((40, 260, 600, 260), fill=(210, 220, 230), width=2) draw.text((40, 270), "near 0", fill=(210, 220, 230)) draw.text((548, 270), "far 1", fill=(210, 220, 230)) charts.append(np.asarray(canvas, dtype=np.float32) / 255.0) return (json.dumps({"batch": reports}, indent=2), _torch(np.stack(charts))) def _camera_points(depth: np.ndarray, fov: float, depth_scale: float, stride: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: h, w = depth.shape ys = np.arange(0, h, stride, dtype=np.int32) xs = np.arange(0, w, stride, dtype=np.int32) xx, yy = np.meshgrid(xs, ys) z = depth[yy, xx] * float(depth_scale) focal = 0.5 * w / np.tan(np.deg2rad(float(fov)) * 0.5) x = (xx - (w - 1) * 0.5) * z / focal y = -((yy - (h - 1) * 0.5) * z / focal) return np.stack((x, y, z), axis=-1).astype(np.float32), xs, ys class DepthToPointCloud: @classmethod def INPUT_TYPES(cls): return {"required": {"depth_map": ("IMAGE",), "field_of_view": ("FLOAT", {"default": 60.0, "min": 1.0, "max": 179.0, "step": 1.0}), "depth_scale": ("FLOAT", {"default": 1.0, "min": 0.001, "max": 1000.0, "step": 0.01}), "stride": ("INT", {"default": 4, "min": 1, "max": 64}), "drop_zero_depth": ("BOOLEAN", {"default": True}), "filename_prefix": ("STRING", {"default": "depth_exports/point_cloud"})}, "optional": {"color_image": ("IMAGE",)}} RETURN_TYPES = ("STRING", "STRING") RETURN_NAMES = ("ply_paths", "manifest_json") FUNCTION = "export" CATEGORY = CATEGORY_3D OUTPUT_NODE = True DESCRIPTION = "Exports binary PLY point clouds with camera-space coordinates and optional RGB color." def export(self, depth_map, field_of_view, depth_scale, stride, drop_zero_depth, filename_prefix, color_image=None): depths = _depth(depth_map) colors = _resize_batch(_numpy_batch(color_image), depths.shape[1], depths.shape[2]) if color_image is not None else None paths, counts = [], [] for batch_index, depth in enumerate(depths): points, xs, ys = _camera_points(depth, field_of_view, depth_scale, int(stride)) points = points.reshape(-1, 3) valid = np.isfinite(points).all(axis=1) if drop_zero_depth: valid &= points[:, 2] > 1e-8 points = points[valid] rgb = None if colors is not None: color = colors[min(batch_index, len(colors) - 1)][np.ix_(ys, xs)].reshape(-1, 3) rgb = (color[valid] * 255.0).round().astype(np.uint8) path = _safe_output_path(f"{filename_prefix}_{batch_index:03d}", "ply") properties = "property float x\nproperty float y\nproperty float z\n" if rgb is not None: properties += "property uchar red\nproperty uchar green\nproperty uchar blue\n" header = f"ply\nformat binary_little_endian 1.0\ncomment ComfyUI Depth Visualization\nelement vertex {len(points)}\n{properties}end_header\n".encode("ascii") with open(path, "xb") as handle: handle.write(header) if rgb is None: handle.write(points.astype(" None: while len(blob) % alignment: blob.append(0) def _write_glb(path: str, positions: np.ndarray, colors: np.ndarray, indices: np.ndarray) -> None: binary = bytearray() pos_offset = len(binary) binary.extend(positions.astype(" 0: edges = (np.linalg.norm(vertices[0] - vertices[1]), np.linalg.norm(vertices[1] - vertices[2]), np.linalg.norm(vertices[2] - vertices[0])) if max(edges) > threshold: continue indices.extend(tri) index_array = np.asarray(indices, dtype=np.uint32) if len(index_array) < 3: raise ValueError("No valid mesh triangles remain; increase max_edge_length or reduce stride.") path = _safe_output_path(f"{filename_prefix}_{batch_index:03d}", "glb") _write_glb(path, positions, rgb, index_array) paths.append(path) triangle_counts.append(len(index_array) // 3) return ("\n".join(paths), json.dumps({"format": "glTF 2.0 binary", "paths": paths, "triangle_counts": triangle_counts, "fov_degrees": float(field_of_view), "depth_scale": float(depth_scale), "stride": int(stride), "max_edge_length": float(max_edge_length)}, indent=2)) def _bilinear_clamp(image: np.ndarray, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]: h, w = image.shape[:2] valid = (x >= 0.0) & (x <= w - 1.0) & (y >= 0.0) & (y <= h - 1.0) x = np.clip(x, 0.0, w - 1.0) y = np.clip(y, 0.0, h - 1.0) x0, y0 = np.floor(x).astype(np.int32), np.floor(y).astype(np.int32) x1, y1 = np.minimum(x0 + 1, w - 1), np.minimum(y0 + 1, h - 1) wx, wy = (x - x0)[..., None], (y - y0)[..., None] top = image[y0, x0] * (1 - wx) + image[y0, x1] * wx bottom = image[y1, x0] * (1 - wx) + image[y1, x1] * wx return top * (1 - wy) + bottom * wy, valid.astype(np.float32) class DepthParallaxFrames: @classmethod def INPUT_TYPES(cls): return {"required": {"image": ("IMAGE",), "depth_map": ("IMAGE",), "frames": ("INT", {"default": 24, "min": 2, "max": 240}), "amplitude_px": ("FLOAT", {"default": 24.0, "min": 0.0, "max": 512.0, "step": 1.0}), "path": (["horizontal sway", "vertical sway", "ellipse", "dolly"],), "loop": ("BOOLEAN", {"default": True}), "depth_center": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01})}} RETURN_TYPES = ("IMAGE", "MASK", "STRING") RETURN_NAMES = ("frames", "validity_masks", "manifest_json") FUNCTION = "animate" CATEGORY = CATEGORY DESCRIPTION = "Creates video-ready depth parallax frames plus validity masks for inpainting exposed edges." def animate(self, image, depth_map, frames, amplitude_px, path, loop, depth_center): colors = _numpy_batch(image) depths = _depth(depth_map) count = max(len(colors), len(depths)) if len(colors) not in (1, count) or len(depths) not in (1, count): raise ValueError("image and depth_map batches must match or contain one image.") depths = _resize_batch(_depth_image(depths), colors.shape[1], colors.shape[2])[..., 0] h, w = colors.shape[1:3] yy, xx = np.mgrid[0:h, 0:w] outputs, masks = [], [] frame_count = int(frames) denominator = frame_count if loop else max(frame_count - 1, 1) for batch_index in range(count): color = colors[min(batch_index, len(colors) - 1)] depth = depths[min(batch_index, len(depths) - 1)] relative = (depth - float(depth_center)) * float(amplitude_px) for frame in range(frame_count): phase = 2.0 * np.pi * frame / denominator sx = sy = 0.0 scale = 0.0 if path == "horizontal sway": sx = np.sin(phase) elif path == "vertical sway": sy = np.sin(phase) elif path == "ellipse": sx, sy = np.cos(phase), np.sin(phase) * 0.55 else: scale = np.sin(phase) sample_x = xx - relative * sx - (xx - (w - 1) * 0.5) * relative * scale / max(w, 1) sample_y = yy - relative * sy - (yy - (h - 1) * 0.5) * relative * scale / max(h, 1) warped, valid = _bilinear_clamp(color, sample_x, sample_y) outputs.append(warped) masks.append(valid) manifest = {"source_batches": count, "frames_per_source": frame_count, "total_frames": len(outputs), "path": path, "loop": bool(loop), "amplitude_px": float(amplitude_px), "mask_semantics": "1 = sampled inside source; 0 = exposed edge requiring fill"} return (_torch(np.stack(outputs)), _torch(np.stack(masks)), json.dumps(manifest, indent=2)) NODE_CLASS_MAPPINGS = { "DepthNormalize": DepthNormalize, "DepthColormap": DepthColormap, "DepthToNormal": DepthToNormal, "DepthRangeMask": DepthRangeMask, "DepthCleanup": DepthCleanup, "DepthStats": DepthStats, "DepthToPointCloud": DepthToPointCloud, "DepthToMesh": DepthToMesh, "DepthParallaxFrames": DepthParallaxFrames, } NODE_DISPLAY_NAME_MAPPINGS = { "DepthNormalize": "Normalize Depth", "DepthColormap": "Depth Colormap", "DepthToNormal": "Depth to Surface Normal", "DepthRangeMask": "Depth Range Masks", "DepthCleanup": "Clean & Repair Depth", "DepthStats": "Analyze Depth", "DepthToPointCloud": "Export Depth Point Cloud (PLY)", "DepthToMesh": "Export Depth Mesh (GLB)", "DepthParallaxFrames": "Depth Parallax Frames", }