diff --git a/genesis/engine/mesh.py b/genesis/engine/mesh.py index f6a7ca0893..1f7f2cc5b9 100644 --- a/genesis/engine/mesh.py +++ b/genesis/engine/mesh.py @@ -232,6 +232,7 @@ def from_trimesh( must_update_surface = True roughness_factor = None color_image = None + color_image_path = None color_factor = None opacity = 1.0 @@ -254,6 +255,8 @@ def from_trimesh( elif isinstance(material, trimesh.visual.material.SimpleMaterial): if material.image is not None: color_image = mu.PIL_to_array(material.image) + # Check if the material has stored the original image path + color_image_path = material.kwargs.get("image_path", None) elif material.diffuse is not None: color_factor = tuple(np.array(material.diffuse, dtype=np.float32) / 255.0) @@ -284,7 +287,7 @@ def from_trimesh( color_factor = (1.0, 1.0, 1.0, 1.0) if must_update_surface: - color_texture = mu.create_texture(color_image, color_factor, "srgb") + color_texture = mu.create_texture(color_image, color_factor, "srgb", image_path=color_image_path) opacity_texture = None if color_texture is not None: opacity_texture = color_texture.check_dim(3) @@ -340,6 +343,9 @@ def from_morph_surface(cls, morph, surface=None): if isinstance(morph, gs.options.morphs.Mesh): if morph.is_format(gs.options.morphs.MESH_FORMATS): meshes = mu.parse_mesh_trimesh(morph.file, morph.group_by_material, morph.scale, surface) + if morph.parse_glb_with_zup: + for mesh in meshes: + mesh.convert_to_zup() elif morph.is_format(gs.options.morphs.GLTF_FORMATS): if morph.parse_glb_with_trimesh: meshes = mu.parse_mesh_trimesh(morph.file, morph.group_by_material, morph.scale, surface) diff --git a/genesis/engine/sensors/camera.py b/genesis/engine/sensors/camera.py index 11725c782d..8c296d5a55 100644 --- a/genesis/engine/sensors/camera.py +++ b/genesis/engine/sensors/camera.py @@ -96,6 +96,9 @@ def __init__(self, sensor: "BatchRendererCameraSensor"): self.idx = len(sensor._shared_metadata.sensors) # Camera index in batch self.debug = False + # Camera model attribute for BatchRenderer.build() compatibility + self.model = getattr(sensor._options, "model", "pinhole") + # Initial pose pos = torch.tensor(sensor._options.pos, dtype=gs.tc_float, device=gs.device) lookat = torch.tensor(sensor._options.lookat, dtype=gs.tc_float, device=gs.device) diff --git a/genesis/options/textures.py b/genesis/options/textures.py index 985c28fdcc..402df7abe6 100644 --- a/genesis/options/textures.py +++ b/genesis/options/textures.py @@ -112,7 +112,8 @@ class Config: def __init__(self, **data): super().__init__(**data) - if not (self.image_path is None) ^ (self.image_array is None): + # Allow both image_path and image_array, but require at least one + if self.image_path is None and self.image_array is None: gs.raise_exception("Please set either `image_path` or `image_array`.") if self.image_path is not None: @@ -121,17 +122,21 @@ def __init__(self, **data): self.image_path = os.path.join(gs.utils.get_assets_dir(), self.image_path) if not os.path.exists(self.image_path): - gs.raise_exception( - f"File not found in either current directory or assets directory: '{input_image_path}'." - ) + if self.image_array is None: + # Only error if we don't have image_array as fallback + gs.raise_exception( + f"File not found in either current directory or assets directory: '{input_image_path}'." + ) - # Load image_path as actual image_array, unless for special texture images (e.g. `.hdr` and `.exr`) that are only supported by raytracers - if self.image_path.endswith(HDR_EXTENSIONS): - self.encoding = "linear" # .exr or .hdr images should be encoded with 'linear' - if self.image_path.endswith((".exr")): - self.image_path = mu.check_exr_compression(self.image_path) - else: - self.image_array = np.array(Image.open(self.image_path)) + # Only load image if we don't already have image_array + if self.image_array is None: + # Load image_path as actual image_array, unless for special texture images (e.g. `.hdr` and `.exr`) that are only supported by raytracers + if self.image_path.endswith(HDR_EXTENSIONS): + self.encoding = "linear" # .exr or .hdr images should be encoded with 'linear' + if self.image_path.endswith((".exr")): + self.image_path = mu.check_exr_compression(self.image_path) + else: + self.image_array = np.array(Image.open(self.image_path)) elif self.image_array is not None: if not isinstance(self.image_array, np.ndarray): diff --git a/genesis/utils/mesh.py b/genesis/utils/mesh.py index c353b4d26d..b347177031 100644 --- a/genesis/utils/mesh.py +++ b/genesis/utils/mesh.py @@ -207,11 +207,12 @@ def surface_uvs_to_trimesh_visual(surface, uvs=None, n_verts=None): uvs = uvs.copy() uvs[:, 1] = 1.0 - uvs[:, 1] assert texture.image_array.dtype == np.uint8 + material_kwargs = {"image": Image.fromarray(texture.image_array), "diffuse": (1.0, 1.0, 1.0, 1.0)} + if texture.image_path is not None: + material_kwargs["image_path"] = texture.image_path visual = trimesh.visual.TextureVisuals( uv=uvs, - material=trimesh.visual.material.SimpleMaterial( - image=Image.fromarray(texture.image_array), diffuse=(1.0, 1.0, 1.0, 1.0) - ), + material=trimesh.visual.material.SimpleMaterial(**material_kwargs), ) else: # fall back to color texture @@ -499,9 +500,14 @@ def tonemapped(image): return (np.clip(np.power(image / 255 * np.power(2, exposure), 1 / 2.2), 0, 1) * 255).astype(np.uint8) -def create_texture(image, factor, encoding): +def create_texture(image, factor, encoding, image_path=None): if image is not None: - return gs.textures.ImageTexture(image_array=image, image_color=factor, encoding=encoding) + return gs.textures.ImageTexture( + image_array=image, + image_path=image_path, + image_color=factor, + encoding=encoding, + ) if factor is not None: return gs.textures.ColorTexture(color=factor) return None @@ -928,6 +934,7 @@ def create_plane(normal=(0.0, 0.0, 1.0), plane_size=(1e3, 1e3), tile_size=(1, 1) ), material=trimesh.visual.material.SimpleMaterial( image=Image.open(os.path.join(get_assets_dir(), "textures/checker.png")), + image_path="textures/checker.png", ), ) else: diff --git a/tests/test_mesh.py b/tests/test_mesh.py index a67dfca91a..2cc8988b7a 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -510,6 +510,28 @@ def test_2_channels_luminance_alpha_textures(show_viewer): ) ) +@pytest.mark.required +def test_plane_texture_path_preservation(show_viewer): + """Test that plane primitives preserve texture paths through the mesh pipeline.""" + scene = gs.Scene(show_viewer=show_viewer, show_FPS=False) + plane = scene.add_entity(gs.morphs.Plane()) + scene.build() + + # Check that the plane's vgeom has a texture with a path + assert len(plane.vgeoms) > 0 + vgeom = plane.vgeoms[0] + assert vgeom.vmesh is not None + assert vgeom.vmesh.surface is not None + + texture = vgeom.vmesh.surface.diffuse_texture + assert texture is not None + assert isinstance(texture, gs.textures.ImageTexture) + + # The texture should have both image_path and image_array + assert texture.image_path is not None + assert texture.image_array is not None + assert "checker.png" in texture.image_path + @pytest.mark.required @pytest.mark.skipif(platform.machine() == "aarch64", reason="Module 'tetgen' is crashing on Linux ARM.")