twanghcmut's picture
download
raw
9.53 kB
"""Stage 2 stand-in: proxy meshes fitted to the observed point cloud.
The real generator (TRELLIS.2, driven off :mod:`fpgm.objects.crop`'s output) is
not always available -- this module lets stages 3-5 run end-to-end without it,
by fitting a closed proxy mesh directly to the stage-3 point cloud. Both proxies
are returned in their own *canonical* frame (centred near the origin, axes =
the point cloud's PCA axes) with the fitted world placement stashed in
``ObjectMesh.metadata["world_transform"]`` -- this matches the convention
:mod:`fpgm.objects.align` expects a mesh to already be in, so a proxy and a
future real generator output are interchangeable inputs to that stage.
Both builders take an optional ``point_colors`` (the same per-point colours
:func:`fpgm.objects.align.object_point_cloud` returns) and, when given, set
``ObjectMesh.vertex_colors`` from them -- each mesh vertex takes its *nearest*
observed point's colour (see :func:`_nearest_point_colors`). Without this a
proxy mesh has no colour at all and renders as a flat grey blob regardless of
the object's real appearance.
"""
from __future__ import annotations
import numpy as np
import trimesh
from scipy.spatial import ConvexHull, QhullError, cKDTree
from fpgm.objects.types import MeshSource, ObjectMesh
from fpgm.utils.logging import get_logger
logger = get_logger(__name__)
#: PCA needs at least this many points to define three axes non-degenerately.
_MIN_POINTS = 4
#: Guard against a zero-thickness box dimension (e.g. an almost-planar cloud).
_MIN_EXTENT_M = 1e-6
def _nearest_point_colors(
verts_world: np.ndarray, points_world: np.ndarray, point_colors: np.ndarray
) -> np.ndarray:
"""Per-vertex colour: the *nearest observed point's* colour, in world frame.
Picked over a single flat (e.g. median) colour for the whole mesh because
a real object is rarely one colour -- a nearest-point lookup lets
different faces of the proxy pick up whatever was actually observed near
them (e.g. a brick's printed top vs its plain sides), while still never
inventing a colour that wasn't observed.
Args:
verts_world: ``(V, 3)`` mesh vertices, world frame.
points_world: ``(N, 3)`` observed object points, world frame.
point_colors: ``(N, 3)`` uint8 colours, one per ``points_world`` row.
Returns:
``(V, 3)`` uint8 colours, one per vertex.
"""
tree = cKDTree(points_world)
_, idx = tree.query(verts_world)
return np.asarray(point_colors, dtype=np.uint8)[idx]
def _pca_frame(points_world: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Centroid, right-handed PCA axes (columns, descending variance), and points in that frame.
Args:
points_world: ``(N, 3)`` observed points.
Returns:
``(centroid, axes, local)``: ``centroid`` is ``(3,)``, ``axes`` is
``(3, 3)`` with columns the principal directions (largest variance
first) forming a proper rotation, and ``local`` is
``(points_world - centroid) @ axes``.
Raises:
ValueError: If there are fewer than :data:`_MIN_POINTS` points.
"""
points_world = np.asarray(points_world, dtype=np.float64)
if points_world.ndim != 2 or points_world.shape[1] != 3:
raise ValueError(f"expected (N, 3) points, got shape {points_world.shape}")
if points_world.shape[0] < _MIN_POINTS:
raise ValueError(
f"need at least {_MIN_POINTS} points to fit a proxy mesh, got "
f"{points_world.shape[0]}"
)
centroid = points_world.mean(axis=0)
centered = points_world - centroid
cov = (centered.T @ centered) / points_world.shape[0]
eigvals, eigvecs = np.linalg.eigh(cov) # ascending
order = np.argsort(eigvals)[::-1]
axes = eigvecs[:, order]
if np.linalg.det(axes) < 0:
axes[:, -1] *= -1.0 # flip the least-informative axis to keep a proper rotation
local = centered @ axes
return centroid, axes, local
def oriented_box_mesh(
points_world: np.ndarray, point_colors: np.ndarray | None = None
) -> ObjectMesh:
"""PCA-oriented bounding box of the observed points.
The box is built axis-aligned in its own canonical frame (extents = the
points' range along each PCA axis), so it is the tightest box that
contains every observed point under *some* rotation -- not the (looser)
world-axis-aligned bounding box.
Args:
points_world: ``(N, 3)`` observed object points, world frame.
point_colors: ``(N, 3)`` uint8 colours, one per ``points_world`` row,
e.g. from :func:`fpgm.objects.align.object_point_cloud`. When
given, each box vertex gets the colour of its nearest observed
point (see :func:`_nearest_point_colors`); when omitted (the
default), ``vertex_colors`` is ``None`` and the mesh is
colourless, as before.
Returns:
A closed, watertight :class:`~fpgm.objects.types.ObjectMesh` with
``source=MeshSource.PROXY_BOX`` and
``metadata["world_transform"]`` (a ``(4, 4)`` matrix, no scale --
the box is already metric) mapping its canonical vertices to world
coordinates.
Raises:
ValueError: If there are fewer than :data:`_MIN_POINTS` points.
"""
centroid, axes, local = _pca_frame(points_world)
lo, hi = local.min(axis=0), local.max(axis=0)
extents = np.maximum(hi - lo, _MIN_EXTENT_M)
local_center = (hi + lo) / 2.0
box = trimesh.creation.box(extents=extents)
box_vertices = np.asarray(box.vertices, dtype=np.float64)
world_transform = np.eye(4, dtype=np.float64)
world_transform[:3, :3] = axes
# The box's own vertices are centred on the *bbox* centre, not the point
# centroid the local frame is centred on (a skewed cloud can have those
# differ), so the extra local_center offset is needed here.
world_transform[:3, 3] = centroid + axes @ local_center
vertex_colors = None
if point_colors is not None:
verts_world = (world_transform[:3, :3] @ box_vertices.T).T + world_transform[:3, 3]
vertex_colors = _nearest_point_colors(verts_world, points_world, point_colors)
return ObjectMesh(
vertices=box_vertices,
faces=np.asarray(box.faces, dtype=np.int64),
source=MeshSource.PROXY_BOX,
vertex_colors=vertex_colors,
metadata={
"world_transform": world_transform,
"half_extents": (extents / 2.0).tolist(),
"n_points": int(local.shape[0]),
},
)
def convex_hull_mesh(
points_world: np.ndarray, point_colors: np.ndarray | None = None
) -> ObjectMesh:
"""Convex hull of the observed points, closer to the true silhouette than a box.
Args:
points_world: ``(N, 3)`` observed object points, world frame.
point_colors: ``(N, 3)`` uint8 colours, one per ``points_world`` row,
e.g. from :func:`fpgm.objects.align.object_point_cloud`. When
given, each hull vertex gets the colour of its nearest observed
point (see :func:`_nearest_point_colors`); when omitted (the
default), ``vertex_colors`` is ``None`` and the mesh is
colourless, as before.
Returns:
A closed, watertight :class:`~fpgm.objects.types.ObjectMesh` with
``source=MeshSource.PROXY_HULL`` and ``metadata["world_transform"]``
mapping its canonical vertices to world coordinates.
Raises:
ValueError: If there are fewer than :data:`_MIN_POINTS` points, the
points are degenerate (coplanar/collinear -- no 3D hull exists),
or the resulting hull is not watertight.
"""
centroid, axes, local = _pca_frame(points_world)
try:
hull = ConvexHull(local)
except QhullError as exc:
raise ValueError(
"convex_hull_mesh: points are degenerate (coplanar/collinear); "
"cannot compute a 3D convex hull"
) from exc
# ConvexHull.simplices indexes into the full input array; most of those
# points are interior and never referenced, so compact down to only the
# vertices actually on the hull before building the mesh.
used = np.unique(hull.simplices)
remap = np.full(local.shape[0], -1, dtype=np.int64)
remap[used] = np.arange(used.shape[0])
vertices = local[used]
faces = remap[hull.simplices]
tri = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
tri.fix_normals() # qhull's facet winding isn't guaranteed outward-consistent
if not tri.is_watertight:
raise ValueError("convex_hull_mesh: resulting hull mesh is not watertight")
# `local` is already centred at `centroid` (see _pca_frame), so unlike the
# box no extra offset is needed: canonical -> world is just axes + centroid.
world_transform = np.eye(4, dtype=np.float64)
world_transform[:3, :3] = axes
world_transform[:3, 3] = centroid
hull_vertices = np.asarray(tri.vertices, dtype=np.float64)
vertex_colors = None
if point_colors is not None:
verts_world = (world_transform[:3, :3] @ hull_vertices.T).T + world_transform[:3, 3]
vertex_colors = _nearest_point_colors(verts_world, points_world, point_colors)
return ObjectMesh(
vertices=hull_vertices,
faces=np.asarray(tri.faces, dtype=np.int64),
source=MeshSource.PROXY_HULL,
vertex_colors=vertex_colors,
metadata={"world_transform": world_transform, "n_points": int(local.shape[0])},
)

Xet Storage Details

Size:
9.53 kB
·
Xet hash:
3929cf32ac47c9635578633a5ab71e30f7c0ca474e2be2c909dc10b1896db92a

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.