Unverified Commit 1bcd9480 authored by David Hoese's avatar David Hoese Committed by GitHub
Browse files

Update to work with VisPy 0.7 (#312)



* Update minimum required vispy version to 0.7

Includes fixes for removed vispy modules

* Update TiledGeolocatedImageVisual to work with VisPy 0.7

* Remove unused shader code for basic Image layer

* Update CompositeLayerVisual to work with VisPy 0.7

* Refactor custom ImageVisuals to prepare for VisPy-compatible MultiBandImageVisual

* Remove unnecessary _MultiBandTextureAtlas2D delegate/wrapper class

* Extract non-SIFT-specific MultiChannelImageVisual stuff to subclasses and mixins

This should allow us to move the MultiChannelImageVisual to vispy-core in the future

* Add vispy-compatible tests for MultiChannelImageVisual

* Fix unnecessary ArrayProxy usage

* Refactor fill array for MultiChannelImageVisual

* Fix flake8 issues

* Use QtWidgets instead of QtGui for some base classes and decorators

* Fix two additional QtGui->QtWidgets misattributions

Co-authored-by: default avatarR.K.Garcia <r.keoni@gmail.com>
parent 6795c665
......@@ -190,7 +190,7 @@ setup(
"Topic :: Scientific/Engineering"],
zip_safe=False,
include_package_data=True,
install_requires=['numpy', 'pillow', 'numba', 'vispy>=0.6.0,<0.7.0',
install_requires=['numpy', 'pillow', 'numba', 'vispy>=0.7.1',
'netCDF4', 'h5py', 'pyproj',
'pyshp', 'shapely', 'rasterio', 'sqlalchemy',
'appdirs', 'pyyaml', 'pyqtgraph', 'satpy', 'matplotlib',
......
......@@ -402,17 +402,17 @@ class UserControlsAnimation(QtCore.QObject):
self.ui.statusbar.showMessage("ERROR: Layer with time steps or band siblings needed", STATUS_BAR_DURATION)
LOG.info('using siblings of {} for animation loop'.format(uuids[0] if uuids else '-unknown-'))
def toggle_animation(self, action: QtGui.QAction = None, *args):
def toggle_animation(self, action: QtWidgets.QAction = None, *args):
"""Toggle animation on/off."""
new_state = self.scene_manager.layer_set.toggle_animation()
self.ui.animPlayPause.setChecked(new_state)
class Main(QtGui.QMainWindow):
class Main(QtWidgets.QMainWindow):
_last_open_dir: str = None # directory to open files in
_recent_files_menu: QtGui.QMenu = None # QMenu
_open_cache_dialog: QtGui.QDialog = None
_screenshot_dialog: QtGui.QDialog = None
_recent_files_menu: QtWidgets.QMenu = None # QMenu
_open_cache_dialog: QtWidgets.QDialog = None
_screenshot_dialog: QtWidgets.QDialog = None
_cmap_editor = None # Gradient editor widget
_resource_collector: ResourceSearchPathCollector = None
_resource_collector_timer: QtCore.QTimer = None
......@@ -639,7 +639,7 @@ class Main(QtGui.QMainWindow):
gv = self.ui.timelineView
# set up the widget itself
gv.setViewportUpdateMode(QtGui.QGraphicsView.FullViewportUpdate)
gv.setViewportUpdateMode(QtWidgets.QGraphicsView.FullViewportUpdate)
gv.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
gv.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
# gv.setRenderHints(QtGui.QPainter.Antialiasing)
......@@ -935,7 +935,7 @@ class Main(QtGui.QMainWindow):
LOG.debug("Wizard closed, nothing to load")
self._wizard_dialog = None
def remove_region_polygon(self, action: QtGui.QAction = None, *args):
def remove_region_polygon(self, action: QtWidgets.QAction = None, *args):
if self.scene_manager.has_pending_polygon():
self.scene_manager.clear_pending_polygon()
return
......@@ -945,7 +945,7 @@ class Main(QtGui.QMainWindow):
LOG.info("Clearing polygon with name '%s'", removed_name)
self.scene_manager.remove_polygon(removed_name)
def create_algebraic(self, action: QtGui.QAction = None, uuids=None, composite_type=CompositeType.ARITHMETIC):
def create_algebraic(self, action: QtWidgets.QAction = None, uuids=None, composite_type=CompositeType.ARITHMETIC):
if uuids is None:
uuids = list(self.layer_list_model.current_selected_uuids())
dialog = CreateAlgebraicDialog(self.document, uuids, parent=self)
......
......@@ -294,7 +294,7 @@ class ColormapEditor(QtWidgets.QDialog):
for cmap_name in cmap_content:
if cmap_name in self.builtin_colormap_states:
QtGui.QMessageBox.information(
QtWidgets.QMessageBox.information(
self, "Error", "You cannot import a colormap with "
"the same name as one of the internal "
"colormaps: {}".format(cmap_name))
......
......@@ -58,7 +58,7 @@ class OpenFileWizard(QtWidgets.QWizard):
self.file_groups = {}
self.unknown_files = set()
app = QtWidgets.QApplication.instance()
self._unknown_icon = app.style().standardIcon(QtGui.QStyle.SP_DialogCancelButton)
self._unknown_icon = app.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton)
self._known_icon = QtGui.QIcon()
# self._known_icon = app.style().standardIcon(QtGui.QStyle.SP_DialogApplyButton)
......
......@@ -624,7 +624,7 @@ class SceneGraphManager(QObject):
clim=(0., 1.),
gamma=1.,
interpolation='nearest',
method='tiled',
method='subdivide',
cmap=self.document.find_colormap('grays'),
double=False,
texture_shape=DEFAULT_TEXTURE_SHAPE,
......@@ -976,7 +976,7 @@ class SceneGraphManager(QObject):
clim=p.climits,
gamma=p.gamma,
interpolation='nearest',
method='tiled',
method='subdivide',
cmap=self.document.find_colormap(p.colormap),
double=False,
texture_shape=DEFAULT_TEXTURE_SHAPE,
......@@ -1012,7 +1012,7 @@ class SceneGraphManager(QObject):
clim=p.climits,
gamma=p.gamma,
interpolation='nearest',
method='tiled',
method='subdivide',
cmap=None,
double=False,
texture_shape=DEFAULT_TEXTURE_SHAPE,
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Tests for the MultiChannelImageVisual."""
from uwsift.view.visuals import MultiChannelImage
import numpy as np
from vispy.testing import TestingCanvas, run_tests_if_main, requires_application
@requires_application()
def test_multiband_visual():
size = (400, 600)
with TestingCanvas(size=size) as c:
r_data = np.random.rand(*size)
g_data = np.random.rand(*size)
b_data = np.random.rand(*size)
image = MultiChannelImage(
[r_data, None, None],
parent=c.scene)
# Assign only R
result = c.render()
r_result = result[..., 0]
g_result = result[..., 1]
b_result = result[..., 2]
assert not np.allclose(r_result, 0)
np.testing.assert_allclose(g_result, 0)
np.testing.assert_allclose(b_result, 0)
# Add B
image.set_data([r_data, None, b_data])
image.clim = ("auto", "auto", "auto")
result = c.render()
r_result = result[..., 0]
g_result = result[..., 1]
b_result = result[..., 2]
assert not np.allclose(r_result, 0)
np.testing.assert_allclose(g_result, 0)
assert not np.allclose(b_result, 0)
# Unset R, add G
image.set_data([None, g_data, b_data])
image.clim = ("auto", "auto", "auto")
result = c.render()
r_result = result[..., 0]
g_result = result[..., 1]
b_result = result[..., 2]
np.testing.assert_allclose(r_result, 0)
assert not np.allclose(g_result, 0)
assert not np.allclose(b_result, 0)
run_tests_if_main()
......@@ -20,9 +20,10 @@ REQUIRES
import logging
import os
import warnings
import numpy as np
from vispy.gloo import Texture2D
from vispy.visuals._scalable_textures import GPUScaledTexture2D
from uwsift.common import DEFAULT_TILE_HEIGHT, DEFAULT_TILE_WIDTH
......@@ -34,26 +35,37 @@ __docformat__ = 'reStructuredText'
LOG = logging.getLogger(__name__)
class TextureAtlas2D(Texture2D):
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),
format=None, resizable=True,
interpolation=None, wrapping=None,
internalformat=None, resizeable=None):
assert len(texture_shape) == 2
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 = texture_shape
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])
self.texture_size = shape
self._fill_array = np.tile(np.nan, self.tile_shape).astype(np.float32)
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), dtype=np.float32)
# will add self.shape:
super(TextureAtlas2D, self).__init__(None, format, resizable, interpolation,
wrapping, shape, internalformat, resizeable)
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.
......@@ -88,4 +100,139 @@ class TextureAtlas2D(Texture2D):
data[-5:, :] = 1000.
data[:, :5] = 1000.
data[:, -5:] = 1000.
super(TextureAtlas2D, self).set_data(data, offset=offset, copy=copy)
super(TextureAtlas2D, self).scale_and_set_data(data, offset=offset, copy=copy)
class MultiChannelGPUScaledTexture2D:
"""Wrapper class around indiviual 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)
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 tex in self._textures:
self._texture.interpolation = value
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)
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)
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)
class MultiChannelTextureAtlas2D(MultiChannelGPUScaledTexture2D):
"""Helper texture for working with RGB images in SIFT."""
_singular_texture_class = TextureAtlas2D
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)
This diff is collapsed.
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment