-
Notifications
You must be signed in to change notification settings - Fork 98
Add a new renderer for LOD images #494
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
b315117
fa105cc
5e1adb2
5ce4ede
a2e1c2f
4048980
0d18f54
7d5ad43
9cd7302
403c5e3
1a48c3d
e476dec
e41cfd3
edf6b98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,15 +15,16 @@ | |||||||||||||||
| from math import ceil, floor, pi | ||||||||||||||||
| from contextlib import contextmanager | ||||||||||||||||
|
|
||||||||||||||||
| import six | ||||||||||||||||
| import six.moves as sm | ||||||||||||||||
|
|
||||||||||||||||
| import numpy as np | ||||||||||||||||
|
|
||||||||||||||||
| # Enthought library imports. | ||||||||||||||||
| from traits.api import (Bool, Either, Enum, Instance, List, Range, Trait, | ||||||||||||||||
| Tuple, Property, cached_property) | ||||||||||||||||
| Tuple, Property, cached_property, on_trait_change) | ||||||||||||||||
| from traits_futures.api import CallFuture, TraitsExecutor | ||||||||||||||||
| from kiva.agg import GraphicsContextArray | ||||||||||||||||
| from traitsui.api import Handler | ||||||||||||||||
|
|
||||||||||||||||
| # Local relative imports | ||||||||||||||||
| from .base_2d_plot import Base2DPlot | ||||||||||||||||
|
|
@@ -43,7 +44,7 @@ | |||||||||||||||
| KIVA_DEPTH_MAP = {3: "rgb24", 4: "rgba32"} | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class ImagePlot(Base2DPlot): | ||||||||||||||||
| class ImagePlot(Base2DPlot, Handler): | ||||||||||||||||
|
||||||||||||||||
| """ A plot based on an image. | ||||||||||||||||
| """ | ||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
|
|
@@ -63,6 +64,9 @@ class ImagePlot(Base2DPlot): | |||||||||||||||
| #: Bool indicating whether y-axis is flipped. | ||||||||||||||||
| y_axis_is_flipped = Property(depends_on=['orientation', 'origin']) | ||||||||||||||||
|
|
||||||||||||||||
| #: Does the plot use downsampling? | ||||||||||||||||
| use_downsampling = Bool(False) | ||||||||||||||||
|
|
||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
| # Private traits | ||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
|
|
@@ -81,6 +85,12 @@ class ImagePlot(Base2DPlot): | |||||||||||||||
| # The name "principal diagonal" is borrowed from linear algebra. | ||||||||||||||||
| _origin_on_principal_diagonal = Property(depends_on='origin') | ||||||||||||||||
|
|
||||||||||||||||
| #: The Traits executor for the background jobs. | ||||||||||||||||
| _traits_executor = Instance(TraitsExecutor, ()) | ||||||||||||||||
|
|
||||||||||||||||
| #: Submitted job. Only keeping track of the last submitted one. | ||||||||||||||||
| _future = Instance(CallFuture) | ||||||||||||||||
|
|
||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
| # Properties | ||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
|
|
@@ -111,6 +121,29 @@ def _value_data_changed_fired(self): | |||||||||||||||
| self._image_cache_valid = False | ||||||||||||||||
| self.request_redraw() | ||||||||||||||||
|
|
||||||||||||||||
| @on_trait_change("index_mapper:updated, bounds[]") | ||||||||||||||||
| def _update_lod_cache_image(self): | ||||||||||||||||
| if not self.use_downsampling: | ||||||||||||||||
| return | ||||||||||||||||
| lod = self._calculate_necessary_lod() | ||||||||||||||||
| self._future = self._traits_executor.submit_call( | ||||||||||||||||
| self._compute_cached_image, lod=lod | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| @on_trait_change("_future:done", dispatch='ui') | ||||||||||||||||
| def _handle_lod_cached_image(self): | ||||||||||||||||
| self._cached_image, self._cached_dest_rect = self._future.result | ||||||||||||||||
| self._image_cache_valid = True | ||||||||||||||||
| self.request_redraw() | ||||||||||||||||
|
|
||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
| # Hander interface | ||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
|
|
||||||||||||||||
| def closed(self, info, is_ok): | ||||||||||||||||
| self._traits_executor.stop() | ||||||||||||||||
| super(ImagePlot, self).closed(info, is_ok) | ||||||||||||||||
|
|
||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
| # Base2DPlot interface | ||||||||||||||||
| #------------------------------------------------------------------------ | ||||||||||||||||
|
|
@@ -121,7 +154,9 @@ def _render(self, gc): | |||||||||||||||
| Implements the Base2DPlot interface. | ||||||||||||||||
| """ | ||||||||||||||||
| if not self._image_cache_valid: | ||||||||||||||||
| self._compute_cached_image() | ||||||||||||||||
| self._cached_image, self._cached_dest_rect = \ | ||||||||||||||||
| self._compute_cached_image() | ||||||||||||||||
| self._image_cache_valid = True | ||||||||||||||||
|
|
||||||||||||||||
| scale_x = -1 if self.x_axis_is_flipped else 1 | ||||||||||||||||
| scale_y = 1 if self.y_axis_is_flipped else -1 | ||||||||||||||||
|
|
@@ -234,42 +269,55 @@ def _calc_virtual_screen_bbox(self): | |||||||||||||||
| y_min += 0.5 | ||||||||||||||||
| return [x_min, y_min, virtual_x_size, virtual_y_size] | ||||||||||||||||
|
|
||||||||||||||||
| def _compute_cached_image(self, data=None, mapper=None): | ||||||||||||||||
| """ Computes the correct screen coordinates and renders an image into | ||||||||||||||||
| `self._cached_image`. | ||||||||||||||||
| def _compute_cached_image(self, mapper=None, lod=None): | ||||||||||||||||
| """ Computes the correct screen coordinates and image cache | ||||||||||||||||
|
|
||||||||||||||||
| Parameters | ||||||||||||||||
| ---------- | ||||||||||||||||
| data : array | ||||||||||||||||
| Image data. If None, image is derived from the `value` attribute. | ||||||||||||||||
| mapper : function | ||||||||||||||||
| Allows subclasses to transform the displayed values for the visible | ||||||||||||||||
| region. This may be used to adapt grayscale images to RGB(A) | ||||||||||||||||
| images. | ||||||||||||||||
| lod : int | ||||||||||||||||
| Level of detail for cached image. If None, use the in-memory part | ||||||||||||||||
| `self.value._data`. | ||||||||||||||||
|
|
||||||||||||||||
| Returns | ||||||||||||||||
| ------- | ||||||||||||||||
| cache_image : `kiva.agg.GraphicsContextArray` | ||||||||||||||||
| Computed cache image. | ||||||||||||||||
| cache_dest_rect : 4-tuple | ||||||||||||||||
| (x, y, width, height) rectangle describing the pixels bounds where | ||||||||||||||||
| the image will be rendered in the plot | ||||||||||||||||
| """ | ||||||||||||||||
| if data is None: | ||||||||||||||||
| data = self.value.data | ||||||||||||||||
| # Not to transpose the full matrix ahead in case it is too large | ||||||||||||||||
| data = self.value.get_data(lod=lod, transpose_inplace=False) | ||||||||||||||||
|
|
||||||||||||||||
| virtual_rect = self._calc_virtual_screen_bbox() | ||||||||||||||||
| index_bounds, screen_rect = self._calc_zoom_coords(virtual_rect) | ||||||||||||||||
| index_bounds, screen_rect = self._calc_zoom_coords(virtual_rect, | ||||||||||||||||
| lod=lod) | ||||||||||||||||
| col_min, col_max, row_min, row_max = index_bounds | ||||||||||||||||
|
|
||||||||||||||||
| view_rect = self.position + self.bounds | ||||||||||||||||
| sub_array_size = (col_max - col_min, row_max - row_min) | ||||||||||||||||
| screen_rect = trim_screen_rect(screen_rect, view_rect, sub_array_size) | ||||||||||||||||
|
|
||||||||||||||||
| data = data[row_min:row_max, col_min:col_max] | ||||||||||||||||
| if self.value.transposed: | ||||||||||||||||
| # Swap after slicing to avoid transposing the whole matrix | ||||||||||||||||
| data = data[col_min:col_max, row_min:row_max] | ||||||||||||||||
| data = data.swapaxes(0, 1) | ||||||||||||||||
| else: | ||||||||||||||||
| data = data[row_min:row_max, col_min:col_max] | ||||||||||||||||
|
|
||||||||||||||||
| if mapper is not None: | ||||||||||||||||
| data = mapper(data) | ||||||||||||||||
|
|
||||||||||||||||
| if len(data.shape) != 3: | ||||||||||||||||
| raise RuntimeError("`ImagePlot` requires color images.") | ||||||||||||||||
|
|
||||||||||||||||
| # Update cached image and rectangle. | ||||||||||||||||
| self._cached_image = self._kiva_array_from_numpy_array(data) | ||||||||||||||||
| self._cached_dest_rect = screen_rect | ||||||||||||||||
| self._image_cache_valid = True | ||||||||||||||||
| cached_image = self._kiva_array_from_numpy_array(data) | ||||||||||||||||
| cached_dest_rect = screen_rect | ||||||||||||||||
| return cached_image, cached_dest_rect | ||||||||||||||||
|
|
||||||||||||||||
| def _kiva_array_from_numpy_array(self, data): | ||||||||||||||||
| if data.shape[2] not in KIVA_DEPTH_MAP: | ||||||||||||||||
|
|
@@ -281,7 +329,7 @@ def _kiva_array_from_numpy_array(self, data): | |||||||||||||||
| data = np.ascontiguousarray(data) | ||||||||||||||||
| return GraphicsContextArray(data, pix_format=kiva_depth) | ||||||||||||||||
|
|
||||||||||||||||
| def _calc_zoom_coords(self, image_rect): | ||||||||||||||||
| def _calc_zoom_coords(self, image_rect, lod=None): | ||||||||||||||||
| """ Calculates the coordinates of a zoomed sub-image. | ||||||||||||||||
|
|
||||||||||||||||
| Because of floating point limitations, it is not advisable to request a | ||||||||||||||||
|
|
@@ -307,12 +355,12 @@ def _calc_zoom_coords(self, image_rect): | |||||||||||||||
| if 0 in (image_width, image_height) or 0 in self.bounds: | ||||||||||||||||
| return (None, None) | ||||||||||||||||
|
|
||||||||||||||||
| array_bounds = self._array_bounds_from_screen_rect(image_rect) | ||||||||||||||||
| array_bounds = self._array_bounds_from_screen_rect(image_rect, lod=lod) | ||||||||||||||||
| col_min, col_max, row_min, row_max = array_bounds | ||||||||||||||||
| # Convert array indices back into screen coordinates after its been | ||||||||||||||||
| # clipped to fit within the bounds. | ||||||||||||||||
| array_width = self.value.get_width() | ||||||||||||||||
| array_height = self.value.get_height() | ||||||||||||||||
| array_width = self.value.get_width(lod=lod) | ||||||||||||||||
| array_height = self.value.get_height(lod=lod) | ||||||||||||||||
| x_min = float(col_min) / array_width * image_width + ix | ||||||||||||||||
| x_max = float(col_max) / array_width * image_width + ix | ||||||||||||||||
| y_min = float(row_min) / array_height * image_height + iy | ||||||||||||||||
|
|
@@ -333,7 +381,7 @@ def _calc_zoom_coords(self, image_rect): | |||||||||||||||
| screen_rect = [x_min, y_min, x_max - x_min, y_max - y_min] | ||||||||||||||||
| return index_bounds, screen_rect | ||||||||||||||||
|
|
||||||||||||||||
| def _array_bounds_from_screen_rect(self, image_rect): | ||||||||||||||||
| def _array_bounds_from_screen_rect(self, image_rect, lod=None): | ||||||||||||||||
| """ Transform virtual-image rectangle into array indices. | ||||||||||||||||
|
|
||||||||||||||||
| The virtual-image rectangle is in screen coordinates and can be outside | ||||||||||||||||
|
|
@@ -357,8 +405,8 @@ def _array_bounds_from_screen_rect(self, image_rect): | |||||||||||||||
| x_max = x_min + plot_width | ||||||||||||||||
| y_max = y_min + plot_height | ||||||||||||||||
|
|
||||||||||||||||
| array_width = self.value.get_width() | ||||||||||||||||
| array_height = self.value.get_height() | ||||||||||||||||
| array_width = self.value.get_width(lod=lod) | ||||||||||||||||
| array_height = self.value.get_height(lod=lod) | ||||||||||||||||
| # Convert screen coordinates to array indexes | ||||||||||||||||
| col_min = floor(float(x_min) / image_width * array_width) | ||||||||||||||||
| col_max = ceil(float(x_max) / image_width * array_width) | ||||||||||||||||
|
|
@@ -372,3 +420,18 @@ def _array_bounds_from_screen_rect(self, image_rect): | |||||||||||||||
| row_max = min(row_max, array_height) | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An edge case that isn't handled here occurs when While When these bounds are used to compute the LOD in Lines 432 to 438 in e41cfd3
no LOD will satisfy col_max - col_min >= screen_rect[2], and the necessary LOD is set to 0. This causes the highest resolution image to be unnecessarily cached, potentially crashing the program if the image is too large to fit in memory (actually, only the slice [row_min:row_max, :-abs(col_max)] is cached, but that can still be large).
The fix is simple: - col_min = max(col_min, 0)
- col_max = min(col_max, array_width)
- row_min = max(row_min, 0)
- row_max = min(row_max, array_height)
+ col_min, col_max = np.clip([col_min, col_max], 0, array_width)
+ row_min, row_max = np.clip([row_min, row_max], 0, array_height) |
||||||||||||||||
|
|
||||||||||||||||
| return col_min, col_max, row_min, row_max | ||||||||||||||||
|
|
||||||||||||||||
| def _calculate_necessary_lod(self): | ||||||||||||||||
| """ Computes the necessary lod so that array has more pixels than | ||||||||||||||||
| the screen rectangle. | ||||||||||||||||
| """ | ||||||||||||||||
| virtual_rect = self._calc_virtual_screen_bbox() | ||||||||||||||||
| # NOTE: LOD numbers are assumed to be continuous integers | ||||||||||||||||
| # starting from 0 | ||||||||||||||||
| for lod in range(len(self.value.lod_data_entry))[::-1]: | ||||||||||||||||
| index_bounds, screen_rect = self._calc_zoom_coords(virtual_rect, lod=lod) | ||||||||||||||||
| array_width = index_bounds[1] - index_bounds[0] | ||||||||||||||||
| array_height = index_bounds[3] - index_bounds[2] | ||||||||||||||||
| if (array_width >= screen_rect[2]) and (array_height >= screen_rect[3]): | ||||||||||||||||
| break | ||||||||||||||||
| return lod | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,6 +89,7 @@ | |
| "cython", | ||
| # Needed to install enable from source | ||
| "swig", | ||
| "traits_futures" | ||
| } | ||
|
|
||
| extra_dependencies = { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should update the docstring below to describe the new optional argument
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated.