diff --git a/.comfyignore b/.comfyignore new file mode 100644 index 0000000..c167695 --- /dev/null +++ b/.comfyignore @@ -0,0 +1,5 @@ +.github/ +tests/ +__pycache__/ +.pytest_cache/ +*.py[cod] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2fd8fa..fe28dbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,27 +3,69 @@ name: CI on: pull_request: push: - branches: - - main + branches: [main] permissions: contents: read jobs: - test: - runs-on: ubuntu-latest + python: + name: Python ${{ matrix.python }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12", "3.14"] + include: + - os: ubuntu-latest + python: "3.10" + - os: ubuntu-latest + python: "3.13" + - os: windows-latest + python: "3.12" + - os: macos-latest + python: "3.12" steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python }} cache: pip - - run: python -m pip install -r requirements.txt pytest - - run: python -m compileall -q . - - run: pytest -q - - run: node --input-type=module --check < web/visualization.js - - run: node --check web/js/threeVisualizer.mjs + - name: Install CPU PyTorch + if: runner.os != 'macOS' + run: python -m pip install --disable-pip-version-check --index-url https://download.pytorch.org/whl/cpu torch + - name: Install CPU PyTorch on macOS + if: runner.os == 'macOS' + run: python -m pip install --disable-pip-version-check torch + - name: Install test dependencies + run: python -m pip install --disable-pip-version-check -r requirements.txt pytest tomli + - name: Compile Python + run: python -m compileall -q . + - name: Test + run: pytest -q + - name: Validate Registry metadata + run: python -c "import pathlib,tomli; d=tomli.loads(pathlib.Path('pyproject.toml').read_text('utf-8')); assert d['project']['version']=='3.0.0'; assert d['tool']['comfy']['PublisherId']=='gokayfem'" + + frontend: + name: Frontend and examples + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - name: Check browser modules + run: | + node --input-type=module --check < web/viewer_extension_3_0.js + node --check web/js/threeVisualizer.mjs + node --check web/vendor/three.module.min.mjs + node --check web/vendor/three.core.min.mjs + - name: Reject runtime CDN imports + run: | + if grep -R -n -E 'https?://|@latest' web --include='*.html' --include='*.js' --include='*.mjs' --exclude-dir=vendor; then + echo 'Runtime remote dependency found.' >&2 + exit 1 + fi + - name: Validate example JSON + run: | + for file in examples/workflows/*.json examples/api/*.json; do + python -m json.tool "$file" >/dev/null + done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ecb1308 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +name: Publish to Comfy Registry + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "pyproject.toml" + - ".github/workflows/publish.yml" + +concurrency: + group: comfy-registry-${{ github.repository }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish-node: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Check Registry credential + id: credential + env: + REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }} + shell: bash + run: | + if [[ -n "$REGISTRY_ACCESS_TOKEN" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Registry publish skipped::Add the REGISTRY_ACCESS_TOKEN repository secret to enable publishing." + fi + - name: Publish Custom Node + if: steps.credential.outputs.available == 'true' + uses: Comfy-Org/publish-node-action@main + with: + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} + - name: Record skipped publication + if: steps.credential.outputs.available != 'true' + run: echo "Registry publication was skipped because REGISTRY_ACCESS_TOKEN is not configured." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish_action.yml b/.github/workflows/publish_action.yml deleted file mode 100644 index cf720e6..0000000 --- a/.github/workflows/publish_action.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: Publish to Comfy registry -on: - workflow_dispatch: - push: - branches: - - main - paths: - - "pyproject.toml" - - ".github/workflows/publish_action.yml" - -concurrency: - group: comfy-registry-${{ github.repository }} - cancel-in-progress: false - -env: - COMFY_CLI_VERSION: "1.13.0" - -jobs: - publish-node: - name: Publish Custom Node to registry - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Check out code - uses: actions/checkout@v7 - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - name: Read release metadata - id: metadata - run: | - python - <<'PY' - import os - import tomllib - from pathlib import Path - - metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - node_id = metadata["project"]["name"] - version = metadata["project"]["version"] - publisher = metadata["tool"]["comfy"]["PublisherId"] - if publisher != "gokayfem": - raise SystemExit(f"Expected publisher 'gokayfem', found {publisher!r}") - - with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: - print(f"node_id={node_id}", file=output) - print(f"version={version}", file=output) - PY - - name: Check Registry version - id: registry - env: - NODE_ID: ${{ steps.metadata.outputs.node_id }} - VERSION: ${{ steps.metadata.outputs.version }} - run: | - python - <<'PY' - import json - import os - import urllib.parse - import urllib.request - from pathlib import Path - - node_id = urllib.parse.quote(os.environ["NODE_ID"], safe="") - request = urllib.request.Request( - f"https://api.comfy.org/nodes/{node_id}/versions", - headers={"Accept": "application/json", "User-Agent": "comfy-node-publisher"}, - ) - with urllib.request.urlopen(request, timeout=30) as response: - versions = json.load(response) - - exists = any(item.get("version") == os.environ["VERSION"] for item in versions) - with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: - print(f"exists={'true' if exists else 'false'}", file=output) - PY - - name: Check publisher credential - if: steps.registry.outputs.exists != 'true' - id: credentials - env: - REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }} - run: | - if [[ -n "$REGISTRY_ACCESS_TOKEN" ]]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Central publisher enabled::The secure fleet publisher will publish this release within one hour." - fi - - name: Install pinned Comfy CLI - if: steps.registry.outputs.exists != 'true' && steps.credentials.outputs.available == 'true' - shell: bash - run: python -m pip install --disable-pip-version-check "comfy-cli==${COMFY_CLI_VERSION}" - - name: Publish Custom Node - if: steps.registry.outputs.exists != 'true' && steps.credentials.outputs.available == 'true' - id: publish - continue-on-error: true - shell: bash - env: - REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }} - run: comfy --skip-prompt --no-enable-telemetry node publish --token "$REGISTRY_ACCESS_TOKEN" - - name: Record publication result - env: - NODE_ID: ${{ steps.metadata.outputs.node_id }} - VERSION: ${{ steps.metadata.outputs.version }} - ALREADY_PUBLISHED: ${{ steps.registry.outputs.exists }} - PUBLISH_OUTCOME: ${{ steps.publish.outcome }} - run: | - if [[ "$ALREADY_PUBLISHED" == "true" ]]; then - echo "### $NODE_ID $VERSION already published" >> "$GITHUB_STEP_SUMMARY" - elif [[ "$PUBLISH_OUTCOME" == "success" ]]; then - echo "### Published $NODE_ID $VERSION" >> "$GITHUB_STEP_SUMMARY" - else - echo "::notice title=Central publishing handoff::The secure fleet publisher will retry this release within one hour." - echo "### $NODE_ID $VERSION queued for the fleet publisher" >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..75deaf2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 3.0.0 + +- Added normalization, colormap, surface-normal, range-mask, cleanup, and statistics nodes. +- Added binary PLY point-cloud export and a zero-dependency glTF 2.0 GLB depth-mesh writer. +- Added video-ready parallax frame and validity-mask generation. +- Upgraded the live viewer with adaptive mesh quality, wireframe, 16-bit depth previews, passthrough outputs, cached output restoration, iframe reconnects, offscreen rendering, and WebGL recovery. +- Completed the pinned Three.js r185 module set and added real ComfyUI workflow/API proof, cross-platform CI, and official Registry publishing. diff --git a/CITATION.cff b/CITATION.cff index 8dc768f..6f19e7b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,13 +2,13 @@ cff-version: 1.2.0 message: "If you use ComfyUI Depth Visualization in your work, please cite it using the metadata below." type: software title: "ComfyUI Depth Visualization" -version: "2.0.0" -date-released: 2026-07-28 +version: "3.0.0" +date-released: 2026-08-01 authors: - family-names: "Aydoğan" given-names: "Gökay" orcid: "https://orcid.org/0000-0002-2343-9433" -abstract: "An interactive, batch-aware depth-map visualization and mesh-export extension for ComfyUI." +abstract: "An offline depth conditioning, diagnostics, mask, normal, geometry export, parallax, and interactive 3D visualization toolkit for ComfyUI." keywords: - ComfyUI - depth map diff --git a/README.md b/README.md index 36dcf8a..75b6260 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,36 @@ # ComfyUI Depth Visualization -An interactive, in-node 3D preview for any ComfyUI reference image and depth -map. The current implementation is compatible with the modern ComfyUI -frontend, works offline, supports batches, and cleans up its WebGL resources -when a node is removed. +A complete offline depth-conditioning, diagnostics, 3D export, and parallax toolkit for ComfyUI. It cleans and normalizes arbitrary depth maps, creates masks, normals and colormaps, measures quality, exports real geometry, generates motion frames, and previews depth interactively in the graph. -![Depth Visualization](https://github.com/gokayfem/ComfyUI-Depth-Visualization/assets/88277926/0b63c2ed-60d4-44a6-9d44-b3548ec58d48) +![Executed Depth Toolkit workflow in ComfyUI](docs/assets/live-comfyui.png) -## Features +## Nodes -- Interactive orbit, pan, and zoom controls -- Adjustable positive or negative displacement -- Batch frame selector with single-image broadcasting -- PNG screenshots -- Baked depth-mesh export as GLB, GLTF, or OBJ -- Local, pinned Three.js assets with no runtime CDN dependency -- Correct copy/paste, collapse, resize, and node-removal behavior -- Stale-load cancellation and visible error reporting +| Node | Purpose | Outputs | +| --- | --- | --- | +| **Depth Viewer Pro** | GPU-accelerated displaced-mesh preview with adaptive quality | image/depth passthrough + live UI | +| **Normalize Depth** | Percentile, min-max, or fixed-range normalization with gamma/invert | normalized depth + JSON report | +| **Clean & Repair Depth** | Fill invalid holes and reduce noise while preserving edges | clean depth, repair MASK, report | +| **Colorize Depth** | Viridis, magma, turbo, plasma, or grayscale visualization | IMAGE | +| **Depth to Surface Normal** | Camera-aware normal generation with OpenGL/DirectX convention | normal map | +| **Depth Range Masks** | Feathered near/far selection | inside MASK, outside MASK, masked depth | +| **Analyze Depth** | Per-batch statistics and histogram rendering | JSON + histogram IMAGE | +| **Depth to Point Cloud** | Binary PLY export with optional colors | file paths + manifest | +| **Depth to Mesh** | Zero-dependency binary glTF 2.0 GLB export with optional colors | file paths + manifest | +| **Depth Parallax Frames** | Horizontal, vertical, ellipse, or dolly camera motion | IMAGE batch, validity MASK batch, manifest | -## Installation +## Viewer highlights -Install with ComfyUI Manager, or clone the repository manually: +- Orbit, pan, zoom, positive/negative displacement, reset, and wireframe +- Fast, balanced, and high adaptive mesh density +- Batch selection with single-image broadcasting +- PNG capture and baked GLB, GLTF, or OBJ export +- Lazy/offscreen rendering, bounded pixel ratio, stale-load cancellation, cleanup, and WebGL context recovery +- Local pinned Three.js r185 assets; no runtime CDN or telemetry + +## Install + +Install with ComfyUI Manager, or clone manually: ```bash cd ComfyUI/custom_nodes @@ -28,43 +38,53 @@ git clone https://github.com/gokayfem/ComfyUI-Depth-Visualization.git python -m pip install -r ComfyUI-Depth-Visualization/requirements.txt ``` -Restart ComfyUI after installation. +Restart ComfyUI. Nodes are under `visualization/3D` and `depth/toolkit`. -## Usage +## Start with the live example -1. Add **Depth Viewer** from `visualization/3D`. -2. Connect a reference `IMAGE` and a depth-map `IMAGE`. -3. Queue the workflow. -4. Drag to orbit, scroll to zoom, or right-drag to pan. +Load [`examples/workflows/Depth-Toolkit-Live.json`](examples/workflows/Depth-Toolkit-Live.json), select a reference image and depth map, and queue it. The bundled demonstration uses image luminance as stand-in depth so it runs without a model; replace that connection with any real depth estimator for production work. The API-format version is [`examples/api/depth_toolkit_api.json`](examples/api/depth_toolkit_api.json). -When one input contains a single image and the other contains a batch, the -single image is reused for every frame. Other unequal batch sizes produce a -clear validation error instead of silently dropping images. +The graph normalizes and repairs depth, renders a diagnostic colormap and surface normals, extracts range masks, produces a histogram, generates loopable parallax frames, and opens the live displaced-mesh viewer. Point-cloud and GLB exporters can be added anywhere after cleanup. -Mesh export bakes the current depth slider value into the vertices. GLB is the -recommended portable format; OBJ contains geometry only. +## Geometry conventions + +- Depth is interpreted in normalized `[0, 1]` space after conditioning. +- Export nodes use a pinhole camera model and configurable field of view/depth scale. +- GLB is the recommended portable mesh format; binary PLY is recommended for point clouds. +- `validity_masks` from parallax indicate pixels not introduced by camera warping and can drive compositing/inpainting. + +## Compatibility and performance + +- Python 3.10+; tested in CI on Linux, Windows, and macOS +- Real ComfyUI test: ComfyUI 0.3.60, frontend 1.26.13, Windows, NVIDIA RTX 3090 +- Conditioning/export nodes run deterministically on CPU tensors; the live viewer uses browser WebGL and is independent of CUDA, ROCm, or MPS +- 16-bit grayscale temp previews preserve depth precision; browser mesh quality is selectable to control GPU load +- No runtime network requests; images and geometry stay local ## Development ```bash -python -m pip install pytest +python -m pip install -r requirements.txt pytest build +python -m compileall -q . pytest -q +python -m build +node --check web/viewer_extension_3_0.js +node --check web/js/threeVisualizer.mjs ``` -The browser assets are vendored from Three.js 0.185.1. Its MIT license is in -`web/vendor/THREE-LICENSE.txt`. +The vendored Three.js files are MIT licensed; see `web/vendor/THREE-LICENSE.txt`. See [`SECURITY.md`](SECURITY.md) for privacy and disclosure guidance.
Cite this project -If ComfyUI Depth Visualization supports your work, please cite the software. -GitHub also provides ready-to-copy APA and BibTeX entries via **Cite this repository**. +If ComfyUI Depth Visualization supports your work, GitHub provides ready-to-copy +APA and BibTeX entries via **Cite this repository**. ```bibtex @software{Aydogan_ComfyUI_Depth_Visualization_2026, author = {Aydoğan, Gökay}, title = {ComfyUI Depth Visualization}, - version = {2.0.0}, + version = {3.0.0}, year = {2026}, url = {https://github.com/gokayfem/ComfyUI-Depth-Visualization} } diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e70db6a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security and privacy + +This package does not collect telemetry, call remote model APIs, or transmit images, depth maps, geometry, or credentials. Runtime browser dependencies are vendored. + +Point-cloud and mesh files are resolved beneath ComfyUI's active output directory. Browser-side screenshots and mesh downloads occur only after the user presses the corresponding control. + +Do not include sensitive source media in public workflows or issue reports. Use GitHub private vulnerability reporting when available; otherwise contact the maintainer without attaching private data to a public issue. diff --git a/__init__.py b/__init__.py index 3a9de08..b9a8590 100644 --- a/__init__.py +++ b/__init__.py @@ -9,8 +9,24 @@ import folder_paths import numpy as np from PIL import Image +try: + from .depth_nodes import ( + NODE_CLASS_MAPPINGS as TOOL_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as TOOL_NODE_DISPLAY_NAME_MAPPINGS, + ) +except (ImportError, ModuleNotFoundError): # Standalone import used by pytest. + from depth_nodes import ( # type: ignore[no-redef] + NODE_CLASS_MAPPINGS as TOOL_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as TOOL_NODE_DISPLAY_NAME_MAPPINGS, + ) -def _as_pil(image: Any, *, grayscale: bool = False) -> Image.Image: + +def _as_pil( + image: Any, + *, + grayscale: bool = False, + bit_depth: int = 8, +) -> Image.Image: """Convert one ComfyUI IMAGE tensor to a web-safe PIL image.""" array = image.detach().cpu().float().numpy() array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=0.0) @@ -27,6 +43,10 @@ def _as_pil(image: Any, *, grayscale: bool = False) -> Image.Image: else: raise ValueError(f"Expected an HxW, HxWx1, or HxWx3+ image, got {array.shape}.") + if grayscale and bit_depth == 16: + if mode == "RGB": + array = array[..., 0] * 0.2126 + array[..., 1] * 0.7152 + array[..., 2] * 0.0722 + return Image.fromarray((array * 65535.0).round().astype(np.uint16)) converted = Image.fromarray((array * 255.0).round().astype(np.uint8), mode=mode) return converted.convert("L" if grayscale else "RGB") @@ -75,7 +95,8 @@ class DepthViewer: } } - RETURN_TYPES = () + RETURN_TYPES = ("IMAGE", "IMAGE") + RETURN_NAMES = ("reference_passthrough", "depth_passthrough") OUTPUT_NODE = True FUNCTION = "process_images" CATEGORY = "visualization/3D" @@ -98,6 +119,7 @@ class DepthViewer: depth = _as_pil( _batch_item(depth_map, index, batch_count, "depth_map"), grayscale=True, + bit_depth=16, ) references.append( _save_image( @@ -116,11 +138,17 @@ class DepthViewer: ) ) - return {"ui": {"reference_image": references, "depth_map": depths}} + return { + "ui": {"reference_image": references, "depth_map": depths}, + "result": (reference_image, depth_map), + } -NODE_CLASS_MAPPINGS = {"DepthViewer": DepthViewer} -NODE_DISPLAY_NAME_MAPPINGS = {"DepthViewer": "Depth Viewer"} +NODE_CLASS_MAPPINGS = {"DepthViewer": DepthViewer, **TOOL_NODE_CLASS_MAPPINGS} +NODE_DISPLAY_NAME_MAPPINGS = { + "DepthViewer": "Depth Viewer Pro", + **TOOL_NODE_DISPLAY_NAME_MAPPINGS, +} WEB_DIRECTORY = "./web" __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/depth_nodes.py b/depth_nodes.py new file mode 100644 index 0000000..7708542 --- /dev/null +++ b/depth_nodes.py @@ -0,0 +1,538 @@ +"""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", +} diff --git a/docs/assets/live-comfyui.png b/docs/assets/live-comfyui.png new file mode 100644 index 0000000..9dcb1b2 Binary files /dev/null and b/docs/assets/live-comfyui.png differ diff --git a/examples/api/depth_toolkit_api.json b/examples/api/depth_toolkit_api.json new file mode 100644 index 0000000..8999010 --- /dev/null +++ b/examples/api/depth_toolkit_api.json @@ -0,0 +1,19 @@ +{ + "prompt": { + "1": {"class_type":"LoadImage","inputs":{"image":"NL52ydw04m1e3nXl_A7Xr_image.png"}}, + "2": {"class_type":"DepthNormalize","inputs":{"depth_map":["1",0],"method":"Percentile","low":1.0,"high":99.0,"invert":false,"gamma":1.0}}, + "3": {"class_type":"DepthCleanup","inputs":{"depth_map":["2",0],"hole_threshold":0.001,"fill_iterations":3,"median_radius":1,"preserve_edges":0.8}}, + "4": {"class_type":"DepthColormap","inputs":{"depth_map":["3",0],"colormap":"turbo","invert":false,"show_invalid_magenta":true}}, + "5": {"class_type":"DepthToNormal","inputs":{"depth_map":["3",0],"strength":2.0,"field_of_view":60.0,"convention":"OpenGL (+Y)","invert_depth":false}}, + "6": {"class_type":"DepthRangeMask","inputs":{"depth_map":["3",0],"near":0.2,"far":0.8,"feather":0.04,"invert_depth":false}}, + "7": {"class_type":"DepthStats","inputs":{"depth_map":["3",0],"bins":64}}, + "8": {"class_type":"DepthParallaxFrames","inputs":{"image":["1",0],"depth_map":["3",0],"frames":8,"amplitude_px":24.0,"path":"ellipse","loop":true,"depth_center":0.5}}, + "9": {"class_type":"DepthViewer","inputs":{"reference_image":["1",0],"depth_map":["3",0]}}, + "10": {"class_type":"DepthToPointCloud","inputs":{"depth_map":["3",0],"field_of_view":60.0,"depth_scale":1.0,"stride":8,"drop_zero_depth":true,"filename_prefix":"depth_exports/showcase_cloud","color_image":["1",0]}}, + "11": {"class_type":"DepthToMesh","inputs":{"depth_map":["3",0],"field_of_view":60.0,"depth_scale":1.0,"stride":8,"max_edge_length":0.2,"filename_prefix":"depth_exports/showcase_mesh","color_image":["1",0]}}, + "12": {"class_type":"PreviewImage","inputs":{"images":["4",0]}}, + "13": {"class_type":"PreviewImage","inputs":{"images":["5",0]}}, + "14": {"class_type":"PreviewImage","inputs":{"images":["7",1]}}, + "15": {"class_type":"PreviewImage","inputs":{"images":["8",0]}} + } +} diff --git a/examples/workflows/Depth-Toolkit-Live.json b/examples/workflows/Depth-Toolkit-Live.json new file mode 100644 index 0000000..566006f --- /dev/null +++ b/examples/workflows/Depth-Toolkit-Live.json @@ -0,0 +1,22 @@ +{ + "id":"8f4d34b1-7df0-45ef-a1d8-000000000003","revision":0,"last_node_id":12,"last_link_id":16, + "nodes":[ + {"id":1,"type":"LoadImage","pos":[30,230],"size":[280,315],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["NL52ydw04m1e3nXl_A7Xr_image.png","image"]}, + {"id":2,"type":"ImageScale","pos":[350,230],"size":[300,250],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":1},{"localized_name":"upscale_method","name":"upscale_method","type":"COMBO","widget":{"name":"upscale_method"},"link":null},{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":null},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":null},{"localized_name":"crop","name":"crop","type":"COMBO","widget":{"name":"crop"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[2,9,13]}],"properties":{"Node name for S&R":"ImageScale"},"widgets_values":["lanczos",768,576,"disabled"]}, + {"id":3,"type":"DepthNormalize","pos":[690,50],"size":[360,340],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":2},{"localized_name":"method","name":"method","type":"COMBO","widget":{"name":"method"},"link":null},{"localized_name":"low","name":"low","type":"FLOAT","widget":{"name":"low"},"link":null},{"localized_name":"high","name":"high","type":"FLOAT","widget":{"name":"high"},"link":null},{"localized_name":"invert","name":"invert","type":"BOOLEAN","widget":{"name":"invert"},"link":null},{"localized_name":"gamma","name":"gamma","type":"FLOAT","widget":{"name":"gamma"},"link":null}],"outputs":[{"localized_name":"normalized_depth","name":"normalized_depth","type":"IMAGE","links":[3]},{"localized_name":"range_report_json","name":"range_report_json","type":"STRING","links":null}],"properties":{"Node name for S&R":"DepthNormalize"},"widgets_values":["Percentile",1,99,false,1]}, + {"id":4,"type":"DepthCleanup","pos":[690,440],"size":[360,330],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":3},{"localized_name":"hole_threshold","name":"hole_threshold","type":"FLOAT","widget":{"name":"hole_threshold"},"link":null},{"localized_name":"fill_iterations","name":"fill_iterations","type":"INT","widget":{"name":"fill_iterations"},"link":null},{"localized_name":"median_radius","name":"median_radius","type":"INT","widget":{"name":"median_radius"},"link":null},{"localized_name":"preserve_edges","name":"preserve_edges","type":"FLOAT","widget":{"name":"preserve_edges"},"link":null}],"outputs":[{"localized_name":"clean_depth","name":"clean_depth","type":"IMAGE","links":[4,5,6,7,8,10]},{"localized_name":"repaired_pixels","name":"repaired_pixels","type":"MASK","links":null},{"localized_name":"report_json","name":"report_json","type":"STRING","links":null}],"properties":{"Node name for S&R":"DepthCleanup"},"widgets_values":[0.001,3,1,0.8]}, + {"id":5,"type":"DepthColormap","pos":[1090,30],"size":[350,250],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":4},{"localized_name":"colormap","name":"colormap","type":"COMBO","widget":{"name":"colormap"},"link":null},{"localized_name":"invert","name":"invert","type":"BOOLEAN","widget":{"name":"invert"},"link":null},{"localized_name":"show_invalid_magenta","name":"show_invalid_magenta","type":"BOOLEAN","widget":{"name":"show_invalid_magenta"},"link":null}],"outputs":[{"localized_name":"colored_depth","name":"colored_depth","type":"IMAGE","links":[11]}],"properties":{"Node name for S&R":"DepthColormap"},"widgets_values":["turbo",false,true]}, + {"id":6,"type":"DepthToNormal","pos":[1090,320],"size":[350,300],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":5},{"localized_name":"strength","name":"strength","type":"FLOAT","widget":{"name":"strength"},"link":null},{"localized_name":"field_of_view","name":"field_of_view","type":"FLOAT","widget":{"name":"field_of_view"},"link":null},{"localized_name":"convention","name":"convention","type":"COMBO","widget":{"name":"convention"},"link":null},{"localized_name":"invert_depth","name":"invert_depth","type":"BOOLEAN","widget":{"name":"invert_depth"},"link":null}],"outputs":[{"localized_name":"surface_normal","name":"surface_normal","type":"IMAGE","links":[12]}],"properties":{"Node name for S&R":"DepthToNormal"},"widgets_values":[2,60,"OpenGL (+Y)",false]}, + {"id":7,"type":"DepthRangeMask","pos":[1090,670],"size":[350,310],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":6},{"localized_name":"near","name":"near","type":"FLOAT","widget":{"name":"near"},"link":null},{"localized_name":"far","name":"far","type":"FLOAT","widget":{"name":"far"},"link":null},{"localized_name":"feather","name":"feather","type":"FLOAT","widget":{"name":"feather"},"link":null},{"localized_name":"invert_depth","name":"invert_depth","type":"BOOLEAN","widget":{"name":"invert_depth"},"link":null}],"outputs":[{"localized_name":"inside_range","name":"inside_range","type":"MASK","links":null},{"localized_name":"outside_range","name":"outside_range","type":"MASK","links":null},{"localized_name":"masked_depth","name":"masked_depth","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"DepthRangeMask"},"widgets_values":[0.2,0.8,0.04,false]}, + {"id":8,"type":"DepthStats","pos":[1090,1020],"size":[350,210],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":7},{"localized_name":"bins","name":"bins","type":"INT","widget":{"name":"bins"},"link":null}],"outputs":[{"localized_name":"statistics_json","name":"statistics_json","type":"STRING","links":null},{"localized_name":"histogram","name":"histogram","type":"IMAGE","links":[15]}],"properties":{"Node name for S&R":"DepthStats"},"widgets_values":[64]}, + {"id":9,"type":"DepthViewer","pos":[1510,30],"size":[800,760],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"reference_image","name":"reference_image","type":"IMAGE","link":9},{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":8}],"outputs":[{"localized_name":"reference_passthrough","name":"reference_passthrough","type":"IMAGE","links":null},{"localized_name":"depth_passthrough","name":"depth_passthrough","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"DepthViewer"},"widgets_values":[]}, + {"id":10,"type":"DepthParallaxFrames","pos":[1510,850],"size":[400,390],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":13},{"localized_name":"depth_map","name":"depth_map","type":"IMAGE","link":10},{"localized_name":"frames","name":"frames","type":"INT","widget":{"name":"frames"},"link":null},{"localized_name":"amplitude_px","name":"amplitude_px","type":"FLOAT","widget":{"name":"amplitude_px"},"link":null},{"localized_name":"path","name":"path","type":"COMBO","widget":{"name":"path"},"link":null},{"localized_name":"loop","name":"loop","type":"BOOLEAN","widget":{"name":"loop"},"link":null},{"localized_name":"depth_center","name":"depth_center","type":"FLOAT","widget":{"name":"depth_center"},"link":null}],"outputs":[{"localized_name":"frames","name":"frames","type":"IMAGE","links":[16]},{"localized_name":"validity_masks","name":"validity_masks","type":"MASK","links":null},{"localized_name":"manifest_json","name":"manifest_json","type":"STRING","links":null}],"properties":{"Node name for S&R":"DepthParallaxFrames"},"widgets_values":[8,24,"ellipse",true,0.5]}, + {"id":11,"type":"PreviewImage","pos":[2360,30],"size":[360,280],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":11}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"}}, + {"id":12,"type":"PreviewImage","pos":[2360,350],"size":[360,280],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":12}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"}}, + {"id":13,"type":"PreviewImage","pos":[2360,670],"size":[360,260],"flags":{},"order":12,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":15}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"}}, + {"id":14,"type":"PreviewImage","pos":[1960,850],"size":[360,330],"flags":{},"order":13,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":16}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"}} + ], + "links":[[1,1,0,2,0,"IMAGE"],[2,2,0,3,0,"IMAGE"],[3,3,0,4,0,"IMAGE"],[4,4,0,5,0,"IMAGE"],[5,4,0,6,0,"IMAGE"],[6,4,0,7,0,"IMAGE"],[7,4,0,8,0,"IMAGE"],[8,4,0,9,1,"IMAGE"],[9,2,0,9,0,"IMAGE"],[10,4,0,10,1,"IMAGE"],[11,5,0,11,0,"IMAGE"],[12,6,0,12,0,"IMAGE"],[13,2,0,10,0,"IMAGE"],[15,8,1,13,0,"IMAGE"],[16,10,0,14,0,"IMAGE"]], + "groups":[{"title":"Image + robust depth conditioning","bounding":[10,170,1070,650],"color":"#4d784e","font_size":27,"flags":{}},{"title":"Normals, masks + statistics","bounding":[1060,0,410,1260],"color":"#8b5d3b","font_size":27,"flags":{}},{"title":"Live displaced-mesh viewer","bounding":[1480,0,860,820],"color":"#3a6f92","font_size":29,"flags":{}},{"title":"Parallax motion output","bounding":[1480,820,880,450],"color":"#5b6f92","font_size":26,"flags":{}},{"title":"Diagnostic outputs","bounding":[2330,0,420,970],"color":"#6f4b7d","font_size":26,"flags":{}}], + "config":{},"extra":{"ds":{"scale":0.56,"offset":[40,42]}},"version":0.4 +} diff --git a/pyproject.toml b/pyproject.toml index 99c0858..d18f5ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-depth-visualization" -description = "Interactive, batch-aware depth-map visualization and mesh export for ComfyUI" -version = "2.0.0" +description = "Offline depth conditioning, diagnostics, masks, parallax, point clouds, mesh export, and interactive 3D viewing for ComfyUI" +version = "3.0.0" requires-python = ">=3.10" license = { file = "LICENSE.txt" } dependencies = [ diff --git a/tests/test_frontend_assets.py b/tests/test_frontend_assets.py index 408b782..c913cf3 100644 --- a/tests/test_frontend_assets.py +++ b/tests/test_frontend_assets.py @@ -1,3 +1,4 @@ +import json from pathlib import Path @@ -8,7 +9,7 @@ def test_only_extension_entrypoint_uses_js_suffix(): javascript_files = sorted( path.relative_to(ROOT).as_posix() for path in (ROOT / "web").rglob("*.js") ) - assert javascript_files == ["web/visualization.js"] + assert javascript_files == ["web/viewer_extension_3_0.js"] def test_frontend_has_no_runtime_cdn_dependency(): @@ -23,13 +24,32 @@ def test_frontend_has_no_runtime_cdn_dependency(): assert "@latest" not in frontend_text -def test_vendored_three_modules_are_present(): +def test_vendored_three_modules_are_complete(): vendor = ROOT / "web" / "vendor" expected = { "three.module.min.mjs", + "three.core.min.mjs", "OrbitControls.mjs", "GLTFExporter.mjs", "OBJExporter.mjs", "THREE-LICENSE.txt", } assert expected.issubset({path.name for path in vendor.iterdir()}) + assert './three.core.min.mjs' in (vendor / "three.module.min.mjs").read_text( + encoding="utf-8" + ) + + +def test_live_workflow_and_viewer_bridge_are_present(): + workflow = json.loads( + (ROOT / "examples" / "workflows" / "Depth-Toolkit-Live.json").read_text( + encoding="utf-8" + ) + ) + assert any(node.get("type") == "DepthViewer" for node in workflow["nodes"]) + entrypoint = (ROOT / "web" / "viewer_extension_3_0.js").read_text(encoding="utf-8") + assert 'api.addEventListener("executed"' in entrypoint + assert 'app.nodeOutputs?.[this.id]' in entrypoint + assert 'window.setInterval' in entrypoint + assert 'api.fetchApi("/history?max_items=32")' in entrypoint + assert 'class_type === "DepthViewer"' in entrypoint diff --git a/tests/test_toolkit.py b/tests/test_toolkit.py new file mode 100644 index 0000000..e70e742 --- /dev/null +++ b/tests/test_toolkit.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import struct +from pathlib import Path + +import numpy as np +import torch + +import depth_nodes as nodes + + +def depth(height=24, width=32): + x = torch.linspace(0.05, 1, width).view(1, 1, width, 1) + return x.repeat(1, height, 1, 3) + + +def color(height=24, width=32): + image = torch.zeros((1, height, width, 3)) + image[..., 0] = torch.linspace(0, 1, width) + image[..., 1] = torch.linspace(0, 1, height).view(height, 1) + image[..., 2] = 0.4 + return image + + +def test_normalize_colormap_normal_and_masks(): + normalized, report = nodes.DepthNormalize().normalize(depth(), "Percentile", 1, 99, False, 1) + colored, = nodes.DepthColormap().colorize(normalized, "turbo", False, True) + normal, = nodes.DepthToNormal().convert(normalized, 1.0, 60, "OpenGL (+Y)", False) + inside, outside, masked = nodes.DepthRangeMask().mask(normalized, 0.2, 0.8, 0.05, False) + assert normalized.min() == 0 and normalized.max() == 1 + assert json.loads(report)["method"] == "Percentile" + assert colored.shape == normal.shape == masked.shape == (1, 24, 32, 3) + assert torch.allclose(inside + outside, torch.ones_like(inside), atol=1e-6) + + +def test_cleanup_and_stats_emit_inspectable_outputs(): + source = depth() + source[:, 8:12, 10:15] = 0 + cleaned, repaired, report = nodes.DepthCleanup().clean(source, 0.001, 8, 1, 0.8) + stats, histogram = nodes.DepthStats().analyze(cleaned, 32) + assert repaired[:, 8:12, 10:15].max() == 1 + assert json.loads(report)["changed_fraction"] > 0 + assert json.loads(stats)["batch"][0]["max"] <= 1 + assert histogram.shape == (1, 300, 640, 3) + + +def test_median_filter_preserves_sub_8_bit_depth_precision(): + values = np.array( + [[[0.5001, 0.5002, 0.5003], [0.5004, 0.5005, 0.5006], [0.5007, 0.5008, 0.5009]]], + dtype=np.float32, + ) + filtered = nodes._median_filter(values, 1) + assert np.isclose(filtered[0, 1, 1], 0.5005, atol=1e-6) + assert not np.isclose(filtered[0, 1, 1] * 255, round(filtered[0, 1, 1] * 255)) + + +def test_binary_ply_and_glb_exports(monkeypatch, tmp_path): + import folder_paths + + monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path), raising=False) + ply_paths, ply_manifest = nodes.DepthToPointCloud().export( + depth(), 60, 2, 4, True, "depth_exports/cloud", color() + ) + glb_paths, glb_manifest = nodes.DepthToMesh().export( + depth(), 60, 2, 4, 100, "depth_exports/mesh", color() + ) + ply = Path(ply_paths) + glb = Path(glb_paths) + assert ply.read_bytes().startswith(b"ply\nformat binary_little_endian") + magic, version, total = struct.unpack("<4sII", glb.read_bytes()[:12]) + assert (magic, version, total) == (b"glTF", 2, glb.stat().st_size) + assert json.loads(ply_manifest)["point_counts"][0] > 0 + assert json.loads(glb_manifest)["triangle_counts"][0] > 0 + + +def test_parallax_frames_and_validity_masks(): + frames, masks, manifest = nodes.DepthParallaxFrames().animate( + color(), depth(), 8, 12, "ellipse", True, 0.5 + ) + assert frames.shape == (8, 24, 32, 3) + assert masks.shape == (8, 24, 32) + assert json.loads(manifest)["total_frames"] == 8 + assert masks.min() == 0 and masks.max() == 1 diff --git a/web/html/threeVisualizer.html b/web/html/threeVisualizer.html index 640aa02..73fa15d 100644 --- a/web/html/threeVisualizer.html +++ b/web/html/threeVisualizer.html @@ -35,10 +35,19 @@ min="-5" max="5" step="0.05" - value="2" + value="1.25" > - 2.00 + 1.25 + +