HiTOPS / app.py
royrx93's picture
update with more exampels
d849ae2
Raw
History Blame Contribute Delete
11.6 kB
"""Gradio demo for the complete HiTOPS single-mesh pipeline."""
from __future__ import annotations
import os
import shutil
import tempfile
import time
import uuid
from pathlib import Path
import gradio as gr
import numpy as np
import trimesh
try:
import spaces
except ImportError:
class _LocalSpaces:
"""Use the same app locally when the ZeroGPU helper is unavailable."""
@staticmethod
def GPU(*_args, **_kwargs):
return lambda function: function
spaces = _LocalSpaces()
from scripts.run_batch_sqfit import process_one_isolated
PROJECT_ROOT = Path(__file__).resolve().parent
JOB_ROOT = Path(tempfile.gettempdir()) / "hitops-gradio"
ALLOWED_SUFFIXES = {".ply", ".obj", ".stl", ".glb", ".gltf"}
MAX_UPLOAD_MB = int(os.getenv("HITOPS_MAX_UPLOAD_MB", "50"))
MAX_FACES = int(os.getenv("HITOPS_MAX_FACES", "1000000"))
JOB_TIMEOUT_SEC = int(os.getenv("HITOPS_JOB_TIMEOUT_SEC", "1800"))
JOB_TTL_SEC = int(os.getenv("HITOPS_JOB_TTL_SEC", "21600"))
PRECOMPUTED_ROOT = PROJECT_ROOT / "examples" / "precomputed"
PRECOMPUTED_EXAMPLES = {
"0000c32f": {
"label": "HY3D Example 1 · 0000c32f",
"vertices": 138_148,
"faces": 276_596,
},
"007e1061": {
"label": "HY3D Example 2 · 007e1061",
"vertices": 229_427,
"faces": 459_490,
},
"0005df57": {
"label": "HY3D Example 3 · 0005df57",
"vertices": 275_363,
"faces": 551_188,
},
}
DEFAULT_EXAMPLE_ID = next(iter(PRECOMPUTED_EXAMPLES))
def _cleanup_stale_jobs() -> None:
"""Remove expired jobs from the dedicated temporary-job directory."""
JOB_ROOT.mkdir(parents=True, exist_ok=True)
cutoff = time.time() - JOB_TTL_SEC
for path in JOB_ROOT.iterdir():
try:
if path.is_dir() and path.stat().st_mtime < cutoff:
shutil.rmtree(path)
except OSError:
# Another worker may still own or already have removed the path.
continue
def _load_and_validate_mesh(uploaded_mesh: str) -> trimesh.Trimesh:
"""Load an uploaded mesh and enforce the public-demo resource contract."""
source = Path(uploaded_mesh)
if not source.is_file():
raise gr.Error("The uploaded mesh file is unavailable.")
if source.suffix.lower() not in ALLOWED_SUFFIXES:
supported = ", ".join(sorted(ALLOWED_SUFFIXES))
raise gr.Error(f"Unsupported file type. Supported formats: {supported}.")
size_mb = source.stat().st_size / (1024 * 1024)
if size_mb > MAX_UPLOAD_MB:
raise gr.Error(
f"The upload is {size_mb:.1f} MB; the limit is {MAX_UPLOAD_MB} MB."
)
try:
mesh = trimesh.load(source, force="mesh", process=False)
except Exception as exc:
raise gr.Error(f"Failed to read the mesh: {exc}") from exc
if not isinstance(mesh, trimesh.Trimesh) or mesh.is_empty:
raise gr.Error("The upload does not contain a valid triangle mesh.")
if len(mesh.vertices) == 0 or len(mesh.faces) == 0:
raise gr.Error("The mesh has no vertices or triangular faces.")
if len(mesh.faces) > MAX_FACES:
raise gr.Error(
f"The mesh has {len(mesh.faces):,} faces; the limit is "
f"{MAX_FACES:,} faces."
)
if not np.isfinite(np.asarray(mesh.vertices)).all():
raise gr.Error("The mesh contains NaN or infinite vertex coordinates.")
if not mesh.is_watertight:
gr.Warning(
"The mesh is not watertight. HiTOPS will try to process it, but "
"watertight preprocessing is recommended if the pipeline fails."
)
return mesh
def _export_glb(mesh: trimesh.Trimesh, target: Path) -> str:
"""Export a browser-friendly GLB while preserving mesh vertex colors."""
target.parent.mkdir(parents=True, exist_ok=True)
try:
mesh.export(target, file_type="glb")
except Exception as exc:
raise gr.Error(f"Failed to create the browser preview: {exc}") from exc
if not target.is_file() or target.stat().st_size == 0:
raise gr.Error("The browser preview GLB was not created.")
return str(target)
def prepare_input_preview(uploaded_mesh: str | None) -> str | None:
"""Validate an upload and convert it to GLB for reliable WebGL display."""
if not uploaded_mesh:
return None
_cleanup_stale_jobs()
mesh = _load_and_validate_mesh(uploaded_mesh)
preview_dir = JOB_ROOT / f"preview-{uuid.uuid4().hex[:12]}"
return _export_glb(mesh, preview_dir / "input-preview.glb")
def load_precomputed_example(example_id: str):
"""Return browser previews and original meshes for a bundled result."""
if example_id not in PRECOMPUTED_EXAMPLES:
raise gr.Error("Select one of the bundled examples.")
example = PRECOMPUTED_EXAMPLES[example_id]
example_dir = PRECOMPUTED_ROOT / example_id
input_glb = example_dir / "input.glb"
output_glb = example_dir / "mesh_mapped_v8.glb"
input_ply = example_dir / "input.ply"
output_ply = example_dir / "mesh_mapped_v8.ply"
required = (input_glb, output_glb, input_ply, output_ply)
if not all(path.is_file() for path in required):
raise gr.Error(f"Bundled files are incomplete for {example['label']}.")
summary = (
f"**{example['label']}** — "
f"{example['vertices']:,} vertices · {example['faces']:,} faces. "
"The right viewer shows the final mesh segmented result."
)
return (
summary,
str(input_glb),
str(output_glb),
str(input_ply),
str(output_ply),
)
@spaces.GPU(duration=300)
def run_hitops(
uploaded_mesh: str | None,
progress: gr.Progress = gr.Progress(),
):
"""Run the complete SQ-fit, curvature-segmentation, and mapping pipeline."""
if not uploaded_mesh:
raise gr.Error("Upload a 3D triangle mesh first.")
_cleanup_stale_jobs()
progress(0.03, desc="Validating mesh")
mesh = _load_and_validate_mesh(uploaded_mesh)
job_id = uuid.uuid4().hex[:12]
job_dir = JOB_ROOT / job_id
mesh_root = job_dir / "inputs"
mesh_dir = mesh_root / "00" / job_id
output_root = job_dir / "outputs"
mesh_dir.mkdir(parents=True, exist_ok=False)
# The batch driver expects <mesh_root>/<shard>/<uid>/full.ply. Exporting
# performs a real format conversion instead of merely renaming the upload.
input_ply = mesh_dir / "full.ply"
try:
mesh.export(input_ply, file_type="ply")
except Exception as exc:
shutil.rmtree(job_dir, ignore_errors=True)
raise gr.Error(f"Failed to convert the mesh to PLY: {exc}") from exc
progress(0.08, desc="Running HiTOPS (this can take several minutes)")
try:
result = process_one_isolated(
uid=job_id,
mesh_root=str(mesh_root),
shard="00",
output_root=str(output_root),
timeout_sec=JOB_TIMEOUT_SEC,
)
except Exception as exc:
raise gr.Error(f"HiTOPS failed: {exc}") from exc
if result.get("status") != "ok":
error = result.get("error", "unknown pipeline error")
raise gr.Error(f"HiTOPS failed: {error}")
result_dir = output_root / job_id
mapping_dir = result_dir / "mesh_mapping_v8"
colored_mesh = mapping_dir / "mesh_mapped_v8.ply"
if not colored_mesh.is_file():
raise gr.Error("HiTOPS completed without producing the colored result mesh.")
progress(0.94, desc="Creating browser preview")
try:
result_mesh = trimesh.load(colored_mesh, force="mesh", process=False)
except Exception as exc:
raise gr.Error(f"Failed to read the colored result mesh: {exc}") from exc
preview_glb = _export_glb(result_mesh, mapping_dir / "mesh_mapped_v8.glb")
progress(0.97, desc="Packaging results")
archive_path = shutil.make_archive(
str(job_dir / f"hitops-{job_id}-results"),
"zip",
root_dir=output_root,
base_dir=job_id,
)
progress(1.0, desc="Complete")
return preview_glb, result, archive_path
default_example = load_precomputed_example(DEFAULT_EXAMPLE_ID)
with gr.Blocks(title="HiTOPS") as demo:
gr.Markdown(
"""
# HiTOPS
**Geometry-only 3D part decomposition with adaptive structural carriers and
superquadrics.**
Upload a triangle mesh to run SQ fitting, curvature segmentation, and final
face-to-part mapping. Watertight input is recommended. Processing may take several minutes.
"""
)
gr.Markdown(
"""
## Precomputed examples
Explore three bundled HY3D inputs and their final HiTOPS mesh-mapping results
immediately. Switching examples does not run the pipeline or consume ZeroGPU quota.
"""
)
example_selector = gr.Radio(
choices=[
(example["label"], example_id)
for example_id, example in PRECOMPUTED_EXAMPLES.items()
],
value=DEFAULT_EXAMPLE_ID,
label="Select a precomputed example",
)
example_summary = gr.Markdown(default_example[0])
with gr.Row():
example_input_preview = gr.Model3D(
value=default_example[1],
label="Input mesh",
height=440,
interactive=False,
clear_color=(0.08, 0.08, 0.10, 1.0),
)
example_output_preview = gr.Model3D(
value=default_example[2],
label="HiTOPS mesh mapping result",
height=440,
interactive=False,
clear_color=(0.08, 0.08, 0.10, 1.0),
)
with gr.Row():
example_input_download = gr.File(
value=default_example[3],
label="Download input PLY",
interactive=False,
)
example_output_download = gr.File(
value=default_example[4],
label="Download mapped result PLY",
interactive=False,
)
example_selector.change(
fn=load_precomputed_example,
inputs=example_selector,
outputs=[
example_summary,
example_input_preview,
example_output_preview,
example_input_download,
example_output_download,
],
concurrency_limit=4,
api_name=False,
)
gr.Markdown(
"""
---
## Run HiTOPS on your mesh
"""
)
with gr.Row():
with gr.Column():
input_file = gr.File(
label="Input mesh file",
file_types=sorted(ALLOWED_SUFFIXES),
type="filepath",
)
input_preview = gr.Model3D(
label="Input preview",
height=470,
interactive=False,
clear_color=(0.08, 0.08, 0.10, 1.0),
)
output_mesh = gr.Model3D(
label="HiTOPS part decomposition",
height=520,
interactive=False,
clear_color=(0.08, 0.08, 0.10, 1.0),
)
run_button = gr.Button("Run HiTOPS", variant="primary")
with gr.Row():
run_info = gr.JSON(label="Run summary")
download = gr.File(label="Download complete results")
input_file.change(
fn=prepare_input_preview,
inputs=input_file,
outputs=input_preview,
concurrency_limit=2,
api_name=False,
)
run_button.click(
fn=run_hitops,
inputs=input_file,
outputs=[output_mesh, run_info, download],
concurrency_limit=1,
api_name="run_hitops",
)
demo.queue(max_size=8, default_concurrency_limit=1)
if __name__ == "__main__":
demo.launch()