pixal3d_mrp_mlx/pixal3d_mlx/cleanup.py
m3ultra 516e3e4457 Mesh cleanup: weld, strip floaters, iterative decimation
The raw cascade output is ~4M verts / 8M faces and is not usable as-is. cleanup.py is
ordinary mesh hygiene, kept out of the model code, and the ORDER is the whole point:

  weld -> strip floaters -> decimate -> strip again -> fix normals

CORRECTION TO THE FLOATER COUNT. The health pass reported 52,855 components with
52,838 fragments under 100 faces, and I took those for stray shells. They were mostly
NOT: the decoder emits per-voxel vertices, so coincident corners are duplicated and
the same continuous surface reads as tens of thousands of islands. Welding FIRST
collapses it to a single component, and only 6,332 faces are genuinely stray. Ordering
the pass the other way round removes 250k faces of real geometry and calls it cleaning.

Two performance fixes, both because an operator runs this every job:

- Component labelling is a scipy union-find over the VERTEX graph, not
  trimesh.face_adjacency. Same answer, ~240s -> ~1s on this mesh.
- fix_normals runs LAST, on the decimated mesh. It walks face adjacency, so on the
  raw 7.99M-face mesh it costs minutes and the result is then thrown away by
  decimation. Cleanup went ~243s -> ~20s.

Decimation is iterative. A single fast_simplification call will not reduce past
roughly 4.4% of its input whatever target_reduction (or agg) is asked for: from 7.99M
faces, targets of 200k, 50k and 20k ALL returned 351,535. Repeated smaller passes get
further because each re-evaluates quadrics on the collapsed mesh.

MEASURED, on the sample:

  target 500,000  ->  499,984 faces   silhouette IoU 0.965   19.3s
  target 200,000  ->  214,322 faces   silhouette IoU 0.823   34.8s
  target 100,000  ->  214,322 faces   silhouette IoU 0.823
  target  20,000  ->  214,322 faces   silhouette IoU 0.823

HONEST LIMITATION: ~214k is a hard floor, and reaching it costs real fidelity
(0.965 -> 0.823). The cause is the ~180,000 BOUNDARY edges the Flexible Dual Grid
produces for open surfaces - quadric decimation will not collapse those, and no
aggressiveness setting changes it. Below ~214k needs a REMESH, not a decimator.
500k is effectively lossless and is the setting to use; anything under 214k is not
currently reachable and the loop stops rather than spinning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 16:13:50 +10:00

138 lines
6.4 KiB
Python

