"""Wrapper for :vtk:`vtkTexture`.""" from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING import warnings import numpy as np import pyvista from pyvista.core.dataobject import DataObject from pyvista.core.utilities.fileio import _try_imageio_imread from pyvista.core.utilities.misc import AnnotatedIntEnum from . import _vtk if TYPE_CHECKING: from pyvista.core._typing_core import NumpyArray class Texture(DataObject, _vtk.vtkTexture): """Wrap :vtk:`vtkTexture`. Textures can be used to apply images to surfaces, as in the case of :ref:`texture_example`. They can also be used for environment textures to affect the lighting of the scene, or even as a environment cubemap as in the case of :ref:`pbr_example` and :ref:`planets_example`. Parameters ---------- uinput : str, :vtk:`vtkImageData`, :vtk:`vtkTexture`, sequence[ImageData], optional Filename, :vtk:`vtkImageData`, :vtk:`vtkTexture`, :class:`numpy.ndarray` or a sequence of images to create a cubemap. If a sequence of images, must be of the same size and in the following order: * +X * -X * +Y * -Y * +Z * -Z **kwargs : dict, optional Optional arguments when reading from a file. Generally unused. Examples -------- Load a texture from file. File should be a "image" or "image-like" file. >>> from pathlib import Path >>> import pyvista as pv >>> from pyvista import examples >>> path = examples.download_masonry_texture(load=False) >>> Path(path).name 'masonry.bmp' >>> texture = pv.Texture(path) >>> texture Texture (...) Components: 3 Cube Map: False Dimensions: 256, 256 Create a texture from an RGB array. Note how this is colored per "point" rather than per "pixel". >>> import numpy as np >>> arr = np.array( ... [ ... [255, 255, 255], ... [255, 0, 0], ... [0, 255, 0], ... [0, 0, 255], ... ], ... dtype=np.uint8, ... ) >>> arr = arr.reshape((2, 2, 3)) >>> texture = pv.Texture(arr) >>> texture.plot() Create a cubemap from 6 images. >>> px = examples.download_sky(direction='posx') # doctest:+SKIP >>> nx = examples.download_sky(direction='negx') # doctest:+SKIP >>> py = examples.download_sky(direction='posy') # doctest:+SKIP >>> ny = examples.download_sky(direction='negy') # doctest:+SKIP >>> pz = examples.download_sky(direction='posz') # doctest:+SKIP >>> nz = examples.download_sky(direction='negz') # doctest:+SKIP >>> texture = pv.Texture([px, nx, py, ny, pz, nz]) # doctest:+SKIP >>> texture.cube_map # doctest:+SKIP True """ class WrapType(AnnotatedIntEnum): """Types of wrapping a texture can support. Wrap mode for the texture coordinates valid values are: * CLAMP_TO_EDGE * REPEAT (Default in :class:`pyvista.Texture`) * MIRRORED_REPEAT * CLAMP_TO_BORDER See :attr:`Texture.wrap` for usage. """ CLAMP_TO_EDGE = (0, 'Clamp to edge') REPEAT = (1, 'Repeat') MIRRORED_REPEAT = (2, 'Mirrored repeat') CLAMP_TO_BORDER = (3, 'Clamp to border') def __init__(self, uinput=None, **kwargs): """Initialize the texture.""" super().__init__(uinput) if isinstance(uinput, _vtk.vtkTexture): self._from_texture(uinput) elif isinstance(uinput, np.ndarray): self._from_array(uinput) elif isinstance(uinput, _vtk.vtkImageData): self._from_image_data(uinput) elif isinstance(uinput, str): self._from_file(filename=uinput, **kwargs) elif isinstance(uinput, Sequence) and len(uinput) == 6: # Create a cubemap self.mipmap = True self.interpolate = True self.cube_map = True # Must be set prior to setting images # add each image to the cubemap for i, image in enumerate(uinput): if not isinstance(image, pyvista.ImageData): msg = ( 'If a sequence, the each item in the first argument must be a ' 'pyvista.ImageData' ) raise TypeError(msg) # must flip y for cubemap to display properly self.SetInputDataObject(i, image._flip_uniform(1)) elif uinput is None: pass else: msg = f'Cannot create a pyvista.Texture from ({type(uinput)})' raise TypeError(msg) def _from_file(self, filename, **kwargs): try: image = pyvista.read(filename, **kwargs) if image.n_points < 2: # pragma: no cover msg = 'Problem reading the image with VTK.' raise RuntimeError(msg) self._from_image_data(image) except (KeyError, ValueError, OSError): self._from_array(_try_imageio_imread(filename)) # pragma: no cover def _from_texture(self, texture): image = texture.GetInput() self._from_image_data(image) @property def interpolate(self) -> bool: # numpydoc ignore=RT01 """Return if interpolate is enabled or disabled. Examples -------- Show the masonry texture without interpolation. Here, we zoom to show the individual pixels. >>> from pyvista import examples >>> texture = examples.download_masonry_texture() >>> texture.interpolate = False >>> texture.plot(cpos='xy', zoom=3) Plot the same texture with interpolation. >>> texture.interpolate = True >>> texture.plot(cpos='xy', zoom=3) """ return bool(self.GetInterpolate()) @interpolate.setter def interpolate(self, value: bool): self.SetInterpolate(value) @property def mipmap(self) -> bool: # numpydoc ignore=RT01 """Return if mipmap is enabled or disabled.""" return bool(self.GetMipmap()) @mipmap.setter def mipmap(self, value: bool): self.SetMipmap(value) def _from_image_data(self, image): if not isinstance(image, pyvista.ImageData): image = pyvista.ImageData(image) self.SetInputDataObject(image) self.Update() def _from_array(self, image): """Create a texture from a np.ndarray.""" if image.ndim not in [2, 3]: # we support 2 [single component image] or 3 [e.g. rgb or rgba] dims msg = 'Input image must be nn by nm by RGB[A]' raise ValueError(msg) if image.ndim == 3: if image.shape[2] not in [1, 3, 4]: msg = 'Third dimension of the array must be of size 3 (RGB) or 4 (RGBA)' raise ValueError(msg) n_components = image.shape[2] elif image.ndim == 2: n_components = 1 grid = pyvista.ImageData(dimensions=(image.shape[1], image.shape[0], 1)) grid.point_data['Image'] = np.flip(image.swapaxes(0, 1), axis=1).reshape( (-1, n_components), order='F', ) grid.set_active_scalars('Image') self._from_image_data(grid) @property def repeat(self) -> bool: # numpydoc ignore=RT01 """Repeat the texture. This is provided for convenience and backwards compatibility. For new code, use :func:`Texture.wrap`. Examples -------- Load the masonry texture and create a simple :class:`pyvista.PolyData` with texture coordinates using :func:`pyvista.Plane`. By default the texture coordinates are between 0 and 1. Let's raise these values over 1 by multiplying them in place. This will allow us to wrap the texture. >>> import pyvista as pv >>> from pyvista import examples >>> texture = examples.download_masonry_texture() >>> plane = pv.Plane() >>> plane.active_texture_coordinates *= 2 This is the texture plotted with repeat set to ``False``. >>> texture.repeat = False >>> pl = pv.Plotter() >>> actor = pl.add_mesh(plane, texture=texture) >>> pl.camera.zoom('tight') >>> pl.show() This is the texture plotted with repeat set to ``True``. >>> texture.repeat = True >>> pl = pv.Plotter() >>> actor = pl.add_mesh(plane, texture=texture) >>> pl.camera.zoom('tight') >>> pl.show() """ return bool(self.GetRepeat()) @repeat.setter def repeat(self, flag: bool): self.SetRepeat(flag) def flip_x(self) -> Texture: """Flip the texture in the x direction. Returns ------- pyvista.Texture Flipped texture. Examples -------- >>> from pyvista import examples >>> texture = examples.download_puppy_texture() >>> flipped = texture.flip_x() >>> flipped.plot() """ return Texture(self.to_image()._flip_uniform(0)) # type: ignore[abstract] def flip_y(self) -> Texture: """Flip the texture in the y direction. Returns ------- pyvista.Texture Flipped texture. Examples -------- >>> from pyvista import examples >>> texture = examples.download_puppy_texture() >>> flipped = texture.flip_y() >>> flipped.plot() """ return Texture(self.to_image()._flip_uniform(1)) # type: ignore[abstract] def to_image(self): """Return the texture as an image. Returns ------- pyvista.ImageData Texture represented as a uniform grid. """ return self.GetInput() def to_array(self) -> NumpyArray[float]: """Return the texture as an array. Notes ----- The shape of the array's first two dimensions will be swapped. For example, a ``(300, 200)`` image will return an array of ``(200, 300)``. Returns ------- numpy.ndarray Texture as a numpy array. Examples -------- >>> from pyvista import examples >>> texture = examples.download_puppy_texture() >>> texture Texture (...) Components: 3 Cube Map: False Dimensions: 1600, 1200 >>> texture.to_array().shape (1200, 1600, 3) >>> texture.to_array().dtype dtype('uint8') """ return self.to_image().active_scalars.reshape( [*list(self.dimensions)[::-1], self.n_components] )[::-1] def rotate_cw(self) -> Texture: """Rotate this texture 90 degrees clockwise. Returns ------- pyvista.Texture Rotated texture. Examples -------- >>> from pyvista import examples >>> texture = examples.download_puppy_texture() >>> rotated = texture.rotate_cw() >>> rotated.plot() """ return Texture(np.rot90(self.to_array())) # type: ignore[abstract] def rotate_ccw(self) -> Texture: """Rotate this texture 90 degrees counter-clockwise. Returns ------- pyvista.Texture Rotated texture. Examples -------- >>> from pyvista import examples >>> texture = examples.download_puppy_texture() >>> rotated = texture.rotate_ccw() >>> rotated.plot() """ return Texture(np.rot90(self.to_array(), k=3)) # type: ignore[abstract] @property def cube_map(self) -> bool: # numpydoc ignore=RT01 """Return ``True`` if cube mapping is enabled and ``False`` otherwise.""" return self.GetCubeMap() @cube_map.setter def cube_map(self, flag: bool): self.SetCubeMap(flag) def copy(self): # type: ignore[override] """Make a copy of this texture. Returns ------- pyvista.Texture Copied texture. """ return Texture(self.to_image().copy()) # type: ignore[abstract] def to_skybox(self): """Return the texture as a :vtk:`vtkSkybox` if cube mapping is enabled. Returns ------- :vtk:`vtkSkybox` Skybox if cube mapping is enabled. Otherwise, ``None``. """ if self.cube_map: skybox = _vtk.vtkSkybox() skybox.SetTexture(self) return skybox return None def __repr__(self): """Return the object representation.""" return pyvista.DataSet.__repr__(self) # type: ignore[type-var] def _get_attrs(self): """Return the representation methods (internal helper).""" attrs = [] attrs.append(('Components', self.n_components, '{:d}')) attrs.append(('Cube Map', self.cube_map, '{:}')) attrs.append(('Dimensions', self.dimensions, '{:d}, {:d}')) # type: ignore[arg-type] return attrs @property def n_components(self) -> int: # numpydoc ignore=RT01 """Return the number of components in the image. In textures, 3 or 4 components are used for representing RGB and RGBA images. Examples -------- Show the number of components in the example masonry texture. >>> from pyvista import examples >>> texture = examples.download_masonry_texture() >>> texture.n_components 3 """ input_data = self.GetInput() if input_data is None: return 0 return input_data.GetPointData().GetScalars().GetNumberOfComponents() @property def dimensions(self) -> tuple[int, int]: # numpydoc ignore=RT01 """Dimensions of the texture. Examples -------- >>> from pyvista import examples >>> texture = examples.download_masonry_texture() >>> texture.dimensions (256, 256) """ input_data = self.GetInput() if input_data is None: return (0, 0) return input_data.GetDimensions()[:2] def plot(self, **kwargs): """Plot the texture as an image. If the texture is a cubemap, it will be displayed as a skybox with a sphere in the center reflecting the environment. Parameters ---------- **kwargs : dict, optional Optional keyworld arguments. See :func:`pyvista.plot`. Returns ------- pyvista.Actor | None See the returns section of :func:`pyvista.plot`. Examples -------- Plot a simple texture. >>> from pyvista import examples >>> texture = examples.download_masonry_texture() >>> texture.plot() Plot a cubemap as a skybox. >>> cube_map = examples.download_sky_box_cube_map() >>> cube_map.plot() """ if self.cube_map: return self._plot_skybox(**kwargs) kwargs.setdefault('zoom', 'tight') kwargs.setdefault('lighting', False) kwargs.setdefault('show_axes', False) kwargs.setdefault('show_scalar_bar', False) mesh = pyvista.Plane(i_size=self.dimensions[0], j_size=self.dimensions[1]) return mesh.plot(texture=self, **kwargs) def _plot_skybox(self, **kwargs): """Plot this texture as a skybox.""" cpos = kwargs.pop('cpos', 'xy') zoom = kwargs.pop('zoom', 0.5) show_axes = kwargs.pop('show_axes', True) lighting = kwargs.pop('lighting', None) pl = pyvista.Plotter(lighting=lighting) pl.add_actor(self.to_skybox()) pl.set_environment_texture(self, is_srgb=True) pl.add_mesh(pyvista.Sphere(), pbr=True, roughness=0.5, metallic=1.0) pl.camera_position = cpos pl.camera.zoom(zoom) if show_axes: pl.show_axes() pl.show(**kwargs) @property def wrap(self) -> Texture.WrapType: # numpydoc ignore=RT01 """Return or set the Wrap mode for the texture coordinates. Wrap mode for the texture coordinates valid values are: * ``0`` - CLAMP_TO_EDGE * ``1`` - REPEAT * ``2`` - MIRRORED_REPEAT * ``3`` - CLAMP_TO_BORDER Notes ----- CLAMP_TO_BORDER is not supported with OpenGL ES <= 3.2. Wrap will default to CLAMP_TO_EDGE if it is set to CLAMP_TO_BORDER in this case. Requires ``vtk`` v9.1.0 or newer. Examples -------- Load the masonry texture and create a simple :class:`pyvista.PolyData` with texture coordinates using :func:`pyvista.Plane`. By default the texture coordinates are between 0 and 1. Let's raise these values over 1 by multiplying them in place. This will allow us to wrap the texture. >>> import pyvista as pv >>> from pyvista import examples >>> texture = examples.download_masonry_texture() >>> plane = pv.Plane() >>> plane.active_texture_coordinates *= 2 Let's now set the texture wrap to clamp to edge and visualize it. >>> texture.wrap = pv.Texture.WrapType.CLAMP_TO_EDGE >>> pl = pv.Plotter() >>> actor = pl.add_mesh(plane, texture=texture) >>> pl.camera.zoom('tight') >>> pl.show() Here is the default repeat: >>> texture.wrap = pv.Texture.WrapType.REPEAT >>> pl = pv.Plotter() >>> actor = pl.add_mesh(plane, texture=texture) >>> pl.camera.zoom('tight') >>> pl.show() And here is mirrored repeat: >>> texture.wrap = pv.Texture.WrapType.MIRRORED_REPEAT >>> pl = pv.Plotter() >>> actor = pl.add_mesh(plane, texture=texture) >>> pl.camera.zoom('tight') >>> pl.show() Finally, this is clamp to border: >>> texture.wrap = pv.Texture.WrapType.CLAMP_TO_BORDER >>> pl = pv.Plotter() >>> actor = pl.add_mesh(plane, texture=texture) >>> pl.camera.zoom('tight') >>> pl.show() """ if not hasattr(self, 'GetWrap'): # pragma: no cover from pyvista.core.errors import VTKVersionError # noqa: PLC0415 msg = '`wrap` requires VTK v9.1.0 or newer.' raise VTKVersionError(msg) return Texture.WrapType(self.GetWrap()) # type: ignore[call-arg] @wrap.setter def wrap(self, value: Texture.WrapType | int): if not hasattr(self, 'SetWrap'): # pragma: no cover from pyvista.core.errors import VTKVersionError # noqa: PLC0415 msg = '`wrap` requires VTK v9.1.0 or newer.' raise VTKVersionError(msg) self.SetWrap(value) def to_grayscale(self) -> Texture: """Convert this texture as a single component (grayscale) texture. Returns ------- pyvista.Texture Texture converted to grayscale. If already grayscale, the original texture itself is returned. Notes ----- The transparency channel (if available) will be dropped. Follows the `CCIR 601 `_ luma calculation equation of ``Y = 0.299*R + 0.587*G + 0.114*B``. Examples -------- >>> from pyvista import examples >>> texture = examples.download_masonry_texture() >>> bw_texture = texture.to_grayscale() >>> bw_texture Texture (...) Components: 1 Cube Map: False Dimensions: 256, 256 >>> bw_texture.plot() """ if self.n_components == 1: return self.copy() data = self.to_array() r, g, b = data[..., 0], data[..., 1], data[..., 2] data = (0.299 * r + 0.587 * g + 0.114 * b).round().astype(np.uint8) return Texture(data) # type: ignore[abstract] def image_to_texture(image): """Convert :class:`pyvista.ImageData` to a :class:`pyvista.Texture`. Parameters ---------- image : pyvista.ImageData | :vtk:`vtkImageData` Image to convert. Returns ------- pyvista.Texture The texture. """ return Texture(image) # type: ignore[abstract] def numpy_to_texture(image): """Convert a NumPy image array to a :class:`pyvista.Texture`. Parameters ---------- image : numpy.ndarray Numpy image array. Texture datatype expected to be ``np.uint8``. Returns ------- pyvista.Texture PyVista texture. Examples -------- Create an all white texture. >>> import pyvista as pv >>> import numpy as np >>> tex_arr = np.ones((1024, 1024, 3), dtype=np.uint8) * 255 >>> tex = pv.numpy_to_texture(tex_arr) """ if image.dtype != np.uint8: image = image.astype(np.uint8) warnings.warn( 'Expected `image` dtype to be ``np.uint8``. `image` has been copied ' 'and converted to np.uint8.', UserWarning, ) return Texture(image) # type: ignore[abstract]