Source code for genesis.options.textures

import os
from functools import cached_property
from typing import Annotated, Sequence, Literal, Iterable, cast

import numpy as np
from PIL import Image
from pydantic import model_validator, computed_field, BeforeValidator, Field

import genesis as gs
import genesis.utils.mesh as mu
from genesis.typing import LaxUnitIntervalArrayType, LaxFArrayType, UnitIntervalArrayType, NDArrayType

from .options import Options


IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".hdr", ".exr")
HDR_EXTENSIONS = (".hdr", ".exr")


[docs]class Texture(Options): """ Base class for Genesis's texture objects. Note ---- This class should *not* be instantiated directly. """ def __init__(self, **data): super().__init__(**data) def check_dim(self, dim: int) -> "Texture | None": raise NotImplementedError def check_simplify(self) -> "Texture": raise NotImplementedError def apply_cutoff(self, cutoff: float) -> None: raise NotImplementedError @cached_property def is_black(self) -> bool: raise NotImplementedError @cached_property def requires_uv(self) -> bool: raise NotImplementedError
[docs]class ColorTexture(Texture): """ A texture that consists of a single color. Parameters ---------- color : list of float A list of color values, stored as tuple, supporting any number of channels within the range [0.0, 1.0]. Default is (1.0, 1.0, 1.0). """ color: LaxFArrayType = (1.0, 1.0, 1.0) def check_dim(self, dim: int) -> Texture | None: if len(self.color) > dim: self.color, res = self.color[:dim], self.color[dim] return ColorTexture(color=res) return None def check_simplify(self) -> "ColorTexture": return self def apply_cutoff(self, cutoff: float) -> None: if cutoff is None: return self.color = tuple(1.0 if c >= cutoff else 0.0 for c in self.color) @computed_field @cached_property def is_black(self) -> bool: assert gs.EPS is not None return all(c < gs.EPS for c in self.color) @computed_field @cached_property def requires_uv(self) -> bool: return False
[docs]class ImageTexture(Texture): """ A texture with a texture map (image). Parameters ---------- image_path : str, optional Path to the image file. image_array : np.ndarray, optional Image array. image_color : float or list of float, optional The factor that will be multiplied with the base color, stored as tuple. Default is None. encoding : str, optional The encoding way of the image. Possible values are ['srgb', 'linear']. Default is 'srgb'. - 'srgb': Encoding of some RGB images. - 'linear': All generic images, such as opacity, roughness and normal, should be encoded with 'linear'. """ image_path: str | None = None image_array: NDArrayType | None image_color: UnitIntervalArrayType encoding: Annotated[Literal["srgb", "linear"], BeforeValidator(lambda e: str(e).lower())] = "srgb" def __init__( self, *, image_path: str | None = None, image_array: np.ndarray | None = None, image_color: LaxUnitIntervalArrayType | float | None = None, encoding: str = "srgb", **data, ) -> None: super().__init__( image_path=image_path, image_array=image_array, image_color=image_color, encoding=encoding, **data, ) @model_validator(mode="before") @classmethod def _validate_and_load(cls, data: dict) -> dict: image_path, image_array = data.get("image_path"), data.get("image_array") if not ((image_path is not None) ^ (image_array is not None)): gs.raise_exception("Please set either `image_path` or `image_array`.") if image_path is not None: # Look for absolute image path if not os.path.exists(image_path): candidate_image_path = os.path.join(gs.utils.get_assets_dir(), image_path) if not os.path.exists(candidate_image_path): gs.raise_exception( f"File not found in either current directory or assets directory: '{image_path}'." ) image_path = candidate_image_path # Load image_path as actual image_array, unless for special texture images (e.g. `.hdr` and `.exr`) that # are only supported by Raytracer. if image_path.endswith(HDR_EXTENSIONS): data.setdefault("encoding", "linear") if data["encoding"] != "linear": gs.raise_exception("HDR images requires linear encoding.") if image_path.endswith((".exr")): image_path = mu.check_exr_compression(image_path) else: image_array = np.array(Image.open(image_path)) else: # Normalize image array if not isinstance(image_array, np.ndarray): gs.raise_exception("`image_array` needs to be a numpy array.") if image_array.dtype != np.uint8: if np.issubdtype(image_array.dtype, np.floating): if image_array.max() <= 1.0: image_array = (image_array * 255.0).round() image_array = np.clip(image_array, 0.0, 255.0).astype(np.uint8) elif np.issubdtype(image_array.dtype, np.integer): image_array = np.clip(image_array, 0, 255).astype(np.uint8) elif image_array.dtype == np.bool_: image_array = image_array.astype(np.uint8) * 255 else: gs.raise_exception( f"Unsupported image dtype {image_array.dtype}. Only uint8, integer, floating-point, or bool " "types are supported." ) data["image_path"], data["image_array"] = image_path, image_array # Resolve image color image_color = data.get("image_color") if image_array is None: channel = 3 elif image_array.ndim == 3: channel = image_array.shape[2] else: channel = 1 if image_color is None: image_color = 1.0 if isinstance(image_color, Iterable): image_color = tuple(image_color)[:channel] else: image_color = (image_color,) * channel data["image_color"] = image_color return data @computed_field @cached_property def is_black(self) -> bool: assert gs.EPS is not None assert self.image_color is not None if all(c < gs.EPS for c in self.image_color): return True assert self.image_array is not None if np.max(self.image_array) == 0: return True return False @computed_field @cached_property def requires_uv(self) -> bool: return True @computed_field @property def channel(self) -> int: if self.image_array is None: return 3 return self.image_array.shape[2] if self.image_array.ndim == 3 else 1 @computed_field @cached_property def mean_color(self) -> NDArrayType: if self.image_array is None: return np.ones(3, dtype=np.float16) return cast(np.ndarray, (np.mean(self.image_array, axis=(0, 1), dtype=np.float32) / 255).astype(np.float16)) def check_dim(self, dim: int) -> Texture | None: if self.image_array is not None: if self.channel > dim: self.image_array, res_array = self.image_array[:, :, :dim], self.image_array[:, :, dim] self.image_color, res_color = self.image_color[:dim], self.image_color[dim:] return ImageTexture(image_array=res_array, image_color=res_color, encoding="linear").check_simplify() return None def check_simplify(self) -> "ImageTexture | ColorTexture": if self.image_array is None: return self max_color = np.max(self.image_array, axis=(0, 1)) min_color = np.min(self.image_array, axis=(0, 1)) if np.all(min_color == max_color): return ColorTexture(color=max_color.reshape(-1) / 255.0 * self.image_color) return self def apply_cutoff(self, cutoff): if cutoff is None or self.image_array is None: # Cutoff does not apply on image file. return self.image_array = np.where(self.image_array >= 255.0 * cutoff, 255, 0).astype(np.uint8)
class BatchTexture(Texture): """ A batch of textures for batch rendering. Parameters ---------- textures : List[Optional[Texture]] List of textures. """ textures: Annotated[list[Texture | None], BeforeValidator(list)] = Field(default_factory=list) @staticmethod def from_images( image_paths: Sequence[str] | None = None, image_folder: str | None = None, image_arrays: Sequence[np.ndarray] | None = None, image_colors: Sequence[float] | Sequence[Sequence[float] | None] | None = None, encoding: Literal["srgb", "linear"] = "srgb", ) -> "BatchTexture": """ Create a batch texture from images. Parameters ---------- image_paths : List[str], optional List of paths to the image files. image_folder : str, optional Path to the image folder. image_arrays : List[np.ndarray], optional List of image arrays. image_colors : List[Union[float, List[float]]], optional List of color factors that will be multiplied with the base color, stored as tuple. Default is None. encoding : str, optional The encoding way of the image. Possible values are ['srgb', 'linear']. Default is 'srgb'. - 'srgb': Encoding of some RGB images. - 'linear': All generic images, such as opacity, roughness and normal, should be encoded with 'linear'. """ image_sources = (image_paths, image_folder, image_arrays) if sum(x is not None for x in image_sources) != 1: gs.raise_exception("Please set exactly one of `image_paths`, `image_folder`, `image_arrays`.") image_textures = [] if image_folder is not None: input_image_folder = image_folder if not os.path.exists(image_folder): image_folder = os.path.join(gs.utils.get_assets_dir(), image_folder) if not os.path.exists(image_folder): gs.raise_exception( f"Directory not found in either current directory or assets directory: '{input_image_folder}'." ) image_paths = [ os.path.join(image_folder, image_path) for image_path in sorted(os.listdir(image_folder)) if image_path.lower().endswith(IMAGE_EXTENSIONS) ] if image_paths is not None: num_images = len(image_paths) else: assert image_arrays is not None num_images = len(image_arrays) if num_images == 0: gs.raise_exception("No images found.") if image_colors is not None: if isinstance(image_colors[0], float): # List[float] image_colors = [image_colors for _ in range(num_images)] else: # List[List[float]] if len(image_colors) != num_images: gs.raise_exception("The number of image colors must be the same as the number of images.") else: image_colors = [None] * num_images assert image_colors is not None if image_paths is not None: for image_path, image_color in zip(image_paths, image_colors): image_textures.append(ImageTexture(image_path=image_path, image_color=image_color, encoding=encoding)) else: assert image_arrays is not None for image_array, image_color in zip(image_arrays, image_colors): image_textures.append(ImageTexture(image_array=image_array, image_color=image_color, encoding=encoding)) return BatchTexture(textures=image_textures) @staticmethod def from_colors( colors: Sequence[Sequence[float]], ) -> "BatchTexture": """ Create a batch texture from colors. Parameters ---------- colors : List[List[float]] List of colors. """ return BatchTexture(textures=[ColorTexture(color=color) for color in colors]) @computed_field @cached_property def is_black(self) -> bool: return all(texture is None or texture.is_black for texture in self.textures) @computed_field @cached_property def requires_uv(self) -> bool: return any(texture is not None and texture.requires_uv for texture in self.textures) def check_dim(self, dim: int) -> "BatchTexture": return BatchTexture( textures=[texture.check_dim(dim) if texture is not None else None for texture in self.textures] ).check_simplify() def check_simplify(self) -> "BatchTexture": self.textures = [texture.check_simplify() if texture is not None else None for texture in self.textures] return self def apply_cutoff(self, cutoff: float) -> None: for texture in self.textures: if texture is not None: texture.apply_cutoff(cutoff) def merge(self, other: "BatchTexture") -> None: self.textures.extend(other.textures)