Skip to content

Commit 59fd57f

Browse files
ACMLCZHduburcqaSonSanghughperkins
authored
[Feature] Support Batch Textures (#2077)
* update batch texture * update batch texture * [BUG FIX] Fix URDF color overwrite. (#2065) * Fix URDF color overwrite. * Fix zero-copy being disabled for numpy. * Add warning when enabling requires_grad while using GsTaichi dynamic arrays. * More robust unit tests. * [BUG FIX] Fix differentiable simulation support for ndarray. (#2068) * Fix differentiable simulation support for ndarray. * Fix shape issue with zero-copy for matrix field of shape Nx1. * [MISC] Avoid performance penalty due to differentiability if disabled. (#2063) * [MISC] Fix fastcache and zero-copy bugs. (#2050) * Migrate to gstaichi 4.3.1. * Enable zero-copy for ti.field except on Metal. * Re-enable fastcache on CI. * Get around memory initialization issue with field. * Fix shape issue with zero-copy for matrix field of shape Nx1. * Forcibly disable debug more when gradient is required. * Add marker to set debug mode on each specific unit test. * Fix missing copy. * Fix collider state not reset at init. * Fix unit test not supported on ARM runner. --------- Co-authored-by: Alexis Duburcq <alexis.duburcq@gmail.com> * [MISC] Bump Genesis version (v0.3.8). (#2073) * [BUG FIX] Fix increased memory usage due to differentiable simulation. (#2074) * Fix increased memory usage due to differentiable simulation. * Cleanup differentiable rigid sim. * [MISC] Speed up torch-based geom utils via 'torch.jit.script'. (#2075) * More efficient GO2 env implementation. * Speed up torch-based geom utils via 'torch.jit.script'. * add unittest * update unittest * Update single_franka_batch_render.py * Update pyproject.toml * update snapshots * Update image_exporter.py * Delete utils_old.py * update snapshots * Update test_render.py * debug * update sorted textures * debug(2) * debug(3) * debug(2) * clear * Update conftest.py * update review * update test_render * Fallback to texture assets --------- Co-authored-by: Alexis DUBURCQ <alexis.duburcq@gmail.com> Co-authored-by: Sanghyun Son <shh1295@gmail.com> Co-authored-by: Hugh Perkins <hughperkins@gmail.com>
1 parent bda9e0b commit 59fd57f

File tree

9 files changed

+392
-143
lines changed

9 files changed

+392
-143
lines changed

examples/rigid/single_franka_batch_render.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def main():
3535
########################## entities ##########################
3636
plane = scene.add_entity(
3737
gs.morphs.Plane(),
38+
surface=gs.surfaces.Default(diffuse_texture=gs.textures.BatchTexture.from_images(image_folder="textures")),
3839
)
3940
franka = scene.add_entity(
4041
gs.morphs.MJCF(file="xml/franka_emika_panda/panda.xml"),

genesis/options/surfaces.py

Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from typing import Optional
2+
import math
23

34
import numpy as np
45

56
import genesis as gs
67

78
from .misc import FoamOptions
89
from .options import Options
9-
from .textures import ColorTexture, ImageTexture, Texture
10+
from .textures import Texture, ColorTexture, ImageTexture, BatchTexture
1011

1112

1213
############################ Base ############################
@@ -215,52 +216,76 @@ def update_texture(
215216
self.double_sided = double_sided
216217

217218
def requires_uv(self):
218-
return (
219-
isinstance(self.get_texture(), ImageTexture)
220-
or isinstance(self.opacity_texture, ImageTexture)
221-
or isinstance(self.roughness_texture, ImageTexture)
222-
or isinstance(self.metallic_texture, ImageTexture)
223-
or isinstance(self.normal_texture, ImageTexture)
224-
or isinstance(self.emissive_texture, ImageTexture)
219+
textures = (
220+
self.get_texture(),
221+
self.opacity_texture,
222+
self.roughness_texture,
223+
self.metallic_texture,
224+
self.normal_texture,
225+
self.emissive_texture,
225226
)
227+
return any(texture is not None and texture.requires_uv() for texture in textures)
228+
229+
def get_rgba(self, batch=False):
230+
all_textures = []
231+
for texture in (
232+
self.get_texture() if self.emissive_texture is None else self.emissive_texture,
233+
self.opacity_texture,
234+
):
235+
textures = texture.textures if isinstance(texture, BatchTexture) else [texture]
236+
all_textures.append(textures if batch else textures[:1])
237+
color_textures, opacity_textures = all_textures
238+
239+
rgba_textures = []
240+
num_colors = len(color_textures)
241+
num_opacities = len(opacity_textures)
242+
num_rgba = num_colors * num_opacities // math.gcd(num_colors, num_opacities)
243+
244+
for i in range(num_rgba):
245+
color_texture = color_textures[i % num_colors]
246+
opacity_texture = opacity_textures[i % num_opacities]
247+
248+
if isinstance(color_texture, ColorTexture):
249+
if isinstance(opacity_texture, ColorTexture):
250+
rgba_texture = ColorTexture(color=(*color_texture.color, *opacity_texture.color))
251+
elif isinstance(opacity_texture, ImageTexture) and opacity_texture.image_array is not None:
252+
rgb_color = np.round(np.array(color_texture.color) * 255).astype(np.uint8)
253+
rgb_array = np.full((*opacity_texture.image_array.shape[:2], 3), rgb_color, dtype=np.uint8)
254+
rgba_array = np.dstack((rgb_array, opacity_texture.image_array))
255+
rgba_scale = (1.0, 1.0, 1.0, *opacity_texture.image_color)
256+
rgba_texture = ImageTexture(image_array=rgba_array, image_color=rgba_scale)
257+
else:
258+
rgba_texture = ColorTexture(color=(*color_texture.color, 1.0))
259+
260+
elif isinstance(color_texture, ImageTexture) and color_texture.image_array is not None:
261+
if isinstance(opacity_texture, ColorTexture):
262+
a_color = np.round(np.array(opacity_texture.color) * 255).astype(np.uint8)
263+
a_array = np.full((*color_texture.image_array.shape[:2],), a_color, dtype=np.uint8)
264+
rgba_array = np.dstack((color_texture.image_array, a_array))
265+
rgba_scale = (*color_texture.image_color, 1.0)
266+
elif (
267+
isinstance(opacity_texture, ImageTexture)
268+
and opacity_texture.image_array is not None
269+
and opacity_texture.image_array.shape[:2] == color_texture.image_array.shape[:2]
270+
):
271+
rgba_array = np.dstack((color_texture.image_array, opacity_texture.image_array))
272+
rgba_scale = (*color_texture.image_color, *opacity_texture.image_color)
273+
else:
274+
if isinstance(opacity_texture, ImageTexture) and opacity_texture.image_array is not None:
275+
gs.logger.warning(
276+
"Color and opacity image shapes do not match. Fall back to fully opaque texture."
277+
)
278+
a_array = np.full(color_texture.image_array.shape[:2], 255, dtype=np.uint8)
279+
rgba_array = np.dstack((color_texture.image_array, a_array))
280+
rgba_scale = (*color_texture.image_color, 1.0)
281+
rgba_texture = ImageTexture(image_array=rgba_array, image_color=rgba_scale)
226282

227-
def get_rgba(self):
228-
texture = self.get_texture() if self.emissive_texture is None else self.emissive_texture
229-
opacity_texture = self.opacity_texture
230-
231-
if isinstance(texture, ColorTexture):
232-
if isinstance(opacity_texture, ColorTexture):
233-
return ColorTexture(color=(*texture.color, *opacity_texture.color))
234-
if isinstance(opacity_texture, ImageTexture) and opacity_texture.image_array is not None:
235-
rgb_color = np.round(np.array(texture.color) * 255).astype(np.uint8)
236-
rgb_array = np.full((*opacity_texture.image_array.shape[:2], 3), rgb_color, dtype=np.uint8)
237-
rgba_array = np.dstack((rgb_array, opacity_texture.image_array))
238-
rgba_scale = (1.0, 1.0, 1.0, *opacity_texture.image_color)
239-
return ImageTexture(image_array=rgba_array, image_color=rgba_scale)
240-
return ColorTexture(color=(*texture.color, 1.0))
241-
242-
if isinstance(texture, ImageTexture) and texture.image_array is not None:
243-
if isinstance(opacity_texture, ColorTexture):
244-
a_color = np.round(np.array(opacity_texture.color) * 255).astype(np.uint8)
245-
a_array = np.full((*texture.image_array.shape[:2],), a_color, dtype=np.uint8)
246-
rgba_array = np.dstack((texture.image_array, a_array))
247-
rgba_scale = (*texture.image_color, 1.0)
248-
elif (
249-
isinstance(opacity_texture, ImageTexture)
250-
and opacity_texture.image_array is not None
251-
and opacity_texture.image_array.shape[:2] == texture.image_array.shape[:2]
252-
):
253-
rgba_array = np.dstack((texture.image_array, opacity_texture.image_array))
254-
rgba_scale = (*texture.image_color, *opacity_texture.image_color)
255283
else:
256-
if isinstance(opacity_texture, ImageTexture) and opacity_texture.image_array is not None:
257-
gs.logger.warning("Color and opacity image shapes do not match. Fall back to fully opaque texture.")
258-
a_array = np.full(texture.image_array.shape[:2], 255, dtype=np.uint8)
259-
rgba_array = np.dstack((texture.image_array, a_array))
260-
rgba_scale = (*texture.image_color, 1.0)
261-
return ImageTexture(image_array=rgba_array, image_color=rgba_scale)
262-
263-
return ColorTexture(color=(1.0, 1.0, 1.0, 1.0))
284+
rgba_texture = ColorTexture(color=(1.0, 1.0, 1.0, 1.0))
285+
286+
rgba_textures.append(rgba_texture)
287+
288+
return BatchTexture(textures=rgba_textures) if batch else rgba_textures[0]
264289

265290
def set_texture(self, texture):
266291
raise NotImplementedError

genesis/options/textures.py

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import os
22
from typing import Optional, List, Union
33

4-
import Imath
54
import numpy as np
6-
import OpenEXR
75
from PIL import Image
86

97
import genesis as gs
108
import genesis.utils.mesh as mu
119

1210
from .options import Options
1311

12+
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".hdr", ".exr")
13+
HDR_EXTENSIONS = (".hdr", ".exr")
14+
1415

1516
class Texture(Options):
1617
"""
@@ -36,6 +37,9 @@ def apply_cutoff(self, cutoff):
3637
def is_black(self):
3738
raise NotImplementedError
3839

40+
def requires_uv(self):
41+
raise NotImplementedError
42+
3943

4044
class ColorTexture(Texture):
4145
"""
@@ -74,6 +78,9 @@ def apply_cutoff(self, cutoff):
7478
def is_black(self):
7579
return all(c < gs.EPS for c in self.color)
7680

81+
def requires_uv(self):
82+
return False
83+
7784

7885
class ImageTexture(Texture):
7986
"""
@@ -119,7 +126,7 @@ def __init__(self, **data):
119126
)
120127

121128
# Load image_path as actual image_array, unless for special texture images (e.g. `.hdr` and `.exr`) that are only supported by raytracers
122-
if self.image_path.endswith((".hdr", ".exr")):
129+
if self.image_path.endswith(HDR_EXTENSIONS):
123130
self.encoding = "linear" # .exr or .hdr images should be encoded with 'linear'
124131
if self.image_path.endswith((".exr")):
125132
self.image_path = mu.check_exr_compression(self.image_path)
@@ -186,16 +193,139 @@ def check_simplify(self):
186193
else:
187194
return self
188195

196+
def apply_cutoff(self, cutoff):
197+
if cutoff is None or self.image_array is None: # Cutoff does not apply on image file.
198+
return
199+
self.image_array = np.where(self.image_array >= 255.0 * cutoff, 255, 0).astype(np.uint8)
200+
201+
def is_black(self):
202+
return all(c < gs.EPS for c in self.image_color) or np.max(self.image_array) == 0
203+
204+
def requires_uv(self):
205+
return True
206+
189207
def mean_color(self):
190208
return self._mean_color
191209

192210
def channel(self):
193211
return self._channel
194212

213+
214+
class BatchTexture(Texture):
215+
"""
216+
A batch of textures for batch rendering.
217+
218+
Parameters
219+
----------
220+
textures : List[Optional[Texture]]
221+
List of textures.
222+
"""
223+
224+
textures: Optional[List[Optional[Texture]]]
225+
226+
def __init__(self, **data):
227+
super().__init__(**data)
228+
229+
@staticmethod
230+
def from_images(
231+
image_paths: Optional[List[str]] = None,
232+
image_folder: Optional[str] = None,
233+
image_arrays: Optional[List[np.ndarray]] = None,
234+
image_colors: Optional[Union[List[float], List[List[float]]]] = None,
235+
encoding: str = "srgb",
236+
):
237+
"""
238+
Create a batch texture from images.
239+
240+
Parameters
241+
----------
242+
image_paths : List[str], optional
243+
List of paths to the image files.
244+
image_folder : str, optional
245+
Path to the image folder.
246+
image_arrays : List[np.ndarray], optional
247+
List of image arrays.
248+
image_colors : List[Union[float, List[float]]], optional
249+
List of color factors that will be multiplied with the base color, stored as tuple. Default is None.
250+
encoding : str, optional
251+
The encoding way of the image. Possible values are ['srgb', 'linear']. Default is 'srgb'.
252+
253+
- 'srgb': Encoding of some RGB images.
254+
- 'linear': All generic images, such as opacity, roughness and normal, should be encoded with 'linear'.
255+
"""
256+
image_sources = (image_paths, image_folder, image_arrays)
257+
if sum(x is not None for x in image_sources) != 1:
258+
gs.raise_exception("Please set exactly one of `image_paths`, `image_folder`, `image_arrays`.")
259+
260+
image_textures = []
261+
if image_folder is not None:
262+
input_image_folder = image_folder
263+
if not os.path.exists(image_folder):
264+
image_folder = os.path.join(gs.utils.get_assets_dir(), image_folder)
265+
if not os.path.exists(image_folder):
266+
gs.raise_exception(
267+
f"Directory not found in either current directory or assets directory: '{input_image_folder}'."
268+
)
269+
image_paths = [
270+
os.path.join(image_folder, image_path)
271+
for image_path in sorted(os.listdir(image_folder))
272+
if image_path.lower().endswith(IMAGE_EXTENSIONS)
273+
]
274+
275+
num_images = len(image_paths) if image_paths is not None else len(image_arrays)
276+
if num_images == 0:
277+
gs.raise_exception("No images found.")
278+
279+
if image_colors is not None:
280+
if isinstance(image_colors[0], float): # List[float]
281+
image_colors = [image_colors for _ in range(num_images)]
282+
else: # List[List[float]]
283+
if len(image_colors) != num_images:
284+
gs.raise_exception("The number of image colors must be the same as the number of images.")
285+
else:
286+
image_colors = [None] * num_images
287+
288+
if image_paths is not None:
289+
for image_path, image_color in zip(image_paths, image_colors):
290+
image_textures.append(ImageTexture(image_path=image_path, image_color=image_color, encoding=encoding))
291+
else:
292+
for image_array, image_color in zip(image_arrays, image_colors):
293+
image_textures.append(ImageTexture(image_array=image_array, image_color=image_color, encoding=encoding))
294+
295+
return BatchTexture(textures=image_textures)
296+
297+
@staticmethod
298+
def from_colors(
299+
colors: List[List[float]],
300+
):
301+
"""
302+
Create a batch texture from colors.
303+
304+
Parameters
305+
----------
306+
colors : List[List[float]]
307+
List of colors.
308+
"""
309+
color_textures = [ColorTexture(color=color) for color in colors]
310+
return BatchTexture(textures=color_textures)
311+
312+
def check_dim(self, dim):
313+
return BatchTexture(textures=[texture.check_dim(dim) for texture in self.textures]).check_simplify()
314+
315+
def check_simplify(self):
316+
self.textures = [texture.check_simplify() if texture is not None else None for texture in self.textures]
317+
return self
318+
195319
def apply_cutoff(self, cutoff):
196-
if cutoff is None or self.image_array is None: # Cutoff does not apply on image file.
197-
return
198-
self.image_array = np.where(self.image_array >= 255.0 * cutoff, 255, 0).astype(np.uint8)
320+
for texture in self.textures:
321+
if texture is not None:
322+
texture.apply_cutoff(cutoff)
199323

200324
def is_black(self):
201-
return all(c < gs.EPS for c in self.image_color) or np.max(self.image_array) == 0
325+
return all(texture.is_black() if texture is not None else True for texture in self.textures)
326+
327+
def requires_uv(self):
328+
return any(texture is not None and texture.requires_uv() for texture in self.textures)
329+
330+
def merge(self, other: "BatchTexture"):
331+
self.textures.extend(other.textures)

0 commit comments

Comments
 (0)