File size: 5,281 Bytes
e5a5074
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
"""2026-10-07:交付版局部补洞、Taubin平滑及几何变化保护,无需GT。"""
import numpy as np
import trimesh
from scipy import ndimage
from skimage.measure import marching_cubes
from mesh_repair import repair_small_holes


def topology(mesh):
    counts = np.bincount(mesh.edges_unique_inverse)
    bodies = int(mesh.body_count)
    return {'boundary_edges': int((counts == 1).sum()),
            'components': bodies, 'watertight': bool(mesh.is_watertight),
            'handles': int(round((2*bodies-mesh.euler_number)/2)) if mesh.is_watertight else None}


def scene_for(mesh):
    import open3d as o3d
    scene = o3d.t.geometry.RaycastingScene(nthreads=2)
    scene.add_triangles(o3d.t.geometry.TriangleMesh(
        o3d.core.Tensor(np.asarray(mesh.vertices, np.float32)),
        o3d.core.Tensor(np.asarray(mesh.faces, np.int32))))
    return scene


def geometry_change(raw, result, seed):
    import open3d as o3d
    distances = []
    for target, source in [(raw, result), (result, raw)]:
        points = trimesh.sample.sample_surface(source, 100000, seed=seed)[0]
        distances.append(scene_for(target).compute_distance(
            o3d.core.Tensor(np.asarray(points, np.float32)), nthreads=2).numpy())
    distances = np.concatenate(distances)
    return {'extent_change_max': float(np.max(np.abs(result.extents / np.maximum(raw.extents, 1e-12)-1))),
            'surface_shift_p95': float(np.quantile(distances, .95)),
            'surface_shift_mean': float(distances.mean())}


def within_limits(change):
    return change['extent_change_max'] <= .02 and change['surface_shift_p95'] <= .005


def close_small_tunnels(mesh):
    import open3d as o3d
    pitch, pad = 1/384, 4
    voxels = mesh.voxelized(pitch=pitch)
    occupied = np.pad(voxels.matrix, pad)
    origin = voxels.transform[:3, 3] - pad*pitch
    x, y, z = np.meshgrid(*([np.arange(-2, 3)]*3), indexing='ij')
    ball = x*x+y*y+z*z <= 4
    expanded = ndimage.binary_dilation(occupied, structure=ball)
    closed = ndimage.binary_erosion(ndimage.binary_fill_holes(expanded), structure=ball)
    closed |= occupied
    field = ndimage.gaussian_filter(closed.astype(np.float32), sigma=.55)
    vertices, faces, _, _ = marching_cubes(field, .5, spacing=(pitch,)*3, allow_degenerate=False)
    result = trimesh.Trimesh(vertices+origin, faces, process=False)
    result.fix_normals(multibody=True)
    nearest = scene_for(mesh).compute_closest_points(
        o3d.core.Tensor(np.asarray(result.vertices, np.float32)), nthreads=2)
    target = nearest['points'].numpy()
    ids = nearest['primitive_ids'].numpy().astype(np.int64)
    selected = ((np.linalg.norm(target-result.vertices, axis=1) < 2*pitch) &
                (np.einsum('ij,ij->i', result.vertex_normals, mesh.face_normals[ids]) > .5))
    projected = np.asarray(result.vertices).copy()
    projected[selected] = .9*target[selected]+.1*projected[selected]
    result.vertices = projected
    return trimesh.smoothing.filter_taubin(result, lamb=.5, nu=.53, iterations=80)


def postprocess(mesh, seed=1234):
    if not len(mesh.faces) or not np.isfinite(mesh.vertices).all():
        raise ValueError('Invalid input crown mesh')
    seed = int(seed) % (2**32)
    before = topology(mesh)
    raw, boundary_meta = repair_small_holes(mesh, max_boundary_edges=32)
    repaired = False
    rejected = None
    if raw.is_watertight and (topology(raw)['handles'] or 0) > 0:
        try:
            candidate = close_small_tunnels(raw)
            change = geometry_change(mesh, candidate, seed)
            after = topology(candidate)
            repaired = bool(after['watertight'] and after['components'] <= before['components'] and
                            (after['handles'] or 0) < (topology(raw)['handles'] or 0) and within_limits(change))
            if not repaired:
                rejected = {'topology': after, **change, 'reason': 'topology or geometry gate'}
        except Exception as exc:
            rejected = {'reason': repr(exc)}
    result = candidate if repaired else trimesh.smoothing.filter_taubin(raw.copy(), lamb=.5, nu=.53, iterations=80)
    change = geometry_change(mesh, result, seed)
    passes = 80
    # Guard the smoothing fallback as well as the remeshing candidate.
    if not within_limits(change):
        repaired = False
        result = trimesh.smoothing.filter_taubin(mesh.copy(), lamb=.5, nu=.53, iterations=20)
        passes = 20
        change = geometry_change(mesh, result, seed)
        if not within_limits(change):
            result, passes = mesh.copy(), 0
            change = geometry_change(mesh, result, seed)
    after = topology(result)
    if after['boundary_edges']:
        status = 'open_boundaries_remaining'
    elif after['handles']:
        status = 'major_topology_hole'
    elif repaired or before['boundary_edges'] > after['boundary_edges']:
        status = 'repaired'
    else:
        status = 'clean'
    return result, {'status': status, 'topology_before': before, 'topology_after': after,
        'hole_repair_accepted': repaired, 'boundary_repair': boundary_meta, 'rejected_candidate': rejected,
        'taubin': {'lambda': .5, 'nu': .53, 'passes': passes}, 'pitch': 1/384,
        'closing_radius_voxels': 2, 'gaussian_sigma_voxels': .55, **change}