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(" 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()