Initial release: PlyPreview nodes and viewer
This commit is contained in:
@@ -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]
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user