|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import os
|
| import trimesh
|
| import numpy as np
|
| from scipy.spatial import cKDTree
|
| import faiss
|
|
|
|
|
|
|
| def load_mesh(p_file, gt_file):
|
| """
|
| :param p_file:
|
| :param gt_file:
|
| :return:
|
| """
|
| p_mesh = trimesh.load(p_file)
|
| gt_mesh = trimesh.load(gt_file)
|
| return p_mesh, gt_mesh
|
|
|
| def normalization(p_mesh, gt_mesh):
|
| gt_vertices = np.asarray(gt_mesh.vertices)
|
| p_vertices = np.asarray(p_mesh.vertices)
|
| vert_min = gt_vertices.min(axis=0)
|
| vert_max = gt_vertices.max(axis=0)
|
|
|
| vert_center = 0.5 * (vert_min + vert_max)
|
|
|
| gt_vertices = gt_vertices - vert_center
|
|
|
|
|
| vert_min = gt_vertices.min(axis=0)
|
| vert_max = gt_vertices.max(axis=0)
|
| extents = vert_max - vert_min
|
| scale = np.max(extents)
|
|
|
| gt_vertices = gt_vertices / (scale + 1e-6)
|
|
|
| p_vertices = p_vertices * np.sqrt(np.sum(extents ** 2)) / (scale + 1e-6)
|
|
|
| return trimesh.Trimesh(vertices=p_vertices,faces=p_mesh.faces), trimesh.Trimesh(vertices=gt_vertices,faces=gt_mesh.faces)
|
|
|
|
|
|
|
| def chamfer_l1_distance_kdtree(p, q):
|
| """
|
| p: (N,3) prediction
|
| q: (M,3) ground truth
|
| """
|
|
|
|
|
| p = p[np.isfinite(p).all(axis=1)]
|
| q = q[np.isfinite(q).all(axis=1)]
|
|
|
|
|
| tree_p = cKDTree(p)
|
| tree_q = cKDTree(q)
|
|
|
|
|
| dist_pq, _ = tree_q.query(p)
|
| dist_qp, _ = tree_p.query(q)
|
|
|
|
|
| chamfer_distance = np.mean(dist_pq) + np.mean(dist_qp)
|
|
|
| return chamfer_distance
|
|
|
| def chamfer_l1_distance_faiss(p, q, use_gpu=False):
|
| """
|
| p: (N,3) prediction
|
| q: (M,3) ground truth
|
| """
|
|
|
|
|
| p = p[np.isfinite(p).all(axis=1)]
|
| q = q[np.isfinite(q).all(axis=1)]
|
|
|
|
|
| p = p.astype(np.float32)
|
| q = q.astype(np.float32)
|
|
|
|
|
| index_p = faiss.IndexFlatL2(3)
|
| index_q = faiss.IndexFlatL2(3)
|
|
|
|
|
| if use_gpu:
|
| res = faiss.StandardGpuResources()
|
| index_p = faiss.index_cpu_to_gpu(res, 0, index_p)
|
| index_q = faiss.index_cpu_to_gpu(res, 0, index_q)
|
|
|
| index_p.add(p)
|
| index_q.add(q)
|
|
|
|
|
|
|
| D_pq, _ = index_q.search(p, 1)
|
| D_qp, _ = index_p.search(q, 1)
|
|
|
|
|
| dist_pq = np.sqrt(D_pq[:, 0])
|
| dist_qp = np.sqrt(D_qp[:, 0])
|
|
|
| chamfer_distance = dist_pq.mean() + dist_qp.mean()
|
|
|
| return float(chamfer_distance)
|
|
|
|
|
| def mesh_sample_points(p_mesh, gt_mesh, sample_points=1000000):
|
| """
|
| :param p_mesh: trimesh mesh
|
| :param gt_mesh: Trimesh mesh
|
| :param sample_points:
|
| :return: (sample_points, 3)
|
| """
|
| p_points = p_mesh.sample(sample_points)
|
| gt_points = gt_mesh.sample(sample_points)
|
| return p_points, gt_points
|
|
|
|
|
| def extract_sharp_edges(mesh, angle_threshold_deg=30.0):
|
| """
|
| Version-agnostic sharp edge extraction.
|
| Works with any trimesh version.
|
| """
|
| faces = np.asarray(mesh.faces)
|
| face_normals = np.asarray(mesh.face_normals)
|
|
|
|
|
| face_normals = face_normals / (
|
| np.linalg.norm(face_normals, axis=1, keepdims=True) + 1e-12
|
| )
|
|
|
|
|
| edge_faces = dict()
|
|
|
| for f_idx, face in enumerate(faces):
|
| edges = [
|
| tuple(sorted((face[0], face[1]))),
|
| tuple(sorted((face[1], face[2]))),
|
| tuple(sorted((face[2], face[0]))),
|
| ]
|
| for e in edges:
|
| if e not in edge_faces:
|
| edge_faces[e] = []
|
| edge_faces[e].append(f_idx)
|
|
|
|
|
| cos_thresh = np.cos(np.deg2rad(angle_threshold_deg))
|
| sharp_edges = []
|
|
|
| for edge, f_list in edge_faces.items():
|
|
|
| if len(f_list) == 1:
|
| sharp_edges.append(edge)
|
| continue
|
|
|
|
|
| if len(f_list) > 2:
|
| sharp_edges.append(edge)
|
| continue
|
|
|
|
|
| f1, f2 = f_list
|
| n1 = face_normals[f1]
|
| n2 = face_normals[f2]
|
|
|
| dot = np.dot(n1, n2)
|
| dot = np.clip(dot, -1.0, 1.0)
|
| if np.abs(dot) < cos_thresh:
|
| sharp_edges.append(edge)
|
|
|
| if len(sharp_edges) == 0:
|
| return np.zeros((0, 2), dtype=np.int64)
|
|
|
| return np.asarray(sharp_edges, dtype=np.int64)
|
|
|
|
|
| def sample_points_on_edges_global(vertices, edges, total_samples=100000):
|
| """
|
| Sample points uniformly along edges, proportional to edge length.
|
|
|
| Args:
|
| vertices (np.ndarray): (V, 3)
|
| edges (np.ndarray): (E, 2)
|
| total_samples (int): total number of sampled points
|
|
|
| Returns:
|
| np.ndarray: (total_samples, 3)
|
| """
|
|
|
| if edges.shape[0] == 0:
|
| return np.zeros((0, 3), dtype=np.float32)
|
|
|
|
|
| p1 = vertices[edges[:, 0]]
|
| p2 = vertices[edges[:, 1]]
|
|
|
|
|
| edge_lengths = np.linalg.norm(p2 - p1, axis=1)
|
|
|
|
|
| probs = edge_lengths / (edge_lengths.sum() + 1e-12)
|
|
|
|
|
| edge_indices = np.random.choice(len(edges), size=total_samples, p=probs)
|
|
|
|
|
| t = np.random.rand(total_samples, 1)
|
|
|
| sampled_p1 = p1[edge_indices]
|
| sampled_p2 = p2[edge_indices]
|
|
|
| points = (1 - t) * sampled_p1 + t * sampled_p2
|
|
|
| return points.astype(np.float32)
|
|
|
|
|
| def compute_edge_chamfer_distance(p_mesh, gt_mesh, angle_threshold_deg=30.0):
|
| """
|
| :param p_mesh:
|
| :param gt_mesh:
|
| :param angle_threshold_deg:
|
| :return:
|
| """
|
|
|
| sharp_edges_gt = extract_sharp_edges(gt_mesh, angle_threshold_deg)
|
| sharp_edges_pred = extract_sharp_edges(p_mesh, angle_threshold_deg)
|
|
|
|
|
| edge_pts_gt = sample_points_on_edges_global(
|
| gt_mesh.vertices, sharp_edges_gt
|
| )
|
| edge_pts_pred = sample_points_on_edges_global(
|
| p_mesh.vertices, sharp_edges_pred
|
| )
|
|
|
|
|
| ecd = chamfer_l1_distance_kdtree(edge_pts_pred, edge_pts_gt)
|
|
|
| return ecd
|
|
|
|
|
|
|
|
|
| def normal_consistency(
|
| p_mesh,
|
| gt_mesh,
|
| num_samples=100000
|
| ):
|
| """
|
| mesh_gt, mesh_pred: trimesh.Trimesh
|
| return: NC in [0, 1]
|
| """
|
|
|
|
|
| pts_gt, face_ids = trimesh.sample.sample_surface(gt_mesh, num_samples)
|
| normals_gt = gt_mesh.face_normals[face_ids]
|
|
|
|
|
| closest_points, distance, face_id = p_mesh.nearest.on_surface(pts_gt)
|
| normals_pred = p_mesh.face_normals[face_id]
|
|
|
|
|
| normals_gt = normals_gt / np.linalg.norm(normals_gt, axis=1, keepdims=True)
|
| normals_pred = normals_pred / np.linalg.norm(normals_pred, axis=1, keepdims=True)
|
|
|
|
|
| cos_sim = np.abs(np.sum(normals_gt * normals_pred, axis=1))
|
|
|
| return float(cos_sim.mean())
|
|
|
|
|
| def calculate_vertices_face_ratio(p_mesh, gt_mesh):
|
| """
|
| :param p_mesh: trimesh.Trimesh
|
| :param gt_mesh: trimesh.Trimesh
|
| :return: float, float
|
| """
|
| f_ratio = len(p_mesh.faces) / len(gt_mesh.faces)
|
| v_ratio = len(p_mesh.vertices) / len(gt_mesh.vertices)
|
| return v_ratio, f_ratio
|
|
|
|
|
|
|
| def mesh_evaluation(p_file, gt_file):
|
| """
|
| :param p_file: the path of predicted mesh
|
| :param gt_file: the path of ground truth mesh
|
| :return: mesh_chamfer_distance
|
| """
|
|
|
| p_mesh, gt_mesh = load_mesh(p_file, gt_file)
|
| p_mesh, gt_mesh = normalization(p_mesh, gt_mesh)
|
|
|
|
|
| p_points, gt_points = mesh_sample_points(p_mesh, gt_mesh)
|
| mesh_chamfer_distance = chamfer_l1_distance_kdtree(p_points, gt_points)
|
|
|
|
|
| edge_chamfer_distance = compute_edge_chamfer_distance(p_mesh, gt_mesh, angle_threshold_deg=30.0)
|
|
|
|
|
| normals_consistency = normal_consistency(p_mesh, gt_mesh)
|
|
|
|
|
| v_ratio, f_ratio = calculate_vertices_face_ratio(p_mesh, gt_mesh)
|
|
|
| return mesh_chamfer_distance, edge_chamfer_distance, normals_consistency, v_ratio, f_ratio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|