"""Raw cascade output -> a mesh you can actually put in a game.
The decoder emits ~4M vertices / 8M faces at 1024^3, and on a measured run that was
one good body (96.9% of faces) plus **52,838 disconnected fragments** under 100 faces
each, inconsistent winding, and 1.5% non-manifold edges. Those floaters are the
"stray shells" failure Pixal3D is known for. They are also why feeding the raw mesh
straight into o_voxel's remesher hangs: it is being asked to remesh fifty thousand
objects, most of them noise.
So the order matters and it is: **strip floaters -> fix winding -> decimate**. Doing
it the other way round spends all the time on geometry that gets thrown away.
Nothing here is Pixal3D-specific; it is ordinary mesh hygiene, deliberately kept out
of the model code.
"""
from __future__ import annotations
import numpy as np
def largest_components(mesh, keep_ratio: float = 0.01, min_faces: int = 100):
"""Drop disconnected fragments. Returns (mesh, stats).
A component survives if it has at least `min_faces` faces AND at least
`keep_ratio` of the largest component's faces. Two thresholds because neither
alone is safe: an absolute floor keeps genuine small parts on a big model, and the
relative test drops proportionally-large noise on a small one.
Labelling runs as a union-find over the VERTEX graph rather than through
`trimesh.face_adjacency`. Same answer, very different cost: building face
adjacency on this mesh took ~240s, which is unaffordable when an operator does it
on every job. Vertices must already be welded — the decoder emits per-voxel
vertices, so without a merge every triangle is its own island.
"""
import trimesh
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components as cc
faces = np.asarray(mesh.faces)
n_v = len(mesh.vertices)
if not len(faces):
return mesh, {"components": 0, "kept": 0, "faces_removed": 0}
e = np.vstack([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]])
g = coo_matrix((np.ones(len(e), np.int8), (e[:, 0], e[:, 1])), shape=(n_v, n_v))
n_comp, vert_label = cc(g, directed=False)
face_label = vert_label[faces[:, 0]]
sizes = np.bincount(face_label, minlength=n_comp)
threshold = max(min_faces, int(sizes.max() * keep_ratio))
keep_labels = np.where(sizes >= threshold)[0]
mask = np.isin(face_label, keep_labels)
removed = int((~mask).sum())
out = mesh.submesh([np.where(mask)[0]], append=True) if removed else mesh
return out, {"components": int((sizes > 0).sum()), "kept": len(keep_labels),
"faces_removed": removed, "threshold": threshold}
def decimate(mesh, target_faces: int, max_passes: int = 8, step: float = 0.7):
"""Quadric decimation to a face budget, iteratively.
A single `simplify` call will not reduce past roughly 4.4% of its input no matter
what `target_reduction` (or `agg`) is asked for: from 7.99M faces, targets of
200k, 50k and 20k all returned the same 351,535. Repeated smaller passes get
further — 2.4M -> 719k -> 270k -> 238k — because each pass re-evaluates the
quadrics on the already-collapsed mesh.
It still converges to a floor near ~240k on this geometry, set by the ~180k
BOUNDARY edges the Flexible Dual Grid produces for open surfaces; quadric
decimation will not collapse those. Getting below that needs a remesh, not a
decimator. The loop therefore stops when a pass stops making progress rather than
spinning, and the caller gets whatever floor the mesh actually has.
"""
import fast_simplification
import trimesh
v = np.asarray(mesh.vertices, np.float32)
f = np.asarray(mesh.faces, np.int32)
for _ in range(max_passes):
if len(f) <= target_faces:
break
reduction = min(step, 1.0 - target_faces / len(f))
nv, nf = fast_simplification.simplify(v, f, target_reduction=reduction)
if len(nf) >= len(f) * 0.98: # no meaningful progress: at the floor
break
v, f = np.asarray(nv, np.float32), np.asarray(nf, np.int32)
return trimesh.Trimesh(v, f, process=False)
def clean(vertices, faces, target_faces: int | None = 100_000,
keep_ratio: float = 0.01, min_faces: int = 100, log=print):
"""The whole hygiene pass. Returns (mesh, stats)."""
import trimesh
mesh = trimesh.Trimesh(np.asarray(vertices), np.asarray(faces), process=False)
before = (len(mesh.vertices), len(mesh.faces))
# WELD FIRST. The decoder emits per-voxel vertices, so coincident corners are
# duplicated and neither component labelling nor winding propagation can cross a
# shared edge. Run before either and every triangle looks like its own island.
mesh.merge_vertices()
log(f" welded {before[0]:,} -> {len(mesh.vertices):,} verts")
mesh, comp = largest_components(mesh, keep_ratio, min_faces)
log(f" floaters {comp['components']:6} components -> {comp['kept']} kept, "
f"{comp['faces_removed']:,} faces dropped")
if target_faces:
mesh = decimate(mesh, target_faces)
log(f" decimated {before[1]:,} -> {len(mesh.faces):,} faces")
# SECOND floater pass. Quadric decimation collapses thin features and pinches
# the surface apart, so a mesh that was ONE component before decimation came
# back as 7,257 at a 500k budget. Cleaning only beforehand therefore ships a
# fragmented result while reporting a clean intermediate.
mesh.merge_vertices()
mesh, post = largest_components(mesh, keep_ratio, min_faces)
if post["faces_removed"]:
log(f" re-cleaned {post['components']} components after decimation -> "
f"{post['kept']} kept, {post['faces_removed']:,} faces dropped")
comp = {**comp, "post_decimation_components": post["components"],
"post_decimation_removed": post["faces_removed"]}
# Normals LAST, deliberately. fix_normals walks face adjacency, which costs
# minutes on the raw 7.7M-face mesh and seconds on a decimated one — and it is
# the shipped mesh whose winding actually matters. Doing it before decimation
# spends the time and then throws most of the result away.
mesh.fix_normals()
log(f" normals winding consistent={mesh.is_winding_consistent}")
return mesh, {"before_verts": before[0], "before_faces": before[1],
"after_verts": len(mesh.vertices), "after_faces": len(mesh.faces),
**comp}