Reverb commited on
Commit
4fab588
·
1 Parent(s): 5c6c726

Milestone 4+5: UV unwrap (xatlas), symmetry, and normal map baking (nvdiffrast)

Browse files

Milestone 4 — Stage 2D-2E:
- stage2_symmetry.py: bilateral-X/Y symmetry via half-mesh mirror + merge
- stage2_uv.py: xatlas atlas generation with configurable texels_per_unit
and padding; exports unwrapped.glb with TEXCOORD_0

Milestone 5 — Stage 2F:
- stage2_bake_normal.py: full tangent-space normal map baker
- Rasterizes low-poly in UV space via nvdiffrast
- Interpolates per-pixel world position + TBN frame
- Queries high-poly nearest-surface normals via trimesh.proximity
- Transforms to tangent space, packs RGB, dilates past UV islands
- Saves both GL (Y-up) and DX/UE5 (Y-down) variants
- @spaces.GPU(duration=120)

app.py: wire M4/M5 steps in run_post_process (removed from stubs)
requirements.txt: add xatlas, scipy, nvdiffrast (PyPI version)

app.py CHANGED
@@ -125,11 +125,39 @@ def run_post_process(
125
  except Exception as e:
126
  log.append(f"⚠️ Decimation error: {e}")
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # Later milestones — stubs with informative messages
129
  pending = []
130
- if do_symmetry: pending.append("Symmetry (M4)")
131
- if do_unwrap: pending.append("UV unwrap (M4)")
132
- if do_normal_bake: pending.append("Normal bake (M5)")
133
  if do_albedo: pending.append("Albedo bake (M6)")
134
  if do_material: pending.append("Material bake (M6)")
135
  if do_ao: pending.append("AO bake (M6)")
 
125
  except Exception as e:
126
  log.append(f"⚠️ Decimation error: {e}")
127
 
128
+ if do_symmetry:
129
+ try:
130
+ from src.stages.stage2_symmetry import apply_symmetry
131
+ sym_axis = "bilateral-X" # default; exposed via Advanced in M4 polish
132
+ current_glb, msg = apply_symmetry(current_glb, sym_axis)
133
+ log.append(f"✅ {msg}")
134
+ except Exception as e:
135
+ log.append(f"⚠️ Symmetry error: {e}")
136
+
137
+ if do_unwrap:
138
+ try:
139
+ from src.stages.stage2_uv import unwrap_uvs
140
+ current_glb, msg = unwrap_uvs(current_glb)
141
+ log.append(f"✅ {msg}")
142
+ except Exception as e:
143
+ log.append(f"⚠️ UV unwrap error: {e}")
144
+
145
+ if do_normal_bake:
146
+ try:
147
+ from src.stages.stage2_bake_normal import bake_normal_map
148
+ st = workspace.get_state()
149
+ hp = st.high_poly_glb
150
+ lo = st.unwrapped_glb or current_glb
151
+ if not hp or not hp.exists():
152
+ log.append("⚠️ Normal bake: no high-poly GLB. Generate first.")
153
+ else:
154
+ _gl, _dx, msg = bake_normal_map(hp, lo, map_size=2048, dx_format=True)
155
+ log.append(f"✅ {msg}")
156
+ except Exception as e:
157
+ log.append(f"⚠️ Normal bake error: {e}")
158
+
159
  # Later milestones — stubs with informative messages
160
  pending = []
 
 
 
161
  if do_albedo: pending.append("Albedo bake (M6)")
162
  if do_material: pending.append("Material bake (M6)")
163
  if do_ao: pending.append("AO bake (M6)")
requirements.txt CHANGED
@@ -32,7 +32,14 @@ trimesh[easy]
32
  pymeshfix
33
  pymeshlab
34
  fast-simplification
35
- # xatlas — Milestone 4
 
 
 
 
 
 
 
36
  # coacd==1.0.4 — Milestone 8
37
 
38
  # ===== Milestone 4: Stage 2 GPU baking =====
 
32
  pymeshfix
33
  pymeshlab
34
  fast-simplification
35
+
36
+ # ===== Milestone 4: Stage 2D-2E UV + Symmetry =====
37
+ xatlas
38
+ scipy # normal map dilation
39
+
40
+ # ===== Milestone 5: Stage 2F Normal Baking =====
41
+ nvdiffrast # PyPI version — compiles against installed CUDA at runtime
42
+
43
  # coacd==1.0.4 — Milestone 8
44
 
45
  # ===== Milestone 4: Stage 2 GPU baking =====
src/stages/stage2_bake_normal.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 2F — Normal map baking with nvdiffrast.
3
+
4
+ Pipeline:
5
+ 1. Convert low-poly UV coords to clip space.
6
+ 2. Rasterize the low-poly in UV-space → per-pixel triangle + bary coords.
7
+ 3. Interpolate world positions and per-vertex TBN frame at each pixel.
8
+ 4. Query the high-poly mesh for the nearest-surface normal at each pixel's
9
+ world position (trimesh proximity).
10
+ 5. Transform that world-space normal into the low-poly's tangent space.
11
+ 6. Pack as RGB [0, 255], dilate past UV island edges.
12
+ 7. Save both GL (Y-up) and DX (Y-down / UE5) variants.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import spaces
20
+
21
+ from src import workspace
22
+ from src.workspace import CURRENT
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _vertex_tangents(verts: np.ndarray, faces: np.ndarray, uvs: np.ndarray) -> np.ndarray:
30
+ """Compute per-vertex tangent vectors from UV parametrisation."""
31
+ v0, v1, v2 = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]]
32
+ u0, u1, u2 = uvs[faces[:, 0]], uvs[faces[:, 1]], uvs[faces[:, 2]]
33
+
34
+ e1, e2 = v1 - v0, v2 - v0
35
+ du1, dv1 = u1[:, 0] - u0[:, 0], u1[:, 1] - u0[:, 1]
36
+ du2, dv2 = u2[:, 0] - u0[:, 0], u2[:, 1] - u0[:, 1]
37
+
38
+ denom = du1 * dv2 - du2 * dv1
39
+ r = np.where(np.abs(denom) > 1e-10, 1.0 / denom, 0.0)
40
+
41
+ T = (dv2[:, None] * e1 - dv1[:, None] * e2) * r[:, None]
42
+
43
+ tangents = np.zeros_like(verts, dtype=np.float64)
44
+ np.add.at(tangents, faces[:, 0], T)
45
+ np.add.at(tangents, faces[:, 1], T)
46
+ np.add.at(tangents, faces[:, 2], T)
47
+
48
+ norms = np.linalg.norm(tangents, axis=1, keepdims=True)
49
+ return (tangents / np.where(norms < 1e-8, 1.0, norms)).astype(np.float32)
50
+
51
+
52
+ def _dilate(img: np.ndarray, mask: np.ndarray, iterations: int = 8) -> np.ndarray:
53
+ """Push pixel values outward from UV islands using nearest-neighbour dilation."""
54
+ from scipy.ndimage import binary_dilation, convolve
55
+
56
+ result = img.astype(np.float32)
57
+ valid = mask.astype(bool)
58
+ kernel = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]], dtype=np.float32)
59
+
60
+ for _ in range(iterations):
61
+ expanded = binary_dilation(valid)
62
+ border = expanded & ~valid
63
+ if not border.any():
64
+ break
65
+ for c in range(3):
66
+ weighted = convolve(result[:, :, c] * valid, kernel)
67
+ weight_n = convolve(valid.astype(np.float32), kernel)
68
+ blended = np.where(weight_n > 0, weighted / weight_n, 0.0)
69
+ result[:, :, c] = np.where(border, blended, result[:, :, c])
70
+ valid = expanded
71
+
72
+ return result.clip(0, 255).astype(np.uint8)
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # GPU bake
77
+ # ---------------------------------------------------------------------------
78
+
79
+ @spaces.GPU(duration=120)
80
+ def bake_normal_map(
81
+ high_poly_path: Path,
82
+ unwrapped_glb_path: Path,
83
+ map_size: int = 2048,
84
+ dx_format: bool = True,
85
+ ) -> tuple[Path, Path, str]:
86
+ """Bake tangent-space normal maps. Returns (gl_path, dx_path, message)."""
87
+ import torch
88
+ import trimesh
89
+ import nvdiffrast.torch as dr
90
+ from PIL import Image
91
+
92
+ device = torch.device("cuda")
93
+ ctx = dr.RasterizeCudaContext()
94
+
95
+ # ---- Load meshes -------------------------------------------------------
96
+ hi = trimesh.load(str(high_poly_path), force="mesh")
97
+
98
+ lo = trimesh.load(str(unwrapped_glb_path), force="mesh")
99
+ if not isinstance(lo.visual, trimesh.visual.TextureVisuals) or lo.visual.uv is None:
100
+ raise ValueError("Low-poly has no UV coordinates — run UV unwrap first.")
101
+
102
+ uvs = np.array(lo.visual.uv, dtype=np.float32)
103
+ lo_v = np.array(lo.vertices, dtype=np.float32)
104
+ lo_f = np.array(lo.faces, dtype=np.int32)
105
+ lo_n = np.array(lo.vertex_normals, dtype=np.float32)
106
+ lo_t = _vertex_tangents(lo_v.astype(np.float64), lo_f, uvs)
107
+ lo_b = np.cross(lo_n, lo_t).astype(np.float32)
108
+ bn = np.linalg.norm(lo_b, axis=1, keepdims=True)
109
+ lo_b /= np.where(bn < 1e-8, 1.0, bn)
110
+
111
+ # ---- UV → clip space ---------------------------------------------------
112
+ # UV (u, v) in [0,1] → NDC (2u-1, 1-2v, 0, 1)
113
+ pos_clip = np.zeros((len(uvs), 4), dtype=np.float32)
114
+ pos_clip[:, 0] = uvs[:, 0] * 2.0 - 1.0
115
+ pos_clip[:, 1] = (1.0 - uvs[:, 1]) * 2.0 - 1.0
116
+ pos_clip[:, 3] = 1.0
117
+
118
+ v_clip = torch.tensor(pos_clip, device=device).unsqueeze(0) # [1, N, 4]
119
+ faces_t = torch.tensor(lo_f, device=device, dtype=torch.int32) # [M, 3]
120
+
121
+ # ---- Rasterize ---------------------------------------------------------
122
+ rast, _ = dr.rasterize(ctx, v_clip, faces_t, resolution=[map_size, map_size])
123
+ # rast: [1, H, W, 4]; last channel = tri_id + 1 (0 = background)
124
+
125
+ # ---- Interpolate world pos + TBN frame ---------------------------------
126
+ lo_v_t = torch.tensor(lo_v, device=device).unsqueeze(0)
127
+ T_t = torch.tensor(lo_t, device=device).unsqueeze(0)
128
+ B_t = torch.tensor(lo_b, device=device).unsqueeze(0)
129
+ N_t = torch.tensor(lo_n, device=device).unsqueeze(0)
130
+
131
+ world_pos, _ = dr.interpolate(lo_v_t, rast, faces_t) # [1, H, W, 3]
132
+ T_px, _ = dr.interpolate(T_t, rast, faces_t)
133
+ B_px, _ = dr.interpolate(B_t, rast, faces_t)
134
+ N_px, _ = dr.interpolate(N_t, rast, faces_t)
135
+
136
+ mask = (rast[..., 3] > 0).squeeze(0).cpu().numpy() # [H, W]
137
+ wp = world_pos.squeeze(0).cpu().numpy() # [H, W, 3]
138
+ T_np = T_px.squeeze(0).cpu().numpy()
139
+ B_np = B_px.squeeze(0).cpu().numpy()
140
+ N_np = N_px.squeeze(0).cpu().numpy()
141
+
142
+ # ---- Query high-poly normals at pixel world positions ------------------
143
+ ys, xs = np.where(mask)
144
+ if len(ys) == 0:
145
+ raise ValueError("No UV-covered pixels — check that the mesh has valid UVs.")
146
+
147
+ query_pts = wp[ys, xs]
148
+ prox = trimesh.proximity.ProximityQuery(hi)
149
+ _, _, tri_ids = prox.on_surface(query_pts)
150
+ world_normals = hi.face_normals[tri_ids].astype(np.float32)
151
+
152
+ # ---- Transform to tangent space ----------------------------------------
153
+ T_q = T_np[ys, xs]
154
+ B_q = B_np[ys, xs]
155
+ N_q = N_np[ys, xs]
156
+
157
+ for v in (T_q, B_q, N_q):
158
+ norms = np.linalg.norm(v, axis=1, keepdims=True)
159
+ v /= np.where(norms < 1e-8, 1.0, norms)
160
+
161
+ ts_x = (world_normals * T_q).sum(axis=1)
162
+ ts_y = (world_normals * B_q).sum(axis=1)
163
+ ts_z = (world_normals * N_q).sum(axis=1)
164
+ ts = np.stack([ts_x, ts_y, ts_z], axis=1)
165
+
166
+ # ---- Pack + dilate -----------------------------------------------------
167
+ normal_map = np.full((map_size, map_size, 3), 127, dtype=np.uint8)
168
+ packed = np.clip(ts * 127.5 + 127.5, 0, 255).astype(np.uint8)
169
+ normal_map[ys, xs] = packed
170
+ normal_map = _dilate(normal_map, mask)
171
+
172
+ # GL variant (Y-up, default for most renderers)
173
+ gl_map = normal_map
174
+ # DX variant (Y-down, UE5)
175
+ dx_map = normal_map.copy()
176
+ dx_map[:, :, 1] = 255 - dx_map[:, :, 1]
177
+
178
+ tex_dir = CURRENT / "textures"
179
+ tex_dir.mkdir(parents=True, exist_ok=True)
180
+ gl_path = tex_dir / "normal_gl.png"
181
+ dx_path = tex_dir / "normal_dx.png"
182
+ Image.fromarray(gl_map).save(str(gl_path))
183
+ Image.fromarray(dx_map).save(str(dx_path))
184
+
185
+ state = workspace.get_state()
186
+ state.normal_gl_png = gl_path
187
+ state.normal_dx_png = dx_path
188
+
189
+ return gl_path, dx_path, f"Normal maps baked: {map_size}×{map_size}"
src/stages/stage2_symmetry.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2D — Bilateral symmetry enforcement."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from src import workspace
9
+ from src.workspace import CURRENT
10
+
11
+
12
+ def apply_symmetry(input_glb: Path, axis: str) -> tuple[Path, str]:
13
+ """
14
+ Enforce bilateral symmetry. Takes faces from the negative-axis half
15
+ (e.g. x<=0 for bilateral-X) and mirrors them to fill the other half.
16
+
17
+ Works best when the mesh is already roughly centred at the symmetry plane.
18
+ """
19
+ if axis == "off":
20
+ return input_glb, "Symmetry: skipped"
21
+
22
+ import trimesh
23
+
24
+ mesh = trimesh.load(str(input_glb), force="mesh")
25
+ v = np.array(mesh.vertices, dtype=np.float64)
26
+ f = np.array(mesh.faces, dtype=np.int32)
27
+
28
+ ax_map = {"bilateral-X": 0, "bilateral": 0, "bilateral-Y": 1}
29
+ if axis not in ax_map:
30
+ return input_glb, f"Symmetry: '{axis}' not supported"
31
+ ax = ax_map[axis]
32
+
33
+ # Pick faces whose centroid is on the negative side
34
+ centroids = v[f].mean(axis=1)
35
+ keep = centroids[:, ax] <= 0
36
+ if not keep.any():
37
+ keep = centroids[:, ax] >= 0 # fallback: use positive side
38
+
39
+ kept_f = f[keep]
40
+ used = np.unique(kept_f)
41
+ remap = np.full(len(v), -1, dtype=np.int32)
42
+ remap[used] = np.arange(len(used))
43
+
44
+ half_v = v[used]
45
+ half_f = remap[kept_f]
46
+
47
+ mirror_v = half_v.copy()
48
+ mirror_v[:, ax] *= -1
49
+ mirror_f = half_f[:, [0, 2, 1]] # flip winding for mirrored half
50
+
51
+ combined_v = np.vstack([half_v, mirror_v])
52
+ combined_f = np.vstack([half_f, mirror_f + len(half_v)])
53
+
54
+ sym = trimesh.Trimesh(vertices=combined_v, faces=combined_f, process=False)
55
+ sym.merge_vertices(merge_tex=False)
56
+ sym.remove_duplicate_faces()
57
+ sym.remove_unreferenced_vertices()
58
+
59
+ out_path = CURRENT / "symmetric.glb"
60
+ sym.export(str(out_path))
61
+
62
+ state = workspace.get_state()
63
+ state.cleaned_glb = out_path # replaces the pre-UV working mesh
64
+
65
+ return out_path, f"Symmetry ({axis}): {len(sym.faces):,} faces"
src/stages/stage2_uv.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2E — UV unwrap with xatlas."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from src import workspace
9
+ from src.workspace import CURRENT
10
+
11
+
12
+ def unwrap_uvs(
13
+ input_glb: Path,
14
+ texels_per_unit: float = 256.0,
15
+ padding: int = 2,
16
+ ) -> tuple[Path, str]:
17
+ """
18
+ Generate a UV atlas with xatlas and attach the coordinates to the mesh.
19
+ Output GLB has TEXCOORD_0 set — required before any texture baking step.
20
+ """
21
+ import trimesh
22
+ import xatlas
23
+
24
+ mesh = trimesh.load(str(input_glb), force="mesh")
25
+
26
+ positions = np.array(mesh.vertices, dtype=np.float32)
27
+ indices = np.array(mesh.faces, dtype=np.uint32)
28
+ normals = np.array(mesh.vertex_normals, dtype=np.float32)
29
+
30
+ atlas = xatlas.Atlas()
31
+ atlas.add_mesh(positions, indices, normals)
32
+
33
+ pack_opts = xatlas.PackOptions()
34
+ pack_opts.padding = padding
35
+ pack_opts.texels_per_unit = texels_per_unit
36
+
37
+ atlas.generate(chart_options=xatlas.ChartOptions(), pack_options=pack_opts)
38
+
39
+ vmapping, new_indices, uvs = atlas[0]
40
+ # vmapping: [new_N] — original vertex index for each new vertex
41
+ # new_indices: [new_F * 3] flat
42
+ # uvs: [new_N, 2] in [0, 1]
43
+
44
+ new_verts = positions[vmapping]
45
+ new_faces = new_indices.reshape(-1, 3).astype(np.int32)
46
+
47
+ unwrapped = trimesh.Trimesh(vertices=new_verts, faces=new_faces, process=False)
48
+ unwrapped.visual = trimesh.visual.TextureVisuals(uv=uvs.astype(np.float32))
49
+
50
+ out_path = CURRENT / "unwrapped.glb"
51
+ unwrapped.export(str(out_path))
52
+
53
+ state = workspace.get_state()
54
+ state.unwrapped_glb = out_path
55
+
56
+ atlas_w, atlas_h = atlas.width, atlas.height
57
+ return out_path, (
58
+ f"UV unwrap: {len(new_faces):,} faces · "
59
+ f"atlas {atlas_w}×{atlas_h} · {texels_per_unit:.0f} texels/unit"
60
+ )