Hunyuan3D-2.2-mrp-MLX/hy3dpaint/textureGenPipeline_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

389 lines
15 KiB
Python

"""Hybrid texture generation pipeline: MLX renderer + PyTorch ML models.
Uses MeshRenderMLX for all rendering and texture baking (Metal GPU),
while keeping multiview diffusion and super-resolution in PyTorch.
The interface between them is PIL Images.
"""
import copy
import os
import warnings
import numpy as np
import trimesh
from PIL import Image
from typing import List
from DifferentiableRenderer.mesh_render_mlx import MeshRenderMLX
from utils.pipeline_utils_mlx import ViewProcessorMLX
def _convert_obj_to_glb(obj_path: str, glb_path: str) -> bool:
"""Convert OBJ + MTL + textures to GLB.
Tries Blender (mesh_utils.convert_obj_to_glb) first for highest fidelity,
falls back to trimesh (pure Python, no CUDA/Blender dependency) so the
pipeline runs on Apple Silicon without `bpy`.
"""
try:
from DifferentiableRenderer.mesh_utils import convert_obj_to_glb
return convert_obj_to_glb(obj_path, glb_path)
except ImportError:
pass
import trimesh
mesh = trimesh.load(obj_path, process=False)
mesh.export(glb_path)
return os.path.exists(glb_path)
try:
from utils.simplify_mesh_utils import remesh_mesh
except ImportError:
remesh_mesh = None # pymeshlab unavailable; require use_remesh=False
try:
from utils.uvwrap_utils import mesh_uv_wrap
except ImportError:
mesh_uv_wrap = None # xatlas unavailable; require pre-wrapped mesh
warnings.filterwarnings("ignore")
class Hunyuan3DPaintConfigMLX:
def __init__(self, max_num_view, resolution):
self.device = "cuda" # for PyTorch ML models only
self.multiview_cfg_path = "hy3dpaint/cfgs/hunyuan-paint-pbr.yaml"
self.custom_pipeline = "hunyuanpaintpbr"
self.multiview_pretrained_path = "tencent/Hunyuan3D-2.1"
self.dino_ckpt_path = "facebook/dinov2-giant"
self.realesrgan_ckpt_path = "ckpt/RealESRGAN_x4plus.pth"
# Fully-MLX diffusion (Apple Silicon, no CUDA).
# When True, loads HunyuanPaintModelMLX from mlx_weights_source and
# skips the PyTorch multiview model / super-resolution.
#
# mlx_weights_source may be either a HuggingFace repo ID (auto-
# downloaded via huggingface_hub) or an absolute local path. The
# env var HUNYUAN3D_MLX_WEIGHTS_DIR overrides this value.
self.use_mlx_diffusion = True
self.mlx_weights_source = "dgrauet/hunyuan3d-2.1-mlx"
self.mlx_num_inference_steps = 15
self.mlx_guidance_scale = 3.0
self.mlx_seed = 42
# Fully-MLX super-resolution (RealESRGAN x4, ~17M params).
# Upscales each 512^2 generated view to 2048^2 before baking so
# the UV atlas keeps detail. Disable for faster iteration.
self.use_mlx_super_res = True
self.mlx_super_res_filename = "realesrgan_x4plus.safetensors"
self.raster_mode = "mlx"
self.bake_mode = "back_sample"
self.render_size = 1024 * 2
self.texture_size = 1024 * 4
self.max_selected_view_num = max_num_view
self.resolution = resolution
self.bake_exp = 4
self.merge_method = "fast"
# View selection candidates
self.candidate_camera_azims = [0, 90, 180, 270, 0, 180]
self.candidate_camera_elevs = [0, 0, 0, 0, 90, -90]
self.candidate_view_weights = [1, 0.1, 0.5, 0.1, 0.05, 0.05]
for azim in range(0, 360, 30):
self.candidate_camera_azims.append(azim)
self.candidate_camera_elevs.append(20)
self.candidate_view_weights.append(0.01)
self.candidate_camera_azims.append(azim)
self.candidate_camera_elevs.append(-20)
self.candidate_view_weights.append(0.01)
class Hunyuan3DPaintPipelineMLX:
"""Texture generation pipeline with MLX rendering backend.
The rendering, rasterization, and texture baking run on Apple Silicon
via MLX Metal kernels. The ML models (multiview diffusion, super-res)
remain in PyTorch and communicate via PIL Images.
"""
def __init__(self, config=None):
self.config = config if config is not None else Hunyuan3DPaintConfigMLX(
max_num_view=6, resolution=1024,
)
self.models = {}
self.render = MeshRenderMLX(
default_resolution=self.config.render_size,
texture_size=self.config.texture_size,
)
self.view_processor = ViewProcessorMLX(self.config, self.render)
self.load_models()
def load_models(self):
"""Load diffusion model.
MLX path: HunyuanPaintModelMLX on Apple Silicon.
PyTorch path: diffusion + super-res on CUDA (legacy).
"""
if self.config.use_mlx_diffusion:
from hunyuanpaintpbr_mlx.load_model import HunyuanPaintModelMLX
self.models["mlx_model"] = HunyuanPaintModelMLX.from_pretrained(
self.config.mlx_weights_source,
)
print("MLX diffusion model loaded.")
if getattr(self.config, "use_mlx_super_res", False):
from utils.image_super_utils_mlx import imageSuperNetMLX
sr_path = self._resolve_super_res_weights()
self.models["mlx_super"] = imageSuperNetMLX(sr_path)
print(f"MLX super-resolution loaded ({sr_path}).")
return
try:
import torch
torch.cuda.empty_cache()
from utils.image_super_utils import imageSuperNet
from utils.multiview_utils import multiviewDiffusionNet
self.models["super_model"] = imageSuperNet(self.config)
self.models["multiview_model"] = multiviewDiffusionNet(self.config)
print("ML models loaded (PyTorch).")
except (ImportError, RuntimeError) as e:
print(f"ML models not loaded ({e}). "
"Pipeline can still run render/bake with pre-generated images.")
def _resolve_super_res_weights(self) -> str:
"""Resolve RealESRGAN MLX weights path.
Reuses the HF repo (or local dir) used for the diffusion weights so a
single ``HUNYUAN3D_MLX_WEIGHTS_DIR`` override covers everything.
"""
src = self.config.mlx_weights_source
filename = self.config.mlx_super_res_filename
env_override = os.environ.get("HUNYUAN3D_MLX_WEIGHTS_DIR")
if env_override:
return os.path.join(env_override, filename)
if os.path.isdir(src):
return os.path.join(src, filename)
from huggingface_hub import hf_hub_download
return hf_hub_download(repo_id=src, filename=filename)
def _run_multiview_mlx(self, image_style, normal_maps, position_maps,
camera_azims):
"""Run MLX multiview diffusion.
Args:
image_style: list of PIL reference images (we use only the first).
normal_maps: list of PIL normal maps (one per view).
position_maps: list of PIL position maps.
camera_azims: list of azimuth angles.
Returns:
dict with 'albedo' and 'mr', each a list of PIL images.
"""
from hunyuanpaintpbr_mlx.inference import generate_multiview_pbr
ref_np = np.array(image_style[0].convert("RGB"))
def _pil_to_np_f32(img):
"""Convert a PIL image to (H, W, 3) float32 in [0, 1]."""
arr = np.array(img.convert("RGB"), dtype=np.float32) / 255.0
return arr
n_maps = [_pil_to_np_f32(m) for m in normal_maps]
p_maps = [_pil_to_np_f32(m) for m in position_maps]
result = generate_multiview_pbr(
model=self.models["mlx_model"],
normal_maps=n_maps,
position_maps=p_maps,
reference_image=ref_np,
camera_azims=camera_azims,
num_inference_steps=self.config.mlx_num_inference_steps,
guidance_scale=self.config.mlx_guidance_scale,
view_size=self.config.resolution,
seed=self.config.mlx_seed,
)
return {
"albedo": [Image.fromarray(a) for a in result["albedo"]],
"mr": [Image.fromarray(m) for m in result["mr"]],
}
def __call__(self, mesh_path=None, image_path=None,
output_mesh_path=None, use_remesh=True, save_glb=True):
"""Generate texture for 3D mesh using multiview diffusion.
Args:
mesh_path: Path to input mesh.
image_path: Path to reference image or PIL Image.
output_mesh_path: Output path for textured mesh.
use_remesh: Whether to remesh before texturing.
save_glb: Whether to also save as GLB.
Returns:
Path to the output mesh file.
"""
# --- Prepare image prompt ---
if isinstance(image_path, str):
image_prompt = Image.open(image_path)
elif isinstance(image_path, Image.Image):
image_prompt = image_path
else:
image_prompt = image_path
if not isinstance(image_prompt, list):
image_prompt = [image_prompt]
# --- Process mesh ---
path = os.path.dirname(mesh_path)
if use_remesh:
processed_mesh_path = os.path.join(path, "white_mesh_remesh.obj")
remesh_mesh(mesh_path, processed_mesh_path)
else:
processed_mesh_path = mesh_path
if output_mesh_path is None:
output_mesh_path = os.path.join(path, "textured_mesh.obj")
mesh = trimesh.load(processed_mesh_path)
# trimesh.load can return a Scene for .glb/.gltf; flatten to a single
# Trimesh so we can inspect UVs (a Scene wrapper hides them).
if isinstance(mesh, trimesh.Scene):
mesh = mesh.dump(concatenate=True)
# Only UV-wrap if the mesh has no UVs (xatlas creates fragmented
# islands; a hand-authored UV layout produces much cleaner textures).
uv = getattr(getattr(mesh, "visual", None), "uv", None)
needs_wrap = uv is None or len(uv) == 0
if needs_wrap:
if mesh_uv_wrap is None:
raise RuntimeError(
"Mesh has no UVs and `xatlas` is not installed. "
"Install xatlas or provide a pre-wrapped mesh."
)
mesh = mesh_uv_wrap(mesh)
self.render.load_mesh(mesh=mesh)
# --- View selection ---
selected_elevs, selected_azims, selected_weights = \
self.view_processor.bake_view_selection(
self.config.candidate_camera_elevs,
self.config.candidate_camera_azims,
self.config.candidate_view_weights,
self.config.max_selected_view_num,
)
# --- Render reference maps (MLX, Metal GPU) ---
normal_maps = self.view_processor.render_normal_multiview(
selected_elevs, selected_azims, use_abs_coor=True,
)
position_maps = self.view_processor.render_position_multiview(
selected_elevs, selected_azims,
)
# --- Style prep ---
image_style = []
for image in image_prompt:
image = image.resize((512, 512))
if image.mode == "RGBA":
white_bg = Image.new("RGB", image.size, (255, 255, 255))
white_bg.paste(image, mask=image.getchannel("A"))
image = white_bg
image_style.append(image.convert("RGB"))
# --- Multiview diffusion ---
if self.config.use_mlx_diffusion:
multiviews_pbr = self._run_multiview_mlx(
image_style, normal_maps, position_maps, selected_azims,
)
enhance_images = {
"albedo": list(multiviews_pbr["albedo"]),
"mr": list(multiviews_pbr["mr"]),
}
# RealESRGAN x4 super-resolution per view (MLX)
if "mlx_super" in self.models:
sr = self.models["mlx_super"]
for i in range(len(enhance_images["albedo"])):
enhance_images["albedo"][i] = sr(enhance_images["albedo"][i])
enhance_images["mr"][i] = sr(enhance_images["mr"][i])
else:
if "multiview_model" not in self.models:
raise RuntimeError(
"Multiview diffusion model not loaded. "
"Install PyTorch with CUDA support."
)
multiviews_pbr = self.models["multiview_model"](
image_style,
normal_maps + position_maps,
prompt="high quality",
custom_view_size=self.config.resolution,
resize_input=True,
)
# Super-resolution (CUDA-only)
enhance_images = {
"albedo": copy.deepcopy(multiviews_pbr["albedo"]),
"mr": copy.deepcopy(multiviews_pbr["mr"]),
}
if "super_model" in self.models:
for i in range(len(enhance_images["albedo"])):
enhance_images["albedo"][i] = self.models["super_model"](
enhance_images["albedo"][i]
)
enhance_images["mr"][i] = self.models["super_model"](
enhance_images["mr"][i]
)
# --- Texture baking (MLX, Metal GPU) ---
render_size = self.config.render_size
for i in range(len(enhance_images["albedo"])):
enhance_images["albedo"][i] = enhance_images["albedo"][i].resize(
(render_size, render_size),
)
enhance_images["mr"][i] = enhance_images["mr"][i].resize(
(render_size, render_size),
)
texture, mask = self.view_processor.bake_from_multiview(
enhance_images["albedo"],
selected_elevs, selected_azims, selected_weights,
)
mask_np = (mask[..., 0] * 255).astype(np.uint8)
texture_mr, mask_mr = self.view_processor.bake_from_multiview(
enhance_images["mr"],
selected_elevs, selected_azims, selected_weights,
)
mask_mr_np = (mask_mr[..., 0] * 255).astype(np.uint8)
# --- Inpainting (OpenCV, CPU) ---
texture = self.view_processor.texture_inpaint(texture, mask_np)
self.render.set_texture(texture)
if "mr" in enhance_images:
texture_mr = self.view_processor.texture_inpaint(
texture_mr, mask_mr_np,
)
self.render.set_texture_mr(texture_mr)
# --- Save ---
# Do NOT downsample: cv2.resize blends across UV gutters and produces
# visible color bleeding at island edges in the textured mesh.
self.render.save_mesh(output_mesh_path, downsample=False)
if save_glb:
glb_path = output_mesh_path.replace(".obj", ".glb")
# Export directly from the in-memory trimesh so the PBRMaterial
# (metallicRoughnessTexture, doubleSided, sRGB baseColor) is
# preserved. OBJ round-trip would drop all of it.
in_mem = getattr(self.render, "_last_exported_mesh", None)
if in_mem is not None:
in_mem.export(glb_path)
else:
_convert_obj_to_glb(output_mesh_path, glb_path)
return output_mesh_path