#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
.py
~~~
PURPOSE
gloo.Program wrappers for different purposes such as tile drawing
REFERENCES
REQUIRES
:author: R.K.Garcia <rayg@ssec.wisc.edu>
:copyright: 2015 by University of Wisconsin Regents, see AUTHORS for more details
:license: GPLv3, see LICENSE for more details
"""
import logging
import os
import warnings
import numpy as np
from vispy.visuals._scalable_textures import GPUScaledTexture2D
from uwsift.common import DEFAULT_TILE_HEIGHT, DEFAULT_TILE_WIDTH
DEBUG_IMAGE_TILE = bool(os.environ.get("SIFT_DEBUG_TILES", False))
__author__ = "rayg"
__docformat__ = "reStructuredText"
LOG = logging.getLogger(__name__)
[docs]
class TextureAtlas2D(GPUScaledTexture2D):
"""A 2D Texture Array structure implemented as a 2D Texture Atlas."""
def __init__(self, texture_shape, tile_shape=(DEFAULT_TILE_HEIGHT, DEFAULT_TILE_WIDTH), **texture_kwargs):
# Number of tiles in each direction (y, x)
self.texture_shape = self._check_texture_shape(texture_shape)
# Number of rows and columns for each tile
self.tile_shape = tile_shape
# Number of rows and columns to hold all of these tiles in one texture
shape = (self.texture_shape[0] * self.tile_shape[0], self.texture_shape[1] * self.tile_shape[1])
if len(tile_shape) == 3:
shape = (shape[0], shape[1], tile_shape[2])
self.texture_size = shape
self._fill_array = np.tile(np.float32(np.nan), self.tile_shape)
# create a representative array so the texture can be initialized properly with the right dtype
rep_arr = (
np.zeros((10, 10, tile_shape[2]), dtype=np.float32)
if len(tile_shape) == 3
else np.zeros((10, 10), dtype=np.float32)
)
# will add self.shape:
super(TextureAtlas2D, self).__init__(data=rep_arr, **texture_kwargs)
# GPUScaledTexture2D always uses a "representative" size
# we need to force the shape to our final size so we can start setting tiles right away
self._resize(shape)
def _check_texture_shape(self, texture_shape):
if isinstance(texture_shape, tuple):
if len(texture_shape) != 2:
raise ValueError("A shape tuple must be two elements.")
texture_shape = texture_shape
else:
texture_shape = texture_shape.shape
return texture_shape
def _tex_offset(self, idx):
"""Return the X, Y texture index offset for the 1D tile index.
This class presents a 1D indexing scheme, but internally can hold multiple tiles in both X and Y direction.
"""
row = int(idx / self.texture_shape[1])
col = idx % self.texture_shape[1]
return row * self.tile_shape[0], col * self.tile_shape[1]
[docs]
def set_tile_data(self, tile_idx, data, copy=False):
"""Write a single tile of data into the texture."""
offset = self._tex_offset(tile_idx)
if data is None:
# Special "fill" parameter
data = self._fill_array
else:
# FIXME: Doesn't this always return the shape of the input data?
tile_offset = (min(self.tile_shape[0], data.shape[0]), min(self.tile_shape[1], data.shape[1]))
if tile_offset[0] < self.tile_shape[0] or tile_offset[1] < self.tile_shape[1]:
# FIXME: This should be handled by the caller to expand the array to be NaN filled and aligned
# Assign a fill value, make sure to copy the data so that we don't overwrite the original
data_orig = data
data = np.zeros(self.tile_shape, dtype=data.dtype)
# data = data.copy()
data[:] = np.nan
data[: tile_offset[0], : tile_offset[1]] = data_orig[: tile_offset[0], : tile_offset[1]]
if DEBUG_IMAGE_TILE:
data[:5, :] = 1000.0
data[-5:, :] = 1000.0
data[:, :5] = 1000.0
data[:, -5:] = 1000.0
super(TextureAtlas2D, self).scale_and_set_data(data, offset=offset, copy=copy)
[docs]
class MultiChannelGPUScaledTexture2D:
"""Wrapper class around individual textures.
This helper class allows for easier handling of multiple textures that
represent individual R, G, and B channels of an image.
"""
_singular_texture_class = GPUScaledTexture2D
_ndim = 2
def __init__(self, data, **texture_kwargs):
# data to sent to texture when not being used
self._fill_arr = np.full((10, 10), np.float32(np.nan), dtype=np.float32)
self.num_channels = len(data)
data = [x if x is not None else self._fill_arr for x in data]
self._textures = self._create_textures(self.num_channels, data, **texture_kwargs)
def _create_textures(self, num_channels, data, **texture_kwargs):
return [self._singular_texture_class(data[i], **texture_kwargs) for i in range(num_channels)]
@property
def textures(self):
return self._textures
@property
def clim(self):
"""Get color limits used when rendering the image (cmin, cmax)."""
return tuple(t.clim for t in self._textures)
[docs]
def set_clim(self, clim):
if isinstance(clim, str) or len(clim) == 2:
clim = [clim] * self.num_channels
need_tex_upload = False
for tex, single_clim in zip(self._textures, clim):
if single_clim is None or single_clim[0] is None:
single_clim = (0, 0) # let VisPy decide what to do with unusable clims
if tex.set_clim(single_clim):
need_tex_upload = True
return need_tex_upload
@property
def clim_normalized(self):
return tuple(tex.clim_normalized for tex in self._textures)
@property
def internalformat(self):
return self._textures[0].internalformat
@internalformat.setter
def internalformat(self, value):
for tex in self._textures:
tex.internalformat = value
@property
def interpolation(self):
return self._textures[0].interpolation
@interpolation.setter
def interpolation(self, value):
for _ in self._textures:
self._texture.interpolation = value
[docs]
def check_data_format(self, data_arrays):
if len(data_arrays) != self.num_channels:
raise ValueError(f"Expected {self.num_channels} number of channels, got {len(data_arrays)}.")
for tex, data in zip(self._textures, data_arrays):
if data is not None:
tex.check_data_format(data)
[docs]
def scale_and_set_data(self, data, offset=None, copy=False):
"""Scale and set data for one or all sub-textures.
Parameters
----------
data : list | ndarray
Texture data in the form of a numpy array or as a list of numpy
arrays. If a list is provided then it must be the same length as
``num_channels`` for this texture. If a numpy array is provided
then ``offset`` should also be provided with the first value
representing which sub-texture to update. For example,
``offset=(1, 0, 0)`` would update the entire the second (index 1)
sub-texture with an offset of ``(0, 0)``. The list can also contain
``None`` to not update the sub-texture at that index.
offset: tuple | None
Offset into the texture where to write the provided data. If
``None`` then data will be written with no offset (0). If
provided as a 2-element tuple then that offset will be used
for all sub-textures. If a 3-element tuple then the first offset
index represents the sub-texture to update.
"""
is_multi = isinstance(data, (list, tuple))
index_provided = offset is not None and len(offset) == self._ndim + 1
if not is_multi and not index_provided:
raise ValueError(
"Setting texture data for a single sub-texture "
"requires 'offset' to be passed with the first "
"element specifying the sub-texture index."
)
elif is_multi and index_provided:
warnings.warn(
"Multiple texture arrays were passed, but so was "
"sub-texture index in 'offset'. Ignoring that index.",
UserWarning,
stacklevel=4,
)
offset = offset[1:]
if is_multi and len(data) != self.num_channels:
raise ValueError(
"Multiple provided arrays must match number of channels. "
f"Got {len(data)}, expected {self.num_channels}."
)
if offset is not None and len(offset) == self._ndim + 1:
tex_indexes = offset[:1]
offset = offset[1:]
data = [data]
else:
tex_indexes = range(self.num_channels)
for tex_idx, _data in zip(tex_indexes, data):
if _data is None:
_data = self._fill_arr
self._textures[tex_idx].scale_and_set_data(_data, offset=offset, copy=copy)
[docs]
class MultiChannelTextureAtlas2D(MultiChannelGPUScaledTexture2D):
"""Helper texture for working with RGB images in SIFT."""
_singular_texture_class = TextureAtlas2D
[docs]
def set_tile_data(self, tile_idx, data_arrays, copy=False):
for idx, data in enumerate(data_arrays):
self._textures[idx].set_tile_data(tile_idx, data, copy=copy)