Reverb commited on
Commit
c6d0961
·
1 Parent(s): 299ba9b

Fix all post-processing errors on ZeroGPU/headless environment

Browse files

Repair (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 CHANGED
@@ -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 =====
src/stages/stage2_cleanup.py CHANGED
@@ -1,7 +1,12 @@
1
- """Stage 2B — Geometry cleanup with trimesh + PyMeshLab."""
 
 
 
 
 
 
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
- import pymeshlab
15
-
16
- # Load and do a first pass with trimesh (fast, handles basic issues)
17
- mesh = trimesh.load(str(input_glb), force="mesh")
18
- mesh.remove_duplicate_faces()
19
- mesh.remove_unreferenced_vertices()
20
- mesh.remove_infinite_values()
21
- mesh.merge_vertices(merge_tex=False)
22
-
23
- # Export to PLY for PyMeshLab (more reliable than GLB roundtrip in pymeshlab)
24
- with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
25
- ply_in = tmp.name
26
- mesh.export(ply_in)
27
-
28
- ms = pymeshlab.MeshSet()
29
- ms.load_new_mesh(ply_in)
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
- out_mesh.export(str(out_path))
51
 
52
  state = workspace.get_state()
53
  state.cleaned_glb = out_path
54
 
55
- return out_path, f"Cleanup: {face_count:,} faces"
 
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"
src/stages/stage2_decimate.py CHANGED
@@ -1,18 +1,24 @@
1
  """Stage 2C — Mesh decimation.
2
 
3
- Two modes:
4
- preview — fast-simplification (CPU, ~seconds) for live slider feedback
5
- final — PyMeshLab quadric edge collapse (quality-preserving) for export
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 = 1.0 - target_faces / current_faces
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
- """Quality-preserving decimation via PyMeshLab quadric edge collapse."""
39
  import trimesh
40
- import pymeshlab
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
- with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
54
- ply_in = tmp.name
55
- mesh.export(ply_in)
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
- face_count = ms.current_mesh().face_number()
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:,} → {face_count:,} 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"
src/stages/stage2_finalize.py CHANGED
@@ -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
- Generate LOD0/LOD1/LOD2 via PyMeshLab quadric decimation.
58
- lod_ratios: face-count multipliers relative to input (1.0 = full).
59
  """
60
  import trimesh
61
- import pymeshlab
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
- target = max(100, int(base_faces * ratio))
76
-
77
- if ratio == 1.0:
78
- out = CURRENT_LODS / f"LOD{idx}.glb"
79
  mesh.export(str(out))
 
80
  else:
81
- ms = pymeshlab.MeshSet()
82
- ms.load_new_mesh(ply_in)
83
- ms.apply_filter(
84
- "meshing_decimation_quadric_edge_collapse",
85
- targetfacenum=target,
86
- preservenormal=True,
87
- preservetopology=True,
88
- autoclean=True,
89
  )
90
- with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
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
- counts = [int(base_faces * r) for r in lod_ratios]
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
  # ---------------------------------------------------------------------------
src/stages/stage2_repair.py CHANGED
@@ -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(verbose=False)
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)