dashdoas commited on
Commit
84efd98
·
verified ·
1 Parent(s): 5c8eaa1

Upload 4 files

Browse files
utils/eval_utils.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified from https://github.com/zhan-xu/RigNet
2
+
3
+ import numpy as np
4
+
5
+ ##### for quantitative calculation
6
+ def chamfer_dist(pt1, pt2):
7
+ pt1 = pt1[np.newaxis, :, :]
8
+ pt2 = pt2[:, np.newaxis, :]
9
+ dist = np.sqrt(np.sum((pt1 - pt2) ** 2, axis=2))
10
+ min_left = np.mean(np.min(dist, axis=0))
11
+ min_right = np.mean(np.min(dist, axis=1))
12
+ return (min_left + min_right) / 2
13
+
14
+ def oneway_chamfer(pt_src, pt_dst):
15
+ pt1 = pt_src[np.newaxis, :, :]
16
+ pt2 = pt_dst[:, np.newaxis, :]
17
+ dist = np.sqrt(np.sum((pt1 - pt2) ** 2, axis=2))
18
+ avg_dist = np.mean(np.min(dist, axis=0))
19
+ return avg_dist
20
+
21
+ def joint2bone_chamfer_dist(joints1, bones1, joints2, bones2):
22
+ bone_sample_1 = sample_skel(joints1, bones1)
23
+ bone_sample_2 = sample_skel(joints2, bones2)
24
+ dist1 = oneway_chamfer(joints1, bone_sample_2)
25
+ dist2 = oneway_chamfer(joints2, bone_sample_1)
26
+ return (dist1 + dist2) / 2
27
+
28
+ def bone2bone_chamfer_dist(joints1, bones1, joints2, bones2):
29
+ bone_sample_1 = sample_skel(joints1, bones1)
30
+ bone_sample_2 = sample_skel(joints2, bones2)
31
+ return chamfer_dist(bone_sample_1, bone_sample_2)
32
+
33
+ def sample_bone(p_pos, ch_pos):
34
+ ray = ch_pos - p_pos
35
+
36
+ bone_length = np.linalg.norm(p_pos - ch_pos)
37
+ num_step = np.round(bone_length / 0.005).astype(int)
38
+ i_step = np.arange(0, num_step + 1)
39
+ unit_step = ray / (num_step + 1e-30)
40
+ unit_step = np.repeat(unit_step[np.newaxis, :], num_step + 1, axis=0)
41
+ res = p_pos + unit_step * i_step[:, np.newaxis]
42
+ return res
43
+
44
+ def sample_skel(joints, bones):
45
+ bone_sample = []
46
+ for parent_idx, child_idx in bones:
47
+ p_pos = joints[parent_idx]
48
+ ch_pos = joints[child_idx]
49
+ res = sample_bone(p_pos, ch_pos)
50
+ bone_sample.append(res)
51
+
52
+ if bone_sample:
53
+ bone_sample = np.concatenate(bone_sample, axis=0)
54
+ else:
55
+ bone_sample = np.empty((0, 3))
56
+
57
+ return bone_sample
utils/mesh_to_pc.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified from https://github.com/buaacyw/MeshAnything
2
+ import mesh2sdf.core
3
+ import numpy as np
4
+ import skimage.measure
5
+ import trimesh
6
+ import time
7
+ from typing import List, Tuple
8
+
9
+ class MeshProcessor:
10
+ """A class to handle mesh normalization, watertight conversion and point cloud sampling."""
11
+
12
+ @staticmethod
13
+ def normalize_mesh_vertices(vertices: np.ndarray, scaling_factor: float = 0.95) -> Tuple[np.ndarray, np.ndarray, float]:
14
+ """
15
+ Normalize mesh vertices to be centered at origin and scaled appropriately.
16
+ """
17
+ min_bounds = vertices.min(axis=0)
18
+ max_bounds = vertices.max(axis=0)
19
+
20
+ center = (min_bounds + max_bounds) * 0.5
21
+ max_dimension = (max_bounds - min_bounds).max()
22
+ scale = 2.0 * scaling_factor / max_dimension
23
+
24
+ normalized_vertices = (vertices - center) * scale
25
+ return normalized_vertices, center, scale
26
+
27
+ @staticmethod
28
+ def convert_to_watertight(mesh: trimesh.Trimesh, octree_depth: int = 7) -> trimesh.Trimesh:
29
+ """
30
+ Convert to watertight using mesh2sdf and marching cubes.
31
+ """
32
+ grid_size = 2 ** octree_depth
33
+ iso_level = 2 / grid_size
34
+
35
+ # Normalize vertices for SDF computation
36
+ normalized_vertices, original_center, original_scale = MeshProcessor.normalize_mesh_vertices(mesh.vertices)
37
+
38
+ # Compute signed distance field
39
+ sdf = mesh2sdf.core.compute(normalized_vertices, mesh.faces, size=grid_size)
40
+
41
+ # Run marching cubes algorithm
42
+ vertices, faces, normals, _ = skimage.measure.marching_cubes(np.abs(sdf), iso_level)
43
+
44
+ # Transform vertices back to original coordinate system
45
+ vertices = vertices / grid_size * 2 - 1 # Map to [-1, 1] range
46
+ vertices = vertices / original_scale + original_center
47
+
48
+ # Create new watertight mesh
49
+ watertight_mesh = trimesh.Trimesh(vertices, faces, normals=normals)
50
+ return watertight_mesh
51
+
52
+ @staticmethod
53
+ def convert_meshes_to_point_clouds(
54
+ meshes: List[trimesh.Trimesh],
55
+ points_per_mesh: int = 8192,
56
+ apply_marching_cubes: bool = False,
57
+ octree_depth: int = 7
58
+ ) -> List[np.ndarray]:
59
+ """
60
+ Process a list of meshes into point clouds with normals.
61
+ """
62
+ point_clouds_with_normals = []
63
+ processed_meshes = []
64
+
65
+ for mesh in meshes:
66
+ # Optionally convert to watertight mesh
67
+ if apply_marching_cubes:
68
+ start_time = time.time()
69
+ mesh = MeshProcessor.convert_to_watertight(mesh, octree_depth=octree_depth)
70
+ processing_time = time.time() - start_time
71
+ print(f"Marching cubes complete! Time: {processing_time:.2f}s")
72
+
73
+ # Store processed mesh
74
+ processed_meshes.append(mesh)
75
+
76
+ # Sample points and get corresponding face normals
77
+ points, face_indices = mesh.sample(points_per_mesh, return_index=True)
78
+ point_normals = mesh.face_normals[face_indices]
79
+
80
+ # Combine points and normals
81
+ points_with_normals = np.concatenate([points, point_normals], axis=-1, dtype=np.float16)
82
+ point_clouds_with_normals.append(points_with_normals)
83
+
84
+ return point_clouds_with_normals
utils/save_utils.py ADDED
@@ -0,0 +1,648 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import os
15
+ import numpy as np
16
+ import cv2
17
+ import json
18
+ import trimesh
19
+
20
+ from collections import deque, defaultdict
21
+ from scipy.cluster.hierarchy import linkage, fcluster
22
+ from scipy.spatial.distance import cdist
23
+
24
+ from data_utils.pyrender_wrapper import PyRenderWrapper
25
+ from data_utils.data_loader import DataLoader
26
+
27
+ def save_mesh(vertices, faces, filename):
28
+
29
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
30
+ mesh.export(filename, file_type='obj')
31
+
32
+ def pred_joints_and_bones(bone_coor):
33
+ """
34
+ get joints (j,3) and bones (b,2) from (b,2,3), preserve the parent-child relationship
35
+ """
36
+ parent_coords = bone_coor[:, 0, :] # (b, 3)
37
+ child_coords = bone_coor[:, 1, :] # (b, 3)
38
+
39
+ all_coords = np.vstack([parent_coords, child_coords]) # (2b, 3)
40
+ pred_joints, indices = np.unique(all_coords, axis=0, return_inverse=True)
41
+
42
+ b = bone_coor.shape[0]
43
+ parent_indices = indices[:b]
44
+ child_indices = indices[b:]
45
+
46
+ pred_bones = np.column_stack([parent_indices, child_indices])
47
+
48
+ valid_bones = pred_bones[parent_indices != child_indices]
49
+
50
+ return pred_joints, valid_bones
51
+
52
+ def find_connected_components(joints, bones):
53
+ """Find connected components in the skeleton graph."""
54
+ n_joints = len(joints)
55
+ graph = defaultdict(list)
56
+
57
+ # Build adjacency list
58
+ for parent, child in bones:
59
+ graph[parent].append(child)
60
+ graph[child].append(parent)
61
+
62
+ visited = [False] * n_joints
63
+ components = []
64
+
65
+ for i in range(n_joints):
66
+ if not visited[i]:
67
+ component = []
68
+ queue = deque([i])
69
+ visited[i] = True
70
+
71
+ while queue:
72
+ node = queue.popleft()
73
+ component.append(node)
74
+
75
+ for neighbor in graph[node]:
76
+ if not visited[neighbor]:
77
+ visited[neighbor] = True
78
+ queue.append(neighbor)
79
+
80
+ components.append(component)
81
+
82
+ return components
83
+
84
+ def ensure_skeleton_connectivity(joints, bones, root_index=None, merge_distance_threshold=0.01):
85
+ """
86
+ Ensure skeleton is fully connected.
87
+ - If distance < merge_distance_threshold: merge joints
88
+ - If distance >= merge_distance_threshold: connect with bone
89
+ """
90
+ current_joints = joints.copy()
91
+ current_bones = list(bones)
92
+ current_root = root_index
93
+
94
+ iteration = 0
95
+ while True:
96
+ components = find_connected_components(current_joints, current_bones)
97
+ if len(components) == 1:
98
+ # print("Successfully ensured skeleton connectivity")
99
+ break
100
+
101
+ # Find the globally closest pair of components
102
+ min_distance = float('inf')
103
+ best_pair = None
104
+
105
+ for i in range(len(components)):
106
+ for j in range(i + 1, len(components)):
107
+ comp1_joints = current_joints[components[i]]
108
+ comp2_joints = current_joints[components[j]]
109
+
110
+ distances = cdist(comp1_joints, comp2_joints)
111
+ min_idx = np.unravel_index(np.argmin(distances), distances.shape)
112
+ distance = distances[min_idx]
113
+
114
+ if distance < min_distance:
115
+ min_distance = distance
116
+ best_pair = (i, j, components[i][min_idx[0]], components[j][min_idx[1]], min_idx)
117
+
118
+ if best_pair is None:
119
+ print("Warning: Could not find valid component pair to connect")
120
+ break
121
+
122
+ comp1_idx, comp2_idx, joint1_idx, joint2_idx, min_idx = best_pair
123
+
124
+ if min_distance < merge_distance_threshold:
125
+ # Merge the joints
126
+ # print(f"Iteration {iteration + 1}: Merging closest joints {joint1_idx} and {joint2_idx} "
127
+ # f"(distance: {min_distance:.4f})")
128
+
129
+ # Always merge joint2 into joint1
130
+ merge_map = {joint2_idx: joint1_idx}
131
+
132
+ # Update bones
133
+ updated_bones = []
134
+ for parent, child in current_bones:
135
+ new_parent = merge_map.get(parent, parent)
136
+ new_child = merge_map.get(child, child)
137
+ if new_parent != new_child: # Remove self-loops
138
+ updated_bones.append([new_parent, new_child])
139
+
140
+ # Update root
141
+ if current_root == joint2_idx:
142
+ current_root = joint1_idx
143
+
144
+ # Remove the merged joint and update indices
145
+ joint_to_remove = joint2_idx
146
+ mask = np.ones(len(current_joints), dtype=bool)
147
+ mask[joint_to_remove] = False
148
+ current_joints = current_joints[mask]
149
+
150
+ # Create index mapping for remaining joints
151
+ old_to_new = {}
152
+ new_idx = 0
153
+ for old_idx in range(len(mask)):
154
+ if mask[old_idx]:
155
+ old_to_new[old_idx] = new_idx
156
+ new_idx += 1
157
+
158
+ # Update bone indices
159
+ current_bones = [[old_to_new[parent], old_to_new[child]]
160
+ for parent, child in updated_bones
161
+ if parent in old_to_new and child in old_to_new]
162
+
163
+ # Update root index
164
+ if current_root is not None and current_root in old_to_new:
165
+ current_root = old_to_new[current_root]
166
+
167
+ else:
168
+ # Connect with bone
169
+ # print(f"Iteration {iteration + 1}: Connecting closest components with bone {joint1_idx} -> {joint2_idx} "
170
+ # f"(distance: {min_distance:.4f})")
171
+ current_bones.append([joint1_idx, joint2_idx])
172
+
173
+ iteration += 1
174
+
175
+ # prevent infinite loops
176
+ if iteration > len(joints):
177
+ print(f"Warning: Maximum iterations reached ({iteration}), stopping")
178
+ break
179
+
180
+ current_bones = np.array(current_bones) if len(current_bones) > 0 else np.array([]).reshape(0, 2)
181
+
182
+ # Final connectivity verification
183
+ final_components = find_connected_components(current_joints, current_bones)
184
+ if len(final_components) == 1:
185
+ pass
186
+ else:
187
+ print(f"Warning: Still have {len(final_components)} disconnected components after {iteration} iterations")
188
+
189
+ return current_joints, current_bones, current_root
190
+
191
+ def merge_duplicate_joints_and_fix_bones(joints, bones, tolerance=0.0025, root_index=None):
192
+ """
193
+ merge duplicate joints that are within a certain tolerance distance, and fix bones to maintain connectivity.
194
+ Also merge bones that become duplicates after joint merging.
195
+ """
196
+ n_joints = len(joints)
197
+
198
+ # find merge joint groups
199
+ merge_groups = []
200
+ used = [False] * n_joints
201
+
202
+ for i in range(n_joints):
203
+ if used[i]:
204
+ continue
205
+
206
+ # find all joints within tolerance distance to joint i
207
+ group = [i]
208
+ for j in range(i + 1, n_joints):
209
+ if not used[j]:
210
+ dist = np.linalg.norm(joints[i] - joints[j])
211
+ if dist < tolerance:
212
+ group.append(j)
213
+ used[j] = True
214
+
215
+ used[i] = True
216
+ merge_groups.append(group)
217
+
218
+ # if len(group) > 1:
219
+ # print(f"find duplicate joints group: {group}")
220
+
221
+ # build merge map: choose representative joint
222
+ merge_map = {}
223
+ for group in merge_groups:
224
+ if root_index is not None and root_index in group:
225
+ representative = root_index
226
+ else:
227
+ representative = group[0] # else choose the first one as representative
228
+ for joint_idx in group:
229
+ merge_map[joint_idx] = representative
230
+
231
+ # track root joint change
232
+ intermediate_root_index = None
233
+ if root_index is not None:
234
+ intermediate_root_index = merge_map.get(root_index, root_index)
235
+ # if intermediate_root_index != root_index:
236
+ # print(f"root joint index changed from {root_index} to {intermediate_root_index}")
237
+
238
+ # update bones: remove self-loop bones, and merge duplicate bones
239
+ updated_bones = []
240
+
241
+ for parent, child in bones:
242
+ new_parent = merge_map.get(parent, parent)
243
+ new_child = merge_map.get(child, child)
244
+
245
+ if new_parent != new_child: # remove self-loop bones
246
+ updated_bones.append([new_parent, new_child])
247
+
248
+ # remove duplicate bones
249
+ unique_bones = []
250
+ seen_bones = set()
251
+
252
+ for bone in updated_bones:
253
+ bone_key = tuple(bone) # keep the order of [parent, child]
254
+ if bone_key not in seen_bones:
255
+ seen_bones.add(bone_key)
256
+ unique_bones.append(bone)
257
+
258
+ # re-index joints to remove unused joints
259
+ used_joint_indices = set()
260
+ for parent, child in unique_bones:
261
+ used_joint_indices.add(parent)
262
+ used_joint_indices.add(child)
263
+ if intermediate_root_index is not None:
264
+ used_joint_indices.add(intermediate_root_index)
265
+
266
+
267
+ used_joint_indices = sorted(list(used_joint_indices))
268
+
269
+ # new index for used joints
270
+ old_to_new = {old_idx: new_idx for new_idx, old_idx in enumerate(used_joint_indices)}
271
+
272
+ final_joints = joints[used_joint_indices]
273
+ final_bones = np.array([[old_to_new[parent], old_to_new[child]]
274
+ for parent, child in unique_bones])
275
+
276
+ final_root_index = None
277
+ if intermediate_root_index is not None:
278
+ final_root_index = old_to_new[intermediate_root_index]
279
+ if root_index is not None and final_root_index != root_index:
280
+ print(f"final root index: {root_index} -> {final_root_index}")
281
+
282
+ removed_joints = n_joints - len(final_joints)
283
+ removed_bones = len(bones) - len(final_bones)
284
+
285
+ # print
286
+ # if removed_joints > 0 or removed_bones > 0:
287
+ # print(f"merge results:")
288
+ # print(f" joint number: {n_joints} -> {len(final_joints)} (remove {removed_joints})")
289
+ # print(f" bone number: {len(bones)} -> {len(final_bones)} (remove {removed_bones})")
290
+
291
+ # Ensure skeleton connectivity with relaxed threshold
292
+ final_joints, final_bones, final_root_index = ensure_skeleton_connectivity(
293
+ final_joints, final_bones, final_root_index,
294
+ merge_distance_threshold=tolerance*8 # More relaxed threshold for connectivity
295
+ )
296
+
297
+ if root_index is not None:
298
+ return final_joints, final_bones, final_root_index
299
+ else:
300
+ return final_joints, final_bones
301
+
302
+ def save_skeleton_to_txt(pred_joints, pred_bones, pred_root_index, hier_order, vertices, filename='skeleton.txt'):
303
+ """
304
+ save skeleton to txt file, the format follows Rignet (joints, root, hier)
305
+
306
+ if hier_order: the first joint index in bone is root joint index, and parent-child relationship is established in bones.
307
+ else: we set the joint nearest to the mesh center as the root joint, and then build hierarchy starting from root.
308
+ """
309
+
310
+ num_joints = pred_joints.shape[0]
311
+
312
+ # assign joint names
313
+ joint_names = [f'joint{i}' for i in range(num_joints)]
314
+
315
+ adjacency = defaultdict(list)
316
+ for bone in pred_bones:
317
+ idx_a, idx_b = bone
318
+ adjacency[idx_a].append(idx_b)
319
+ adjacency[idx_b].append(idx_a)
320
+
321
+ # find root joint
322
+ if hier_order:
323
+ root_idx = pred_root_index
324
+ else:
325
+ centroid = np.mean(vertices, axis=0)
326
+ distances = np.linalg.norm(pred_joints - centroid, axis=1)
327
+ root_idx = np.argmin(distances)
328
+
329
+ root_name = joint_names[root_idx]
330
+
331
+ # build hierarchy
332
+ parent_map = {}
333
+
334
+ if hier_order:
335
+ visited = set()
336
+
337
+ for parent_idx, child_idx in pred_bones:
338
+ if child_idx not in parent_map:
339
+ parent_map[child_idx] = parent_idx
340
+ visited.add(child_idx)
341
+ visited.add(parent_idx)
342
+
343
+ parent_map[root_idx] = None
344
+
345
+ else:
346
+ visited = set([root_idx])
347
+ queue = deque([root_idx])
348
+ parent_map[root_idx] = None
349
+
350
+ while queue:
351
+ current_idx = queue.popleft()
352
+ for neighbor_idx in adjacency[current_idx]:
353
+ if neighbor_idx not in visited:
354
+ parent_map[neighbor_idx] = current_idx
355
+ visited.add(neighbor_idx)
356
+ queue.append(neighbor_idx)
357
+
358
+ if len(visited) != num_joints:
359
+ print(f"bones are not fully connected, leaving {num_joints - len(visited)} joints unconnected.")
360
+
361
+ # save joints
362
+ joints_lines = []
363
+ for idx, coord in enumerate(pred_joints):
364
+ name = joint_names[idx]
365
+ joints_line = f'joints {name} {coord[0]:.8f} {coord[1]:.8f} {coord[2]:.8f}'
366
+ joints_lines.append(joints_line)
367
+
368
+ # save root name
369
+ root_line = f'root {root_name}'
370
+
371
+ # save hierarchy
372
+ hier_lines = []
373
+ for child_idx, parent_idx in parent_map.items():
374
+ if parent_idx is not None:
375
+ parent_name = joint_names[parent_idx]
376
+ child_name = joint_names[child_idx]
377
+ hier_line = f'hier {parent_name} {child_name}'
378
+ hier_lines.append(hier_line)
379
+
380
+ with open(filename, 'w') as file:
381
+ for line in joints_lines:
382
+ file.write(line + '\n')
383
+
384
+ file.write(root_line + '\n')
385
+
386
+ for line in hier_lines:
387
+ file.write(line + '\n')
388
+
389
+ def save_skeleton_to_txt_joint(pred_joints, pred_bones, filename='skeleton.txt'):
390
+ """
391
+ save skeleton to txt file, the format follows Rignet (joints, root, hier)
392
+ """
393
+
394
+ num_joints = pred_joints.shape[0]
395
+
396
+ # assign joint names
397
+ joint_names = [f'joint{i}' for i in range(num_joints)]
398
+
399
+ # find potential root joints
400
+ all_parents = set([bone[0] for bone in pred_bones])
401
+ all_children = set([bone[1] for bone in pred_bones])
402
+ potential_roots = all_parents - all_children
403
+
404
+ # determine root joint
405
+ if not potential_roots:
406
+ print("Warning: No joint is only a parent, choosing the first joint as root.")
407
+ root_idx = pred_bones[0, 0]
408
+ else:
409
+ if len(potential_roots) > 1:
410
+ print(f"Warning: Multiple potential root joints found ({len(potential_roots)}), choosing the first one.")
411
+ root_idx = list(potential_roots)[0]
412
+
413
+ root_name = joint_names[root_idx]
414
+
415
+ # build hierarchy
416
+ parent_map = {}
417
+ visited = set()
418
+
419
+ for parent_idx, child_idx in pred_bones:
420
+ if child_idx not in parent_map:
421
+ parent_map[child_idx] = parent_idx
422
+ visited.add(child_idx)
423
+ visited.add(parent_idx)
424
+
425
+ parent_map[root_idx] = None
426
+
427
+ if len(visited) != num_joints:
428
+ print(f"Warning: bones are not fully connected, leaving {num_joints - len(visited)} joints unconnected.")
429
+
430
+ # save joints
431
+ joints_lines = []
432
+ for idx, coord in enumerate(pred_joints):
433
+ name = joint_names[idx]
434
+ joints_line = f'joints {name} {coord[0]:.8f} {coord[1]:.8f} {coord[2]:.8f}'
435
+ joints_lines.append(joints_line)
436
+
437
+ # save root name
438
+ root_line = f'root {root_name}'
439
+
440
+ # save hierarchy
441
+ hier_lines = []
442
+ for child_idx, parent_idx in parent_map.items():
443
+ if parent_idx is not None:
444
+ parent_name = joint_names[parent_idx]
445
+ child_name = joint_names[child_idx]
446
+ hier_line = f'hier {parent_name} {child_name}'
447
+ hier_lines.append(hier_line)
448
+
449
+ with open(filename, 'w') as file:
450
+ for line in joints_lines:
451
+ file.write(line + '\n')
452
+
453
+ file.write(root_line + '\n')
454
+
455
+ for line in hier_lines:
456
+ file.write(line + '\n')
457
+ return root_idx
458
+
459
+
460
+ def save_skeleton_obj(joints, bones, save_path, root_index=None, radius_sphere=0.01,
461
+ radius_bone=0.005, segments=16, stacks=16, use_cone=False):
462
+ """
463
+ Save skeletons to obj file, each connection contains two red spheres (joint) and one blue cylinder (bone).
464
+ if root index is known, set root sphere to green.
465
+ """
466
+
467
+ all_vertices = []
468
+ all_colors = []
469
+ all_faces = []
470
+ vertex_offset = 0
471
+
472
+ # create spheres for joints
473
+ for i, joint in enumerate(joints):
474
+ # define color
475
+ if root_index is not None and i == root_index:
476
+ color = (0, 1, 0) # green for root joint
477
+ else:
478
+ color = (1, 0, 0) # red for other joints
479
+
480
+ # create joint sphere
481
+ sphere_vertices, sphere_faces = create_sphere(joint, radius=radius_sphere, segments=segments, stacks=stacks)
482
+ all_vertices.extend(sphere_vertices)
483
+ all_colors.extend([color] * len(sphere_vertices))
484
+
485
+ # adjust face index
486
+ adjusted_sphere_faces = [(v1 + vertex_offset, v2 + vertex_offset, v3 + vertex_offset) for (v1, v2, v3) in sphere_faces]
487
+ all_faces.extend(adjusted_sphere_faces)
488
+ vertex_offset += len(sphere_vertices)
489
+
490
+ # create bones
491
+ for bone in bones:
492
+ parent_idx, child_idx = bone
493
+ parent = joints[parent_idx]
494
+ child = joints[child_idx]
495
+
496
+ try:
497
+ bone_vertices, bone_faces = create_bone(parent, child, radius=radius_bone, segments=segments, use_cone=use_cone)
498
+ except ValueError as e:
499
+ print(f"Skipping connection {parent_idx}-{child_idx}, reason: {e}")
500
+ continue
501
+
502
+ all_vertices.extend(bone_vertices)
503
+ all_colors.extend([(0, 0, 1)] * len(bone_vertices)) # blue
504
+
505
+ # adjust face index
506
+ adjusted_bone_faces = [(v1 + vertex_offset, v2 + vertex_offset, v3 + vertex_offset) for (v1, v2, v3) in bone_faces]
507
+ all_faces.extend(adjusted_bone_faces)
508
+ vertex_offset += len(bone_vertices)
509
+
510
+ # save to obj
511
+ obj_lines = []
512
+ for v, c in zip(all_vertices, all_colors):
513
+ obj_lines.append(f"v {v[0]} {v[1]} {v[2]} {c[0]} {c[1]} {c[2]}")
514
+ obj_lines.append("")
515
+
516
+ for face in all_faces:
517
+ obj_lines.append(f"f {face[0]} {face[1]} {face[2]}")
518
+
519
+ with open(save_path, 'w') as obj_file:
520
+ obj_file.write("\n".join(obj_lines))
521
+
522
+ def create_sphere(center, radius=0.01, segments=16, stacks=16):
523
+ vertices = []
524
+ faces = []
525
+ for i in range(stacks + 1):
526
+ lat = np.pi / 2 - i * np.pi / stacks
527
+ xy = radius * np.cos(lat)
528
+ z = radius * np.sin(lat)
529
+ for j in range(segments):
530
+ lon = j * 2 * np.pi / segments
531
+ x = xy * np.cos(lon) + center[0]
532
+ y = xy * np.sin(lon) + center[1]
533
+ vertices.append((x, y, z + center[2]))
534
+ for i in range(stacks):
535
+ for j in range(segments):
536
+ first = i * segments + j
537
+ second = first + segments
538
+ third = first + 1 if (j + 1) < segments else i * segments
539
+ fourth = second + 1 if (j + 1) < segments else (i + 1) * segments
540
+ faces.append((first + 1, second + 1, fourth + 1))
541
+ faces.append((first + 1, fourth + 1, third + 1))
542
+ return vertices, faces
543
+
544
+ def create_bone(start, end, radius=0.005, segments=16, use_cone=False):
545
+ dir_vector = np.array(end) - np.array(start)
546
+ height = np.linalg.norm(dir_vector)
547
+ if height == 0:
548
+ raise ValueError("Start and end points cannot be the same for a cone.")
549
+ dir_vector = dir_vector / height
550
+
551
+ z = np.array([0, 0, 1])
552
+ if np.allclose(dir_vector, z):
553
+ R = np.identity(3)
554
+ elif np.allclose(dir_vector, -z):
555
+ R = np.array([[-1,0,0],[0,-1,0],[0,0,1]])
556
+ else:
557
+ v = np.cross(z, dir_vector)
558
+ s = np.linalg.norm(v)
559
+ c = np.dot(z, dir_vector)
560
+ kmat = np.array([[0, -v[2], v[1]],
561
+ [v[2], 0, -v[0]],
562
+ [-v[1], v[0], 0]])
563
+ R = np.identity(3) + kmat + np.matmul(kmat, kmat) * ((1 - c) / (s**2))
564
+
565
+ theta = np.linspace(0, 2 * np.pi, segments, endpoint=False)
566
+ base_circle = np.array([np.cos(theta), np.sin(theta), np.zeros(segments)]) * radius
567
+
568
+ vertices = []
569
+ for point in base_circle.T:
570
+ rotated = np.dot(R, point) + np.array(start)
571
+ vertices.append(tuple(rotated))
572
+
573
+
574
+ faces = []
575
+
576
+ if use_cone:
577
+ vertices.append(tuple(end))
578
+
579
+ apex_idx = segments + 1
580
+ for i in range(segments):
581
+ next_i = (i + 1) % segments
582
+ faces.append((i + 1, next_i + 1, apex_idx))
583
+ else:
584
+ top_circle = np.array([np.cos(theta), np.sin(theta), np.ones(segments)]) * radius
585
+ for point in top_circle.T:
586
+ point_scaled = np.array([point[0], point[1], height])
587
+ rotated = np.dot(R, point_scaled) + np.array(start)
588
+ vertices.append(tuple(rotated))
589
+ for i in range(segments):
590
+ next_i = (i + 1) % segments
591
+ faces.append((i + 1, next_i + 1, next_i + segments + 1))
592
+ faces.append((i + 1, next_i + segments + 1, i + segments + 1))
593
+
594
+ return vertices, faces
595
+
596
+ def render_mesh_with_skeleton(joints, bones, vertices, faces, output_dir, filename, prefix='pred', root_idx=None):
597
+ """
598
+ Render the mesh with skeleton using PyRender.
599
+ """
600
+ loader = DataLoader()
601
+
602
+ raw_size = (960, 960)
603
+ renderer = PyRenderWrapper(raw_size)
604
+
605
+ save_dir = os.path.join(output_dir, 'render_results')
606
+ os.makedirs(save_dir, exist_ok=True)
607
+
608
+ loader.joints = joints
609
+ loader.bones = bones
610
+ loader.root_idx = root_idx
611
+
612
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
613
+ mesh.visual.vertex_colors[:, 3] = 100 # set transparency
614
+ loader.mesh = mesh
615
+ v = mesh.vertices
616
+ xmin, ymin, zmin = v.min(axis=0)
617
+ xmax, ymax, zmax = v.max(axis=0)
618
+ loader.bbox_center = np.array([(xmax + xmin)/2, (ymax + ymin)/2, (zmax + zmin)/2])
619
+ loader.bbox_size = np.array([xmax - xmin, ymax - ymin, zmax - zmin])
620
+ loader.bbox_scale = max(xmax - xmin, ymax - ymin, zmax - zmin)
621
+ loader.normalize_coordinates()
622
+
623
+ input_dict = loader.query_mesh_rig()
624
+
625
+ angles = [0, np.pi/2, np.pi, 3*np.pi/2]
626
+ distance = np.max(loader.bbox_size) * 2
627
+
628
+ subfolder_path = os.path.join(save_dir, filename + '_' + prefix)
629
+
630
+ os.makedirs(subfolder_path, exist_ok=True)
631
+
632
+ for i, angle in enumerate(angles):
633
+ renderer.set_camera_view(angle, loader.bbox_center, distance)
634
+ renderer.align_light_to_camera()
635
+
636
+ color = renderer.render(input_dict)[0]
637
+
638
+ output_filename = f"{filename}_{prefix}_view{i+1}.png"
639
+ output_filepath = os.path.join(subfolder_path, output_filename)
640
+ cv2.imwrite(output_filepath, color)
641
+
642
+
643
+ def save_args(args, output_dir, filename="config.json"):
644
+ args_dict = vars(args)
645
+ os.makedirs(output_dir, exist_ok=True)
646
+ config_path = os.path.join(output_dir, filename)
647
+ with open(config_path, 'w') as f:
648
+ json.dump(args_dict, f, indent=4)
utils/skeleton_data_loader.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import torch
15
+ from torch import is_tensor
16
+ from torch.utils.data import Dataset
17
+ from torch.nn.utils.rnn import pad_sequence
18
+ from data_utils.save_npz import normalize_to_unit_cube
19
+
20
+ import numpy as np
21
+
22
+ class SkeletonData(Dataset):
23
+ """
24
+ A PyTorch Dataset to load and process skeleton data.
25
+ """
26
+ def __init__(self, data, args, is_training):
27
+ self.data = data
28
+
29
+ self.input_pc_num = args.input_pc_num
30
+ self.is_training = is_training
31
+
32
+ self.hier_order = args.hier_order
33
+ print(f"[Dataset] Created from {len(self.data)} entries")
34
+
35
+ def __len__(self):
36
+ return len(self.data)
37
+
38
+ def __getitem__(self, idx):
39
+ data = self.data[idx]
40
+
41
+ joints = data['joints']
42
+ vertices = data['vertices']
43
+ pc_normal = data['pc_w_norm']
44
+
45
+ indices = np.random.choice(pc_normal.shape[0], self.input_pc_num, replace=False)
46
+ pc_normal = pc_normal[indices, :]
47
+
48
+ pc_coor = pc_normal[:, :3]
49
+ normal = pc_normal[:, 3:]
50
+ if np.linalg.norm(normal, axis=1, keepdims=True).min() < 0.99:
51
+ print("normal reroll")
52
+ return self.__getitem__(np.random.randint(0, len(self.data)))
53
+
54
+ data_dict = {}
55
+
56
+ # normalize normal
57
+ normal = normal / np.linalg.norm(normal, axis=1, keepdims=True)
58
+
59
+ # scale to -0.5 to 0.5
60
+ _, center, scale = normalize_to_unit_cube(vertices.copy(), scale_factor=0.9995)
61
+ joints = (joints - center) * scale # align joints with pc first
62
+
63
+ bounds = np.array([pc_coor.min(axis=0), pc_coor.max(axis=0)])
64
+ pc_center = (bounds[0] + bounds[1])[None, :] / 2
65
+ pc_scale = (bounds[1] - bounds[0]).max() + 1e-5
66
+ pc_coor = (pc_coor - pc_center) / pc_scale
67
+ joints = (joints - pc_center) / pc_scale
68
+
69
+ joints = joints.clip(-0.5, 0.5)
70
+
71
+ data_dict['joints'] = torch.from_numpy(np.asarray(joints).astype(np.float16))
72
+ data_dict['bones'] = torch.from_numpy(data['bones'].astype(np.int64))
73
+ pc_coor = pc_coor / np.abs(pc_coor).max() * 0.9995
74
+ data_dict['pc_normal'] = torch.from_numpy(np.concatenate([pc_coor, normal], axis=-1).astype(np.float16))
75
+ data_dict['vertices'] = torch.from_numpy(data['vertices'].astype(np.float16))
76
+ data_dict['faces'] = torch.from_numpy(data['faces'].astype(np.int64))
77
+ data_dict['uuid'] = data['uuid']
78
+ data_dict['root_index'] = str(data['root_index'])
79
+ data_dict['transform_params'] = torch.tensor([
80
+ center[0], center[1], center[2],
81
+ scale,
82
+ pc_center[0][0], pc_center[0][1], pc_center[0][2],
83
+ pc_scale
84
+ ], dtype=torch.float32)
85
+
86
+ return data_dict
87
+
88
+ @classmethod
89
+ def load(cls, args, is_training=True):
90
+ loaded_data = np.load(args.dataset_path, allow_pickle=True)
91
+ data = []
92
+ for item in loaded_data["arr_0"]:
93
+ data.append(item)
94
+ print(f"[Dataset] Loaded {len(data)} entries")
95
+ return cls(data, args, is_training)
96
+
97
+