Hunyuan3D-2.2-mrp-MLX/hy3dpaint/DifferentiableRenderer/mesh_render_mlx.py
modelbeast e4cfa9d1e9 Clean MLX build for MODELBEAST (inference-only)
Fork of dgrauet/Hunyuan3D-2.1-mlx + our generate_e2e.py CLI, env-tunable
remesh (HY3D_REMESH_FACES), and HARDWARE.md. Upstream training data
(mini_trainset) and demo images stripped — inference needs none of it.
Full upstream history: github.com/dgrauet/Hunyuan3D-2.1-mlx
2026-07-16 14:38:14 +10:00

1138 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MLX mesh renderer for Hunyuan3D texture pipeline.
Port of MeshRender's rendering and texture baking pipeline to MLX,
using mlx_arsenal.rasterize as the Metal rasterization backend.
"""
import mlx.core as mx
import numpy as np
from mlx_arsenal.rasterize import interpolate as mlx_interpolate
from mlx_arsenal.rasterize import rasterize_triangles
from .camera_utils_mlx import (
get_mv_matrix,
get_orthographic_projection_matrix,
get_perspective_projection_matrix,
transform_pos,
)
# ---------------------------------------------------------------------------
# Rasterizer adapter
# ---------------------------------------------------------------------------
class MLXRasterizer:
"""Adapter wrapping mlx_arsenal.rasterize to match MeshRender's interface."""
@staticmethod
def rasterize(pos, tri, resolution, clamp_depth=None, use_depth_prior=0):
"""Rasterize clip-space triangles.
Args:
pos: (1, N, 4) or (N, 4) mx.array clip-space vertices.
tri: (F, 3) mx.array int32 face indices.
resolution: (height, width) tuple.
Returns:
findices: (H, W) int32, 1-indexed face IDs.
barycentric: (H, W, 3) float32.
"""
vertices = pos[0] if pos.ndim == 3 else pos
height, width = int(resolution[0]), int(resolution[1])
depth_prior = None
if clamp_depth is not None and use_depth_prior:
depth_prior = clamp_depth
findices, barycentric = rasterize_triangles(
vertices.astype(mx.float32),
tri.astype(mx.int32),
width,
height,
depth_prior=depth_prior,
)
return findices, barycentric
@staticmethod
def interpolate(col, findices, barycentric, tri):
"""Interpolate per-vertex attributes at rasterized pixels.
Args:
col: (1, N, C) or (N, C) mx.array per-vertex data.
findices: (H, W) int32.
barycentric: (H, W, 3) float32.
tri: (F, 3) int32.
Returns:
(1, H, W, C) mx.array.
"""
attributes = col[0] if col.ndim == 3 else col
result = mlx_interpolate(
attributes.astype(mx.float32),
findices,
barycentric,
tri.astype(mx.int32),
)
return mx.expand_dims(result, axis=0)
# ---------------------------------------------------------------------------
# Mesh renderer
# ---------------------------------------------------------------------------
class MeshRenderMLX:
"""MLX mesh renderer for normal / position / alpha rendering.
Drop-in replacement for MeshRender's core render pipeline.
All internal computation uses MLX arrays; public render methods
return numpy arrays for downstream compatibility (PIL, cv2, etc.).
"""
def __init__(
self,
camera_distance=1.45,
camera_type="orth",
default_resolution=1024,
texture_size=1024,
shader_type="face",
scale_factor=1.15,
bake_angle_thres=75,
boundary_scale=2,
bake_mode="back_sample",
raster_mode="mlx",
):
self.camera_distance = camera_distance
self.shader_type = shader_type
self._default_scale_factor = scale_factor
self.bake_angle_thres = bake_angle_thres
self.raster = MLXRasterizer()
if isinstance(default_resolution, int):
self.default_resolution = (default_resolution, default_resolution)
else:
self.default_resolution = tuple(default_resolution)
if isinstance(texture_size, int):
self.texture_size = (texture_size, texture_size)
else:
self.texture_size = tuple(texture_size)
self.bake_unreliable_kernel_size = int(
(boundary_scale / 512)
* max(self.default_resolution[0], self.default_resolution[1])
)
if camera_type == "orth":
self.set_orth_scale(1.2)
elif camera_type == "perspective":
self.camera_proj_mat = get_perspective_projection_matrix(
49.13,
self.default_resolution[1] / self.default_resolution[0],
0.01,
100.0,
)
else:
raise ValueError(f"Unknown camera type: {camera_type}")
# Geometry (set by set_mesh)
self.vtx_pos = None
self.pos_idx = None
self.vtx_uv = None
self.uv_idx = None
self.scale_factor = 1.0
self.mesh_normalize_scale_factor = 1.0
self.mesh_normalize_scale_center = np.array([[0, 0, 0]])
# Texture-space data (set by extract_textiles)
self.tex_position = None
self.tex_normal = None
self.tex_grid = None
self.texture_indices = None
# ------------------------------------------------------------------
# Projection
# ------------------------------------------------------------------
def set_orth_scale(self, ortho_scale):
self.ortho_scale = ortho_scale
self.camera_proj_mat = get_orthographic_projection_matrix(
left=-ortho_scale * 0.5,
right=ortho_scale * 0.5,
bottom=-ortho_scale * 0.5,
top=ortho_scale * 0.5,
near=0.1,
far=100,
)
# ------------------------------------------------------------------
# Mesh loading
# ------------------------------------------------------------------
def set_mesh(self, vtx_pos, pos_idx, vtx_uv=None, uv_idx=None,
scale_factor=None, auto_center=True):
"""Load mesh geometry from numpy arrays.
Applies the same coordinate transform and normalization
as the original MeshRender.set_mesh().
"""
if scale_factor is None:
scale_factor = self._default_scale_factor
vp = np.array(vtx_pos, dtype=np.float32)
pi = np.array(pos_idx, dtype=np.int32)
# Coordinate transform: flip X/Y, swap Y/Z
vp[:, [0, 1]] = -vp[:, [0, 1]]
vp[:, [1, 2]] = vp[:, [2, 1]].copy()
if auto_center:
max_bb = vp.max(axis=0)
min_bb = vp.min(axis=0)
center = (max_bb + min_bb) / 2.0
scale = np.linalg.norm(vp - center, axis=1).max() * 2.0
vp = (vp - center) * (scale_factor / scale)
self.scale_factor = scale_factor
self.mesh_normalize_scale_factor = scale_factor / scale
self.mesh_normalize_scale_center = center[None, :]
else:
self.scale_factor = 1.0
self.mesh_normalize_scale_factor = 1.0
self.mesh_normalize_scale_center = np.array([[0, 0, 0]])
self.vtx_pos = mx.array(vp)
self.pos_idx = mx.array(pi)
if vtx_uv is not None and uv_idx is not None:
uv = np.array(vtx_uv, dtype=np.float32)
uv[:, 1] = 1.0 - uv[:, 1]
self.vtx_uv = mx.array(uv)
self.uv_idx = mx.array(np.array(uv_idx, dtype=np.int32))
self.extract_textiles()
else:
self.vtx_uv = None
self.uv_idx = None
def load_mesh(self, mesh, scale_factor=1.15, auto_center=True):
"""Load mesh from file path or trimesh object."""
import trimesh
if isinstance(mesh, str):
mesh = trimesh.load(mesh, process=False, force="mesh")
vtx_pos = np.array(mesh.vertices, dtype=np.float32)
pos_idx = np.array(mesh.faces, dtype=np.int32)
vtx_uv, uv_idx = None, None
if hasattr(mesh.visual, "uv") and mesh.visual.uv is not None:
vtx_uv = np.array(mesh.visual.uv, dtype=np.float32)
uv_idx = pos_idx # same topology
self.set_mesh(
vtx_pos, pos_idx, vtx_uv=vtx_uv, uv_idx=uv_idx,
scale_factor=scale_factor, auto_center=auto_center,
)
# ------------------------------------------------------------------
# Internal pipeline
# ------------------------------------------------------------------
def _create_view_state(self, elev, azim, camera_distance=None,
center=None, resolution=None):
"""Build clip-space vertices and camera-space positions."""
if camera_distance is None:
camera_distance = self.camera_distance
if resolution is None:
resolution = self.default_resolution
mv = get_mv_matrix(elev, azim, camera_distance, center)
pos_camera = transform_pos(mv, self.vtx_pos, keepdim=True) # (N, 4)
pos_clip = transform_pos(self.camera_proj_mat, pos_camera) # (1, N, 4)
return pos_camera, pos_clip, resolution
def _rasterize(self, pos_clip, resolution):
"""Run rasterization, return packed rast_out (1, H, W, 4)."""
findices, bary = self.raster.rasterize(
pos_clip, self.pos_idx, resolution
)
# Pack [bary(3) | findices(1)] -> (1, H, W, 4)
rast_out = mx.concatenate(
[bary, mx.expand_dims(findices.astype(mx.float32), axis=-1)],
axis=-1,
)
return mx.expand_dims(rast_out, axis=0)
def _rasterize_tiled(self, pos_clip, resolution, tiles=4):
"""Tile rasterization across clip-space quadrants.
At 4096² the single-dispatch raster exceeds Metal's command-buffer
budget. Split clip space into ``tiles`` × ``tiles`` regions; for
each region, scale vertex clip coords so the region maps to
[-1, 1]² and rasterize at (H/tiles, W/tiles). Concatenate.
Assumes ``tiles`` divides H and W evenly. Preserves the row-to-
clip-y convention of the underlying rasterizer (verified by
comparing tiled output to single-dispatch output at 2048²).
"""
H, W = int(resolution[0]), int(resolution[1])
if H % tiles != 0 or W % tiles != 0:
return self._rasterize(pos_clip, resolution)
tH, tW = H // tiles, W // tiles
verts = pos_clip[0] if pos_clip.ndim == 3 else pos_clip
vx, vy, vz, vw = verts[:, 0], verts[:, 1], verts[:, 2], verts[:, 3]
rows_fi, rows_bary = [], []
for i in range(tiles):
cols_fi, cols_bary = [], []
# Tile i covers rows [i*tH, (i+1)*tH). Row 0 = first tile output.
# Matches the full-raster convention (tiled(0,0) == full[0:tH, 0:tW]).
y_lo = -1.0 + 2.0 * i / tiles
y_hi = -1.0 + 2.0 * (i + 1) / tiles
mid_y = 0.5 * (y_lo + y_hi)
span_y = 0.5 * (y_hi - y_lo)
for j in range(tiles):
x_lo = -1.0 + 2.0 * j / tiles
x_hi = -1.0 + 2.0 * (j + 1) / tiles
mid_x = 0.5 * (x_lo + x_hi)
span_x = 0.5 * (x_hi - x_lo)
# Homogeneous-safe clip transform: NDC' = (NDC - mid)/span
# ⇒ clip' = (clip - w*mid)/span (w unchanged).
new_verts = mx.stack([
(vx - vw * mid_x) / span_x,
(vy - vw * mid_y) / span_y,
vz,
vw,
], axis=-1)
fi, bary = self.raster.rasterize(
new_verts, self.pos_idx, (tH, tW),
)
mx.synchronize()
cols_fi.append(fi)
cols_bary.append(bary)
rows_fi.append(mx.concatenate(cols_fi, axis=1))
rows_bary.append(mx.concatenate(cols_bary, axis=1))
findices = mx.concatenate(rows_fi, axis=0)
bary = mx.concatenate(rows_bary, axis=0)
rast_out = mx.concatenate(
[bary, mx.expand_dims(findices.astype(mx.float32), axis=-1)],
axis=-1,
)
return mx.expand_dims(rast_out, axis=0)
def _compute_face_normals(self, triangles):
"""Face normals from (F, 3, 3) triangle vertices."""
e1 = triangles[:, 1, :] - triangles[:, 0, :]
e2 = triangles[:, 2, :] - triangles[:, 0, :]
# Cross product
n = mx.stack([
e1[:, 1] * e2[:, 2] - e1[:, 2] * e2[:, 1],
e1[:, 2] * e2[:, 0] - e1[:, 0] * e2[:, 2],
e1[:, 0] * e2[:, 1] - e1[:, 1] * e2[:, 0],
], axis=-1)
norm = mx.sqrt((n * n).sum(axis=-1, keepdims=True) + 1e-12)
return n / norm
def _get_normals(self, pos_camera, pos_clip, resolution, use_abs_coor=False):
"""Compute per-pixel normals via face or vertex shading."""
if use_abs_coor:
mesh_tris = self.vtx_pos[self.pos_idx] # (F, 3, 3)
else:
pc3 = pos_camera[:, :3] / pos_camera[:, 3:4]
mesh_tris = pc3[self.pos_idx]
face_normals = self._compute_face_normals(mesh_tris)
rast_out = self._rasterize(pos_clip, resolution)
if self.shader_type == "vertex":
vertex_normals = self._vertex_normals_from_faces(face_normals)
normal = self.raster.interpolate(
mx.expand_dims(vertex_normals, 0),
rast_out[0, ..., -1].astype(mx.int32),
rast_out[0, ..., :-1],
self.pos_idx,
)
else:
# Face shader: direct face-normal lookup per pixel
tri_ids = rast_out[..., 3].astype(mx.int32) # (1, H, W)
mask = (tri_ids > 0).astype(mx.float32)
tri_ids_0 = mx.maximum(tri_ids - 1, 0) # 0-indexed
normal = face_normals[tri_ids_0[0]] # (H, W, 3)
normal = normal * mask[0, ..., None]
normal = mx.expand_dims(normal, 0) # (1, H, W, 3)
return normal, rast_out
def _vertex_normals_from_faces(self, face_normals):
"""Compute vertex normals as average of adjacent face normals."""
num_verts = self.vtx_pos.shape[0]
fi = np.array(self.pos_idx)
fn = np.array(face_normals)
vn = np.zeros((num_verts, 3), dtype=np.float32)
counts = np.zeros((num_verts, 1), dtype=np.float32)
for c in range(3):
np.add.at(vn, fi[:, c], fn)
np.add.at(counts, fi[:, c], 1.0)
counts = np.maximum(counts, 1.0)
vn = vn / counts
norms = np.maximum(np.linalg.norm(vn, axis=-1, keepdims=True), 1e-12)
return mx.array(vn / norms)
# ------------------------------------------------------------------
# Public render API
# ------------------------------------------------------------------
@staticmethod
def _format_output(image_np, return_type="np"):
"""Convert numpy output to requested format."""
if return_type == "pl":
from PIL import Image as PILImage
img = np.clip(image_np, 0, 1)
return PILImage.fromarray((img * 255).astype(np.uint8))
return image_np
def render_normal(self, elev, azim, resolution=None, bg_color=(1, 1, 1),
use_abs_coor=False, camera_distance=None, center=None,
return_type="np"):
"""Render surface normals from a given viewpoint.
Returns:
(H, W, 3) numpy array (return_type="np") or PIL Image ("pl").
"""
pos_camera, pos_clip, res = self._create_view_state(
elev, azim, camera_distance, center, resolution,
)
normal, rast_out = self._get_normals(
pos_camera, pos_clip, res, use_abs_coor,
)
visible = mx.clip(rast_out[..., -1:], 0, 1)
bg = mx.array(list(bg_color), dtype=mx.float32).reshape(1, 1, 1, 3)
result = normal * visible + bg * (1.0 - visible)
result = (result + 1.0) * 0.5
mx.synchronize()
return self._format_output(np.array(result[0]), return_type)
def render_position(self, elev, azim, resolution=None, bg_color=(1, 1, 1),
camera_distance=None, center=None, return_type="np"):
"""Render world-space positions from a given viewpoint.
Returns:
(H, W, 3) numpy array (return_type="np") or PIL Image ("pl").
"""
_, pos_clip, res = self._create_view_state(
elev, azim, camera_distance, center, resolution,
)
rast_out = self._rasterize(pos_clip, res)
findices = rast_out[0, ..., -1].astype(mx.int32)
bary = rast_out[0, ..., :-1]
tex_pos = 0.5 - self.vtx_pos[:, :3] / self.scale_factor
position = self.raster.interpolate(
mx.expand_dims(tex_pos, 0), findices, bary, self.pos_idx,
)
visible = mx.clip(rast_out[..., -1:], 0, 1)
bg = mx.array(list(bg_color), dtype=mx.float32).reshape(1, 1, 1, 3)
result = position * visible + bg * (1.0 - visible)
mx.synchronize()
return self._format_output(np.array(result[0]), return_type)
def render_alpha(self, elev, azim, resolution=None,
camera_distance=None, center=None, return_type="np"):
"""Render face-index map from a given viewpoint.
Returns face indices (1-indexed, 0=background) matching the original
MeshRender behavior. Shape: (1, H, W, 1) for return_type="np".
"""
_, pos_clip, res = self._create_view_state(
elev, azim, camera_distance, center, resolution,
)
rast_out = self._rasterize(pos_clip, res)
# Return face indices (int), same as original: rast_out[..., -1:].long()
face_ids = np.array(rast_out[..., -1:])[0].astype(np.int64)
# Shape (1, H, W, 1) to match original pipeline expectations
return face_ids[None, ...]
# ------------------------------------------------------------------
# Mesh & texture management
# ------------------------------------------------------------------
def set_default_render_resolution(self, default_resolution):
if isinstance(default_resolution, int):
default_resolution = (default_resolution, default_resolution)
self.default_resolution = default_resolution
def set_boundary_unreliable_scale(self, scale):
self.bake_unreliable_kernel_size = int(
(scale / 512)
* max(self.default_resolution[0], self.default_resolution[1])
)
def get_face_areas(self, from_one_index=False):
"""Compute area of each triangle face.
Returns:
numpy array of face areas. If from_one_index, prepends a zero
so areas[face_id] works with 1-indexed face IDs.
"""
vp = np.array(self.vtx_pos)
fi = np.array(self.pos_idx)
v0 = vp[fi[:, 0]]
v1 = vp[fi[:, 1]]
v2 = vp[fi[:, 2]]
cross = np.cross(v1 - v0, v2 - v0)
areas = np.linalg.norm(cross, axis=-1) * 0.5
if from_one_index:
areas = np.insert(areas, 0, 0.0)
return areas
def set_texture(self, tex, force_set=False):
"""Store diffuse texture (numpy uint8 or float, or PIL Image)."""
from PIL import Image as PILImage
if isinstance(tex, PILImage.Image):
tex = np.array(tex).astype(np.float32) / 255.0
elif isinstance(tex, np.ndarray) and tex.dtype == np.uint8:
tex = tex.astype(np.float32) / 255.0
self.tex = tex
def get_texture(self):
"""Return diffuse texture as numpy float32 array."""
return self.tex if hasattr(self, "tex") and self.tex is not None else None
def set_texture_mr(self, mr, force_set=False):
"""Store metallic-roughness texture."""
from PIL import Image as PILImage
if isinstance(mr, PILImage.Image):
mr = np.array(mr).astype(np.float32) / 255.0
elif isinstance(mr, np.ndarray) and mr.dtype == np.uint8:
mr = mr.astype(np.float32) / 255.0
self.tex_mr = mr
def get_texture_mr(self):
metallic, roughness = None, None
if hasattr(self, "tex_mr") and self.tex_mr is not None:
mr = self.tex_mr
metallic = np.repeat(mr[:, :, 0:1], 3, axis=2)
roughness = np.repeat(mr[:, :, 1:2], 3, axis=2)
return metallic, roughness
def get_texture_normal(self):
if hasattr(self, "tex_normalMap") and self.tex_normalMap is not None:
return self.tex_normalMap
return None
def get_mesh(self, normalize=True):
"""Return mesh geometry as numpy arrays.
Applies inverse coordinate transform to restore original space.
"""
vtx_pos = np.array(self.vtx_pos)
pos_idx = np.array(self.pos_idx)
if not normalize:
vtx_pos = vtx_pos / self.mesh_normalize_scale_factor
vtx_pos = vtx_pos + self.mesh_normalize_scale_center
# Inverse of set_mesh transform: undo swap Y/Z, then undo flip X/Y
vtx_pos[:, [1, 2]] = vtx_pos[:, [2, 1]].copy()
vtx_pos[:, [0, 1]] = -vtx_pos[:, [0, 1]]
vtx_uv, uv_idx = None, None
if self.vtx_uv is not None:
vtx_uv = np.array(self.vtx_uv)
vtx_uv[:, 1] = 1.0 - vtx_uv[:, 1]
uv_idx = np.array(self.uv_idx)
return vtx_pos, pos_idx, vtx_uv, uv_idx
def save_mesh(self, mesh_path, downsample=False):
"""Save textured mesh to OBJ file."""
import cv2
import trimesh
vtx_pos, pos_idx, vtx_uv, uv_idx = self.get_mesh(normalize=False)
texture = self.get_texture()
if texture is not None and downsample:
h, w = texture.shape[0] // 2, texture.shape[1] // 2
texture = cv2.resize(texture, (w, h))
mesh = trimesh.Trimesh(
vertices=vtx_pos,
faces=pos_idx,
process=False,
)
if vtx_uv is not None and texture is not None:
from PIL import Image as PILImage
tex_img = PILImage.fromarray(
(np.clip(texture, 0, 1) * 255).astype(np.uint8)
)
# Use PBRMaterial so GLB exports tag the baseColorTexture as
# sRGB. SimpleMaterial doesn't carry color-space metadata and
# GLB viewers assumed the texture was linear, applying an
# unwanted gamma curve that darkened / desaturated the mesh
# relative to the atlas PNG.
mr_tex_img = None
mr_atlas = getattr(self, "tex_mr", None)
if mr_atlas is not None:
# Repack for glTF: the diffusion MR atlas uses R=metallic,
# G=roughness (PT convention, see MeshRender.get_texture_mr),
# but glTF metallicRoughnessTexture expects B=metallic,
# G=roughness, R ignored (occlusion in some pipelines).
# Without this swap, glTF viewers read metallic=0 and the
# mesh looks uniformly dielectric.
mr_u8 = (np.clip(mr_atlas, 0, 1) * 255).astype(np.uint8)
if mr_u8.ndim == 2:
mr_u8 = np.stack([mr_u8, mr_u8, mr_u8], axis=-1)
metallic_ch = mr_u8[..., 0]
roughness_ch = mr_u8[..., 1]
mr_gltf = np.stack(
[np.zeros_like(metallic_ch), roughness_ch, metallic_ch],
axis=-1,
)
mr_tex_img = PILImage.fromarray(mr_gltf)
kwargs = dict(
name="paint_pbr",
baseColorTexture=tex_img,
)
if mr_tex_img is not None:
kwargs["metallicRoughnessTexture"] = mr_tex_img
kwargs["metallicFactor"] = 1.0
kwargs["roughnessFactor"] = 1.0
else:
kwargs["metallicFactor"] = 0.0
kwargs["roughnessFactor"] = 1.0
material = trimesh.visual.material.PBRMaterial(**kwargs)
# doubleSided stops backface culling that was making the
# silhouettes see-through.
material.doubleSided = True
visuals = trimesh.visual.TextureVisuals(
uv=vtx_uv, material=material,
)
mesh.visual = visuals
mesh.export(mesh_path)
# Expose the in-memory trimesh so callers can export GLB directly.
# Going OBJ -> trimesh.load -> GLB would drop the PBRMaterial
# (OBJ/MTL can't carry metallicRoughnessTexture, doubleSided, or
# sRGB hints) and silently erase our PBR work.
self._last_exported_mesh = mesh
# ------------------------------------------------------------------
# Texture-space operations
# ------------------------------------------------------------------
def extract_textiles(self):
"""Rasterize mesh in UV space to build texture-space geometry maps.
Populates self.tex_position, tex_normal, tex_grid, texture_indices.
Required by back_project(method="back_sample").
"""
if self.vtx_uv is None or self.uv_idx is None:
return
# Build clip-space coords from UVs: (u, v, 0, 1) * 2 - 1
vnum = self.vtx_uv.shape[0]
vtx_uv_clip = mx.concatenate([
self.vtx_uv,
mx.zeros((vnum, 1)),
mx.ones((vnum, 1)),
], axis=1) * 2.0 - 1.0
vtx_uv_clip = mx.expand_dims(vtx_uv_clip, 0) # (1, V, 4)
# Rasterize in UV space. At ≥4096² the single-dispatch raster
# exceeds Metal's command-buffer budget, so tile it.
th, tw = self.texture_size
max_dim = max(th, tw)
tiles = 1
if max_dim >= 4096:
tiles = 4
elif max_dim >= 3072:
tiles = 2
if tiles > 1:
rast_out = self._rasterize_tiled(vtx_uv_clip, self.texture_size, tiles=tiles)
else:
rast_out = self._rasterize(vtx_uv_clip, self.texture_size)
fi = rast_out[0, ..., -1].astype(mx.int32)
bary = rast_out[0, ..., :-1]
# Interpolate world positions in UV space
position = self.raster.interpolate(
mx.expand_dims(self.vtx_pos, 0), fi, bary, self.pos_idx,
)[0] # (H, W, 3)
# Compute face normals and per-pixel normals (face shader)
face_normals = self._compute_face_normals(self.vtx_pos[self.pos_idx])
tri_ids = rast_out[0, ..., 3].astype(mx.int32)
mask = tri_ids > 0
tri_ids_0 = mx.maximum(tri_ids - 1, 0)
position_normal = face_normals[tri_ids_0] # (H, W, 3)
position_normal = position_normal * mask[..., None].astype(mx.float32)
visible_mask = mx.clip(rast_out[0, ..., -1], 0, 1) # (H, W)
mx.synchronize()
# Extract visible pixels to flat arrays (numpy for indexing)
vis_np = np.array(visible_mask).reshape(-1)
mask_flat = vis_np > 0
pos_np = np.array(position).reshape(-1, 3)
norm_np = np.array(position_normal).reshape(-1, 3)
th, tw = self.texture_size
row, col = np.meshgrid(np.arange(th), np.arange(tw), indexing="ij")
grid_np = np.stack([row, col], axis=-1).reshape(-1, 2)
pos_visible = pos_np[mask_flat]
norm_visible = norm_np[mask_flat]
grid_visible = grid_np[mask_flat]
# Add homogeneous w=1
pos_visible = np.concatenate(
[pos_visible, np.ones((pos_visible.shape[0], 1), dtype=np.float32)],
axis=1,
)
# Build reverse index: texture pixel -> flat visible index
texture_indices = np.full(th * tw, -1, dtype=np.int64)
flat_idx = grid_visible[:, 0] * tw + grid_visible[:, 1]
texture_indices[flat_idx] = np.arange(len(grid_visible))
self.tex_position = pos_visible # (K, 4) float32
self.tex_normal = norm_visible # (K, 3) float32
self.tex_grid = grid_visible # (K, 2) int
self.texture_indices = texture_indices.reshape(th, tw) # (H, W)
# Per-texel face id (1-indexed, 0 = empty), for per-face bake merging
self.tex_face_id = np.array(tri_ids).astype(np.int32) # (H, W)
def uv_feature_map(self, vert_feat):
"""Map per-vertex features to UV texture space.
Args:
vert_feat: (N, C) mx.array of per-vertex features.
Returns:
(H, W, C) mx.array feature map.
"""
vtx_uv_clip = mx.concatenate([
self.vtx_uv * 2 - 1,
mx.zeros((self.vtx_uv.shape[0], 1)),
mx.ones((self.vtx_uv.shape[0], 1)),
], axis=1)
vtx_uv_clip = mx.expand_dims(vtx_uv_clip, 0)
rast_out = self._rasterize(vtx_uv_clip, self.texture_size)
fi = rast_out[0, ..., -1].astype(mx.int32)
bary = rast_out[0, ..., :-1]
feat_map = self.raster.interpolate(
mx.expand_dims(vert_feat, 0), fi, bary, self.uv_idx,
)
return feat_map[0] # (H, W, C)
# ------------------------------------------------------------------
# Texture baking
# ------------------------------------------------------------------
@staticmethod
def _render_sketch_from_depth(depth_image_np):
"""Edge detection on depth map (CPU, cv2)."""
import cv2
depth_u8 = (depth_image_np * 255).astype(np.uint8)
edges = cv2.Canny(depth_u8, 30, 80)
return (edges.astype(np.float32) / 255.0)[..., None] # (H, W, 1)
@staticmethod
def _erode_boundary(mask_np, sketch_np, kernel_size):
"""Erode visible mask near silhouette boundaries (CPU, cv2)."""
import cv2
if kernel_size <= 0:
return mask_np
ks = kernel_size * 2 + 1
kernel = np.ones((ks, ks), dtype=np.float32)
# Erode visible mask: pixels near background become unreliable
inv = 1.0 - mask_np[..., 0]
eroded = cv2.filter2D(inv, -1, kernel)
mask_np = mask_np * (eroded <= 0).astype(np.float32)[..., None]
# Also exclude pixels near depth edges
sketch_dilated = cv2.filter2D(sketch_np[..., 0], -1, kernel)
mask_np = mask_np * (sketch_dilated < 0.5).astype(np.float32)[..., None]
return mask_np
def back_project(self, image, elev, azim, camera_distance=None,
center=None):
"""Back-project a rendered image onto UV texture space.
Uses the "back_sample" method: projects texture-space positions
into image space and bilinearly samples colors.
Args:
image: (H, W, C) numpy array or PIL Image, values in [0, 1].
elev: Camera elevation in degrees.
azim: Camera azimuth in degrees.
Returns:
texture: (H, W, C) numpy array in UV space.
cos_map: (H, W, 1) numpy array cosine weights.
boundary_map: (H, W, 1) numpy array boundary mask.
"""
from PIL import Image as PILImage
if isinstance(image, PILImage.Image):
image = np.array(image).astype(np.float32) / 255.0
elif not isinstance(image, np.ndarray):
image = np.array(image)
if image.ndim == 2:
image = image[..., None]
image = image.astype(np.float32)
resolution = image.shape[:2] # (H, W)
channel = image.shape[2]
th, tw = self.texture_size
if camera_distance is None:
camera_distance = self.camera_distance
# --- Camera setup ---
proj = self.camera_proj_mat
mv = get_mv_matrix(elev, azim, camera_distance, center)
pos_camera = transform_pos(mv, self.vtx_pos, keepdim=True) # (N, 4)
pos_clip = transform_pos(self.camera_proj_mat, pos_camera) # (1, N, 4)
pc3 = pos_camera[:, :3] / pos_camera[:, 3:4]
# --- Face normals in camera space ---
mesh_tris = pc3[self.pos_idx] # (F, 3, 3)
face_normals = self._compute_face_normals(mesh_tris)
# --- Rasterize from camera view ---
rast_out = self._rasterize(pos_clip, resolution)
fi = rast_out[0, ..., -1].astype(mx.int32)
bary = rast_out[0, ..., :-1]
visible = mx.clip(rast_out[0, ..., -1:], 0, 1) # (H, W, 1)
# Per-pixel normals (face shader)
tri_ids = fi
tri_mask = (tri_ids > 0).astype(mx.float32)
tri_ids_0 = mx.maximum(tri_ids - 1, 0)
normal = face_normals[tri_ids_0] * tri_mask[..., None] # (H, W, 3)
# Depth
depth_attr = pc3[:, 2:3].reshape(1, -1, 1)
depth = self.raster.interpolate(depth_attr, fi, bary, self.pos_idx)
depth = depth[0] # (H, W, 1)
mx.synchronize()
visible_np = np.array(visible)
normal_np = np.array(normal)
depth_np = np.array(depth)
# Depth normalization for sketch
vis_flat = visible_np.reshape(-1) > 0
if vis_flat.sum() == 0:
return (np.zeros((*self.texture_size, channel), dtype=np.float32),
np.zeros((*self.texture_size, 1), dtype=np.float32),
np.zeros((*self.texture_size, 1), dtype=np.float32))
d_vis = depth_np.reshape(-1)[vis_flat]
d_max, d_min = d_vis.max(), d_vis.min()
depth_norm = (depth_np - d_min) / max(d_max - d_min, 1e-8)
depth_image = depth_norm * visible_np
sketch_np = self._render_sketch_from_depth(depth_image[..., 0])
# Cosine weighting (view angle vs surface normal)
lookat = np.array([0, 0, -1], dtype=np.float32)
normal_flat = normal_np.reshape(-1, 3)
cos_flat = (normal_flat * lookat).sum(axis=-1)
cos_np = cos_flat.reshape(resolution[0], resolution[1], 1)
cos_thres = np.cos(np.radians(self.bake_angle_thres))
cos_np[cos_np < cos_thres] = 0.0
# Boundary erosion
visible_np = self._erode_boundary(
visible_np, sketch_np, self.bake_unreliable_kernel_size,
)
cos_np[visible_np[..., 0] == 0] = 0.0
# --- Back-sample: project tex_position to image space ---
if self.tex_position is None:
raise RuntimeError("extract_textiles() must be called first")
img_proj = np.array([
[proj[0, 0], 0, 0, 0],
[0, proj[1, 1], 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
], dtype=np.float32)
mv_f = mv.astype(np.float32)
tex_pos = self.tex_position # (K, 4)
v_proj = tex_pos @ mv_f.T @ img_proj # (K, 4)
# Check which tex pixels project inside the image
inner = (
(v_proj[:, 0] >= -1.0) & (v_proj[:, 0] <= 1.0)
& (v_proj[:, 1] >= -1.0) & (v_proj[:, 1] <= 1.0)
)
inner_idx = np.where(inner)[0]
img_x = np.clip(
((np.clip(v_proj[:, 0], -1, 1) * 0.5 + 0.5) * resolution[0]).astype(np.int64),
0, resolution[0] - 1,
)
img_y = np.clip(
((np.clip(v_proj[:, 1], -1, 1) * 0.5 + 0.5) * resolution[1]).astype(np.int64),
0, resolution[1] - 1,
)
indices = img_y * resolution[0] + img_x
depth_flat = depth_np.reshape(-1)
vis_mask_flat = visible_np.reshape(-1)
cos_flat = cos_np.reshape(-1)
sampled_z = depth_flat[indices]
sampled_m = vis_mask_flat[indices]
sampled_w = cos_flat[indices]
v_z = v_proj[:, 2]
depth_thres = 3e-3
valid_mask = (np.abs(v_z - sampled_z) < depth_thres) & (sampled_m * sampled_w > 0)
valid_idx = np.where(valid_mask)[0]
valid_idx = np.intersect1d(valid_idx, inner_idx)
# Bilinear sampling from image
wx = ((v_proj[:, 0] * 0.5 + 0.5) * resolution[0] - img_x)[valid_idx].reshape(-1, 1)
wy = ((v_proj[:, 1] * 0.5 + 0.5) * resolution[1] - img_y)[valid_idx].reshape(-1, 1)
ix = img_x[valid_idx]
iy = img_y[valid_idx]
ix_r = np.clip(ix + 1, 0, resolution[0] - 1)
iy_r = np.clip(iy + 1, 0, resolution[1] - 1)
rgb = image.reshape(-1, channel)
i00 = iy * resolution[0] + ix
i10 = iy * resolution[0] + ix_r
i01 = iy_r * resolution[0] + ix
i11 = iy_r * resolution[0] + ix_r
sampled_rgb = (
(rgb[i00] * (1 - wx) + rgb[i10] * wx) * (1 - wy)
+ (rgb[i01] * (1 - wx) + rgb[i11] * wx) * wy
)
sampled_b = sketch_np.reshape(-1)[indices[valid_idx]]
sampled_w_valid = sampled_w[valid_idx]
# Write to texture space
texture = np.zeros((th * tw, channel), dtype=np.float32)
cos_map = np.zeros(th * tw, dtype=np.float32)
boundary_map = np.zeros(th * tw, dtype=np.float32)
tex_grid = self.tex_grid[valid_idx]
tex_flat_idx = tex_grid[:, 0] * tw + tex_grid[:, 1]
texture[tex_flat_idx] = sampled_rgb
cos_map[tex_flat_idx] = sampled_w_valid
boundary_map[tex_flat_idx] = sampled_b
return (
texture.reshape(th, tw, channel),
cos_map.reshape(th, tw, 1),
boundary_map.reshape(th, tw, 1),
)
def fast_bake_texture(self, textures, cos_maps, mode="weighted"):
"""Merge multiple view textures into a single UV atlas.
Three modes (PT parity is default):
- ``"weighted"`` (default, PyTorch parity): cosine-weighted
average of all views. Smooth across face boundaries; matches
MeshRender.fast_bake_texture exactly.
- ``"face_wta"``: pick one view per UV face. Sharper but creates
visible discontinuities at mesh-face seams where adjacent
faces pick different winning views.
- ``"wta"``: per-texel argmax. Fastest, stripiest.
Args:
textures: list of (H, W, C) numpy arrays.
cos_maps: list of (H, W, 1) numpy arrays (already weighted).
mode: "wta" or "weighted".
Returns:
texture_merge: (H, W, C) numpy array.
trust_map: (H, W, 1) boolean numpy array.
"""
if not textures:
raise ValueError("fast_bake_texture: no textures provided")
if mode == "face_wta":
# Per-face WTA: aggregate cos per (face, view), pick best view per
# face, then bake all of that face's texels from that view.
if getattr(self, "tex_face_id", None) is None:
# Fall back to per-texel WTA if face ids aren't available
mode = "wta"
else:
stack_t = np.stack(textures, axis=0) # (V, H, W, C)
stack_c = np.stack([c[..., 0] for c in cos_maps], axis=0) # (V, H, W)
face_ids = self.tex_face_id # (H, W) 1-indexed
V = stack_c.shape[0]
n_faces = int(face_ids.max())
# Sum cos per (view, face)
flat_face = face_ids.reshape(-1)
valid = flat_face > 0
face_idx = (flat_face[valid] - 1).astype(np.int64)
stack_c_flat = stack_c.reshape(V, -1)[:, valid] # (V, K)
cos_per_face = np.zeros((V, n_faces), dtype=np.float32)
for v in range(V):
np.add.at(cos_per_face[v], face_idx, stack_c_flat[v])
best_view_per_face = cos_per_face.argmax(axis=0) # (n_faces,)
# Per-texel best view from face lookup
best_per_texel = np.zeros_like(face_ids, dtype=np.int64)
best_per_texel[face_ids > 0] = best_view_per_face[face_ids[face_ids > 0] - 1]
# Painted = any view contributed at this texel
best_c = stack_c.max(axis=0)
H, W = face_ids.shape
ii, jj = np.meshgrid(np.arange(H), np.arange(W), indexing="ij")
merged = stack_t[best_per_texel, ii, jj]
merged[best_c == 0] = 0
return merged, (best_c > 0)[..., None]
if mode == "wta":
stack_t = np.stack(textures, axis=0)
stack_c = np.stack([c[..., 0] for c in cos_maps], axis=0)
best = stack_c.argmax(axis=0)
best_c = stack_c.max(axis=0)
H, W = best.shape
ii, jj = np.meshgrid(np.arange(H), np.arange(W), indexing="ij")
merged = stack_t[best, ii, jj]
merged[best_c == 0] = 0
return merged, (best_c > 0)[..., None]
# PyTorch-parity weighted average path
channel = textures[0].shape[-1]
th, tw = self.texture_size
texture_merge = np.zeros((th, tw, channel), dtype=np.float32)
trust_map = np.zeros((th, tw, 1), dtype=np.float32)
for texture, cos_map in zip(textures, cos_maps):
view_sum = (cos_map > 0).sum()
if view_sum == 0:
continue
painted_sum = ((cos_map > 0) & (trust_map > 0)).sum()
if painted_sum / view_sum > 0.99:
continue
texture_merge += texture * cos_map
trust_map += cos_map
texture_merge = texture_merge / np.maximum(trust_map, 1e-8)
return texture_merge, trust_map > 1e-8
def bake_texture(self, colors, elevs, azims, camera_distance=None,
center=None, exp=6, weights=None):
"""Bake multiple views into a single UV texture.
Args:
colors: list of images (numpy or PIL).
elevs: list of elevation angles.
azims: list of azimuth angles.
exp: exponent for cosine weighting.
weights: optional per-view weights.
Returns:
texture_merge: (H, W, C) numpy array.
trust_map: (H, W, 1) boolean numpy array.
"""
from PIL import Image as PILImage
if weights is None:
weights = [1.0] * len(colors)
textures = []
cos_maps = []
for color, elev, azim, weight in zip(colors, elevs, azims, weights):
if isinstance(color, PILImage.Image):
color = np.array(color).astype(np.float32) / 255.0
texture, cos_map, _ = self.back_project(
color, elev, azim, camera_distance, center,
)
cos_map = weight * (cos_map ** exp)
textures.append(texture)
cos_maps.append(cos_map)
return self.fast_bake_texture(textures, cos_maps)
def uv_inpaint(self, texture, mask, method="NS", vertex_inpaint=True):
"""Inpaint missing regions in UV texture.
Two-pass pipeline matching PyTorch's bake flow:
1. Mesh-aware vertex-color propagation + face-bary rasterization
(fills most of the UV islands using 3D adjacency).
2. Gutter fill for the remaining empty texels.
For step 2 the default ``"edt"`` mode copies each gutter texel from
its nearest painted texel via ``scipy.ndimage.distance_transform_edt``.
It's deterministic and never blends colors across distant UV islands —
unlike ``"NS"`` (cv2.INPAINT_NS) which diffuses and tends to mix
unrelated islands together when they happen to be 2D-close in atlas
space.
Args:
texture: (H, W, C) numpy array in [0, 1].
mask: (H, W) or (H, W, 1) numpy uint8, 255=keep, 0=inpaint.
method: "edt" (default, EDT nearest-fill) or "NS" (cv2 NS).
vertex_inpaint: Run the mesh-aware propagation pass first.
Returns:
(H, W, C) numpy uint8 array.
"""
if isinstance(texture, mx.array):
texture = np.array(texture)
if mask.ndim == 3:
mask = mask[..., 0]
if mask.dtype != np.uint8:
mask = (mask * 255).astype(np.uint8)
tex_f = np.clip(texture, 0, 1).astype(np.float32)
if vertex_inpaint and self.vtx_uv is not None and self.uv_idx is not None:
from .mesh_inpaint_py import mesh_vertex_inpaint
tex_f, mask = mesh_vertex_inpaint(
tex_f, mask,
np.asarray(self.vtx_pos), np.asarray(self.vtx_uv),
np.asarray(self.pos_idx), np.asarray(self.uv_idx),
)
if method == "edt":
from scipy.ndimage import distance_transform_edt
painted = mask > 0
if painted.any():
idx = distance_transform_edt(
~painted, return_distances=False, return_indices=True,
)
tex_f = tex_f[idx[0], idx[1]]
return (np.clip(tex_f, 0, 1) * 255).astype(np.uint8)
# cv2.INPAINT_NS for near-field fill (PT parity), then an explicit
# EDT edge-padding pass for UV gutter. NS's 3-px radius is smaller
# than typical UV-island spacing in a 2048^2 atlas, so 3D viewers
# doing bilinear texture sampling across island boundaries read
# the NS-smoothed (near-black) gutter and display thin wireframe
# lines on the mesh. EDT pads every unpainted texel with the
# nearest painted color — this is the standard post-bake step in
# production UV pipelines and never affects texels that are
# visible in 3D (painted).
import cv2
from scipy.ndimage import distance_transform_edt
texture_u8 = (np.clip(tex_f, 0, 1) * 255).astype(np.uint8)
inpainted = cv2.inpaint(texture_u8, 255 - mask, 3, cv2.INPAINT_NS)
painted = mask > 0
if painted.any() and (~painted).any():
idx = distance_transform_edt(
~painted, return_distances=False, return_indices=True,
)
inpainted = inpainted[idx[0], idx[1]]
return inpainted