Spaces:
Sleeping
Fix all post-processing errors on ZeroGPU/headless environment
Browse filesRepair (stage2_repair.py):
- Remove verbose=False from PyTMesh() — kwarg dropped in pymeshfix ≥0.17
Cleanup (stage2_cleanup.py):
- Remove remove_duplicate_faces() call — removed from trimesh 4.x
- Replace entire PyMeshLab filter chain with trimesh process=True +
trimesh.repair.fix_winding/fix_normals (no OpenGL dependency)
Decimation (stage2_decimate.py):
- Replace PyMeshLab quadric decimation with fast_simplification
(VTK-based, no libOpenGL.so.0 needed); both preview and final pass
now use the same fast_simplification.simplify() call
LODs (stage2_finalize.py):
- Same: replace PyMeshLab+PLY intermediate with fast_simplification
directly on trimesh vertices/faces
requirements.txt:
- Remove pymeshlab (its filter plugins need libOpenGL.so.0 which is
absent on headless ZeroGPU Spaces — causes "Unknown format for load: ply")
nvdiffrast ABI mismatch (_ZN3c107WarningC1E... symbol) is a known issue
with the torch 2.6.0-compiled wheel on torch 2.8.0; baking stages already
degrade gracefully to default values.
- requirements.txt +3 -1
- src/stages/stage2_cleanup.py +26 -39
- src/stages/stage2_decimate.py +18 -31
- src/stages/stage2_finalize.py +14 -29
- src/stages/stage2_repair.py +1 -1
|
@@ -38,7 +38,9 @@ onnxruntime
|
|
| 38 |
# ===== Milestone 3: Stage 2A-2C Mesh Cleanup =====
|
| 39 |
trimesh[easy]
|
| 40 |
pymeshfix
|
| 41 |
-
pymeshlab
|
|
|
|
|
|
|
| 42 |
fast-simplification
|
| 43 |
|
| 44 |
# ===== Milestone 4: Stage 2D-2E UV + Symmetry =====
|
|
|
|
| 38 |
# ===== Milestone 3: Stage 2A-2C Mesh Cleanup =====
|
| 39 |
trimesh[easy]
|
| 40 |
pymeshfix
|
| 41 |
+
# pymeshlab excluded: its mesh-filter plugins need libOpenGL.so.0 which is
|
| 42 |
+
# absent on ZeroGPU Spaces (headless, no display server). Decimation and
|
| 43 |
+
# LOD generation use fast-simplification (VTK, no OpenGL) instead.
|
| 44 |
fast-simplification
|
| 45 |
|
| 46 |
# ===== Milestone 4: Stage 2D-2E UV + Symmetry =====
|
|
@@ -1,7 +1,12 @@
|
|
| 1 |
-
"""Stage 2B — Geometry cleanup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
import tempfile
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
from src import workspace
|
|
@@ -11,45 +16,27 @@ from src.workspace import CURRENT
|
|
| 11 |
def cleanup_mesh(input_glb: Path) -> tuple[Path, str]:
|
| 12 |
"""Remove duplicate verts/faces, degenerate geometry, non-manifold elements."""
|
| 13 |
import trimesh
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
mesh.
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
# Remove degenerate and null faces
|
| 32 |
-
ms.apply_filter("meshing_remove_null_faces")
|
| 33 |
-
# Remove duplicate verts
|
| 34 |
-
ms.apply_filter("meshing_remove_duplicate_vertices")
|
| 35 |
-
# Remove unreferenced verts
|
| 36 |
-
ms.apply_filter("meshing_remove_unreferenced_vertices")
|
| 37 |
-
# Fix non-manifold edges (remove offending faces)
|
| 38 |
-
ms.apply_filter("meshing_repair_non_manifold_edges")
|
| 39 |
-
# Fix non-manifold verts (split)
|
| 40 |
-
ms.apply_filter("meshing_repair_non_manifold_vertices")
|
| 41 |
-
|
| 42 |
-
face_count = ms.current_mesh().face_number()
|
| 43 |
-
|
| 44 |
-
with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
|
| 45 |
-
ply_out = tmp.name
|
| 46 |
-
ms.save_current_mesh(ply_out)
|
| 47 |
-
|
| 48 |
-
out_mesh = trimesh.load(ply_out, force="mesh")
|
| 49 |
out_path = CURRENT / "cleaned.glb"
|
| 50 |
-
|
| 51 |
|
| 52 |
state = workspace.get_state()
|
| 53 |
state.cleaned_glb = out_path
|
| 54 |
|
| 55 |
-
return out_path, f"Cleanup: {
|
|
|
|
| 1 |
+
"""Stage 2B — Geometry cleanup.
|
| 2 |
+
|
| 3 |
+
Uses trimesh only (no PyMeshLab) because PyMeshLab's mesh-processing
|
| 4 |
+
plugins require libOpenGL.so.0 which is absent on ZeroGPU Spaces.
|
| 5 |
+
trimesh's process=True flag handles duplicate verts/faces, unreferenced
|
| 6 |
+
vertices, degenerate triangles, and zero-length edges.
|
| 7 |
+
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
from src import workspace
|
|
|
|
| 16 |
def cleanup_mesh(input_glb: Path) -> tuple[Path, str]:
|
| 17 |
"""Remove duplicate verts/faces, degenerate geometry, non-manifold elements."""
|
| 18 |
import trimesh
|
| 19 |
+
|
| 20 |
+
raw = trimesh.load(str(input_glb), force="mesh")
|
| 21 |
+
|
| 22 |
+
# Re-instantiate with process=True: trims duplicate faces, unreferenced
|
| 23 |
+
# vertices, degenerate triangles, and merges identical vertices.
|
| 24 |
+
mesh = trimesh.Trimesh(
|
| 25 |
+
vertices=raw.vertices,
|
| 26 |
+
faces=raw.faces,
|
| 27 |
+
process=True,
|
| 28 |
+
validate=True,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Fix face winding inconsistencies
|
| 32 |
+
trimesh.repair.fix_winding(mesh)
|
| 33 |
+
# Remove faces whose normal is degenerate (zero-length cross product)
|
| 34 |
+
trimesh.repair.fix_normals(mesh)
|
| 35 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
out_path = CURRENT / "cleaned.glb"
|
| 37 |
+
mesh.export(str(out_path))
|
| 38 |
|
| 39 |
state = workspace.get_state()
|
| 40 |
state.cleaned_glb = out_path
|
| 41 |
|
| 42 |
+
return out_path, f"Cleanup: {len(mesh.faces):,} faces"
|
|
@@ -1,18 +1,24 @@
|
|
| 1 |
"""Stage 2C — Mesh decimation.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
import tempfile
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
from src import workspace
|
| 13 |
from src.workspace import CURRENT
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def decimate_preview(input_glb: Path, target_faces: int) -> tuple[int, int]:
|
| 17 |
"""Quick decimation for live slider feedback. Returns (face_count, vertex_count)."""
|
| 18 |
import trimesh
|
|
@@ -24,57 +30,38 @@ def decimate_preview(input_glb: Path, target_faces: int) -> tuple[int, int]:
|
|
| 24 |
if target_faces >= current_faces:
|
| 25 |
return current_faces, len(mesh.vertices)
|
| 26 |
|
| 27 |
-
ratio =
|
| 28 |
-
ratio = max(0.0, min(ratio, 0.99))
|
| 29 |
-
|
| 30 |
pts_out, faces_out = fast_simplification.simplify(
|
| 31 |
mesh.vertices, mesh.faces, target_reduction=ratio
|
| 32 |
)
|
| 33 |
-
|
| 34 |
return len(faces_out), len(pts_out)
|
| 35 |
|
| 36 |
|
| 37 |
def decimate_mesh_final(input_glb: Path, target_faces: int) -> tuple[Path, str]:
|
| 38 |
-
"""
|
| 39 |
import trimesh
|
| 40 |
-
import
|
| 41 |
|
| 42 |
mesh = trimesh.load(str(input_glb), force="mesh")
|
| 43 |
current_faces = len(mesh.faces)
|
| 44 |
|
| 45 |
if target_faces >= current_faces:
|
| 46 |
-
# Already at or below target — just copy state
|
| 47 |
out_path = CURRENT / "low_poly.glb"
|
| 48 |
mesh.export(str(out_path))
|
| 49 |
state = workspace.get_state()
|
| 50 |
state.low_poly_glb = out_path
|
| 51 |
return out_path, f"Decimation skipped (mesh already at {current_faces:,} faces)"
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
ms = pymeshlab.MeshSet()
|
| 58 |
-
ms.load_new_mesh(ply_in)
|
| 59 |
-
ms.apply_filter(
|
| 60 |
-
"meshing_decimation_quadric_edge_collapse",
|
| 61 |
-
targetfacenum=target_faces,
|
| 62 |
-
preservenormal=True,
|
| 63 |
-
preservetopology=True,
|
| 64 |
-
autoclean=True,
|
| 65 |
)
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
|
| 70 |
-
ply_out = tmp.name
|
| 71 |
-
ms.save_current_mesh(ply_out)
|
| 72 |
-
|
| 73 |
-
out_mesh = trimesh.load(ply_out, force="mesh")
|
| 74 |
out_path = CURRENT / "low_poly.glb"
|
| 75 |
out_mesh.export(str(out_path))
|
| 76 |
|
| 77 |
state = workspace.get_state()
|
| 78 |
state.low_poly_glb = out_path
|
| 79 |
|
| 80 |
-
return out_path, f"Decimation: {current_faces:,} → {
|
|
|
|
| 1 |
"""Stage 2C — Mesh decimation.
|
| 2 |
|
| 3 |
+
Uses fast_simplification (VTK-based, no OpenGL) for both the live preview
|
| 4 |
+
and the final quality pass. PyMeshLab is not used here because its mesh
|
| 5 |
+
filter plugins require libOpenGL.so.0 which is absent on ZeroGPU Spaces.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
from src import workspace
|
| 12 |
from src.workspace import CURRENT
|
| 13 |
|
| 14 |
|
| 15 |
+
def _reduction_ratio(current_faces: int, target_faces: int) -> float:
|
| 16 |
+
"""Fraction of faces to REMOVE (fast_simplification convention)."""
|
| 17 |
+
if target_faces >= current_faces:
|
| 18 |
+
return 0.0
|
| 19 |
+
return max(0.0, min(1.0 - target_faces / current_faces, 0.99))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
def decimate_preview(input_glb: Path, target_faces: int) -> tuple[int, int]:
|
| 23 |
"""Quick decimation for live slider feedback. Returns (face_count, vertex_count)."""
|
| 24 |
import trimesh
|
|
|
|
| 30 |
if target_faces >= current_faces:
|
| 31 |
return current_faces, len(mesh.vertices)
|
| 32 |
|
| 33 |
+
ratio = _reduction_ratio(current_faces, target_faces)
|
|
|
|
|
|
|
| 34 |
pts_out, faces_out = fast_simplification.simplify(
|
| 35 |
mesh.vertices, mesh.faces, target_reduction=ratio
|
| 36 |
)
|
|
|
|
| 37 |
return len(faces_out), len(pts_out)
|
| 38 |
|
| 39 |
|
| 40 |
def decimate_mesh_final(input_glb: Path, target_faces: int) -> tuple[Path, str]:
|
| 41 |
+
"""Decimation via fast_simplification (quadric-based, VTK backend)."""
|
| 42 |
import trimesh
|
| 43 |
+
import fast_simplification
|
| 44 |
|
| 45 |
mesh = trimesh.load(str(input_glb), force="mesh")
|
| 46 |
current_faces = len(mesh.faces)
|
| 47 |
|
| 48 |
if target_faces >= current_faces:
|
|
|
|
| 49 |
out_path = CURRENT / "low_poly.glb"
|
| 50 |
mesh.export(str(out_path))
|
| 51 |
state = workspace.get_state()
|
| 52 |
state.low_poly_glb = out_path
|
| 53 |
return out_path, f"Decimation skipped (mesh already at {current_faces:,} faces)"
|
| 54 |
|
| 55 |
+
ratio = _reduction_ratio(current_faces, target_faces)
|
| 56 |
+
pts_out, faces_out = fast_simplification.simplify(
|
| 57 |
+
mesh.vertices, mesh.faces, target_reduction=ratio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
+
out_mesh = trimesh.Trimesh(vertices=pts_out, faces=faces_out, process=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
out_path = CURRENT / "low_poly.glb"
|
| 62 |
out_mesh.export(str(out_path))
|
| 63 |
|
| 64 |
state = workspace.get_state()
|
| 65 |
state.low_poly_glb = out_path
|
| 66 |
|
| 67 |
+
return out_path, f"Decimation: {current_faces:,} → {len(faces_out):,} faces"
|
|
@@ -53,54 +53,39 @@ def pack_orm(ao_path: Path | None, roughness_path: Path | None, metallic_path: P
|
|
| 53 |
# ---------------------------------------------------------------------------
|
| 54 |
|
| 55 |
def generate_lods(input_glb: Path, lod_ratios: tuple = (1.0, 0.5, 0.25)) -> tuple[list[Path], str]:
|
| 56 |
-
"""
|
| 57 |
-
|
| 58 |
-
lod_ratios: face-count multipliers relative to input (1.0 = full).
|
| 59 |
"""
|
| 60 |
import trimesh
|
| 61 |
-
import
|
| 62 |
-
import tempfile
|
| 63 |
|
| 64 |
mesh = trimesh.load(str(input_glb), force="mesh")
|
| 65 |
base_faces = len(mesh.faces)
|
| 66 |
|
| 67 |
-
with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
|
| 68 |
-
ply_in = tmp.name
|
| 69 |
-
mesh.export(ply_in)
|
| 70 |
-
|
| 71 |
CURRENT_LODS.mkdir(parents=True, exist_ok=True)
|
| 72 |
|
| 73 |
lod_paths = []
|
|
|
|
| 74 |
for idx, ratio in enumerate(lod_ratios):
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
if ratio == 1.0:
|
| 78 |
-
out = CURRENT_LODS / f"LOD{idx}.glb"
|
| 79 |
mesh.export(str(out))
|
|
|
|
| 80 |
else:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
"meshing_decimation_quadric_edge_collapse",
|
| 85 |
-
targetfacenum=target,
|
| 86 |
-
preservenormal=True,
|
| 87 |
-
preservetopology=True,
|
| 88 |
-
autoclean=True,
|
| 89 |
)
|
| 90 |
-
|
| 91 |
-
ply_out = tmp.name
|
| 92 |
-
ms.save_current_mesh(ply_out)
|
| 93 |
-
lod_mesh = trimesh.load(ply_out, force="mesh")
|
| 94 |
-
out = CURRENT_LODS / f"LOD{idx}.glb"
|
| 95 |
lod_mesh.export(str(out))
|
| 96 |
-
|
| 97 |
lod_paths.append(out)
|
| 98 |
|
| 99 |
state = workspace.get_state()
|
| 100 |
state.lod_glbs = lod_paths
|
| 101 |
|
| 102 |
-
|
| 103 |
-
return lod_paths, f"LODs: {' / '.join(str(c) for c in counts)} faces"
|
| 104 |
|
| 105 |
|
| 106 |
# ---------------------------------------------------------------------------
|
|
|
|
| 53 |
# ---------------------------------------------------------------------------
|
| 54 |
|
| 55 |
def generate_lods(input_glb: Path, lod_ratios: tuple = (1.0, 0.5, 0.25)) -> tuple[list[Path], str]:
|
| 56 |
+
"""Generate LOD0/LOD1/LOD2 via fast_simplification (VTK, no OpenGL needed).
|
| 57 |
+
|
| 58 |
+
lod_ratios: face-count multipliers relative to input (1.0 = full res).
|
| 59 |
"""
|
| 60 |
import trimesh
|
| 61 |
+
import fast_simplification
|
|
|
|
| 62 |
|
| 63 |
mesh = trimesh.load(str(input_glb), force="mesh")
|
| 64 |
base_faces = len(mesh.faces)
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
CURRENT_LODS.mkdir(parents=True, exist_ok=True)
|
| 67 |
|
| 68 |
lod_paths = []
|
| 69 |
+
actual_counts = []
|
| 70 |
for idx, ratio in enumerate(lod_ratios):
|
| 71 |
+
out = CURRENT_LODS / f"LOD{idx}.glb"
|
| 72 |
+
if ratio >= 1.0:
|
|
|
|
|
|
|
| 73 |
mesh.export(str(out))
|
| 74 |
+
actual_counts.append(base_faces)
|
| 75 |
else:
|
| 76 |
+
reduction = max(0.0, min(1.0 - ratio, 0.99))
|
| 77 |
+
pts_out, faces_out = fast_simplification.simplify(
|
| 78 |
+
mesh.vertices, mesh.faces, target_reduction=reduction
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
)
|
| 80 |
+
lod_mesh = trimesh.Trimesh(vertices=pts_out, faces=faces_out, process=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
lod_mesh.export(str(out))
|
| 82 |
+
actual_counts.append(len(faces_out))
|
| 83 |
lod_paths.append(out)
|
| 84 |
|
| 85 |
state = workspace.get_state()
|
| 86 |
state.lod_glbs = lod_paths
|
| 87 |
|
| 88 |
+
return lod_paths, f"LODs: {' / '.join(str(c) for c in actual_counts)} faces"
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
# ---------------------------------------------------------------------------
|
|
@@ -15,7 +15,7 @@ def repair_mesh(input_glb: Path) -> tuple[Path, str]:
|
|
| 15 |
mesh = trimesh.load(str(input_glb), force="mesh")
|
| 16 |
v, f = mesh.vertices.copy(), mesh.faces.copy()
|
| 17 |
|
| 18 |
-
tin = pymeshfix.PyTMesh(
|
| 19 |
tin.load_array(v, f)
|
| 20 |
tin.fill_small_boundaries()
|
| 21 |
tin.clean(max_iters=10, inner_loops=3)
|
|
|
|
| 15 |
mesh = trimesh.load(str(input_glb), force="mesh")
|
| 16 |
v, f = mesh.vertices.copy(), mesh.faces.copy()
|
| 17 |
|
| 18 |
+
tin = pymeshfix.PyTMesh() # verbose kwarg removed in pymeshfix ≥ 0.17
|
| 19 |
tin.load_array(v, f)
|
| 20 |
tin.fill_small_boundaries()
|
| 21 |
tin.clean(max_iters=10, inner_loops=3)
|