choephix commited on
Commit
676f248
·
1 Parent(s): e4d46b2

Refactor GLB export logic and update remesh handling

Browse files

- Introduced a new `glb_export.py` module to centralize GLB export functionality.
- Updated `export_worker.py` to utilize the new export logic and added a remesh flag.
- Modified `app.py` to remove redundant GLB export functions and integrate the new module.
- Changed `schemas.py` to update the remesh field type from Literal[True] to bool.
- Adjusted `server.py` to pass the remesh parameter correctly during export requests.

Files changed (5) hide show
  1. app.py +3 -305
  2. export_worker.py +8 -5
  3. glb_export.py +377 -0
  4. schemas.py +1 -1
  5. server.py +2 -0
app.py CHANGED
@@ -27,32 +27,13 @@ from trellis2.pipelines import Trellis2ImageTo3DPipeline
27
  from trellis2.renderers import EnvMap
28
  from trellis2.utils import render_utils
29
  import o_voxel
 
30
 
31
 
32
  MAX_SEED = np.iinfo(np.int32).max
33
  TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tmp")
34
 
35
 
36
- def _env_flag(name: str, default: bool) -> bool:
37
- value = os.environ.get(name)
38
- if value is None:
39
- return default
40
- return value.strip().lower() in {"1", "true", "yes", "on"}
41
-
42
-
43
- SAFE_NONREMESH_GLB_EXPORT = _env_flag("SAFE_NONREMESH_GLB_EXPORT", True)
44
-
45
-
46
- def _cumesh_counts(mesh: Any) -> str:
47
- num_vertices = getattr(mesh, "num_vertices", "?")
48
- num_faces = getattr(mesh, "num_faces", "?")
49
- return f"vertices={num_vertices}, faces={num_faces}"
50
-
51
-
52
- def _log_cumesh_counts(label: str, mesh: Any) -> None:
53
- print(f"{label}: {_cumesh_counts(mesh)}", flush=True)
54
-
55
-
56
  MODES = [
57
  {"name": "Normal", "icon": "assets/app/normal.png", "render_key": "normal"},
58
  {"name": "Clay render", "icon": "assets/app/clay.png", "render_key": "clay"},
@@ -563,252 +544,6 @@ def image_to_3d(
563
  return state, full_html
564
 
565
 
566
- def _to_glb_without_risky_nonremesh_cleanup(
567
- *,
568
- vertices: torch.Tensor,
569
- faces: torch.Tensor,
570
- attr_volume: torch.Tensor,
571
- coords: torch.Tensor,
572
- attr_layout: Dict[str, slice],
573
- aabb: Any,
574
- voxel_size: Any = None,
575
- grid_size: Any = None,
576
- decimation_target: int = 1000000,
577
- texture_size: int = 2048,
578
- mesh_cluster_threshold_cone_half_angle_rad=np.radians(90.0),
579
- mesh_cluster_refine_iterations=0,
580
- mesh_cluster_global_iterations=1,
581
- mesh_cluster_smooth_strength=1,
582
- verbose: bool = False,
583
- use_tqdm: bool = False,
584
- ):
585
- postprocess = o_voxel.postprocess
586
-
587
- def _try_unify_face_orientations(current_mesh: Any) -> Any:
588
- _log_cumesh_counts("Before face-orientation unification", current_mesh)
589
- try:
590
- current_mesh.unify_face_orientations()
591
- _log_cumesh_counts("After face-orientation unification", current_mesh)
592
- return current_mesh
593
- except RuntimeError as error:
594
- if "[CuMesh] CUDA error" not in str(error):
595
- raise
596
- print(
597
- "Face-orientation unification failed in remesh=False fallback; "
598
- f"retrying once from readback. error={error}",
599
- flush=True,
600
- )
601
-
602
- try:
603
- retry_vertices, retry_faces = current_mesh.read()
604
- retry_mesh = postprocess.cumesh.CuMesh()
605
- retry_mesh.init(retry_vertices, retry_faces)
606
- retry_mesh.remove_duplicate_faces()
607
- retry_mesh.remove_small_connected_components(1e-5)
608
- _log_cumesh_counts("Before face-orientation retry", retry_mesh)
609
- retry_mesh.unify_face_orientations()
610
- _log_cumesh_counts("After face-orientation retry", retry_mesh)
611
- return retry_mesh
612
- except RuntimeError as retry_error:
613
- if "[CuMesh] CUDA error" not in str(retry_error):
614
- raise
615
- print(
616
- "Skipping face-orientation unification in remesh=False fallback after "
617
- f"retry failure: {retry_error}",
618
- flush=True,
619
- )
620
- return current_mesh
621
-
622
- if isinstance(aabb, (list, tuple)):
623
- aabb = np.array(aabb)
624
- if isinstance(aabb, np.ndarray):
625
- aabb = torch.tensor(aabb, dtype=torch.float32, device=coords.device)
626
- assert isinstance(aabb, torch.Tensor)
627
- assert aabb.dim() == 2 and aabb.size(0) == 2 and aabb.size(1) == 3
628
-
629
- if voxel_size is not None:
630
- if isinstance(voxel_size, float):
631
- voxel_size = [voxel_size, voxel_size, voxel_size]
632
- if isinstance(voxel_size, (list, tuple)):
633
- voxel_size = np.array(voxel_size)
634
- if isinstance(voxel_size, np.ndarray):
635
- voxel_size = torch.tensor(
636
- voxel_size, dtype=torch.float32, device=coords.device
637
- )
638
- grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
639
- else:
640
- assert grid_size is not None, "Either voxel_size or grid_size must be provided"
641
- if isinstance(grid_size, int):
642
- grid_size = [grid_size, grid_size, grid_size]
643
- if isinstance(grid_size, (list, tuple)):
644
- grid_size = np.array(grid_size)
645
- if isinstance(grid_size, np.ndarray):
646
- grid_size = torch.tensor(grid_size, dtype=torch.int32, device=coords.device)
647
- voxel_size = (aabb[1] - aabb[0]) / grid_size
648
-
649
- assert isinstance(voxel_size, torch.Tensor)
650
- assert voxel_size.dim() == 1 and voxel_size.size(0) == 3
651
- assert isinstance(grid_size, torch.Tensor)
652
- assert grid_size.dim() == 1 and grid_size.size(0) == 3
653
-
654
- pbar = None
655
- if use_tqdm:
656
- pbar = postprocess.tqdm(total=6, desc="Extracting GLB")
657
-
658
- vertices = vertices.cuda()
659
- faces = faces.cuda()
660
-
661
- mesh = postprocess.cumesh.CuMesh()
662
- mesh.init(vertices, faces)
663
- _log_cumesh_counts("Fallback mesh init", mesh)
664
- if pbar is not None:
665
- pbar.update(1)
666
-
667
- if pbar is not None:
668
- pbar.set_description("Building BVH")
669
- bvh = postprocess.cumesh.cuBVH(vertices, faces)
670
- if pbar is not None:
671
- pbar.update(1)
672
-
673
- if pbar is not None:
674
- pbar.set_description("Cleaning mesh")
675
- mesh.simplify(decimation_target * 3, verbose=verbose)
676
- _log_cumesh_counts("After fallback coarse simplification", mesh)
677
- mesh.remove_duplicate_faces()
678
- mesh.remove_small_connected_components(1e-5)
679
- _log_cumesh_counts("After fallback initial cleanup", mesh)
680
- mesh.simplify(decimation_target, verbose=verbose)
681
- _log_cumesh_counts("After fallback target simplification", mesh)
682
- mesh.remove_duplicate_faces()
683
- mesh.remove_small_connected_components(1e-5)
684
- _log_cumesh_counts("After fallback final cleanup", mesh)
685
- mesh = _try_unify_face_orientations(mesh)
686
- if pbar is not None:
687
- pbar.update(1)
688
-
689
- if pbar is not None:
690
- pbar.set_description("Parameterizing new mesh")
691
- out_vertices, out_faces, out_uvs, out_vmaps = mesh.uv_unwrap(
692
- compute_charts_kwargs={
693
- "threshold_cone_half_angle_rad": mesh_cluster_threshold_cone_half_angle_rad,
694
- "refine_iterations": mesh_cluster_refine_iterations,
695
- "global_iterations": mesh_cluster_global_iterations,
696
- "smooth_strength": mesh_cluster_smooth_strength,
697
- },
698
- return_vmaps=True,
699
- verbose=verbose,
700
- )
701
- out_vertices = out_vertices.cuda()
702
- out_faces = out_faces.cuda()
703
- out_uvs = out_uvs.cuda()
704
- out_vmaps = out_vmaps.cuda()
705
- mesh.compute_vertex_normals()
706
- out_normals = mesh.read_vertex_normals()[out_vmaps]
707
- if pbar is not None:
708
- pbar.update(1)
709
-
710
- if pbar is not None:
711
- pbar.set_description("Sampling attributes")
712
- ctx = postprocess.dr.RasterizeCudaContext()
713
- uvs_rast = torch.cat(
714
- [
715
- out_uvs * 2 - 1,
716
- torch.zeros_like(out_uvs[:, :1]),
717
- torch.ones_like(out_uvs[:, :1]),
718
- ],
719
- dim=-1,
720
- ).unsqueeze(0)
721
- rast = torch.zeros(
722
- (1, texture_size, texture_size, 4), device="cuda", dtype=torch.float32
723
- )
724
-
725
- for i in range(0, out_faces.shape[0], 100000):
726
- rast_chunk, _ = postprocess.dr.rasterize(
727
- ctx,
728
- uvs_rast,
729
- out_faces[i : i + 100000],
730
- resolution=[texture_size, texture_size],
731
- )
732
- mask_chunk = rast_chunk[..., 3:4] > 0
733
- rast_chunk[..., 3:4] += i
734
- rast = torch.where(mask_chunk, rast_chunk, rast)
735
-
736
- mask = rast[0, ..., 3] > 0
737
- pos = postprocess.dr.interpolate(out_vertices.unsqueeze(0), rast, out_faces)[0][0]
738
- valid_pos = pos[mask]
739
- _, face_id, uvw = bvh.unsigned_distance(valid_pos, return_uvw=True)
740
- orig_tri_verts = vertices[faces[face_id.long()]]
741
- valid_pos = (orig_tri_verts * uvw.unsqueeze(-1)).sum(dim=1)
742
-
743
- attrs = torch.zeros(texture_size, texture_size, attr_volume.shape[1], device="cuda")
744
- attrs[mask] = postprocess.grid_sample_3d(
745
- attr_volume,
746
- torch.cat([torch.zeros_like(coords[:, :1]), coords], dim=-1),
747
- shape=torch.Size([1, attr_volume.shape[1], *grid_size.tolist()]),
748
- grid=((valid_pos - aabb[0]) / voxel_size).reshape(1, -1, 3),
749
- mode="trilinear",
750
- )
751
- if pbar is not None:
752
- pbar.update(1)
753
-
754
- if pbar is not None:
755
- pbar.set_description("Finalizing mesh")
756
- mask = mask.cpu().numpy()
757
- base_color = np.clip(
758
- attrs[..., attr_layout["base_color"]].cpu().numpy() * 255, 0, 255
759
- ).astype(np.uint8)
760
- metallic = np.clip(
761
- attrs[..., attr_layout["metallic"]].cpu().numpy() * 255, 0, 255
762
- ).astype(np.uint8)
763
- roughness = np.clip(
764
- attrs[..., attr_layout["roughness"]].cpu().numpy() * 255, 0, 255
765
- ).astype(np.uint8)
766
- alpha = np.clip(
767
- attrs[..., attr_layout["alpha"]].cpu().numpy() * 255, 0, 255
768
- ).astype(np.uint8)
769
-
770
- mask_inv = (~mask).astype(np.uint8)
771
- base_color = cv2.inpaint(base_color, mask_inv, 3, cv2.INPAINT_TELEA)
772
- metallic = cv2.inpaint(metallic, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
773
- roughness = cv2.inpaint(roughness, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
774
- alpha = cv2.inpaint(alpha, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
775
-
776
- material = postprocess.trimesh.visual.material.PBRMaterial(
777
- baseColorTexture=Image.fromarray(np.concatenate([base_color, alpha], axis=-1)),
778
- baseColorFactor=np.array([255, 255, 255, 255], dtype=np.uint8),
779
- metallicRoughnessTexture=Image.fromarray(
780
- np.concatenate([np.zeros_like(metallic), roughness, metallic], axis=-1)
781
- ),
782
- metallicFactor=1.0,
783
- roughnessFactor=1.0,
784
- alphaMode="OPAQUE",
785
- doubleSided=True,
786
- )
787
-
788
- vertices_np = out_vertices.cpu().numpy()
789
- faces_np = out_faces.cpu().numpy()
790
- uvs_np = out_uvs.cpu().numpy()
791
- normals_np = out_normals.cpu().numpy()
792
-
793
- vertices_np[:, 1], vertices_np[:, 2] = vertices_np[:, 2], -vertices_np[:, 1]
794
- normals_np[:, 1], normals_np[:, 2] = normals_np[:, 2], -normals_np[:, 1]
795
- uvs_np[:, 1] = 1 - uvs_np[:, 1]
796
-
797
- textured_mesh = postprocess.trimesh.Trimesh(
798
- vertices=vertices_np,
799
- faces=faces_np,
800
- vertex_normals=normals_np,
801
- process=False,
802
- visual=postprocess.trimesh.visual.TextureVisuals(uv=uvs_np, material=material),
803
- )
804
-
805
- if pbar is not None:
806
- pbar.update(1)
807
- pbar.close()
808
-
809
- return textured_mesh
810
-
811
-
812
  @spaces.GPU(duration=120)
813
  def extract_glb(
814
  state: dict,
@@ -836,7 +571,7 @@ def extract_glb(
836
  shape_slat, tex_slat, res = unpack_state(state)
837
  mesh = pipeline.decode_latent(shape_slat, tex_slat, res)[0]
838
  mesh.simplify(16777216)
839
- glb_kwargs = dict(
840
  vertices=mesh.vertices,
841
  faces=mesh.faces,
842
  attr_volume=mesh.attrs,
@@ -846,46 +581,9 @@ def extract_glb(
846
  aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
847
  decimation_target=decimation_target,
848
  texture_size=texture_size,
 
849
  use_tqdm=True,
850
  )
851
- if remesh:
852
- glb = o_voxel.postprocess.to_glb(
853
- **glb_kwargs,
854
- remesh=True,
855
- remesh_band=1,
856
- remesh_project=0,
857
- )
858
- else:
859
- if SAFE_NONREMESH_GLB_EXPORT:
860
- print(
861
- "Using remesh=False safe GLB export fallback "
862
- "(SAFE_NONREMESH_GLB_EXPORT=1)",
863
- flush=True,
864
- )
865
- glb = _to_glb_without_risky_nonremesh_cleanup(
866
- vertices=mesh.vertices,
867
- faces=mesh.faces,
868
- attr_volume=mesh.attrs,
869
- coords=mesh.coords,
870
- attr_layout=pipeline.pbr_attr_layout,
871
- grid_size=res,
872
- aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
873
- decimation_target=decimation_target,
874
- texture_size=texture_size,
875
- use_tqdm=True,
876
- )
877
- else:
878
- print(
879
- "Using upstream remesh=False GLB export path "
880
- "(SAFE_NONREMESH_GLB_EXPORT=0)",
881
- flush=True,
882
- )
883
- glb = o_voxel.postprocess.to_glb(
884
- **glb_kwargs,
885
- remesh=False,
886
- remesh_band=1,
887
- remesh_project=0,
888
- )
889
  now = datetime.now()
890
  timestamp = now.strftime("%Y-%m-%dT%H%M%S") + f".{now.microsecond // 1000:03d}"
891
  os.makedirs(user_dir, exist_ok=True)
 
27
  from trellis2.renderers import EnvMap
28
  from trellis2.utils import render_utils
29
  import o_voxel
30
+ from glb_export import export_glb as _export_glb
31
 
32
 
33
  MAX_SEED = np.iinfo(np.int32).max
34
  TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tmp")
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  MODES = [
38
  {"name": "Normal", "icon": "assets/app/normal.png", "render_key": "normal"},
39
  {"name": "Clay render", "icon": "assets/app/clay.png", "render_key": "clay"},
 
544
  return state, full_html
545
 
546
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  @spaces.GPU(duration=120)
548
  def extract_glb(
549
  state: dict,
 
571
  shape_slat, tex_slat, res = unpack_state(state)
572
  mesh = pipeline.decode_latent(shape_slat, tex_slat, res)[0]
573
  mesh.simplify(16777216)
574
+ glb = _export_glb(
575
  vertices=mesh.vertices,
576
  faces=mesh.faces,
577
  attr_volume=mesh.attrs,
 
581
  aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
582
  decimation_target=decimation_target,
583
  texture_size=texture_size,
584
+ remesh=remesh,
585
  use_tqdm=True,
586
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  now = datetime.now()
588
  timestamp = now.strftime("%Y-%m-%dT%H%M%S") + f".{now.microsecond // 1000:03d}"
589
  os.makedirs(user_dir, exist_ok=True)
export_worker.py CHANGED
@@ -9,7 +9,7 @@ import runtime_env # noqa: F401
9
  import numpy as np
10
  import torch
11
 
12
- import o_voxel
13
 
14
 
15
  def _deserialize_attr_layout(payload: dict[str, dict[str, int]]) -> dict[str, slice]:
@@ -23,6 +23,7 @@ def export_glb(
23
  output_path: Path,
24
  decimation_target: int,
25
  texture_size: int,
 
26
  ) -> None:
27
  arrays = np.load(payload_npz)
28
  meta = json.loads(payload_meta.read_text(encoding="utf-8"))
@@ -36,7 +37,7 @@ def export_glb(
36
  coords = torch.from_numpy(arrays["coords"]).cuda()
37
 
38
  torch.cuda.synchronize()
39
- glb = o_voxel.postprocess.to_glb(
40
  vertices=vertices,
41
  faces=faces,
42
  attr_volume=attr_volume,
@@ -46,9 +47,7 @@ def export_glb(
46
  aabb=aabb,
47
  decimation_target=decimation_target,
48
  texture_size=texture_size,
49
- remesh=True,
50
- remesh_band=1,
51
- remesh_project=0,
52
  use_tqdm=False,
53
  )
54
  torch.cuda.synchronize()
@@ -62,6 +61,9 @@ def main() -> int:
62
  parser.add_argument("--output", required=True)
63
  parser.add_argument("--decimation-target", type=int, required=True)
64
  parser.add_argument("--texture-size", type=int, required=True)
 
 
 
65
  parser.add_argument("--result-json", required=True)
66
  args = parser.parse_args()
67
 
@@ -73,6 +75,7 @@ def main() -> int:
73
  output_path=Path(args.output),
74
  decimation_target=args.decimation_target,
75
  texture_size=args.texture_size,
 
76
  )
77
  result_path.write_text(
78
  json.dumps(
 
9
  import numpy as np
10
  import torch
11
 
12
+ from glb_export import export_glb as _export_glb
13
 
14
 
15
  def _deserialize_attr_layout(payload: dict[str, dict[str, int]]) -> dict[str, slice]:
 
23
  output_path: Path,
24
  decimation_target: int,
25
  texture_size: int,
26
+ remesh: bool = True,
27
  ) -> None:
28
  arrays = np.load(payload_npz)
29
  meta = json.loads(payload_meta.read_text(encoding="utf-8"))
 
37
  coords = torch.from_numpy(arrays["coords"]).cuda()
38
 
39
  torch.cuda.synchronize()
40
+ glb = _export_glb(
41
  vertices=vertices,
42
  faces=faces,
43
  attr_volume=attr_volume,
 
47
  aabb=aabb,
48
  decimation_target=decimation_target,
49
  texture_size=texture_size,
50
+ remesh=remesh,
 
 
51
  use_tqdm=False,
52
  )
53
  torch.cuda.synchronize()
 
61
  parser.add_argument("--output", required=True)
62
  parser.add_argument("--decimation-target", type=int, required=True)
63
  parser.add_argument("--texture-size", type=int, required=True)
64
+ parser.add_argument(
65
+ "--remesh", type=int, default=1, help="1 = remesh (default), 0 = no remesh"
66
+ )
67
  parser.add_argument("--result-json", required=True)
68
  args = parser.parse_args()
69
 
 
75
  output_path=Path(args.output),
76
  decimation_target=args.decimation_target,
77
  texture_size=args.texture_size,
78
+ remesh=bool(args.remesh),
79
  )
80
  result_path.write_text(
81
  json.dumps(
glb_export.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared GLB export logic used by both the Gradio app and FastAPI export worker.
2
+
3
+ This module owns the remesh=True / remesh=False branching and the
4
+ SAFE_NONREMESH_GLB_EXPORT env-flag behaviour so that the two entry-points
5
+ stay in lock-step.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Dict
12
+
13
+ import cv2
14
+ import numpy as np
15
+ import torch
16
+ from PIL import Image
17
+
18
+ import o_voxel
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Env helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _env_flag(name: str, default: bool) -> bool:
27
+ value = os.environ.get(name)
28
+ if value is None:
29
+ return default
30
+ return value.strip().lower() in {"1", "true", "yes", "on"}
31
+
32
+
33
+ SAFE_NONREMESH_GLB_EXPORT: bool = _env_flag("SAFE_NONREMESH_GLB_EXPORT", True)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Logging helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _cumesh_counts(mesh: Any) -> str:
42
+ num_vertices = getattr(mesh, "num_vertices", "?")
43
+ num_faces = getattr(mesh, "num_faces", "?")
44
+ return f"vertices={num_vertices}, faces={num_faces}"
45
+
46
+
47
+ def _log_cumesh_counts(label: str, mesh: Any) -> None:
48
+ print(f"{label}: {_cumesh_counts(mesh)}", flush=True)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Safe non-remesh fallback (extracted verbatim from app.py)
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _to_glb_without_risky_nonremesh_cleanup(
57
+ *,
58
+ vertices: torch.Tensor,
59
+ faces: torch.Tensor,
60
+ attr_volume: torch.Tensor,
61
+ coords: torch.Tensor,
62
+ attr_layout: Dict[str, slice],
63
+ aabb: Any,
64
+ voxel_size: Any = None,
65
+ grid_size: Any = None,
66
+ decimation_target: int = 1000000,
67
+ texture_size: int = 2048,
68
+ mesh_cluster_threshold_cone_half_angle_rad=np.radians(90.0),
69
+ mesh_cluster_refine_iterations=0,
70
+ mesh_cluster_global_iterations=1,
71
+ mesh_cluster_smooth_strength=1,
72
+ verbose: bool = False,
73
+ use_tqdm: bool = False,
74
+ ):
75
+ postprocess = o_voxel.postprocess
76
+
77
+ def _try_unify_face_orientations(current_mesh: Any) -> Any:
78
+ _log_cumesh_counts("Before face-orientation unification", current_mesh)
79
+ try:
80
+ current_mesh.unify_face_orientations()
81
+ _log_cumesh_counts("After face-orientation unification", current_mesh)
82
+ return current_mesh
83
+ except RuntimeError as error:
84
+ if "[CuMesh] CUDA error" not in str(error):
85
+ raise
86
+ print(
87
+ "Face-orientation unification failed in remesh=False fallback; "
88
+ f"retrying once from readback. error={error}",
89
+ flush=True,
90
+ )
91
+
92
+ try:
93
+ retry_vertices, retry_faces = current_mesh.read()
94
+ retry_mesh = postprocess.cumesh.CuMesh()
95
+ retry_mesh.init(retry_vertices, retry_faces)
96
+ retry_mesh.remove_duplicate_faces()
97
+ retry_mesh.remove_small_connected_components(1e-5)
98
+ _log_cumesh_counts("Before face-orientation retry", retry_mesh)
99
+ retry_mesh.unify_face_orientations()
100
+ _log_cumesh_counts("After face-orientation retry", retry_mesh)
101
+ return retry_mesh
102
+ except RuntimeError as retry_error:
103
+ if "[CuMesh] CUDA error" not in str(retry_error):
104
+ raise
105
+ print(
106
+ "Skipping face-orientation unification in remesh=False fallback after "
107
+ f"retry failure: {retry_error}",
108
+ flush=True,
109
+ )
110
+ return current_mesh
111
+
112
+ if isinstance(aabb, (list, tuple)):
113
+ aabb = np.array(aabb)
114
+ if isinstance(aabb, np.ndarray):
115
+ aabb = torch.tensor(aabb, dtype=torch.float32, device=coords.device)
116
+ assert isinstance(aabb, torch.Tensor)
117
+ assert aabb.dim() == 2 and aabb.size(0) == 2 and aabb.size(1) == 3
118
+
119
+ if voxel_size is not None:
120
+ if isinstance(voxel_size, float):
121
+ voxel_size = [voxel_size, voxel_size, voxel_size]
122
+ if isinstance(voxel_size, (list, tuple)):
123
+ voxel_size = np.array(voxel_size)
124
+ if isinstance(voxel_size, np.ndarray):
125
+ voxel_size = torch.tensor(
126
+ voxel_size, dtype=torch.float32, device=coords.device
127
+ )
128
+ grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
129
+ else:
130
+ assert grid_size is not None, "Either voxel_size or grid_size must be provided"
131
+ if isinstance(grid_size, int):
132
+ grid_size = [grid_size, grid_size, grid_size]
133
+ if isinstance(grid_size, (list, tuple)):
134
+ grid_size = np.array(grid_size)
135
+ if isinstance(grid_size, np.ndarray):
136
+ grid_size = torch.tensor(grid_size, dtype=torch.int32, device=coords.device)
137
+ voxel_size = (aabb[1] - aabb[0]) / grid_size
138
+
139
+ assert isinstance(voxel_size, torch.Tensor)
140
+ assert voxel_size.dim() == 1 and voxel_size.size(0) == 3
141
+ assert isinstance(grid_size, torch.Tensor)
142
+ assert grid_size.dim() == 1 and grid_size.size(0) == 3
143
+
144
+ pbar = None
145
+ if use_tqdm:
146
+ pbar = postprocess.tqdm(total=6, desc="Extracting GLB")
147
+
148
+ vertices = vertices.cuda()
149
+ faces = faces.cuda()
150
+
151
+ mesh = postprocess.cumesh.CuMesh()
152
+ mesh.init(vertices, faces)
153
+ _log_cumesh_counts("Fallback mesh init", mesh)
154
+ if pbar is not None:
155
+ pbar.update(1)
156
+
157
+ if pbar is not None:
158
+ pbar.set_description("Building BVH")
159
+ bvh = postprocess.cumesh.cuBVH(vertices, faces)
160
+ if pbar is not None:
161
+ pbar.update(1)
162
+
163
+ if pbar is not None:
164
+ pbar.set_description("Cleaning mesh")
165
+ mesh.simplify(decimation_target * 3, verbose=verbose)
166
+ _log_cumesh_counts("After fallback coarse simplification", mesh)
167
+ mesh.remove_duplicate_faces()
168
+ mesh.remove_small_connected_components(1e-5)
169
+ _log_cumesh_counts("After fallback initial cleanup", mesh)
170
+ mesh.simplify(decimation_target, verbose=verbose)
171
+ _log_cumesh_counts("After fallback target simplification", mesh)
172
+ mesh.remove_duplicate_faces()
173
+ mesh.remove_small_connected_components(1e-5)
174
+ _log_cumesh_counts("After fallback final cleanup", mesh)
175
+ mesh = _try_unify_face_orientations(mesh)
176
+ if pbar is not None:
177
+ pbar.update(1)
178
+
179
+ if pbar is not None:
180
+ pbar.set_description("Parameterizing new mesh")
181
+ out_vertices, out_faces, out_uvs, out_vmaps = mesh.uv_unwrap(
182
+ compute_charts_kwargs={
183
+ "threshold_cone_half_angle_rad": mesh_cluster_threshold_cone_half_angle_rad,
184
+ "refine_iterations": mesh_cluster_refine_iterations,
185
+ "global_iterations": mesh_cluster_global_iterations,
186
+ "smooth_strength": mesh_cluster_smooth_strength,
187
+ },
188
+ return_vmaps=True,
189
+ verbose=verbose,
190
+ )
191
+ out_vertices = out_vertices.cuda()
192
+ out_faces = out_faces.cuda()
193
+ out_uvs = out_uvs.cuda()
194
+ out_vmaps = out_vmaps.cuda()
195
+ mesh.compute_vertex_normals()
196
+ out_normals = mesh.read_vertex_normals()[out_vmaps]
197
+ if pbar is not None:
198
+ pbar.update(1)
199
+
200
+ if pbar is not None:
201
+ pbar.set_description("Sampling attributes")
202
+ ctx = postprocess.dr.RasterizeCudaContext()
203
+ uvs_rast = torch.cat(
204
+ [
205
+ out_uvs * 2 - 1,
206
+ torch.zeros_like(out_uvs[:, :1]),
207
+ torch.ones_like(out_uvs[:, :1]),
208
+ ],
209
+ dim=-1,
210
+ ).unsqueeze(0)
211
+ rast = torch.zeros(
212
+ (1, texture_size, texture_size, 4), device="cuda", dtype=torch.float32
213
+ )
214
+
215
+ for i in range(0, out_faces.shape[0], 100000):
216
+ rast_chunk, _ = postprocess.dr.rasterize(
217
+ ctx,
218
+ uvs_rast,
219
+ out_faces[i : i + 100000],
220
+ resolution=[texture_size, texture_size],
221
+ )
222
+ mask_chunk = rast_chunk[..., 3:4] > 0
223
+ rast_chunk[..., 3:4] += i
224
+ rast = torch.where(mask_chunk, rast_chunk, rast)
225
+
226
+ mask = rast[0, ..., 3] > 0
227
+ pos = postprocess.dr.interpolate(out_vertices.unsqueeze(0), rast, out_faces)[0][0]
228
+ valid_pos = pos[mask]
229
+ _, face_id, uvw = bvh.unsigned_distance(valid_pos, return_uvw=True)
230
+ orig_tri_verts = vertices[faces[face_id.long()]]
231
+ valid_pos = (orig_tri_verts * uvw.unsqueeze(-1)).sum(dim=1)
232
+
233
+ attrs = torch.zeros(texture_size, texture_size, attr_volume.shape[1], device="cuda")
234
+ attrs[mask] = postprocess.grid_sample_3d(
235
+ attr_volume,
236
+ torch.cat([torch.zeros_like(coords[:, :1]), coords], dim=-1),
237
+ shape=torch.Size([1, attr_volume.shape[1], *grid_size.tolist()]),
238
+ grid=((valid_pos - aabb[0]) / voxel_size).reshape(1, -1, 3),
239
+ mode="trilinear",
240
+ )
241
+ if pbar is not None:
242
+ pbar.update(1)
243
+
244
+ if pbar is not None:
245
+ pbar.set_description("Finalizing mesh")
246
+ mask = mask.cpu().numpy()
247
+ base_color = np.clip(
248
+ attrs[..., attr_layout["base_color"]].cpu().numpy() * 255, 0, 255
249
+ ).astype(np.uint8)
250
+ metallic = np.clip(
251
+ attrs[..., attr_layout["metallic"]].cpu().numpy() * 255, 0, 255
252
+ ).astype(np.uint8)
253
+ roughness = np.clip(
254
+ attrs[..., attr_layout["roughness"]].cpu().numpy() * 255, 0, 255
255
+ ).astype(np.uint8)
256
+ alpha = np.clip(
257
+ attrs[..., attr_layout["alpha"]].cpu().numpy() * 255, 0, 255
258
+ ).astype(np.uint8)
259
+
260
+ mask_inv = (~mask).astype(np.uint8)
261
+ base_color = cv2.inpaint(base_color, mask_inv, 3, cv2.INPAINT_TELEA)
262
+ metallic = cv2.inpaint(metallic, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
263
+ roughness = cv2.inpaint(roughness, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
264
+ alpha = cv2.inpaint(alpha, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
265
+
266
+ material = postprocess.trimesh.visual.material.PBRMaterial(
267
+ baseColorTexture=Image.fromarray(np.concatenate([base_color, alpha], axis=-1)),
268
+ baseColorFactor=np.array([255, 255, 255, 255], dtype=np.uint8),
269
+ metallicRoughnessTexture=Image.fromarray(
270
+ np.concatenate([np.zeros_like(metallic), roughness, metallic], axis=-1)
271
+ ),
272
+ metallicFactor=1.0,
273
+ roughnessFactor=1.0,
274
+ alphaMode="OPAQUE",
275
+ doubleSided=True,
276
+ )
277
+
278
+ vertices_np = out_vertices.cpu().numpy()
279
+ faces_np = out_faces.cpu().numpy()
280
+ uvs_np = out_uvs.cpu().numpy()
281
+ normals_np = out_normals.cpu().numpy()
282
+
283
+ vertices_np[:, 1], vertices_np[:, 2] = vertices_np[:, 2], -vertices_np[:, 1]
284
+ normals_np[:, 1], normals_np[:, 2] = normals_np[:, 2], -normals_np[:, 1]
285
+ uvs_np[:, 1] = 1 - uvs_np[:, 1]
286
+
287
+ textured_mesh = postprocess.trimesh.Trimesh(
288
+ vertices=vertices_np,
289
+ faces=faces_np,
290
+ vertex_normals=normals_np,
291
+ process=False,
292
+ visual=postprocess.trimesh.visual.TextureVisuals(uv=uvs_np, material=material),
293
+ )
294
+
295
+ if pbar is not None:
296
+ pbar.update(1)
297
+ pbar.close()
298
+
299
+ return textured_mesh
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Public entry-point -- mirrors the branching in app.py extract_glb()
304
+ # ---------------------------------------------------------------------------
305
+
306
+
307
+ def export_glb(
308
+ *,
309
+ vertices: torch.Tensor,
310
+ faces: torch.Tensor,
311
+ attr_volume: torch.Tensor,
312
+ coords: torch.Tensor,
313
+ attr_layout: Dict[str, slice],
314
+ grid_size: Any,
315
+ aabb: Any,
316
+ decimation_target: int,
317
+ texture_size: int,
318
+ remesh: bool,
319
+ use_tqdm: bool = False,
320
+ ):
321
+ """Export a trimesh GLB scene from decoded mesh data.
322
+
323
+ Branches identically to the Gradio ``extract_glb`` function:
324
+
325
+ * ``remesh=True`` -> upstream ``o_voxel.postprocess.to_glb(remesh=True)``
326
+ * ``remesh=False`` + ``SAFE_NONREMESH_GLB_EXPORT=1`` -> safe fallback
327
+ * ``remesh=False`` + ``SAFE_NONREMESH_GLB_EXPORT=0`` -> upstream ``to_glb(remesh=False)``
328
+ """
329
+ glb_kwargs = dict(
330
+ vertices=vertices,
331
+ faces=faces,
332
+ attr_volume=attr_volume,
333
+ coords=coords,
334
+ attr_layout=attr_layout,
335
+ grid_size=grid_size,
336
+ aabb=aabb,
337
+ decimation_target=decimation_target,
338
+ texture_size=texture_size,
339
+ use_tqdm=use_tqdm,
340
+ )
341
+
342
+ if remesh:
343
+ return o_voxel.postprocess.to_glb(
344
+ **glb_kwargs,
345
+ remesh=True,
346
+ remesh_band=1,
347
+ remesh_project=0,
348
+ )
349
+
350
+ if SAFE_NONREMESH_GLB_EXPORT:
351
+ print(
352
+ "Using remesh=False safe GLB export fallback (SAFE_NONREMESH_GLB_EXPORT=1)",
353
+ flush=True,
354
+ )
355
+ return _to_glb_without_risky_nonremesh_cleanup(
356
+ vertices=vertices,
357
+ faces=faces,
358
+ attr_volume=attr_volume,
359
+ coords=coords,
360
+ attr_layout=attr_layout,
361
+ grid_size=grid_size,
362
+ aabb=aabb,
363
+ decimation_target=decimation_target,
364
+ texture_size=texture_size,
365
+ use_tqdm=use_tqdm,
366
+ )
367
+
368
+ print(
369
+ "Using upstream remesh=False GLB export path (SAFE_NONREMESH_GLB_EXPORT=0)",
370
+ flush=True,
371
+ )
372
+ return o_voxel.postprocess.to_glb(
373
+ **glb_kwargs,
374
+ remesh=False,
375
+ remesh_band=1,
376
+ remesh_project=0,
377
+ )
schemas.py CHANGED
@@ -33,7 +33,7 @@ class ExportRequest(BaseModel):
33
 
34
  decimation_target: int = Field(default=20000, ge=1)
35
  texture_size: int = Field(default=1024, ge=256)
36
- remesh: Literal[True] = True
37
 
38
 
39
  class ImageToGlbRequest(BaseModel):
 
33
 
34
  decimation_target: int = Field(default=20000, ge=1)
35
  texture_size: int = Field(default=1024, ge=256)
36
+ remesh: bool = True
37
 
38
 
39
  class ImageToGlbRequest(BaseModel):
server.py CHANGED
@@ -169,6 +169,8 @@ def _run_export(job_dir: Path, request: ImageToGlbRequest) -> Path:
169
  str(request.export.decimation_target),
170
  "--texture-size",
171
  str(request.export.texture_size),
 
 
172
  "--result-json",
173
  str(result_json),
174
  ]
 
169
  str(request.export.decimation_target),
170
  "--texture-size",
171
  str(request.export.texture_size),
172
+ "--remesh",
173
+ str(int(request.export.remesh)),
174
  "--result-json",
175
  str(result_json),
176
  ]