HorizonRobotics/EmbodiedGen-Image-to-3D
47
1# Project EmbodiedGen2#3# Copyright (c) 2025 Horizon Robotics. All Rights Reserved.4#5# Licensed under the Apache License, Version 2.0 (the "License");6# you may not use this file except in compliance with the License.7# You may obtain a copy of the License at8#9# http://www.apache.org/licenses/LICENSE-2.010#11# Unless required by applicable law or agreed to in writing, software12# distributed under the License is distributed on an "AS IS" BASIS,13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or14# implied. See the License for the specific language governing15# permissions and limitations under the License.16 17import argparse18import logging19import math20import os21from typing import Literal, Union22 23import cv224import numpy as np25import nvdiffrast.torch as dr26import spaces27import torch28import trimesh29import utils3d30import xatlas31from PIL import Image32from tqdm import tqdm33from embodied_gen.data.mesh_operator import MeshFixer34from embodied_gen.data.utils import (35 CameraSetting,36 init_kal_camera,37 kaolin_to_opencv_view,38 normalize_vertices_array,39 post_process_texture,40 save_mesh_with_mtl,41)42from embodied_gen.models.delight_model import DelightingModel43from embodied_gen.models.gs_model import load_gs_model44from embodied_gen.models.sr_model import ImageRealESRGAN45 46logging.basicConfig(47 format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO48)49logger = logging.getLogger(__name__)50 51 52__all__ = [53 "TextureBaker",54]55 56 57class TextureBaker(object):58 """Baking textures onto a mesh from multiple observations.59 60 This class take 3D mesh data, camera settings and texture baking parameters61 to generate texture map by projecting images to the mesh from diff views.62 It supports both a fast texture baking approach and a more optimized method63 with total variation regularization.64 65 Attributes:66 vertices (torch.Tensor): The vertices of the mesh.67 faces (torch.Tensor): The faces of the mesh, defined by vertex indices.68 uvs (torch.Tensor): The UV coordinates of the mesh.69 camera_params (CameraSetting): Camera setting (intrinsics, extrinsics).70 device (str): The device to run computations on ("cpu" or "cuda").71 w2cs (torch.Tensor): World-to-camera transformation matrices.72 projections (torch.Tensor): Camera projection matrices.73 74 Example:75 >>> vertices, faces, uvs = TextureBaker.parametrize_mesh(vertices, faces) # noqa76 >>> texture_backer = TextureBaker(vertices, faces, uvs, camera_params)77 >>> images = get_images_from_grid(args.color_path, image_size)78 >>> texture = texture_backer.bake_texture(79 ... images, texture_size=args.texture_size, mode=args.baker_mode80 ... )81 >>> texture = post_process_texture(texture)82 """83 84 def __init__(85 self,86 vertices: np.ndarray,87 faces: np.ndarray,88 uvs: np.ndarray,89 camera_params: CameraSetting,90 device: str = "cuda",91 ) -> None:92 self.vertices = (93 torch.tensor(vertices, device=device)94 if isinstance(vertices, np.ndarray)95 else vertices.to(device)96 )97 self.faces = (98 torch.tensor(faces.astype(np.int32), device=device)99 if isinstance(faces, np.ndarray)100 else faces.to(device)101 )102 self.uvs = (103 torch.tensor(uvs, device=device)104 if isinstance(uvs, np.ndarray)105 else uvs.to(device)106 )107 self.camera_params = camera_params108 self.device = device109 110 camera = init_kal_camera(camera_params)111 matrix_mv = camera.view_matrix() # (n_cam 4 4) world2cam112 matrix_mv = kaolin_to_opencv_view(matrix_mv)113 matrix_p = (114 camera.intrinsics.projection_matrix()115 ) # (n_cam 4 4) cam2pixel116 self.w2cs = matrix_mv.to(self.device)117 self.projections = matrix_p.to(self.device)118 119 @staticmethod120 def parametrize_mesh(121 vertices: np.array, faces: np.array122 ) -> Union[np.array, np.array, np.array]:123 vmapping, indices, uvs = xatlas.parametrize(vertices, faces)124 125 vertices = vertices[vmapping]126 faces = indices127 128 return vertices, faces, uvs129 130 def _bake_fast(self, observations, w2cs, projections, texture_size, masks):131 texture = torch.zeros(132 (texture_size * texture_size, 3), dtype=torch.float32133 ).cuda()134 texture_weights = torch.zeros(135 (texture_size * texture_size), dtype=torch.float32136 ).cuda()137 rastctx = utils3d.torch.RastContext(backend="cuda")138 for observation, w2c, projection in tqdm(139 zip(observations, w2cs, projections),140 total=len(observations),141 desc="Texture baking (fast)",142 ):143 with torch.no_grad():144 rast = utils3d.torch.rasterize_triangle_faces(145 rastctx,146 self.vertices[None],147 self.faces,148 observation.shape[1],149 observation.shape[0],150 uv=self.uvs[None],151 view=w2c,152 projection=projection,153 )154 uv_map = rast["uv"][0].detach().flip(0)155 mask = rast["mask"][0].detach().bool() & masks[0]156 157 # nearest neighbor interpolation158 uv_map = (uv_map * texture_size).floor().long()159 obs = observation[mask]160 uv_map = uv_map[mask]161 idx = (162 uv_map[:, 0] + (texture_size - uv_map[:, 1] - 1) * texture_size163 )164 texture = texture.scatter_add(165 0, idx.view(-1, 1).expand(-1, 3), obs166 )167 texture_weights = texture_weights.scatter_add(168 0,169 idx,170 torch.ones(171 (obs.shape[0]), dtype=torch.float32, device=texture.device172 ),173 )174 175 mask = texture_weights > 0176 texture[mask] /= texture_weights[mask][:, None]177 texture = np.clip(178 texture.reshape(texture_size, texture_size, 3).cpu().numpy() * 255,179 0,180 255,181 ).astype(np.uint8)182 183 # inpaint184 mask = (185 (texture_weights == 0)186 .cpu()187 .numpy()188 .astype(np.uint8)189 .reshape(texture_size, texture_size)190 )191 texture = cv2.inpaint(texture, mask, 3, cv2.INPAINT_TELEA)192 193 return texture194 195 def _bake_opt(196 self,197 observations,198 w2cs,199 projections,200 texture_size,201 lambda_tv,202 masks,203 total_steps,204 ):205 rastctx = utils3d.torch.RastContext(backend="cuda")206 observations = [observations.flip(0) for observations in observations]207 masks = [m.flip(0) for m in masks]208 _uv = []209 _uv_dr = []210 for observation, w2c, projection in tqdm(211 zip(observations, w2cs, projections),212 total=len(w2cs),213 ):214 with torch.no_grad():215 rast = utils3d.torch.rasterize_triangle_faces(216 rastctx,217 self.vertices[None],218 self.faces,219 observation.shape[1],220 observation.shape[0],221 uv=self.uvs[None],222 view=w2c,223 projection=projection,224 )225 _uv.append(rast["uv"].detach())226 _uv_dr.append(rast["uv_dr"].detach())227 228 texture = torch.nn.Parameter(229 torch.zeros(230 (1, texture_size, texture_size, 3), dtype=torch.float32231 ).cuda()232 )233 optimizer = torch.optim.Adam([texture], betas=(0.5, 0.9), lr=1e-2)234 235 def cosine_anealing(step, total_steps, start_lr, end_lr):236 return end_lr + 0.5 * (start_lr - end_lr) * (237 1 + np.cos(np.pi * step / total_steps)238 )239 240 def tv_loss(texture):241 return torch.nn.functional.l1_loss(242 texture[:, :-1, :, :], texture[:, 1:, :, :]243 ) + torch.nn.functional.l1_loss(244 texture[:, :, :-1, :], texture[:, :, 1:, :]245 )246 247 with tqdm(total=total_steps, desc="Texture baking") as pbar:248 for step in range(total_steps):249 optimizer.zero_grad()250 selected = np.random.randint(0, len(w2cs))251 uv, uv_dr, observation, mask = (252 _uv[selected],253 _uv_dr[selected],254 observations[selected],255 masks[selected],256 )257 render = dr.texture(texture, uv, uv_dr)[0]258 loss = torch.nn.functional.l1_loss(259 render[mask], observation[mask]260 )261 if lambda_tv > 0:262 loss += lambda_tv * tv_loss(texture)263 loss.backward()264 optimizer.step()265 266 optimizer.param_groups[0]["lr"] = cosine_anealing(267 step, total_steps, 1e-2, 1e-5268 )269 pbar.set_postfix({"loss": loss.item()})270 pbar.update()271 272 texture = np.clip(273 texture[0].flip(0).detach().cpu().numpy() * 255, 0, 255274 ).astype(np.uint8)275 mask = 1 - utils3d.torch.rasterize_triangle_faces(276 rastctx,277 (self.uvs * 2 - 1)[None],278 self.faces,279 texture_size,280 texture_size,281 )["mask"][0].detach().cpu().numpy().astype(np.uint8)282 texture = cv2.inpaint(texture, mask, 3, cv2.INPAINT_TELEA)283 284 return texture285 286 def bake_texture(287 self,288 images: list[np.array],289 texture_size: int = 1024,290 mode: Literal["fast", "opt"] = "opt",291 lambda_tv: float = 1e-2,292 opt_step: int = 2000,293 ):294 masks = [np.any(img > 0, axis=-1) for img in images]295 masks = [torch.tensor(m > 0).bool().to(self.device) for m in masks]296 images = [297 torch.tensor(obs / 255.0).float().to(self.device) for obs in images298 ]299 300 if mode == "fast":301 return self._bake_fast(302 images, self.w2cs, self.projections, texture_size, masks303 )304 elif mode == "opt":305 return self._bake_opt(306 images,307 self.w2cs,308 self.projections,309 texture_size,310 lambda_tv,311 masks,312 opt_step,313 )314 else:315 raise ValueError(f"Unknown mode: {mode}")316 317 318def parse_args():319 """Parses command-line arguments for texture backprojection.320 321 Returns:322 argparse.Namespace: Parsed arguments.323 """324 parser = argparse.ArgumentParser(description="Backproject texture")325 parser.add_argument(326 "--gs_path",327 type=str,328 help="Path to the GS.ply gaussian splatting model",329 )330 parser.add_argument(331 "--mesh_path",332 type=str,333 help="Mesh path, .obj, .glb or .ply",334 )335 parser.add_argument(336 "--output_path",337 type=str,338 help="Output mesh path with suffix",339 )340 parser.add_argument(341 "--num_images",342 type=int,343 default=180,344 help="Number of images to render.",345 )346 parser.add_argument(347 "--elevation",348 nargs="+",349 type=float,350 default=list(range(85, -90, -10)),351 help="Elevation angles for the camera",352 )353 parser.add_argument(354 "--distance",355 type=float,356 default=4.5,357 help="Camera distance (default: 4.5)",358 )359 parser.add_argument(360 "--resolution_hw",361 type=int,362 nargs=2,363 default=(512, 512),364 help="Resolution of the render images (default: (512, 512))",365 )366 parser.add_argument(367 "--fov",368 type=float,369 default=30,370 help="Field of view in degrees (default: 30)",371 )372 parser.add_argument(373 "--device",374 type=str,375 choices=["cpu", "cuda"],376 default="cuda",377 help="Device to run on (default: `cuda`)",378 )379 parser.add_argument(380 "--skip_fix_mesh", action="store_true", help="Fix mesh geometry."381 )382 parser.add_argument(383 "--texture_size",384 type=int,385 default=2048,386 help="Texture size for texture baking (default: 1024)",387 )388 parser.add_argument(389 "--baker_mode",390 type=str,391 default="opt",392 help="Texture baking mode, `fast` or `opt` (default: opt)",393 )394 parser.add_argument(395 "--opt_step",396 type=int,397 default=3000,398 help="Optimization steps for texture baking (default: 3000)",399 )400 parser.add_argument(401 "--mesh_sipmlify_ratio",402 type=float,403 default=0.85,404 help="Mesh simplification ratio (default: 0.85)",405 )406 parser.add_argument(407 "--delight", action="store_true", help="Use delighting model."408 )409 parser.add_argument(410 "--no_smooth_texture",411 action="store_true",412 help="Do not smooth the texture.",413 )414 parser.add_argument(415 "--no_coor_trans",416 action="store_true",417 help="Do not transform the asset coordinate system.",418 )419 parser.add_argument(420 "--save_glb_path", type=str, default=None, help="Save glb path."421 )422 parser.add_argument("--n_max_faces", type=int, default=50000)423 args, unknown = parser.parse_known_args()424 425 return args426 427 428@spaces.GPU429def entrypoint(430 delight_model: DelightingModel = None,431 imagesr_model: ImageRealESRGAN = None,432 **kwargs,433) -> trimesh.Trimesh:434 """Entrypoint for texture backprojection from multi-view images.435 436 Args:437 delight_model (DelightingModel, optional): Delighting model.438 imagesr_model (ImageRealESRGAN, optional): Super-resolution model.439 **kwargs: Additional arguments to override CLI.440 441 Returns:442 trimesh.Trimesh: Textured mesh.443 """444 args = parse_args()445 for k, v in kwargs.items():446 if hasattr(args, k) and v is not None:447 setattr(args, k, v)448 449 # Setup camera parameters.450 camera_params = CameraSetting(451 num_images=args.num_images,452 elevation=args.elevation,453 distance=args.distance,454 resolution_hw=args.resolution_hw,455 fov=math.radians(args.fov),456 device=args.device,457 )458 459 # GS render.460 camera = init_kal_camera(camera_params, flip_az=True)461 matrix_mv = camera.view_matrix() # (n_cam 4 4) world2cam462 matrix_mv[:, :3, 3] = -matrix_mv[:, :3, 3]463 w2cs = matrix_mv.to(camera_params.device)464 c2ws = [torch.linalg.inv(matrix) for matrix in w2cs]465 Ks = torch.tensor(camera_params.Ks).to(camera_params.device)466 gs_model = load_gs_model(args.gs_path, pre_quat=[0.0, 0.0, 1.0, 0.0])467 multiviews = []468 for idx in tqdm(range(len(c2ws)), desc="Rendering GS"):469 result = gs_model.render(470 c2ws[idx],471 Ks=Ks,472 image_width=camera_params.resolution_hw[1],473 image_height=camera_params.resolution_hw[0],474 )475 color = cv2.cvtColor(result.rgba, cv2.COLOR_BGRA2RGBA)476 multiviews.append(Image.fromarray(color))477 478 if args.delight and delight_model is None:479 delight_model = DelightingModel()480 481 if args.delight:482 for idx in range(len(multiviews)):483 multiviews[idx] = delight_model(multiviews[idx])484 485 multiviews = [img.convert("RGB") for img in multiviews]486 487 mesh = trimesh.load(args.mesh_path)488 if isinstance(mesh, trimesh.Scene):489 mesh = mesh.dump(concatenate=True)490 491 vertices, scale, center = normalize_vertices_array(mesh.vertices)492 493 # Transform mesh coordinate system by default.494 if not args.no_coor_trans:495 x_rot = np.array([[1, 0, 0], [0, 0, 1], [0, -1, 0]])496 z_rot = np.array([[0, 1, 0], [-1, 0, 0], [0, 0, 1]])497 vertices = vertices @ x_rot498 vertices = vertices @ z_rot499 500 faces = mesh.faces.astype(np.int32)501 vertices = vertices.astype(np.float32)502 503 if not args.skip_fix_mesh:504 mesh_fixer = MeshFixer(vertices, faces, args.device)505 vertices, faces = mesh_fixer(506 filter_ratio=args.mesh_sipmlify_ratio,507 max_hole_size=0.04,508 resolution=1024,509 num_views=1000,510 norm_mesh_ratio=0.5,511 )512 if len(faces) > args.n_max_faces:513 mesh_fixer = MeshFixer(vertices, faces, args.device)514 vertices, faces = mesh_fixer(515 filter_ratio=max(0.1, args.mesh_sipmlify_ratio - 0.1),516 max_hole_size=0.04,517 resolution=1024,518 num_views=1000,519 norm_mesh_ratio=0.5,520 )521 522 vertices, faces, uvs = TextureBaker.parametrize_mesh(vertices, faces)523 texture_backer = TextureBaker(524 vertices,525 faces,526 uvs,527 camera_params,528 )529 530 multiviews = [np.array(img) for img in multiviews]531 texture = texture_backer.bake_texture(532 images=[img[..., :3] for img in multiviews],533 texture_size=args.texture_size,534 mode=args.baker_mode,535 opt_step=args.opt_step,536 )537 if not args.no_smooth_texture:538 texture = post_process_texture(texture)539 540 # Recover mesh original orientation, scale and center.541 if not args.no_coor_trans:542 vertices = vertices @ np.linalg.inv(z_rot)543 vertices = vertices @ np.linalg.inv(x_rot)544 vertices = vertices / scale545 vertices = vertices + center546 547 textured_mesh = save_mesh_with_mtl(548 vertices, faces, uvs, texture, args.output_path549 )550 if args.save_glb_path is not None:551 os.makedirs(os.path.dirname(args.save_glb_path), exist_ok=True)552 textured_mesh.export(args.save_glb_path)553 554 return textured_mesh555 556 557if __name__ == "__main__":558 entrypoint()559 