Initial release: PlyPreview nodes and viewer

This commit is contained in:
XuanYu-github
2025-12-29 01:00:07 +08:00
commit 71d93b72b9
12 changed files with 4166 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[Full GPLv3 text omitted for brevity. Obtain the complete license at https://www.gnu.org/licenses/gpl-3.0.txt]
+43
View File
@@ -0,0 +1,43 @@
# ComfyUI PlyPreview
Gaussian Splat PLY loader + preview nodes for ComfyUI. Forked from GeometryPack and repackaged to avoid upstream overwrites. Includes front-end viewer (gsplat.js) bundled locally.
## Nodes
- Load Gaussian PLY — dropdown from `input/`, `input/3d`, `output/` with auto-resolution, FOV 10–180°, optional opacity filter.
- Load Gaussian PLY (Path) — manual path entry with the same camera/auto-res options.
- Process Gaussian PLY — accept upstream `ply_path` (e.g., SHARP Predict) with camera override/opacity filter.
- Preview Gaussian — gsplat.js WebGL viewer with scale slider, reset, screenshot, info panel.
## Features
- Auto resolution from FOV + target scale (rounded to 16px, calibration factor 0.8).
- Opacity filtering (sigmoid) with threshold.
- Camera intrinsics/extrinsics outputs for consistent preview.
- Wide FOV (10–180°) including fisheye cases.
- Fully self-contained: viewer HTML/JS bundled under `web/`.
## Install (Git clone)
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/XuanYu-github/comfyui-PlyPreview.git
```
Restart ComfyUI.
## Install (ComfyUI Manager)
Search for “comfyui-PlyPreview” in Manager and install. (This repo ships `comfyui_extension.json` so Manager can detect it.)
## Usage
1) Use one of the Load nodes or Process node to produce `ply_path`, `extrinsics`, `intrinsics`.
2) Connect to Preview Gaussian. After execution the viewer iframe shows controls (Scale, Reset View, Screenshot) and info panel.
3) If opacity filtering is enabled, a filtered PLY is written alongside the source (suffix `_opacity{threshold}.ply`).
## Requirements
- ComfyUI recent build with DOM widgets enabled (standard).
- Python deps: `plyfile`, `numpy` (install via your ComfyUI environment). Most ComfyUI setups already have numpy; install plyfile if missing: `pip install plyfile`.
## Notes
- WEB_DIRECTORY is set to `web` so ComfyUI loads the bundled frontend automatically.
- Namespaces are prefixed `PlyPreview*` to avoid collision with GeometryPack.
- Original GeometryPack license is GPL-3.0; this fork keeps the same license.
## License
GPL-3.0-or-later. See [LICENSE](LICENSE).
+34
View File
@@ -0,0 +1,34 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 comfyui-PlyPreview Contributors
"""ComfyUI PLY Preview - Gaussian splat PLY file loading and preview nodes."""
from .load_gaussian_ply import LoadGaussianPLY
from .load_gaussian_ply_path import LoadGaussianPLYPath
from .process_gaussian_ply import ProcessGaussianPLY
from .preview_gaussian import PreviewGaussianNode
WEB_DIRECTORY = "web"
NODE_CLASS_MAPPINGS = {
"PlyPreviewLoadGaussianPLY": LoadGaussianPLY,
"PlyPreviewLoadGaussianPLYPath": LoadGaussianPLYPath,
"PlyPreviewProcessGaussianPLY": ProcessGaussianPLY,
"PlyPreviewPreviewGaussian": PreviewGaussianNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PlyPreviewLoadGaussianPLY": "Load Gaussian PLY",
"PlyPreviewLoadGaussianPLYPath": "Load Gaussian PLY (Path)",
"PlyPreviewProcessGaussianPLY": "Process Gaussian PLY",
"PlyPreviewPreviewGaussian": "Preview Gaussian",
}
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"LoadGaussianPLY",
"LoadGaussianPLYPath",
"ProcessGaussianPLY",
"PreviewGaussianNode",
]
+8
View File
@@ -0,0 +1,8 @@
{
"name": "comfyui-PlyPreview",
"display_name": "PlyPreview",
"description": "Gaussian Splat PLY loader + preview nodes (Load, Load Path, Process, Preview) with bundled gsplat viewer.",
"author": "XuanYu-github",
"repo": "https://github.com/XuanYu-github/comfyui-PlyPreview",
"license": "GPL-3.0-or-later"
}
+82
View File
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import os
# Optional ComfyUI helpers – avoid hard dependency for offline editing
try:
import importlib
folder_paths = importlib.import_module("folder_paths") # type: ignore[import-not-found]
COMFYUI_INPUT_FOLDER: str | None = folder_paths.get_input_directory()
COMFYUI_OUTPUT_FOLDER: str | None = folder_paths.get_output_directory()
except Exception: # pragma: no cover - environment-specific
folder_paths = None # type: ignore[assignment]
COMFYUI_INPUT_FOLDER = None
COMFYUI_OUTPUT_FOLDER = None
def get_default_extrinsics() -> list[list[float]]:
"""Return default 4x4 identity extrinsics matrix (camera at origin)."""
return [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
def get_default_intrinsics(width: int = 512, height: int = 512, fov_degrees: float = 50.0) -> list[list[float]]:
"""Return default 3x3 intrinsics matrix from FOV."""
import math
fov_rad = math.radians(fov_degrees)
fx = width / (2.0 * math.tan(fov_rad / 2.0))
fy = fx # assume square pixels
cx = width / 2.0
cy = height / 2.0
return [
[fx, 0.0, cx],
[0.0, fy, cy],
[0.0, 0.0, 1.0],
]
def get_recommended_resolution(fov_degrees: float, target_scale: float = 10.0) -> tuple[int, int]:
"""Get recommended width/height based on FOV and target gaussian scale."""
reference_points = [
(10.0, 61440.0),
(30.0, 15360.0),
(50.0, 10240.0),
(100.0, 4096.0),
(120.0, 2560.0),
(150.0, 1920.0),
(180.0, 1600.0),
]
fov_clamped = max(10.0, min(180.0, fov_degrees))
product = 0.0
if fov_clamped <= reference_points[0][0]:
product = reference_points[0][1]
elif fov_clamped >= reference_points[-1][0]:
product = reference_points[-1][1]
else:
for i in range(len(reference_points) - 1):
fov1, prod1 = reference_points[i]
fov2, prod2 = reference_points[i + 1]
if fov1 <= fov_clamped <= fov2:
t = (fov_clamped - fov1) / (fov2 - fov1)
product = prod1 + t * (prod2 - prod1)
break
base_resolution = product / 10.0
target_resolution = base_resolution * (10.0 / target_scale)
calibration_factor = 0.8
target_resolution = target_resolution * calibration_factor
resolution = int(target_resolution + 0.5)
resolution = max(256, min(8192, resolution))
resolution = ((resolution + 7) // 16) * 16
return (resolution, resolution)
+242
View File
@@ -0,0 +1,242 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from .common import (
COMFYUI_INPUT_FOLDER,
COMFYUI_OUTPUT_FOLDER,
get_default_extrinsics,
get_default_intrinsics,
get_recommended_resolution,
)
class LoadGaussianPLY:
"""Select a Gaussian splat PLY file from input/output folders or upload one."""
@classmethod
def INPUT_TYPES(cls):
ply_files = cls._get_ply_files()
if not ply_files:
ply_files = ["No PLY files found"]
return {
"required": {
"ply_file": (sorted(ply_files), {
"tooltip": "Select a .ply file from input or output folder, or upload one",
}),
"fov_degrees": ("FLOAT", {
"default": 50.0,
"min": 10.0,
"max": 180.0,
"step": 1.0,
"tooltip": "Horizontal field of view in degrees (10-180°, supports wide-angle and fisheye)",
}),
"auto_resolution": (["disabled", "enabled"], {
"default": "enabled",
"tooltip": "Auto-calculate optimal resolution based on FOV and target scale",
}),
},
"optional": {
"target_scale": ("FLOAT", {
"default": 10.0,
"min": 1.0,
"max": 50.0,
"step": 1.0,
"tooltip": "Target gaussian scale in viewer (used when auto_resolution enabled)",
}),
"image_width": ("INT", {
"default": 512,
"min": 64,
"max": 8192,
"step": 1,
"tooltip": "Image width for camera intrinsics (used when auto_resolution disabled)",
}),
"image_height": ("INT", {
"default": 512,
"min": 64,
"max": 8192,
"step": 1,
"tooltip": "Image height for camera intrinsics (used when auto_resolution disabled)",
}),
"enable_opacity_filter": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Filter out low-opacity Gaussians to reduce background noise",
}),
"opacity_threshold": ("FLOAT", {
"default": 0.1,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Minimum opacity (0-1). Gaussians below this will be removed (used when filter enabled)",
}),
},
}
RETURN_TYPES = ("STRING", "EXTRINSICS", "INTRINSICS")
RETURN_NAMES = ("ply_path", "extrinsics", "intrinsics")
FUNCTION = "load_ply"
CATEGORY = "PlyPreview"
@classmethod
def _get_ply_files(cls) -> list[str]:
ply_files: list[str] = []
if COMFYUI_INPUT_FOLDER and os.path.isdir(COMFYUI_INPUT_FOLDER):
for f in os.listdir(COMFYUI_INPUT_FOLDER):
full_path = os.path.join(COMFYUI_INPUT_FOLDER, f)
if os.path.isfile(full_path) and f.lower().endswith(".ply"):
ply_files.append(f"[input] {f}")
input_3d = os.path.join(COMFYUI_INPUT_FOLDER, "3d")
if os.path.isdir(input_3d):
for f in os.listdir(input_3d):
full_path = os.path.join(input_3d, f)
if os.path.isfile(full_path) and f.lower().endswith(".ply"):
ply_files.append(f"[input/3d] {f}")
if COMFYUI_OUTPUT_FOLDER and os.path.isdir(COMFYUI_OUTPUT_FOLDER):
for f in os.listdir(COMFYUI_OUTPUT_FOLDER):
full_path = os.path.join(COMFYUI_OUTPUT_FOLDER, f)
if os.path.isfile(full_path) and f.lower().endswith(".ply"):
ply_files.append(f"[output] {f}")
return ply_files
@classmethod
def _resolve_selection(cls, selection: str) -> str | None:
if not selection or selection == "No PLY files found":
return None
if selection.startswith("[input/3d] "):
filename = selection[len("[input/3d] "):]
if COMFYUI_INPUT_FOLDER:
return os.path.join(COMFYUI_INPUT_FOLDER, "3d", filename)
elif selection.startswith("[input] "):
filename = selection[len("[input] "):]
if COMFYUI_INPUT_FOLDER:
return os.path.join(COMFYUI_INPUT_FOLDER, filename)
elif selection.startswith("[output] "):
filename = selection[len("[output] "):]
if COMFYUI_OUTPUT_FOLDER:
return os.path.join(COMFYUI_OUTPUT_FOLDER, filename)
else:
if COMFYUI_INPUT_FOLDER:
candidate = os.path.join(COMFYUI_INPUT_FOLDER, selection)
if os.path.exists(candidate):
return candidate
if COMFYUI_OUTPUT_FOLDER:
candidate = os.path.join(COMFYUI_OUTPUT_FOLDER, selection)
if os.path.exists(candidate):
return candidate
return None
def _filter_by_opacity(self, ply_path: str, threshold: float) -> str:
try:
from plyfile import PlyData, PlyElement
import numpy as np
except ImportError:
print("[LoadGaussianPLY] Warning: plyfile not installed, skipping opacity filter")
return ply_path
try:
plydata = PlyData.read(ply_path)
vertex = plydata["vertex"]
field_names = vertex.data.dtype.names
print(f"[LoadGaussianPLY] PLY fields: {field_names}")
if "opacity" in field_names:
opacity = np.array(vertex["opacity"])
opacity = 1.0 / (1.0 + np.exp(-opacity))
print(f"[LoadGaussianPLY] Opacity range after sigmoid: [{opacity.min():.4f}, {opacity.max():.4f}]")
else:
print(f"[LoadGaussianPLY] Warning: No 'opacity' field found in {list(field_names)}")
return ply_path
mask = opacity >= threshold
n_original = len(opacity)
n_filtered = int(np.sum(mask))
print(f"[LoadGaussianPLY] Opacity filter: threshold={threshold:.3f}, kept {n_filtered}/{n_original} gaussians ({100*n_filtered/n_original:.1f}%)")
if n_filtered == n_original or n_filtered == 0:
if n_filtered == 0:
print("[LoadGaussianPLY] Warning: All gaussians filtered out! Using original file")
else:
print("[LoadGaussianPLY] All gaussians passed filter, using original file")
return ply_path
filtered_arrays = []
for prop in vertex.data.dtype.names:
filtered_arrays.append(vertex[prop][mask])
filtered_data = np.empty(n_filtered, dtype=vertex.data.dtype)
for i, prop in enumerate(vertex.data.dtype.names):
filtered_data[prop] = filtered_arrays[i]
filtered_vertex = PlyElement.describe(filtered_data, "vertex")
base_name = os.path.basename(ply_path)
name_without_ext = os.path.splitext(base_name)[0]
output_folder = os.path.dirname(ply_path)
output_path = os.path.join(output_folder, f"{name_without_ext}_opacity{threshold:.2f}.ply")
PlyData([filtered_vertex], text=False).write(output_path)
print(f"[LoadGaussianPLY] Saved filtered PLY: {output_path}")
return output_path
except Exception as e:
print(f"[LoadGaussianPLY] Error filtering PLY: {e}")
return ply_path
@classmethod
def IS_CHANGED(cls, ply_file: str, **kwargs):
resolved = cls._resolve_selection(ply_file)
if resolved and os.path.exists(resolved):
return os.path.getmtime(resolved)
return ply_file
@classmethod
def VALIDATE_INPUTS(cls, ply_file: str, **kwargs):
if not ply_file or ply_file == "No PLY files found":
return "No PLY file selected"
resolved = cls._resolve_selection(ply_file)
if resolved is None or not os.path.exists(resolved):
return f"PLY file not found: {ply_file}"
return True
def load_ply(
self,
ply_file: str,
fov_degrees: float = 50.0,
auto_resolution: str = "enabled",
target_scale: float = 10.0,
image_width: int = 512,
image_height: int = 512,
enable_opacity_filter: str = "disabled",
opacity_threshold: float = 0.1,
):
if not ply_file or ply_file == "No PLY files found":
raise ValueError("No PLY file selected")
resolved = self._resolve_selection(ply_file)
if resolved is None:
raise ValueError(f"Could not resolve PLY file: {ply_file}")
if not os.path.exists(resolved):
raise ValueError(f"PLY file not found: {resolved}")
print(f"[LoadGaussianPLY] Selected: {ply_file}")
print(f"[LoadGaussianPLY] Resolved path: {resolved}")
if auto_resolution == "enabled":
image_width, image_height = get_recommended_resolution(fov_degrees, target_scale)
print(f"[LoadGaussianPLY] Auto-resolution for FOV {fov_degrees}° @ scale {target_scale}: {image_width}x{image_height}")
output_path = resolved
if enable_opacity_filter == "enabled":
output_path = self._filter_by_opacity(resolved, opacity_threshold)
print(f"[LoadGaussianPLY] Filtered PLY saved to: {output_path}")
extrinsics = get_default_extrinsics()
intrinsics = get_default_intrinsics(image_width, image_height, fov_degrees)
print(f"[LoadGaussianPLY] Camera: FOV={fov_degrees}°, size={image_width}x{image_height}")
return (output_path, extrinsics, intrinsics)
+154
View File
@@ -0,0 +1,154 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from .common import (
COMFYUI_INPUT_FOLDER,
COMFYUI_OUTPUT_FOLDER,
get_default_extrinsics,
get_default_intrinsics,
get_recommended_resolution,
)
from .load_gaussian_ply import LoadGaussianPLY
class LoadGaussianPLYPath:
"""Load a Gaussian splat PLY file by entering the path directly."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ply_path": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Path to .ply file (absolute or relative to ComfyUI input/output folders)",
}),
"fov_degrees": ("FLOAT", {
"default": 50.0,
"min": 10.0,
"max": 180.0,
"step": 1.0,
"tooltip": "Horizontal field of view in degrees (10-180°, supports wide-angle and fisheye)",
}),
"auto_resolution": (["disabled", "enabled"], {
"default": "enabled",
"tooltip": "Auto-calculate optimal resolution based on FOV and target scale",
}),
},
"optional": {
"target_scale": ("FLOAT", {
"default": 10.0,
"min": 1.0,
"max": 50.0,
"step": 1.0,
"tooltip": "Target gaussian scale in viewer (used when auto_resolution enabled)",
}),
"image_width": ("INT", {
"default": 512,
"min": 64,
"max": 8192,
"step": 1,
"tooltip": "Image width for camera intrinsics (used when auto_resolution disabled)",
}),
"image_height": ("INT", {
"default": 512,
"min": 64,
"max": 8192,
"step": 1,
"tooltip": "Image height for camera intrinsics (used when auto_resolution disabled)",
}),
"enable_opacity_filter": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Filter out low-opacity Gaussians to reduce background noise",
}),
"opacity_threshold": ("FLOAT", {
"default": 0.1,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Minimum opacity (0-1). Gaussians below this will be removed (used when filter enabled)",
}),
},
}
RETURN_TYPES = ("STRING", "EXTRINSICS", "INTRINSICS")
RETURN_NAMES = ("ply_path", "extrinsics", "intrinsics")
FUNCTION = "load_ply"
CATEGORY = "PlyPreview"
@classmethod
def IS_CHANGED(cls, ply_path, **kwargs):
resolved = cls._resolve_path(ply_path)
if resolved and os.path.exists(resolved):
return os.path.getmtime(resolved)
return ply_path
@classmethod
def _resolve_path(cls, ply_path):
if not ply_path:
return None
candidate = ply_path.strip().strip('"')
if candidate == "":
return None
if os.path.isabs(candidate) and os.path.exists(candidate):
return candidate
if COMFYUI_OUTPUT_FOLDER is not None:
output_candidate = os.path.join(COMFYUI_OUTPUT_FOLDER, candidate)
if os.path.exists(output_candidate):
return output_candidate
if COMFYUI_INPUT_FOLDER is not None:
input_candidate = os.path.join(COMFYUI_INPUT_FOLDER, candidate)
if os.path.exists(input_candidate):
return input_candidate
input_3d_candidate = os.path.join(COMFYUI_INPUT_FOLDER, "3d", candidate)
if os.path.exists(input_3d_candidate):
return input_3d_candidate
if os.path.exists(candidate):
return candidate
return None
def load_ply(
self,
ply_path: str,
fov_degrees: float = 50.0,
auto_resolution: str = "enabled",
target_scale: float = 10.0,
image_width: int = 512,
image_height: int = 512,
enable_opacity_filter: str = "disabled",
opacity_threshold: float = 0.1,
):
if not ply_path or ply_path.strip() == "":
raise ValueError("PLY path cannot be empty")
resolved = self._resolve_path(ply_path)
if resolved is None:
searched = [ply_path]
if COMFYUI_OUTPUT_FOLDER:
searched.append(os.path.join(COMFYUI_OUTPUT_FOLDER, ply_path))
if COMFYUI_INPUT_FOLDER:
searched.append(os.path.join(COMFYUI_INPUT_FOLDER, ply_path))
searched.append(os.path.join(COMFYUI_INPUT_FOLDER, "3d", ply_path))
formatted = "\n".join(f" - {path}" for path in searched)
raise ValueError(f"PLY file not found. Searched in:\n{formatted}")
if not resolved.lower().endswith(".ply"):
raise ValueError("File must be a .ply Gaussian splat")
print(f"[LoadGaussianPLYPath] Using PLY file: {resolved}")
if auto_resolution == "enabled":
image_width, image_height = get_recommended_resolution(fov_degrees, target_scale)
print(f"[LoadGaussianPLYPath] Auto-resolution for FOV {fov_degrees}° @ scale {target_scale}: {image_width}x{image_height}")
output_path = resolved
if enable_opacity_filter == "enabled":
loader = LoadGaussianPLY()
output_path = loader._filter_by_opacity(resolved, opacity_threshold)
print(f"[LoadGaussianPLYPath] Filtered PLY saved to: {output_path}")
extrinsics = get_default_extrinsics()
intrinsics = get_default_intrinsics(image_width, image_height, fov_degrees)
print(f"[LoadGaussianPLYPath] Camera: FOV={fov_degrees}°, size={image_width}x{image_height}")
return (output_path, extrinsics, intrinsics)
+66
View File
@@ -0,0 +1,66 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from .common import COMFYUI_OUTPUT_FOLDER
class PreviewGaussianNode:
"""Preview Gaussian Splatting PLY files in an interactive gsplat.js viewer."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ply_path": ("STRING", {
"forceInput": True,
"tooltip": "Path to a Gaussian Splatting PLY file",
}),
},
"optional": {
"extrinsics": ("EXTRINSICS", {
"tooltip": "4x4 camera extrinsics matrix for initial view",
}),
"intrinsics": ("INTRINSICS", {
"tooltip": "3x3 camera intrinsics matrix for FOV",
}),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "preview_gaussian"
CATEGORY = "PlyPreview/visualization"
def preview_gaussian(self, ply_path: str, extrinsics=None, intrinsics=None):
if not ply_path:
print("[PreviewGaussian] No PLY path provided")
return {"ui": {"error": ["No PLY path provided"]}}
if not os.path.exists(ply_path):
print(f"[PreviewGaussian] PLY file not found: {ply_path}")
return {"ui": {"error": [f"File not found: {ply_path}"]}}
filename = os.path.basename(ply_path)
if COMFYUI_OUTPUT_FOLDER and ply_path.startswith(COMFYUI_OUTPUT_FOLDER):
relative_path = os.path.relpath(ply_path, COMFYUI_OUTPUT_FOLDER)
else:
relative_path = filename
file_size = os.path.getsize(ply_path)
file_size_mb = file_size / (1024 * 1024)
print(f"[PreviewGaussian] Loading PLY: {filename} ({file_size_mb:.2f} MB)")
ui_data: dict[str, list] = {
"ply_file": [relative_path],
"filename": [filename],
"file_size_mb": [round(file_size_mb, 2)],
}
if extrinsics is not None:
ui_data["extrinsics"] = [extrinsics]
if intrinsics is not None:
ui_data["intrinsics"] = [intrinsics]
return {"ui": ui_data}
+135
View File
@@ -0,0 +1,135 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from .common import get_default_extrinsics, get_default_intrinsics, get_recommended_resolution
from .load_gaussian_ply import LoadGaussianPLY
class ProcessGaussianPLY:
"""Process a Gaussian splat PLY file from upstream nodes (e.g., SHARP Predict)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ply_path": ("STRING", {
"forceInput": True,
"tooltip": "PLY file path from upstream node (e.g., SHARP Predict)",
}),
},
"optional": {
"input_extrinsics": ("EXTRINSICS", {
"tooltip": "Extrinsics from upstream node (ignored if override_camera enabled)",
}),
"input_intrinsics": ("INTRINSICS", {
"tooltip": "Intrinsics from upstream node (ignored if override_camera enabled)",
}),
"override_camera": (["disabled", "enabled"], {
"default": "enabled",
"tooltip": "Override input camera params with custom FOV settings",
}),
"fov_degrees": ("FLOAT", {
"default": 50.0,
"min": 10.0,
"max": 180.0,
"step": 1.0,
"tooltip": "Horizontal field of view in degrees (used when override_camera enabled)",
}),
"auto_resolution": (["disabled", "enabled"], {
"default": "enabled",
"tooltip": "Auto-calculate optimal resolution based on FOV and target scale",
}),
"target_scale": ("FLOAT", {
"default": 10.0,
"min": 1.0,
"max": 50.0,
"step": 1.0,
"tooltip": "Target gaussian scale in viewer (used when auto_resolution enabled)",
}),
"image_width": ("INT", {
"default": 512,
"min": 64,
"max": 8192,
"step": 1,
"tooltip": "Image width (used when auto_resolution disabled)",
}),
"image_height": ("INT", {
"default": 512,
"min": 64,
"max": 8192,
"step": 1,
"tooltip": "Image height (used when auto_resolution disabled)",
}),
"enable_opacity_filter": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Filter out low-opacity Gaussians to reduce background noise",
}),
"opacity_threshold": ("FLOAT", {
"default": 0.1,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Minimum opacity (0-1). Gaussians below this will be removed",
}),
},
}
RETURN_TYPES = ("STRING", "EXTRINSICS", "INTRINSICS")
RETURN_NAMES = ("ply_path", "extrinsics", "intrinsics")
FUNCTION = "process_ply"
CATEGORY = "PlyPreview"
def process_ply(
self,
ply_path: str,
input_extrinsics=None,
input_intrinsics=None,
override_camera: str = "enabled",
fov_degrees: float = 50.0,
auto_resolution: str = "enabled",
target_scale: float = 10.0,
image_width: int = 512,
image_height: int = 512,
enable_opacity_filter: str = "disabled",
opacity_threshold: float = 0.1,
):
if not ply_path or ply_path.strip() == "":
raise ValueError("PLY path cannot be empty")
resolved = ply_path.strip().strip('"')
if not os.path.exists(resolved):
raise ValueError(f"PLY file not found: {resolved}")
if not resolved.lower().endswith(".ply"):
raise ValueError("File must be a .ply Gaussian splat")
print(f"[ProcessGaussianPLY] Input PLY: {resolved}")
if override_camera == "enabled":
if auto_resolution == "enabled":
image_width, image_height = get_recommended_resolution(fov_degrees, target_scale)
print(f"[ProcessGaussianPLY] Auto-resolution for FOV {fov_degrees}° @ scale {target_scale}: {image_width}x{image_height}")
extrinsics = get_default_extrinsics()
intrinsics = get_default_intrinsics(image_width, image_height, fov_degrees)
print(f"[ProcessGaussianPLY] Override camera: FOV={fov_degrees}°, size={image_width}x{image_height}")
else:
if input_extrinsics is not None:
extrinsics = input_extrinsics
print("[ProcessGaussianPLY] Using input extrinsics")
else:
extrinsics = get_default_extrinsics()
print("[ProcessGaussianPLY] Using default extrinsics (no input provided)")
if input_intrinsics is not None:
intrinsics = input_intrinsics
print("[ProcessGaussianPLY] Using input intrinsics")
else:
intrinsics = get_default_intrinsics(image_width, image_height, fov_degrees)
print("[ProcessGaussianPLY] Using default intrinsics (no input provided)")
output_path = resolved
if enable_opacity_filter == "enabled":
loader = LoadGaussianPLY()
output_path = loader._filter_by_opacity(resolved, opacity_threshold)
print(f"[ProcessGaussianPLY] Filtered PLY saved to: {output_path}")
return (output_path, extrinsics, intrinsics)
+245
View File
@@ -0,0 +1,245 @@
/**
* ComfyUI PlyPreview - Gaussian Splat Preview Widget
* Interactive 3D Gaussian Splatting viewer using gsplat.js
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name (handles comfyui-PlyPreview or other folder names)
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "comfyui-PlyPreview";
})();
console.log("[PlyPreview Gaussian] Loading extension...");
app.registerExtension({
name: "plypreview.gaussianpreview",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "PlyPreviewPreviewGaussian") {
console.log("[PlyPreview Gaussian] Registering Preview Gaussian node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Create container for viewer + info panel
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = "#1a1a1a";
container.style.overflow = "hidden";
// Create iframe for gsplat.js viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = "0";
iframe.style.border = "none";
iframe.style.backgroundColor = "#1a1a1a";
// Point to gsplat.js HTML viewer (with cache buster)
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian.html?v=` + Date.now();
// Create info panel
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = "#1a1a1a";
infoPanel.style.borderTop = "1px solid #444";
infoPanel.style.padding = "6px 12px";
infoPanel.style.fontSize = "10px";
infoPanel.style.fontFamily = "monospace";
infoPanel.style.color = "#ccc";
infoPanel.style.lineHeight = "1.3";
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = '<span style="color: #888;">Gaussian splat info will appear here after execution</span>';
// Add iframe and info panel to container
container.appendChild(iframe);
container.appendChild(infoPanel);
// Add widget with required options
const widget = this.addDOMWidget("preview_gaussian", "GAUSSIAN_PREVIEW", container, {
getValue() { return ""; },
setValue(v) { }
});
// Store reference to node for dynamic resizing
const node = this;
let currentNodeSize = [512, 580];
widget.computeSize = () => currentNodeSize;
// Store references
this.gaussianViewerIframe = iframe;
this.gaussianInfoPanel = infoPanel;
// Function to resize node dynamically
this.resizeToAspectRatio = function(imageWidth, imageHeight) {
const aspectRatio = imageWidth / imageHeight;
const nodeWidth = 512;
const viewerHeight = Math.round(nodeWidth / aspectRatio);
const nodeHeight = viewerHeight + 60; // Add space for info panel
currentNodeSize = [nodeWidth, nodeHeight];
node.setSize(currentNodeSize);
node.setDirtyCanvas(true, true);
app.graph.setDirtyCanvas(true, true);
console.log("[GeomPack Gaussian] Resized node to:", nodeWidth, "x", nodeHeight, "(aspect ratio:", aspectRatio.toFixed(2), ")");
};
// Track iframe load state
let iframeLoaded = false;
iframe.addEventListener('load', () => {
iframeLoaded = true;
});
// Listen for messages from iframe
window.addEventListener('message', async (event) => {
// Handle screenshot messages
if (event.data.type === 'SCREENSHOT' && event.data.image) {
try {
// Convert base64 data URL to blob
const base64Data = event.data.image.split(',')[1];
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
const blob = new Blob([uint8Array], { type: 'image/png' });
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `gaussian-screenshot-${timestamp}.png`;
// Create FormData for upload
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output');
formData.append('subfolder', '');
// Upload to ComfyUI backend
const response = await fetch('/upload/image', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
console.log('[GeomPack Gaussian] Screenshot saved:', result.name);
} else {
throw new Error(`Upload failed: ${response.status}`);
}
} catch (error) {
console.error('[GeomPack Gaussian] Error saving screenshot:', error);
}
}
// Handle error messages from iframe
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
console.error('[GeomPack Gaussian] Error from viewer:', event.data.error);
if (infoPanel) {
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${event.data.error}</div>`;
}
}
});
// Set initial node size
this.setSize([512, 580]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
console.log("[GeomPack Gaussian] onExecuted called with:", message);
onExecuted?.apply(this, arguments);
// Check for errors
if (message?.error && message.error[0]) {
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${message.error[0]}</div>`;
return;
}
// The message IS the UI data (not message.ui)
if (message?.ply_file && message.ply_file[0]) {
const filename = message.ply_file[0];
const displayName = message.filename?.[0] || filename;
const fileSizeMb = message.file_size_mb?.[0] || 'N/A';
// Extract camera parameters if provided
const extrinsics = message.extrinsics?.[0] || null;
const intrinsics = message.intrinsics?.[0] || null;
// Resize node to match image aspect ratio from intrinsics
if (intrinsics && intrinsics[0] && intrinsics[1]) {
const imageWidth = intrinsics[0][2] * 2; // cx * 2
const imageHeight = intrinsics[1][2] * 2; // cy * 2
this.resizeToAspectRatio(imageWidth, imageHeight);
}
// Update info panel
infoPanel.innerHTML = `
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">File:</span>
<span style="color: #6cc;">${displayName}</span>
<span style="color: #888;">Size:</span>
<span>${fileSizeMb} MB</span>
</div>
`;
// ComfyUI serves output files via /view API endpoint
const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
// Function to fetch and send data to iframe
const fetchAndSend = async () => {
if (!iframe.contentWindow) {
console.error("[GeomPack Gaussian] Iframe contentWindow not available");
return;
}
try {
// Fetch the PLY file from parent context (authenticated)
console.log("[GeomPack Gaussian] Fetching PLY file:", filepath);
const response = await fetch(filepath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
console.log("[GeomPack Gaussian] Fetched PLY file, size:", arrayBuffer.byteLength);
// Send the data to iframe with camera parameters
iframe.contentWindow.postMessage({
type: "LOAD_MESH_DATA",
data: arrayBuffer,
filename: filename,
extrinsics: extrinsics,
intrinsics: intrinsics,
timestamp: Date.now()
}, "*", [arrayBuffer]);
} catch (error) {
console.error("[GeomPack Gaussian] Error fetching PLY:", error);
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error loading PLY: ${error.message}</div>`;
}
};
// Fetch and send when iframe is ready
if (iframeLoaded) {
fetchAndSend();
} else {
setTimeout(fetchAndSend, 500);
}
}
};
return r;
};
}
}
});
File diff suppressed because one or more lines are too long
+667
View File
@@ -0,0 +1,667 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack Gaussian Splat Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #1a1a1a;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
}
#viewerContainer {
position: relative;
flex: 1;
min-height: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #ff6b6b;
padding: 20px;
max-width: 80%;
}
.error.hidden {
display: none;
}
#controlsBar {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
background: #252525;
padding: 8px 12px;
border-top: 1px solid #333;
flex-shrink: 0;
}
#controlsBar button {
background: #444;
border: none;
color: #fff;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
#controlsBar button:hover {
background: #555;
}
.scale-control {
display: flex;
align-items: center;
gap: 6px;
}
.scale-control span {
color: #888;
font-size: 11px;
}
.scale-control input[type="number"] {
width: 60px;
background: #333;
border: 1px solid #555;
color: #fff;
font-size: 11px;
padding: 4px 6px;
border-radius: 3px;
}
.info-panel {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0,0,0,0.7);
padding: 8px 12px;
border-radius: 4px;
color: #aaa;
font-size: 11px;
font-family: monospace;
z-index: 50;
}
.info-panel.hidden {
display: none;
}
</style>
</head>
<body>
<div id="viewerContainer">
<canvas id="canvas"></canvas>
<div id="error" class="error hidden"></div>
<div id="infoPanel" class="info-panel hidden">
<div id="infoContent"></div>
</div>
</div>
<div id="controlsBar">
<div class="scale-control">
<span>Scale:</span>
<input type="number" id="gaussianScaleValue" min="0.01" max="100" step="0.1" value="1.0" title="Gaussian scale multiplier">
</div>
<button id="resetCamera" title="Reset camera view">Reset View</button>
<button id="screenshot" title="Take screenshot">Screenshot</button>
</div>
<!-- Load bundled gsplat.js -->
<script src="./js/gsplat-bundle.js"></script>
<script type="module">
// Use the globally exported GSPLAT
const SPLAT = window.GSPLAT;
// DOM elements
const canvas = document.getElementById('canvas');
const errorEl = document.getElementById('error');
const infoPanel = document.getElementById('infoPanel');
const infoContent = document.getElementById('infoContent');
// gsplat.js components
let scene = null;
let camera = null;
let renderer = null;
let controls = null;
let animationId = null;
let currentSplat = null;
let originalScales = null; // Store original scales for dynamic adjustment
const SCALE_CHUNK_SIZE = 200000; // Number of float entries processed per animation frame
const DEFAULT_SCALE_MULTIPLIER = 10.0; // Default visual scale applied after loading
let scaleUpdateJob = null; // Tracks incremental scale updates to avoid UI stalls
// Default focal length
const DEFAULT_FOCAL_LENGTH = 30;
// Initialize the viewer
function initViewer() {
try {
scene = new SPLAT.Scene();
camera = new SPLAT.Camera();
renderer = new SPLAT.WebGLRenderer(canvas);
controls = new SPLAT.OrbitControls(camera, canvas);
// Set default focal length
camera.data.fx = DEFAULT_FOCAL_LENGTH;
camera.data.fy = DEFAULT_FOCAL_LENGTH;
// Handle resize
const resize = () => {
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
};
window.addEventListener('resize', resize);
resize();
// Start render loop
const frame = () => {
controls.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(frame);
};
frame();
console.log('[GaussianViewer] Initialized successfully');
} catch (err) {
console.error('[GaussianViewer] Init error:', err);
showError('Failed to initialize WebGL viewer: ' + err.message);
}
}
// Show error
function showError(message) {
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// Store initial camera params for reset
let initialCameraData = null;
// Scale compensation: when focal length is scaled down for canvas size,
// gaussian scales need to be multiplied by this factor to maintain correct screen-space size
let gaussianScaleCompensation = 1.0;
// Set camera from extrinsics and intrinsics
// extrinsics: 4x4 matrix (identity for SHARP = camera at origin looking down +Z)
// intrinsics: 3x3 matrix [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
// splat: the loaded Gaussian splat object (optional, used for bounds)
function setCameraFromExtrinsics(extrinsics, intrinsics, splat) {
if (!camera || !controls) return;
console.log('[GaussianViewer] Setting camera from extrinsics/intrinsics');
console.log('[GaussianViewer] Extrinsics:', JSON.stringify(extrinsics));
console.log('[GaussianViewer] Intrinsics:', JSON.stringify(intrinsics));
// Parse extrinsics to get camera position
// For a 4x4 extrinsics matrix [R|t], camera position in world space is -R^T * t
// But for identity matrix, camera is simply at origin
let camPosX = 0, camPosY = 0, camPosZ = 0;
if (extrinsics && Array.isArray(extrinsics) && extrinsics.length === 4) {
// Extract rotation and translation from 4x4 extrinsics
const R = [
[extrinsics[0][0], extrinsics[0][1], extrinsics[0][2]],
[extrinsics[1][0], extrinsics[1][1], extrinsics[1][2]],
[extrinsics[2][0], extrinsics[2][1], extrinsics[2][2]]
];
const t = [extrinsics[0][3], extrinsics[1][3], extrinsics[2][3]];
// Camera position = -R^T * t
camPosX = -(R[0][0] * t[0] + R[1][0] * t[1] + R[2][0] * t[2]);
camPosY = -(R[0][1] * t[0] + R[1][1] * t[1] + R[2][1] * t[2]);
camPosZ = -(R[0][2] * t[0] + R[1][2] * t[1] + R[2][2] * t[2]);
console.log('[GaussianViewer] Computed camera position from extrinsics:', camPosX, camPosY, camPosZ);
}
// Calculate FOV and set camera focal length from intrinsics
// intrinsics[0][0] = fx (focal length in pixels)
// intrinsics[1][1] = fy (focal length in pixels)
// intrinsics[1][2] = cy (principal point y = image_height / 2)
let targetZ = 2; // default
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const fx = intrinsics[0][0];
const fy = intrinsics[1][1];
const cx = intrinsics[0][2];
const cy = intrinsics[1][2];
const imageWidth = cx * 2; // cx is typically image_width / 2
const imageHeight = cy * 2; // cy is typically image_height / 2
console.log('[GaussianViewer] Intrinsics: fx=', fx, 'fy=', fy, 'cx=', cx, 'cy=', cy);
console.log('[GaussianViewer] Image dimensions (from intrinsics):', imageWidth, 'x', imageHeight);
// Set camera focal length to match the original camera
// gsplat.js uses fx/fy in screen pixels, need to scale by canvas size
const canvasWidth = canvas.clientWidth || 512;
const canvasHeight = canvas.clientHeight || 512;
// Use uniform scale factor (canvas should have same aspect ratio as image)
// This keeps fx = fy for undistorted view
const scale = canvasWidth / imageWidth;
const scaledF = fx * scale; // fx and fy are equal in SHARP
camera.data.fx = scaledF;
camera.data.fy = scaledF;
// IMPORTANT: Compensate gaussian scales for the focal length scaling
// Screen-space size = world_scale * fx / depth
// When we scale fx down, gaussians appear smaller. Multiply scales by inverse.
gaussianScaleCompensation = imageWidth / canvasWidth;
console.log('[GaussianViewer] Canvas size:', canvasWidth, 'x', canvasHeight);
console.log('[GaussianViewer] Uniform scale:', scale, 'scaled focal length:', scaledF);
console.log('[GaussianViewer] Gaussian scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
// Calculate vertical FOV in radians for logging
const fovY = 2 * Math.atan(imageHeight / (2 * fy));
const fovYDeg = fovY * 180 / Math.PI;
console.log('[GaussianViewer] Calculated FOV:', fovYDeg, 'degrees');
// For SHARP, the scene is in front of the camera
// Use focal length to estimate a reasonable target distance
// The disparity range in metadata can help estimate scene depth
// For now, use a simple heuristic based on typical SHARP output
targetZ = Math.max(1, fy / imageHeight * 2);
console.log('[GaussianViewer] Target Z distance:', targetZ);
}
// Determine the target position (what the camera looks at)
// For the INITIAL view (matching original image), look STRAIGHT AHEAD along Z axis
// NOT at the scene center - that would tilt the camera
let targetX = 0, targetY = 0;
let orbitZ = targetZ; // For orbiting, use a closer point
if (splat && splat.bounds) {
const center = splat.bounds.center();
const size = splat.bounds.size();
// Use scene center Z for the view direction
targetZ = center.z;
// For orbit center, use distance to closest object + 50%
// Camera is at origin, so minZ is the distance to closest point
const minZ = center.z - size.z / 2;
orbitZ = minZ * 1.5; // 50% past the closest point
console.log('[GaussianViewer] Scene center:', center.x, -center.y, center.z);
console.log('[GaussianViewer] Closest point Z:', minZ.toFixed(1));
console.log('[GaussianViewer] Orbit center Z:', orbitZ.toFixed(1), '(closest + 50%)');
}
// SHARP uses Y-down coordinate system, so flip camera Y as well
camPosY = -camPosY;
// For SHARP: camera is at origin, scene is at positive Z
// The orbit controls work by having the camera orbit around a target point.
// To get the "original image view", we set camera at the computed position
// and look at the scene center.
const camPos = new SPLAT.Vector3(camPosX, camPosY, camPosZ);
// Use orbitZ for the target - closer to camera for better orbit feel
const targetPos = new SPLAT.Vector3(targetX, targetY, orbitZ);
// Store for reset
initialCameraData = {
position: camPos,
target: targetPos,
fx: camera.data.fx,
fy: camera.data.fy
};
// Set camera position - IMPORTANT: must set camera.position, not camera.data.position
// camera.position is used by OrbitControls
// camera.data.position is used for rendering
camera.position.x = camPosX;
camera.position.y = camPosY;
camera.position.z = camPosZ;
// Update orbit controls to orbit around the target point
// This recalculates the internal orbit state from current camera.position
controls.setCameraTarget(targetPos);
console.log('[GaussianViewer] Camera set to position:', camPosX, camPosY, camPosZ);
console.log('[GaussianViewer] Looking at target:', targetX, targetY, targetZ);
console.log('[GaussianViewer] This should match the original input image view');
}
// Load a PLY file from ArrayBuffer data
async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics) {
try {
// Clear ALL existing objects from scene
if (scene.objects && scene.objects.length > 0) {
console.log('[GaussianViewer] Clearing', scene.objects.length, 'existing objects from scene');
// Remove all objects (iterate backwards to avoid index issues)
while (scene.objects.length > 0) {
scene.removeObject(scene.objects[0]);
}
}
currentSplat = null;
if (scaleUpdateJob) {
scaleUpdateJob.cancelled = true;
scaleUpdateJob = null;
}
// Pre-calculate scale compensation from intrinsics BEFORE loading
// This ensures gaussian scales are correct from the start
gaussianScaleCompensation = 1.0; // Default if no intrinsics
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const fx = intrinsics[0][0];
const cx = intrinsics[0][2];
const imageWidth = cx * 2;
const canvasWidth = canvas.clientWidth || 512;
// Compensation = original_size / canvas_size
// This makes gaussians appear at their correct world-space size
gaussianScaleCompensation = imageWidth / canvasWidth;
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
console.log('[GaussianViewer] (Image:', imageWidth, 'px -> Canvas:', canvasWidth, 'px)');
}
console.log('[GaussianViewer] Loading from data, size:', arrayBuffer.byteLength);
// Create a blob URL from the data
const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
// Load the PLY file from blob URL using PLYLoader
await SPLAT.PLYLoader.LoadAsync(blobUrl, scene);
// Clean up blob URL
URL.revokeObjectURL(blobUrl);
// Get the loaded splat (last object in scene)
if (scene.objects && scene.objects.length > 0) {
currentSplat = scene.objects[scene.objects.length - 1];
// Log bounds info for debugging
if (currentSplat.bounds) {
const center = currentSplat.bounds.center();
const size = currentSplat.bounds.size();
console.log('[GaussianViewer] Scene bounds center:', center.x, center.y, center.z);
console.log('[GaussianViewer] Scene bounds size:', size.x, size.y, size.z);
}
// Store original scales and log statistics
if (currentSplat.data && currentSplat.data.scales) {
const scales = currentSplat.data.scales;
const numGaussians = scales.length / 3;
let minScale = Infinity, maxScale = -Infinity, sumScale = 0;
// Store a copy of original scales (BEFORE compensation) for dynamic adjustment
// The stored scales include the compensation factor so user scale=1 shows correct size
originalScales = new Float32Array(scales.length);
for (let i = 0; i < scales.length; i++) {
// Apply compensation: gaussians need to be larger when focal length is scaled down
originalScales[i] = scales[i] * gaussianScaleCompensation;
}
console.log('[GaussianViewer] Stored compensated scales (factor:', gaussianScaleCompensation.toFixed(2), 'x)');
// Apply compensated scales immediately
for (let i = 0; i < scales.length; i++) {
scales[i] = originalScales[i];
}
// Mark data as changed so gsplat.js re-uploads to GPU
currentSplat.data.changed = true;
for (let i = 0; i < scales.length; i++) {
const s = scales[i];
minScale = Math.min(minScale, s);
maxScale = Math.max(maxScale, s);
sumScale += s;
}
const avgScale = sumScale / scales.length;
console.log('[GaussianViewer] === GAUSSIAN SCALE DEBUG (after compensation) ===');
console.log('[GaussianViewer] Number of Gaussians:', numGaussians);
console.log('[GaussianViewer] Compensated scale min:', minScale.toFixed(6));
console.log('[GaussianViewer] Compensated scale max:', maxScale.toFixed(6));
console.log('[GaussianViewer] Compensated scale avg:', avgScale.toFixed(6));
console.log('[GaussianViewer] =================================================');
} else {
console.log('[GaussianViewer] No scales data found on splat object');
console.log('[GaussianViewer] Splat properties:', Object.keys(currentSplat));
if (currentSplat.data) {
console.log('[GaussianViewer] Splat.data properties:', Object.keys(currentSplat.data));
}
originalScales = null;
}
// Reset scale when loading new splat and apply default multiplier
currentScale = DEFAULT_SCALE_MULTIPLIER;
scaleInput.value = DEFAULT_SCALE_MULTIPLIER;
scheduleScaleUpdate(DEFAULT_SCALE_MULTIPLIER);
}
// Set camera from extrinsics and intrinsics if provided
if (extrinsics || intrinsics) {
setCameraFromExtrinsics(extrinsics, intrinsics, currentSplat);
if (controls) controls.update();
}
// Show info panel
infoPanel.classList.remove('hidden');
infoContent.innerHTML = `<span style="color:#6cc;">Gaussian Splat Loaded</span><br><span style="color:#888;">${filename}</span>`;
// Notify parent
window.parent.postMessage({
type: 'MESH_LOADED',
error: null,
timestamp: Date.now()
}, '*');
console.log('[GaussianViewer] Loaded successfully');
} catch (err) {
console.error('[GaussianViewer] Load error:', err);
showError('Failed to load PLY: ' + err.message);
window.parent.postMessage({
type: 'MESH_ERROR',
error: err.message,
timestamp: Date.now()
}, '*');
}
}
// Reset camera to initial position
function resetCamera() {
if (camera && controls) {
if (initialCameraData) {
// Set camera.position (used by OrbitControls), not camera.data.position
const pos = initialCameraData.position;
camera.position.x = pos.x;
camera.position.y = pos.y;
camera.position.z = pos.z;
// Restore focal lengths
if (initialCameraData.fx) {
camera.data.fx = initialCameraData.fx;
}
if (initialCameraData.fy) {
camera.data.fy = initialCameraData.fy;
}
// Set the target - this recalculates orbit state from current camera.position
if (initialCameraData.target) {
controls.setCameraTarget(initialCameraData.target);
}
console.log('[GaussianViewer] Reset camera to position:', pos.x, pos.y, pos.z);
console.log('[GaussianViewer] Reset camera target:', initialCameraData.target?.x, initialCameraData.target?.y, initialCameraData.target?.z);
} else {
// Default fallback
camera.position.x = 0;
camera.position.y = 0;
camera.position.z = 5;
console.log('[GaussianViewer] Reset camera to default position (0, 0, 5)');
}
controls.update();
}
}
// Take screenshot
function takeScreenshot() {
if (!renderer) return;
try {
const dataUrl = canvas.toDataURL('image/png');
window.parent.postMessage({
type: 'SCREENSHOT',
image: dataUrl,
timestamp: Date.now()
}, '*');
console.log('[GaussianViewer] Screenshot captured');
} catch (err) {
console.error('[GaussianViewer] Screenshot error:', err);
}
}
// Gaussian scale control
const scaleInput = document.getElementById('gaussianScaleValue');
let currentScale = 1.0;
function finalizeScaleUpdate(job) {
if (!job || job.cancelled || scaleUpdateJob !== job) {
return;
}
if (!(currentSplat && currentSplat.data)) {
scaleUpdateJob = null;
return;
}
currentSplat.data.changed = true;
currentSplat.data.detached = false;
const renderData = renderer?._renderData || renderer?.renderData;
if (renderData?.markDirty) {
renderData.markDirty(currentSplat);
}
if (renderData) {
renderData.dataChanged = true;
}
if (currentSplat.dispatchEvent && currentSplat._changeEvent) {
currentSplat.dispatchEvent(currentSplat._changeEvent);
}
console.log('[GaussianViewer] Finished gaussian scale update');
scaleUpdateJob = null;
}
function scheduleScaleUpdate(targetScale) {
if (!(currentSplat && currentSplat.data && currentSplat.data.scales && originalScales)) {
console.log('[GaussianViewer] Cannot schedule scale update - missing data');
return;
}
// Cancel any in-flight job so we only apply the latest value
if (scaleUpdateJob) {
scaleUpdateJob.cancelled = true;
}
const job = {
targetScale,
index: 0,
cancelled: false,
splat: currentSplat,
scales: currentSplat.data.scales,
originals: originalScales
};
scaleUpdateJob = job;
const total = job.originals.length;
console.log('[GaussianViewer] Scaling gaussians in chunks. Factor:', targetScale, 'Total entries:', total);
const processChunk = () => {
if (job.cancelled || scaleUpdateJob !== job || job.splat !== currentSplat) {
return;
}
const start = job.index;
const end = Math.min(total, start + SCALE_CHUNK_SIZE);
for (let i = start; i < end; i++) {
job.scales[i] = job.originals[i] * job.targetScale;
}
job.index = end;
if (job.index < total) {
requestAnimationFrame(processChunk);
} else {
finalizeScaleUpdate(job);
}
};
requestAnimationFrame(processChunk);
}
function updateGaussianScale(newScale) {
newScale = Math.max(0.01, Math.min(100, parseFloat(newScale) || 1.0));
currentScale = newScale;
// Update input
scaleInput.value = newScale;
// Apply scale to current splat by modifying the underlying scale data
// gsplat.js applies Math.exp() during PLY loading, so data.scales contains LINEAR values
// To multiply visual size by factor f, we multiply the scale values directly
if (currentSplat && currentSplat.data && currentSplat.data.scales && originalScales) {
scheduleScaleUpdate(newScale);
} else if (currentSplat) {
console.log('[GaussianViewer] Cannot update scales - missing data or originalScales');
console.log('[GaussianViewer] currentSplat.data:', !!currentSplat.data);
console.log('[GaussianViewer] currentSplat.data.scales:', !!(currentSplat.data && currentSplat.data.scales));
console.log('[GaussianViewer] originalScales:', !!originalScales);
}
}
scaleInput.addEventListener('change', (e) => updateGaussianScale(e.target.value));
// Event listeners
document.getElementById('resetCamera').addEventListener('click', resetCamera);
document.getElementById('screenshot').addEventListener('click', takeScreenshot);
// Listen for messages from parent
window.addEventListener('message', (event) => {
const { type, data, filename, extrinsics, intrinsics } = event.data;
if (type === 'LOAD_MESH_DATA' && data) {
console.log('[GaussianViewer] Received LOAD_MESH_DATA, size:', data.byteLength);
console.log('[GaussianViewer] Extrinsics:', extrinsics);
console.log('[GaussianViewer] Intrinsics:', intrinsics);
loadPLYFromData(data, filename || 'gaussian.ply', extrinsics, intrinsics);
}
});
// Initialize on load
initViewer();
console.log('[GaussianViewer] Ready - waiting for LOAD_MESH message');
</script>
</body>
</html>