tooth / app /surface_postprocess.py
zhaxie's picture
Release C80 epoch80 and SLAT raw55k custom checkpoints
e5a5074 verified
Raw History Blame Contribute Delete
5.28 kB
"""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}