Spaces:
Running on L40S
Running on L40S
| import os | |
| import struct | |
| from collections import defaultdict | |
| from typing import Optional, Tuple | |
| import numpy as np | |
| import trimesh | |
| from PIL import Image | |
| # ========================= | |
| # 你只需要改这里 | |
| # ========================= | |
| # INPUT_GLB = "/mnt/pfs/users/huangzehuan/projects/SegviGen/examples/trellis2_output.glb" | |
| # UID = "demonic_warrior_red_bronze_armor" | |
| # UID = "playful_pose_white_top_portrait" | |
| # UID = "african_inspired_metallic_silver_ensemble_with_headwrap" | |
| # UID = "cyberpunk_bowser_motorcycle" | |
| # UID = "crimson_battle_mecha_with_spikes" | |
| UID = "black_lace_lingerie_ensemble" | |
| INPUT_GLB = ( | |
| f"/mnt/pfs/users/maxueqi/studio/datasets/dense_mesh/segvigen_bak/{UID}/output.glb" | |
| ) | |
| # 只用 RGB(忽略透明度/alpha) | |
| COLOR_QUANT_STEP = 16 # RGB 量化步长:0/4/8/16(越大越“合并”) | |
| PALETTE_SAMPLE_PIXELS = 2_000_000 | |
| PALETTE_MIN_PIXELS = 500 # 少于该像素数的颜色当噪声丢掉(边界抗锯齿中间色) | |
| PALETTE_MAX_COLORS = 256 # 最多保留多少个主颜色 | |
| PALETTE_MERGE_DIST = 32 # ✅ 合并 palette 内近似颜色(解决“看着同色却拆两块”) | |
| SAMPLES_PER_FACE = 4 # 1 或 4(推荐 4,能明显减少边界采样误差) | |
| FLIP_V = True # glTF 常见需要 flip V | |
| UV_WRAP_REPEAT = True # True: repeat (mod 1);False: clamp 到 [0,1] | |
| MIN_FACES_PER_PART = 50 | |
| BAKE_TRANSFORMS = True | |
| DEBUG_PRINT = True | |
| # ========================= | |
| CHUNK_TYPE_JSON = 0x4E4F534A # b'JSON' | |
| CHUNK_TYPE_BIN = 0x004E4942 # b'BIN\0' | |
| def _default_out_path(in_path: str) -> str: | |
| root, ext = os.path.splitext(in_path) | |
| if ext.lower() not in [".glb", ".gltf"]: | |
| ext = ".glb" | |
| return f"{root}_seg.glb" | |
| def _quantize_rgb(rgb: np.ndarray, step: int) -> np.ndarray: | |
| """ | |
| rgb: (...,3) uint8 | |
| """ | |
| if step is None or step <= 0: | |
| return rgb | |
| q = (rgb.astype(np.int32) + step // 2) // step * step | |
| q = np.clip(q, 0, 255).astype(np.uint8) | |
| return q | |
| def _load_glb_json_and_bin(glb_path: str) -> Tuple[dict, bytes]: | |
| data = open(glb_path, "rb").read() | |
| if len(data) < 12: | |
| raise RuntimeError("Invalid GLB: too small") | |
| magic, version, length = struct.unpack_from("<4sII", data, 0) | |
| if magic != b"glTF": | |
| raise RuntimeError("Not a GLB file (missing glTF header)") | |
| offset = 12 | |
| gltf_json = None | |
| bin_chunk = None | |
| while offset + 8 <= len(data): | |
| chunk_len, chunk_type = struct.unpack_from("<II", data, offset) | |
| offset += 8 | |
| chunk_data = data[offset : offset + chunk_len] | |
| offset += chunk_len | |
| if chunk_type == CHUNK_TYPE_JSON: | |
| gltf_json = chunk_data.decode("utf-8", errors="replace") | |
| elif chunk_type == CHUNK_TYPE_BIN: | |
| bin_chunk = chunk_data | |
| if gltf_json is None: | |
| raise RuntimeError("GLB missing JSON chunk") | |
| if bin_chunk is None: | |
| raise RuntimeError("GLB missing BIN chunk") | |
| import json | |
| return json.loads(gltf_json), bin_chunk | |
| def _extract_basecolor_texture_image(glb_path: str) -> np.ndarray: | |
| """ | |
| 从 GLB 内嵌资源里拿 baseColorTexture 的 PNG/JPG,返回 (H,W,4) uint8 RGBA | |
| """ | |
| gltf, bin_chunk = _load_glb_json_and_bin(glb_path) | |
| materials = gltf.get("materials", []) | |
| textures = gltf.get("textures", []) | |
| images = gltf.get("images", []) | |
| buffer_views = gltf.get("bufferViews", []) | |
| if not materials: | |
| raise RuntimeError("No materials in GLB") | |
| # 这里按 material[0] 取 baseColorTexture(你的 glb 只有一个材质/primitive) | |
| pbr = materials[0].get("pbrMetallicRoughness", {}) | |
| base_tex_index = pbr.get("baseColorTexture", {}).get("index", None) | |
| if base_tex_index is None: | |
| raise RuntimeError("Material has no baseColorTexture") | |
| if base_tex_index >= len(textures): | |
| raise RuntimeError("baseColorTexture index out of range") | |
| tex = textures[base_tex_index] | |
| img_index = tex.get("source", None) | |
| if img_index is None or img_index >= len(images): | |
| raise RuntimeError("Texture has no valid image source") | |
| img_info = images[img_index] | |
| bv_index = img_info.get("bufferView", None) | |
| mime = img_info.get("mimeType", None) | |
| if bv_index is None: | |
| uri = img_info.get("uri", None) | |
| raise RuntimeError(f"Image is not embedded (bufferView missing). uri={uri}") | |
| if bv_index >= len(buffer_views): | |
| raise RuntimeError("image.bufferView out of range") | |
| bv = buffer_views[bv_index] | |
| bo = int(bv.get("byteOffset", 0)) | |
| bl = int(bv.get("byteLength", 0)) | |
| img_bytes = bin_chunk[bo : bo + bl] | |
| if DEBUG_PRINT: | |
| print( | |
| f"[Texture] baseColorTextureIndex={base_tex_index}, imageIndex={img_index}, " | |
| f"bufferView={bv_index}, mime={mime}, bytes={len(img_bytes)}" | |
| ) | |
| pil = Image.open(trimesh.util.wrap_as_stream(img_bytes)).convert("RGBA") | |
| return np.array(pil, dtype=np.uint8) | |
| def _merge_palette_rgb( | |
| palette_rgb: np.ndarray, counts: np.ndarray, merge_dist: float | |
| ) -> np.ndarray: | |
| """ | |
| 对 palette 内 RGB 做“近似合并”,用 counts 作为权重更新中心。 | |
| palette_rgb: (K,3) uint8 | |
| counts: (K,) int | |
| """ | |
| if palette_rgb is None or len(palette_rgb) == 0: | |
| return palette_rgb | |
| if merge_dist is None or merge_dist <= 0: | |
| return palette_rgb | |
| rgb = palette_rgb.astype(np.float32) | |
| counts = counts.astype(np.int64) | |
| order = np.argsort(-counts) | |
| centers = [] | |
| center_w = [] | |
| thr2 = float(merge_dist) * float(merge_dist) | |
| for idx in order: | |
| x = rgb[idx] | |
| w = int(counts[idx]) | |
| if not centers: | |
| centers.append(x.copy()) | |
| center_w.append(w) | |
| continue | |
| C = np.stack(centers, axis=0) # (M,3) | |
| d2 = np.sum((C - x[None, :]) ** 2, axis=1) | |
| k = int(np.argmin(d2)) | |
| if float(d2[k]) <= thr2: | |
| cw = center_w[k] | |
| centers[k] = (centers[k] * cw + x * w) / (cw + w) | |
| center_w[k] = cw + w | |
| else: | |
| centers.append(x.copy()) | |
| center_w.append(w) | |
| merged = np.clip(np.rint(np.stack(centers, axis=0)), 0, 255).astype(np.uint8) | |
| if DEBUG_PRINT: | |
| print( | |
| f"[PaletteMerge] before={len(palette_rgb)} after={len(merged)} merge_dist={merge_dist}" | |
| ) | |
| return merged | |
| def _build_palette_rgb(tex_rgba: np.ndarray) -> np.ndarray: | |
| """ | |
| 从贴图中提取 RGB 主颜色调色板(忽略 alpha)。 | |
| 返回: (K,3) uint8 | |
| """ | |
| rgb = tex_rgba[:, :, :3].reshape(-1, 3) | |
| n = rgb.shape[0] | |
| if n > PALETTE_SAMPLE_PIXELS: | |
| rng = np.random.default_rng(0) | |
| idx = rng.choice(n, size=PALETTE_SAMPLE_PIXELS, replace=False) | |
| rgb = rgb[idx] | |
| rgb = _quantize_rgb(rgb, COLOR_QUANT_STEP) | |
| uniq, counts = np.unique(rgb, axis=0, return_counts=True) | |
| order = np.argsort(-counts) | |
| uniq = uniq[order] | |
| counts = counts[order] | |
| keep = counts >= PALETTE_MIN_PIXELS | |
| uniq = uniq[keep] | |
| counts = counts[keep] | |
| if len(uniq) > PALETTE_MAX_COLORS: | |
| uniq = uniq[:PALETTE_MAX_COLORS] | |
| counts = counts[:PALETTE_MAX_COLORS] | |
| if DEBUG_PRINT: | |
| print( | |
| f"[Palette] quant_step={COLOR_QUANT_STEP} palette_size(before_merge)={len(uniq)} " | |
| f"min_pixels={PALETTE_MIN_PIXELS}" | |
| ) | |
| for i in range(min(15, len(uniq))): | |
| r, g, b = [int(x) for x in uniq[i]] | |
| print(f" {i:02d} rgb=({r},{g},{b}) count={int(counts[i])}") | |
| uniq = _merge_palette_rgb(uniq.astype(np.uint8), counts, PALETTE_MERGE_DIST) | |
| if DEBUG_PRINT: | |
| print(f"[Palette] palette_size(after_merge)={len(uniq)}") | |
| for i in range(min(15, len(uniq))): | |
| r, g, b = [int(x) for x in uniq[i]] | |
| print(f" {i:02d} rgb=({r},{g},{b})") | |
| return uniq.astype(np.uint8) | |
| def _unwrap_uv3_for_seam(uv3: np.ndarray) -> np.ndarray: | |
| """ | |
| uv3: (F,3,2). 若跨 seam(跨度>0.5),把小于0.5的一侧 +1,避免均值跑到另一边。 | |
| """ | |
| out = uv3.copy() | |
| for d in range(2): | |
| v = out[:, :, d] | |
| vmin = v.min(axis=1) | |
| vmax = v.max(axis=1) | |
| seam = (vmax - vmin) > 0.5 | |
| if np.any(seam): | |
| vv = v[seam] | |
| vv = np.where(vv < 0.5, vv + 1.0, vv) | |
| out[seam, :, d] = vv | |
| return out | |
| def _barycentric_samples(uv3: np.ndarray, samples_per_face: int) -> np.ndarray: | |
| """ | |
| uv3: (F,3,2) | |
| return: (F,S,2) | |
| """ | |
| uv3 = _unwrap_uv3_for_seam(uv3) | |
| if samples_per_face == 1: | |
| w = np.array([1 / 3, 1 / 3, 1 / 3], dtype=np.float32) | |
| uvs = uv3[:, 0, :] * w[0] + uv3[:, 1, :] * w[1] + uv3[:, 2, :] * w[2] | |
| return uvs[:, None, :] | |
| # 4 个点:中心 + 三个靠近顶点的内点(尽量远离边界抗锯齿带) | |
| ws = np.array( | |
| [ | |
| [1 / 3, 1 / 3, 1 / 3], | |
| [0.80, 0.10, 0.10], | |
| [0.10, 0.80, 0.10], | |
| [0.10, 0.10, 0.80], | |
| ], | |
| dtype=np.float32, | |
| ) | |
| uvs = ( | |
| uv3[:, None, 0, :] * ws[None, :, 0, None] | |
| + uv3[:, None, 1, :] * ws[None, :, 1, None] | |
| + uv3[:, None, 2, :] * ws[None, :, 2, None] | |
| ) | |
| return uvs | |
| def _wrap_or_clamp_uv(uv: np.ndarray) -> np.ndarray: | |
| if UV_WRAP_REPEAT: | |
| return np.mod(uv, 1.0) | |
| return np.clip(uv, 0.0, 1.0) | |
| def _sample_texture_nearest_rgb(tex_rgba: np.ndarray, uv: np.ndarray) -> np.ndarray: | |
| """ | |
| tex_rgba: (H,W,4) uint8 | |
| uv: (N,2) float | |
| return: (N,3) uint8 | |
| """ | |
| h, w = tex_rgba.shape[0], tex_rgba.shape[1] | |
| uv = _wrap_or_clamp_uv(uv) | |
| u = uv[:, 0] | |
| v = uv[:, 1] | |
| if FLIP_V: | |
| v = 1.0 - v | |
| x = np.rint(u * (w - 1)).astype(np.int32) | |
| y = np.rint(v * (h - 1)).astype(np.int32) | |
| x = np.clip(x, 0, w - 1) | |
| y = np.clip(y, 0, h - 1) | |
| return tex_rgba[y, x, :3].astype(np.uint8) | |
| def _map_to_palette_rgb( | |
| colors_rgb: np.ndarray, palette_rgb: np.ndarray, chunk: int = 20000 | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| 把采样到的 RGB 映射到最近的 palette RGB. | |
| 如果 palette 为空,则用 colors_rgb 的 unique 作为“临时 palette”. | |
| 返回: | |
| labels: (N,) int | |
| used_palette_rgb: (K,3) uint8 | |
| """ | |
| if palette_rgb is None or len(palette_rgb) == 0: | |
| uniq, inv = np.unique(colors_rgb, axis=0, return_inverse=True) | |
| return inv.astype(np.int32), uniq.astype(np.uint8) | |
| c = colors_rgb.astype(np.float32) | |
| p = palette_rgb.astype(np.float32) | |
| out = np.empty((c.shape[0],), dtype=np.int32) | |
| for i in range(0, c.shape[0], chunk): | |
| cc = c[i : i + chunk] | |
| d2 = ((cc[:, None, :] - p[None, :, :]) ** 2).sum(axis=2) | |
| out[i : i + chunk] = np.argmin(d2, axis=1).astype(np.int32) | |
| return out, palette_rgb | |
| def _face_labels_from_texture_rgb( | |
| mesh: trimesh.Trimesh, | |
| tex_rgba: np.ndarray, | |
| palette_rgb: np.ndarray, | |
| ) -> Optional[Tuple[np.ndarray, np.ndarray]]: | |
| """ | |
| 用 TEXCOORD_0 + baseColorTexture,为每个 face 采样 RGB,并映射到 palette label。 | |
| 返回: | |
| face_label: (F,) int | |
| label_rgb: (K,3) uint8 | |
| """ | |
| uv = getattr(mesh.visual, "uv", None) | |
| if uv is None: | |
| return None | |
| uv = np.asarray(uv, dtype=np.float32) | |
| if uv.ndim != 2 or uv.shape[1] != 2 or uv.shape[0] != len(mesh.vertices): | |
| return None | |
| faces = mesh.faces | |
| uv3 = uv[faces] # (F,3,2) | |
| uvs = _barycentric_samples(uv3, SAMPLES_PER_FACE) # (F,S,2) | |
| F, S = uvs.shape[0], uvs.shape[1] | |
| flat_uv = uvs.reshape(-1, 2) | |
| sampled_rgb = _sample_texture_nearest_rgb(tex_rgba, flat_uv) # (F*S,3) | |
| sampled_rgb = _quantize_rgb(sampled_rgb, COLOR_QUANT_STEP) | |
| sample_label, used_palette = _map_to_palette_rgb(sampled_rgb, palette_rgb) | |
| sample_label = sample_label.reshape(F, S) | |
| if S == 1: | |
| return sample_label[:, 0].astype(np.int32), used_palette | |
| # 4 票投票(向量化) | |
| l0, l1, l2, l3 = ( | |
| sample_label[:, 0], | |
| sample_label[:, 1], | |
| sample_label[:, 2], | |
| sample_label[:, 3], | |
| ) | |
| c0 = 1 + (l0 == l1) + (l0 == l2) + (l0 == l3) | |
| c1 = 1 + (l1 == l0) + (l1 == l2) + (l1 == l3) | |
| c2 = 1 + (l2 == l0) + (l2 == l1) + (l2 == l3) | |
| c3 = 1 + (l3 == l0) + (l3 == l1) + (l3 == l2) | |
| counts = np.stack([c0, c1, c2, c3], axis=1) # (F,4) | |
| vals = np.stack([l0, l1, l2, l3], axis=1) # (F,4) | |
| best = vals[np.arange(F), np.argmax(counts, axis=1)] | |
| return best.astype(np.int32), used_palette | |
| # ========================= | |
| # 拓扑纠错 | |
| # ========================= | |
| import numpy as np | |
| import trimesh | |
| from scipy.sparse import coo_matrix | |
| from scipy.sparse.csgraph import connected_components | |
| def _get_physical_face_adjacency(mesh: trimesh.Trimesh) -> np.ndarray: | |
| """ | |
| 忽略 UV 接缝,计算纯物理空间上的面片相邻关系。 | |
| """ | |
| # 1. 四舍五入顶点坐标(处理浮点数微小误差),找出空间中真正唯一的物理顶点 | |
| v_rounded = np.round(mesh.vertices, decimals=3) | |
| v_unique, inv_indices = np.unique(v_rounded, axis=0, return_inverse=True) | |
| # 2. 将原本的面片索引,映射到这些“唯一物理顶点”上 | |
| # 这样,跨越 UV 接缝的面片,此时它们引用的顶点索引就变成一样的了 | |
| physical_faces = inv_indices[mesh.faces] | |
| # 3. 创建一个临时的“影子网格”(process=False 极其重要,防止 trimesh 内部重排面片) | |
| tmp_mesh = trimesh.Trimesh(vertices=v_unique, faces=physical_faces, process=False) | |
| # 返回影子网格的物理相邻边 | |
| return tmp_mesh.face_adjacency | |
| def smooth_face_labels_by_topology( | |
| mesh: trimesh.Trimesh, face_label: np.ndarray, min_faces: int = 50 | |
| ) -> np.ndarray: | |
| """ | |
| 通过真实的 3D 物理拓扑关系过滤飞点,跨越 UV 接缝合并色块。 | |
| Phase 1: 在同色连通图上,把挨着大块的小块吞并到大块中。 | |
| Phase 2: 对残留小块(邻居全是小块),回退到全物理邻接, | |
| 按物理邻居中的多数 label 吞并。 | |
| Phase 3: 对完全孤立的面片(无物理邻接边),按面片质心距离 | |
| 找最近的非孤立面片,继承其 label。 | |
| """ | |
| labels = face_label.copy() | |
| edges = _get_physical_face_adjacency(mesh) | |
| F = len(mesh.faces) | |
| # ---- Phase 1: 同色连通域平滑 ---- | |
| for iteration in range(3): | |
| same_label = labels[edges[:, 0]] == labels[edges[:, 1]] | |
| sub_edges = edges[same_label] | |
| if len(sub_edges) > 0: | |
| data = np.ones(len(sub_edges), dtype=bool) | |
| graph = coo_matrix((data, (sub_edges[:, 0], sub_edges[:, 1])), shape=(F, F)) | |
| graph = graph.maximum(graph.T) | |
| n_components, comp_labels = connected_components(graph, directed=False) | |
| else: | |
| n_components = F | |
| comp_labels = np.arange(F) | |
| comp_sizes = np.bincount(comp_labels, minlength=n_components) | |
| small_comps = np.where(comp_sizes < min_faces)[0] | |
| if len(small_comps) == 0: | |
| break | |
| is_small = np.isin(comp_labels, small_comps) | |
| mask0 = is_small[edges[:, 0]] | |
| mask1 = is_small[edges[:, 1]] | |
| boundary_edges_0 = edges[mask0 & ~mask1] | |
| boundary_edges_1 = edges[mask1 & ~mask0] | |
| b_inner = np.concatenate([boundary_edges_0[:, 0], boundary_edges_1[:, 1]]) | |
| b_outer = np.concatenate([boundary_edges_0[:, 1], boundary_edges_1[:, 0]]) | |
| if len(b_inner) == 0: | |
| break | |
| outer_labels = labels[b_outer] | |
| inner_comps = comp_labels[b_inner] | |
| for cid in np.unique(inner_comps): | |
| cid_mask = inner_comps == cid | |
| surrounding_labels = outer_labels[cid_mask] | |
| if len(surrounding_labels) > 0: | |
| best_label = np.bincount(surrounding_labels).argmax() | |
| labels[comp_labels == cid] = best_label | |
| # ---- Phase 2: 用全物理邻接处理残留小块 ---- | |
| # 重新计算同色连通域,找出还残留的小块 | |
| same_label = labels[edges[:, 0]] == labels[edges[:, 1]] | |
| sub_edges = edges[same_label] | |
| if len(sub_edges) > 0: | |
| data = np.ones(len(sub_edges), dtype=bool) | |
| graph = coo_matrix((data, (sub_edges[:, 0], sub_edges[:, 1])), shape=(F, F)) | |
| graph = graph.maximum(graph.T) | |
| n_components, comp_labels = connected_components(graph, directed=False) | |
| else: | |
| n_components = F | |
| comp_labels = np.arange(F) | |
| comp_sizes = np.bincount(comp_labels, minlength=n_components) | |
| small_comps_set = set(np.where(comp_sizes < min_faces)[0]) | |
| if small_comps_set: | |
| is_small = np.array([comp_labels[i] in small_comps_set for i in range(F)]) | |
| # 构建全物理邻接查找表: face -> set of neighbor faces | |
| adj = defaultdict(set) | |
| for e0, e1 in edges: | |
| adj[int(e0)].add(int(e1)) | |
| adj[int(e1)].add(int(e0)) | |
| # 迭代:每轮让小块面片从物理邻居(忽略颜色)中投票取多数 label | |
| for _ in range(3): | |
| changed = False | |
| small_comps_now = set( | |
| int(c) | |
| for c in range(n_components) | |
| if comp_sizes[c] < min_faces and c in small_comps_set | |
| ) | |
| if not small_comps_now: | |
| break | |
| for cid in small_comps_now: | |
| cid_faces = np.where(comp_labels == cid)[0] | |
| # 收集所有物理邻居中不属于本连通域的面片的 label | |
| neighbor_labels = [] | |
| for fi in cid_faces: | |
| for nf in adj[int(fi)]: | |
| if comp_labels[nf] != cid: | |
| neighbor_labels.append(labels[nf]) | |
| if len(neighbor_labels) > 0: | |
| best_label = int(np.bincount(neighbor_labels).argmax()) | |
| labels[cid_faces] = best_label | |
| changed = True | |
| if not changed: | |
| break | |
| # 重新计算连通域 | |
| same_label = labels[edges[:, 0]] == labels[edges[:, 1]] | |
| sub_edges = edges[same_label] | |
| if len(sub_edges) > 0: | |
| data = np.ones(len(sub_edges), dtype=bool) | |
| graph = coo_matrix( | |
| (data, (sub_edges[:, 0], sub_edges[:, 1])), shape=(F, F) | |
| ) | |
| graph = graph.maximum(graph.T) | |
| n_components, comp_labels = connected_components(graph, directed=False) | |
| else: | |
| n_components = F | |
| comp_labels = np.arange(F) | |
| comp_sizes = np.bincount(comp_labels, minlength=n_components) | |
| small_comps_set = set(np.where(comp_sizes < min_faces)[0]) | |
| # ---- Phase 3: 完全孤立面片(无物理邻接边),按质心距离继承 label ---- | |
| same_label = labels[edges[:, 0]] == labels[edges[:, 1]] | |
| sub_edges = edges[same_label] | |
| if len(sub_edges) > 0: | |
| data = np.ones(len(sub_edges), dtype=bool) | |
| graph = coo_matrix((data, (sub_edges[:, 0], sub_edges[:, 1])), shape=(F, F)) | |
| graph = graph.maximum(graph.T) | |
| _, comp_labels = connected_components(graph, directed=False) | |
| else: | |
| comp_labels = np.arange(F) | |
| comp_sizes = np.bincount(comp_labels) | |
| orphan_comps = set(np.where(comp_sizes < min_faces)[0]) | |
| if orphan_comps: | |
| orphan_mask = np.array([comp_labels[i] in orphan_comps for i in range(F)]) | |
| non_orphan_mask = ~orphan_mask | |
| if non_orphan_mask.any() and orphan_mask.any(): | |
| centroids = mesh.triangles_center | |
| orphan_indices = np.where(orphan_mask)[0] | |
| non_orphan_indices = np.where(non_orphan_mask)[0] | |
| non_orphan_centroids = centroids[non_orphan_indices] | |
| for oi in orphan_indices: | |
| dists = np.linalg.norm(non_orphan_centroids - centroids[oi], axis=1) | |
| nearest = non_orphan_indices[np.argmin(dists)] | |
| labels[oi] = labels[nearest] | |
| if DEBUG_PRINT: | |
| n_orphan = int(orphan_mask.sum()) | |
| print(f" [Phase3] Assigned {n_orphan} orphan faces by centroid proximity") | |
| return labels | |
| # ========================= | |
| # 分割主函数 | |
| # ========================= | |
| # def split_glb_by_texture_palette_rgb( | |
| # in_glb_path: str, | |
| # out_glb_path: Optional[str] = None, | |
| # min_faces_per_part: int = 1, | |
| # bake_transforms: bool = True, | |
| # ) -> str: | |
| # """ | |
| # 输入:glb(无 COLOR_0,但有 baseColorTexture + TEXCOORD_0) | |
| # 输出:先从贴图提取 RGB 主色 palette(忽略 alpha),再按 palette label 分割 | |
| # """ | |
| # if out_glb_path is None: | |
| # out_glb_path = _default_out_path(in_glb_path) | |
| # tex_rgba = _extract_basecolor_texture_image(in_glb_path) | |
| # palette_rgb = _build_palette_rgb(tex_rgba) | |
| # scene = trimesh.load(in_glb_path, force="scene", process=False) | |
| # out_scene = trimesh.Scene() | |
| # part_count = 0 | |
| # base = os.path.splitext(os.path.basename(in_glb_path))[0] | |
| # for node_name in scene.graph.nodes_geometry: | |
| # geom_name = scene.graph[node_name][1] | |
| # if geom_name is None: | |
| # continue | |
| # geom = scene.geometry.get(geom_name, None) | |
| # if geom is None or not isinstance(geom, trimesh.Trimesh): | |
| # continue | |
| # mesh = geom.copy() | |
| # if bake_transforms: | |
| # T, _ = scene.graph.get(node_name) | |
| # if T is not None: | |
| # mesh.apply_transform(T) | |
| # res = _face_labels_from_texture_rgb(mesh, tex_rgba, palette_rgb) | |
| # if res is None: | |
| # if DEBUG_PRINT: | |
| # print(f"[{node_name}] no uv / cannot sample -> keep orig") | |
| # out_scene.add_geometry(mesh, geom_name=f"{base}__{node_name}__orig") | |
| # continue | |
| # face_label, label_rgb = res | |
| # # ========================= | |
| # # 🔥 新增调用:进行拓扑纠错,合并飞点 | |
| # # ========================= | |
| # face_label = smooth_face_labels_by_topology(mesh, face_label, min_faces=100) | |
| # if DEBUG_PRINT: | |
| # uniq_labels, cnts = np.unique(face_label, return_counts=True) | |
| # order = np.argsort(-cnts) | |
| # print( | |
| # f"[{node_name}] faces={len(mesh.faces)} labels_used={len(uniq_labels)} palette_size={len(label_rgb)}" | |
| # ) | |
| # for i in order[:10]: | |
| # lab = int(uniq_labels[i]) | |
| # r, g, b = ( | |
| # [int(x) for x in label_rgb[lab]] | |
| # if 0 <= lab < len(label_rgb) | |
| # else (0, 0, 0) | |
| # ) | |
| # print(f" label={lab} rgb=({r},{g},{b}) faces={int(cnts[i])}") | |
| # groups = defaultdict(list) | |
| # for fi, lab in enumerate(face_label): | |
| # groups[int(lab)].append(fi) | |
| # for lab, face_ids in groups.items(): | |
| # if len(face_ids) < min_faces_per_part: | |
| # continue | |
| # sub = mesh.submesh( | |
| # [np.array(face_ids, dtype=np.int64)], append=True, repair=False | |
| # ) | |
| # if sub is None: | |
| # continue | |
| # if isinstance(sub, (list, tuple)): | |
| # if not sub: | |
| # continue | |
| # sub = sub[0] | |
| # if 0 <= lab < len(label_rgb): | |
| # r, g, b = [int(x) for x in label_rgb[lab]] | |
| # part_name = f"{base}__{node_name}__label_{lab}__rgb_{r}_{g}_{b}" | |
| # else: | |
| # part_name = f"{base}__{node_name}__label_{lab}" | |
| # out_scene.add_geometry(sub, geom_name=part_name) | |
| # part_count += 1 | |
| # if part_count == 0: | |
| # if DEBUG_PRINT: | |
| # print("[INFO] part_count==0, fallback to original scene export.") | |
| # out_scene = scene | |
| # out_scene.export(out_glb_path) | |
| # return out_glb_path | |
| def split_glb_by_texture_palette_rgb( | |
| in_glb_path: str, | |
| out_glb_path: Optional[str] = None, | |
| min_faces_per_part: int = 1, | |
| bake_transforms: bool = True, | |
| color_quant_step: int = 16, | |
| palette_sample_pixels: int = 2_000_000, | |
| palette_min_pixels: int = 500, | |
| palette_max_colors: int = 256, | |
| palette_merge_dist: int = 32, | |
| samples_per_face: int = 4, | |
| flip_v: bool = True, | |
| uv_wrap_repeat: bool = True, | |
| transition_conf_thresh: float = 1.0, | |
| transition_prop_iters: int = 6, | |
| transition_neighbor_min: int = 1, | |
| small_component_action: str = "reassign", | |
| small_component_min_faces: int = 50, | |
| postprocess_iters: int = 3, | |
| debug_print: bool = True, | |
| ) -> str: | |
| """ | |
| Input: GLB (no COLOR_0, but with baseColorTexture + TEXCOORD_0) | |
| Output: Split based on palette labels derived from baseColorTexture | |
| """ | |
| if out_glb_path is None: | |
| out_glb_path = _default_out_path(in_glb_path) | |
| tex_rgba = _extract_basecolor_texture_image(in_glb_path) | |
| palette_rgb = _build_palette_rgb(tex_rgba) | |
| scene = trimesh.load(in_glb_path, force="scene", process=False) | |
| out_scene = trimesh.Scene() | |
| part_count = 0 | |
| base = os.path.splitext(os.path.basename(in_glb_path))[0] | |
| for node_name in scene.graph.nodes_geometry: | |
| geom_name = scene.graph[node_name][1] | |
| if geom_name is None: | |
| continue | |
| geom = scene.geometry.get(geom_name, None) | |
| if geom is None or not isinstance(geom, trimesh.Trimesh): | |
| continue | |
| mesh = geom.copy() | |
| if bake_transforms: | |
| T, _ = scene.graph.get(node_name) | |
| if T is not None: | |
| mesh.apply_transform(T) | |
| res = _face_labels_from_texture_rgb(mesh, tex_rgba, palette_rgb) | |
| if res is None: | |
| if debug_print: | |
| print(f"[{node_name}] no uv / cannot sample -> keep orig") | |
| out_scene.add_geometry(mesh, geom_name=f"{base}__{node_name}__orig") | |
| continue | |
| face_label, label_rgb = res | |
| # ========================= | |
| # 🔥 New: Apply topology correction to merge small disconnected components | |
| # ========================= | |
| face_label = smooth_face_labels_by_topology(mesh, face_label, min_faces=100) | |
| if debug_print: | |
| uniq_labels, cnts = np.unique(face_label, return_counts=True) | |
| order = np.argsort(-cnts) | |
| print( | |
| f"[{node_name}] faces={len(mesh.faces)} labels_used={len(uniq_labels)} palette_size={len(label_rgb)}" | |
| ) | |
| for i in order[:10]: | |
| lab = int(uniq_labels[i]) | |
| r, g, b = ( | |
| [int(x) for x in label_rgb[lab]] | |
| if 0 <= lab < len(label_rgb) | |
| else (0, 0, 0) | |
| ) | |
| print(f" label={lab} rgb=({r},{g},{b}) faces={int(cnts[i])}") | |
| groups = defaultdict(list) | |
| for fi, lab in enumerate(face_label): | |
| groups[int(lab)].append(fi) | |
| for lab, face_ids in groups.items(): | |
| if len(face_ids) < min_faces_per_part: | |
| continue | |
| sub = mesh.submesh([np.array(face_ids, dtype=np.int64)], append=True, repair=False) | |
| if sub is None: | |
| continue | |
| if isinstance(sub, (list, tuple)): | |
| if not sub: | |
| continue | |
| sub = sub[0] | |
| if 0 <= lab < len(label_rgb): | |
| r, g, b = [int(x) for x in label_rgb[lab]] | |
| part_name = f"{base}__{node_name}__label_{lab}__rgb_{r}_{g}_{b}" | |
| else: | |
| part_name = f"{base}__{node_name}__label_{lab}" | |
| out_scene.add_geometry(sub, geom_name=part_name) | |
| part_count += 1 | |
| if part_count == 0: | |
| if debug_print: | |
| print("[INFO] part_count==0, fallback to original scene export.") | |
| out_scene = scene | |
| out_scene.export(out_glb_path) | |
| return out_glb_path | |
| def main(): | |
| out_path = split_glb_by_texture_palette_rgb( | |
| INPUT_GLB, | |
| out_glb_path=None, | |
| min_faces_per_part=MIN_FACES_PER_PART, | |
| bake_transforms=BAKE_TRANSFORMS, | |
| ) | |
| print("Done. Exported:", out_path) | |
| if __name__ == "__main__": | |
| main() | |