From d2d8e35bfaf82e4d1743e7df1f0c8b4120cb91c5 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 11:26:52 -0600 Subject: [PATCH 01/15] Refactor ImagePlot for clarity --- chaco/image_plot.py | 456 ++++++++++++++++++++++---------------------- 1 file changed, 233 insertions(+), 223 deletions(-) diff --git a/chaco/image_plot.py b/chaco/image_plot.py index d1df84072..3ae67df07 100644 --- a/chaco/image_plot.py +++ b/chaco/image_plot.py @@ -13,15 +13,29 @@ # Standard library imports from math import ceil, floor, pi +from contextlib import contextmanager + +import numpy as np # Enthought library imports. -from traits.api import Bool, Either, Enum, Instance, \ - List, Range, Trait, Tuple +from traits.api import Bool, Either, Enum, Instance, List, Range, Trait, Tuple from kiva.agg import GraphicsContextArray # Local relative imports from base_2d_plot import Base2DPlot +try: + from kiva.quartz.ABCGI import InterpolationQuality +except ImportError: + pass +else: + KIVA_INTERP_QUALITY = {"nearest": InterpolationQuality.none, + "bilinear": InterpolationQuality.low, + "bicubic": InterpolationQuality.high} + + +KIVA_DEPTH_MAP = {3: "rgb24", 4: "rgba32"} + class ImagePlot(Base2DPlot): """ A plot based on an image. @@ -36,7 +50,7 @@ class ImagePlot(Base2DPlot): # The interpolation method to use when rendering an image onto the GC. interpolation = Enum("nearest", "bilinear", "bicubic") - + #------------------------------------------------------------------------ # Private traits #------------------------------------------------------------------------ @@ -51,68 +65,83 @@ class ImagePlot(Base2DPlot): # **_cached_image** is to be drawn. _cached_dest_rect = Either(Tuple, List) + #------------------------------------------------------------------------ + # Properties + #------------------------------------------------------------------------ + + @property + def x_axis_is_flipped(self): + return ((self.orientation == 'h' and 'right' in self.origin) or + (self.orientation == 'v' and 'top' in self.origin)) + + @property + def y_axis_is_flipped(self): + return ((self.orientation == 'h' and 'top' in self.origin) or + (self.orientation == 'v' and 'right' in self.origin)) + + #------------------------------------------------------------------------ + # Event handlers + #------------------------------------------------------------------------ + + def _index_data_changed_fired(self): + self._image_cache_valid = False + self.request_redraw() + + def _index_mapper_changed_fired(self): + self._image_cache_valid = False + self.request_redraw() + + def _value_data_changed_fired(self): + self._image_cache_valid = False + self.request_redraw() + #------------------------------------------------------------------------ # Base2DPlot interface #------------------------------------------------------------------------ def _render(self, gc): - """ Actually draws the plot. + """ Draw the plot to screen. Implements the Base2DPlot interface. """ if not self._image_cache_valid: self._compute_cached_image() - if "bottom" in self.origin: - sy = -1 - else: - sy = 1 - if "left" in self.origin: - sx = 1 - else: - sx = -1 - - # If the orientation is flipped, the BR and TL cases are swapped - if self.orientation == "v" and sx == sy: - sx, sy = -sx, -sy + scale_x = -1 if self.x_axis_is_flipped else 1 + scale_y = 1 if self.y_axis_is_flipped else -1 + x, y, w, h = self._cached_dest_rect + x_center = x + w / 2 + y_center = y + h / 2 with gc: gc.clip_to_rect(self.x, self.y, self.width, self.height) gc.set_alpha(self.alpha) - # Kiva image interpolation note: - # Kiva's Agg backend uses the interpolation setting of the *source* - # image to determine the type of interpolation to use when drawing the - # image. The mac backend uses the interpolation setting on the - # destination GC. - old_interp = self._cached_image.get_image_interpolation() - if hasattr(gc, "set_interpolation_quality"): - from kiva.quartz.ABCGI import InterpolationQuality - interp_quality_dict = {"nearest": InterpolationQuality.none, - "bilinear": InterpolationQuality.low, - "bicubic": InterpolationQuality.high} - gc.set_interpolation_quality(interp_quality_dict[self.interpolation]) - elif hasattr(gc, "set_image_interpolation"): - self._cached_image.set_image_interpolation(self.interpolation) - x, y, w, h = self._cached_dest_rect - if self.orientation == "h": # for horizontal orientation: - gc.translate_ctm(x+w/2, y+h/2) # translate back normally - else: # for vertical orientation: - gc.translate_ctm(y+h/2, x+w/2) # translate back with dx,dy swap - gc.scale_ctm(sx, sy) # flip axes as appropriate - if self.orientation == "v": # for vertical orientation: - gc.scale_ctm(1,-1) # restore origin to lower left - gc.rotate_ctm(pi/2) # rotate 1/4 turn clockwise - gc.translate_ctm(-x-w/2, -y-h/2) # translate image center to origin - gc.draw_image(self._cached_image, self._cached_dest_rect) - self._cached_image.set_image_interpolation(old_interp) + # Translate origin to the center of the graphics context. + if self.orientation == "h": + gc.translate_ctm(x_center, y_center) + else: + gc.translate_ctm(y_center, x_center) + + # Flip axes to move origin to the correct position. + gc.scale_ctm(scale_x, scale_y) + + if self.orientation == "v": + self._transpose_about_origin(gc) + + # Translate the origin back to its original position. + gc.translate_ctm(-x_center, -y_center) + + with self._temporary_interp_setting(gc): + gc.draw_image(self._cached_image, self._cached_dest_rect) def map_index(self, screen_pt, threshold=0.0, outside_returns_none=True, index_only=False): - """ Maps a screen space point to an index into the plot's index array(s). + """ Maps a screen space point to an index into the plot's index + array(s). - Implements the AbstractPlotRenderer interface. Uses 0.0 for *threshold*, - regardless of the passed value. + Implements the AbstractPlotRenderer interface. Uses 0.0 for + *threshold*, regardless of the passed value. """ # For image plots, treat hittesting threshold as 0.0, because it's # the only thing that really makes sense. @@ -123,82 +152,102 @@ def map_index(self, screen_pt, threshold=0.0, outside_returns_none=True, # Private methods #------------------------------------------------------------------------ - def _compute_cached_image(self, data=None, mapper=None): - """ Computes the correct sub-image coordinates and renders an image - into self._cached_image. - - The parameter *data* is for subclasses that might not store an RGB(A) - image as the value, but need to compute one to display (colormaps, etc.). - - The parameter *mapper* is also for subclasses that might not store an - RGB(A) image as their value, and gives an opportunity to produce the - values only for the visible region, rather than for the whole plot, - at the expense of more frequent computation. + @property + def _origin_on_principal_diagonal(self): + # The name "principal diagonal" comes from linear algebra. + bottom_right = 'bottom' in self.origin and 'right' in self.origin + top_left = 'top' in self.origin and 'left' in self.origin + return bottom_right or top_left + + def _transpose_about_origin(self, gc): + if self._origin_on_principal_diagonal: + gc.scale_ctm(-1, 1) + else: + gc.scale_ctm(1, -1) + gc.rotate_ctm(pi/2) + + @contextmanager + def _temporary_interp_setting(self, gc): + if hasattr(gc, "set_interpolation_quality"): + # Quartz uses interpolation setting on the destination GC. + interp_quality = KIVA_INTERP_QUALITY[self.interpolation] + gc.set_interpolation_quality(interp_quality) + yield + elif hasattr(gc, "set_image_interpolation"): + # Agg backend uses the interpolation setting of the *source* + # image to determine the type of interpolation to use when + # drawing. Temporarily change image's interpolation value. + old_interp = self._cached_image.get_image_interpolation() + set_interp = self._cached_image.set_image_interpolation + try: + set_interp(self.interpolation) + yield + finally: + set_interp(old_interp) + + def _calc_virtual_screen_bbox(self): + """ Return the rectangle describing the image in screen space + assuming that the entire image could fit on screen. + + Zoomed-in images will have "virtual" sizes larger than the image. + Note that vertical orientations flip x- and y-axes such that x is + vertical and y is horizontal. """ + # Upper-right values are always larger than lower-left values, + # regardless of origin or orientation... + (lower_left, upper_right) = self.index.get_bounds() + # ... but if the origin is not 'bottom left', the data-to-screen + # mapping will flip min and max values. + x_min, y_min = self.map_screen([lower_left])[0] + x_max, y_max = self.map_screen([upper_right])[0] + if x_min > x_max: + x_min, x_max = x_max, x_min + if y_min > y_max: + y_min, y_max = y_max, y_min + + virtual_x_size = x_max - x_min + virtual_y_size = y_max - y_min + 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`. + 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. + """ if data is None: data = self.value.data - (lpt, upt) = self.index.get_bounds() - ll_x, ll_y = self.map_screen([lpt])[0] - ur_x, ur_y = self.map_screen([upt])[0] - if "right" in self.origin: - ll_x, ur_x = ur_x, ll_x - if "top" in self.origin: - ll_y, ur_y = ur_y, ll_y - virtual_width = ur_x - ll_x - virtual_height = ur_y - ll_y - - args = self.position \ - + self.bounds \ - + [ll_x, ll_y, virtual_width, virtual_height] - img_pixels, gc_rect = self._calc_zoom_coords(*args) - - # Grab the appropriate sub-image, if necessary - if img_pixels is not None: - i1, j1, i2, j2 = img_pixels - if "top" in self.origin: - y_length = self.value.get_array_bounds()[1][1] - j1 = y_length - j1 - j2 = y_length - j2 - # swap so that j1 < j2 - j1, j2 = j2, j1 - if "right" in self.origin: - x_length = self.value.get_array_bounds()[0][1] - i1 = x_length - i1 - i2 = x_length - i2 - # swap so that i1 < i2 - i1, i2 = i2, i1 - - # Since data is row-major, j1 and j2 go first - data = data[j1:j2, i1:i2] - - if mapper is not None: - data = mapper(data) + virtual_rect = self._calc_virtual_screen_bbox() + index_bounds, screen_rect = self._calc_zoom_coords(virtual_rect) - # Furthermore, the data presented to the GraphicsContextArray needs to - # be contiguous. If it is not, we need to make a copy. - if not data.flags['C_CONTIGUOUS']: - data = data.copy() + col_min, col_max, row_min, row_max = index_bounds + data = data[row_min:row_max, col_min:col_max] - if data.shape[2] == 3: - kiva_depth = "rgb24" - elif data.shape[2] == 4: - kiva_depth = "rgba32" - else: - raise RuntimeError, "Unknown colormap depth value: %i" \ - % data.value_depth + if mapper is not None: + data = mapper(data) + if len(data.shape) != 3: + raise RuntimeError("`ImagePlot` requires color images.") + elif data.shape[2] not in KIVA_DEPTH_MAP: + msg = "Unknown colormap depth value: {}" + raise RuntimeError(msg.format(data.shape[2])) + kiva_depth = KIVA_DEPTH_MAP[data.shape[2]] + # Data presented to the GraphicsContextArray needs to be contiguous. + data = np.ascontiguousarray(data) self._cached_image = GraphicsContextArray(data, pix_format=kiva_depth) - if gc_rect is not None: - self._cached_dest_rect = gc_rect - else: - self._cached_dest_rect = (ll_x, ll_y, virtual_width, virtual_height) + self._cached_dest_rect = screen_rect self._image_cache_valid = True - def _calc_zoom_coords(self, px, py, plot_width, plot_height, - ix, iy, image_width, image_height): + def _calc_zoom_coords(self, image_rect): """ Calculates the coordinates of a zoomed sub-image. Because of floating point limitations, it is not advisable to request a @@ -206,125 +255,86 @@ def _calc_zoom_coords(self, px, py, plot_width, plot_height, Parameters ---------- - px : number - X-coordinate of plot pixel bounds - py : number - Y-coordinate of plot pixel bounds - plot_width : number - Width of plot pixel bounds - plot_height : number - Height of plot pixel bounds - ix : number - X-coordinate of image pixel bounds - iy : number - Y-coordinate of image pixel bounds - image_width : number - Width of image pixel bounds - image_height : number - Height of image pixel bounds + image_rect : 4-tuple + (x, y, width, height) rectangle describing the pixels bounds of the + full, **rendered** image. This will be larger than the canvas when + zoomed in since the full image may not fit on the canvas. Returns ------- - ((i1, j1, i2, j2), (x, y, dx, dy)) - Lower left and upper right indices of the sub-image to be extracted, - and graphics context origin and extents to draw the sub-image into. - (None, None) - No image extraction is necessary. + index_bounds : 4-tuple + The column and row indices (col_min, col_max, row_min, row_max) of + the sub-image to be extracted and drawn into `screen_rect`. + screen_rect : 4-tuple + (x, y, width, height) rectangle describing the pixels bounds where + the image will be rendered in the plot. """ - if (image_width < 1.5*plot_width) and (image_height < 1.5*plot_height): - return (None, None) - - if 0 in (plot_width, plot_height, image_width, image_height): + ix, iy, image_width, image_height = image_rect + if 0 in (image_width, image_height) or 0 in self.bounds: return (None, None) - # We figure out the subimage coordinates using a two-step process: - # 1. convert the plot boundaries from screen space into pixel offsets - # in the virtual image - # 2. convert the coordinates in the virtual image into indices - # into the image data array - # 3. from the data array indices, compute the screen coordinates of - # the corners of the data array sub-indices - # in all the cases below, x1,y1 refers to the lower-left corner, and - # x2,y2 refers to the upper-right corner. - - # 1. screen space -> pixel offsets + array_bounds = self._array_bounds_from_screen_rect(image_rect) + 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() + 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 + y_max = float(row_max) / array_height * image_height + iy + + # Flip indexes **after** calculating screen coordinates. + # The screen coordinates will get flipped in the renderer. + if self.y_axis_is_flipped: + row_min = array_height - row_min + row_max = array_height - row_max + row_min, row_max = row_max, row_min + if self.x_axis_is_flipped: + col_min = array_width - col_min + col_max = array_width - col_max + col_min, col_max = col_max, col_min + + index_bounds = map(int, [col_min, col_max, row_min, row_max]) + 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): + """ Transform virtual-image rectangle into array indices. + + The virtual-image rectangle is in screen coordinates and can be outside + the plot bounds. This method converts the rectangle into array indices + and clips to the plot bounds. + """ + # Plot dimensions are independent of orientation and origin, but data + # dimensions vary with orientation. Flip plot dimensions to match data + # since outputs will be in data space. if self.orientation == "h": - x1 = px - ix - x2 = (px + plot_width) - ix - y1 = py - iy - y2 = (py + plot_height) - iy + x_min, y_min = self.position + plot_width, plot_height = self.bounds else: - x1 = px - ix - x2 = (px + plot_height) - ix - y1 = py - iy - y2 = (py + plot_width) - iy - - - # 2. pixel offsets -> data array indices - # X and Y are transposed because for image plot data - pixel_bounds = self.value.get_array_bounds() - xpixels = pixel_bounds[0][1] - pixel_bounds[0][0] - ypixels = pixel_bounds[1][1] - pixel_bounds[1][0] - i1 = max(floor(float(x1) / image_width * xpixels), 0) - i2 = min(ceil(float(x2) / image_width * xpixels), xpixels) - j1 = max(floor(float(y1) / image_height * ypixels), 0) - j2 = min(ceil(float(y2) / image_height * ypixels), ypixels) - - # 3. array indices -> new screen space coordinates - x1 = float(i1)/xpixels * image_width + ix - x2 = float(i2)/xpixels * image_width + ix - y1 = float(j1)/ypixels * image_height + iy - y2 = float(j2)/ypixels * image_height + iy - - # Handle really, really, subpixel cases - subimage_index = [i1, j1, i2, j2] - subimage_coords = [x1, y1, x2-x1, y2-y1] - plot_dimensions = (px, py, plot_width, plot_height) - xparams = (0, 2) - yparams = (1, 3) - for pos_index, size_index in (xparams, yparams): - if subimage_index[pos_index] == subimage_index[pos_index+2]-1: - # xcoords lie inside the same pixel, so set the subimage - # coords to be the width of the image - subimage_coords[pos_index] = plot_dimensions[pos_index] - subimage_coords[size_index] = plot_dimensions[size_index] - elif subimage_index[pos_index] == subimage_index[pos_index+2]-2: - # coords span across a pixel boundary. Find the scaling - # factor of the virtual (and potentially large) subimage - # size to the image size, and scale it down. We can do - # this without distortion b/c we are straddling only one - # pixel boundary. - # - # If we scale down the extent to twice the screen size, we can - # be sure that no matter what the offset, we will cover the - # entire screen, since we are only straddling one pixel boundary. - # The formula for calculating the new origin can be worked out - # on paper. - extent = subimage_coords[size_index] - pixel_extent = extent/2 # we are indexed into two pixels - origin = subimage_coords[pos_index] - scale = float(2 * plot_dimensions[size_index] / extent) - subimage_coords[size_index] *= scale - subimage_coords[pos_index] = origin + (1-scale)*pixel_extent - - subimage_index = map(int, subimage_index) - - return [subimage_index, subimage_coords] - - - #------------------------------------------------------------------------ - # Event handlers - #------------------------------------------------------------------------ - - def _index_data_changed_fired(self): - self._image_cache_valid = False - self.request_redraw() - - def _index_mapper_changed_fired(self): - self._image_cache_valid = False - self.request_redraw() - - def _value_data_changed_fired(self): - self._image_cache_valid = False - self.request_redraw() - + y_min, x_min = self.position + plot_height, plot_width = self.bounds + + ix, iy, image_width, image_height = image_rect + # Screen coordinates of virtual-image that fit into plot window. + x_min -= ix + y_min -= iy + x_max = x_min + plot_width + y_max = y_min + plot_height + + array_width = self.value.get_width() + array_height = self.value.get_height() + # 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) + row_min = floor(float(y_min) / image_height * array_height) + row_max = ceil(float(y_max) / image_height * array_height) + + # Clip index bounds to the array bounds. + 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) + + return col_min, col_max, row_min, row_max From 1d6dab006396b344adc2f26833ee235782dbf315 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 11:27:32 -0600 Subject: [PATCH 02/15] Add test of image plotting --- chaco/tests/test_image_plot.py | 164 +++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 chaco/tests/test_image_plot.py diff --git a/chaco/tests/test_image_plot.py b/chaco/tests/test_image_plot.py new file mode 100644 index 000000000..6cc0db6f6 --- /dev/null +++ b/chaco/tests/test_image_plot.py @@ -0,0 +1,164 @@ +import os +import tempfile +from contextlib import contextmanager + +import numpy as np +from skimage import io +from skimage.data import coins +from skimage.color import gray2rgb + +from traits.etsconfig.api import ETSConfig +from chaco.api import (PlotGraphicsContext, GridDataSource, GridMapper, + DataRange2D, ImageData, ImagePlot) + + +# The Quartz backend rescales pixel values, so use a higher threshold. +MAX_RMS_ERROR = 16 if ETSConfig.kiva_backend == 'quartz' else 1 + +IMAGE = coins() +RGB = gray2rgb(IMAGE) +# Rendering adds rows and columns for some reason. +TRIM_RENDERED = (slice(2, None), slice(0, -2), 0) + + +@contextmanager +def temp_image_file(suffix='.png', prefix='test', dir=None): + fd, filename = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=dir) + try: + yield filename + finally: + os.close(fd) + os.remove(filename) + + +def get_image_index_and_mapper(image): + h, w = image.shape[:2] + index = GridDataSource(np.arange(h), np.arange(w)) + index_mapper = GridMapper(range=DataRange2D(low=(0, 0), high=(h-1, w-1))) + return index, index_mapper + + +def save_renderer_result(renderer, filename): + renderer.padding = 0 + gc = PlotGraphicsContext(renderer.outer_bounds) + with gc: + gc.render_component(renderer) + gc.save(filename) + + +def image_from_renderer(renderer, orientation): + data = renderer.value + # Set bounding box size and origin to align rendered image with array + renderer.bounds = (data.get_width() + 1, data.get_height() + 1) + if orientation == 'v': + renderer.bounds = renderer.bounds[::-1] + renderer.position = 0.5, 0.5 + + with temp_image_file() as filename: + save_renderer_result(renderer, filename) + rendered_image = io.imread(filename)[TRIM_RENDERED] + return rendered_image + + +def rendered_image_result(image, filename=None, **plot_kwargs): + data_source = ImageData(data=image) + index, index_mapper = get_image_index_and_mapper(image) + renderer = ImagePlot(value=data_source, index=index, + index_mapper=index_mapper, + **plot_kwargs) + orientation = plot_kwargs.get('orientation', 'h') + return image_from_renderer(renderer, orientation) + + +def calculate_rms(image_result, expected_image): + """Return root-mean-square error. + + Implementation taken from matplotlib. + """ + # calculate the per-pixel errors, then compute the root mean square error + num_values = np.prod(expected_image.shape) + # Cast to int64 to reduce likelihood of over-/under-flow. + abs_diff_image = abs(np.int64(expected_image) - np.int64(image_result)) + + histogram = np.bincount(abs_diff_image.ravel(), minlength=256) + sum_of_squares = np.sum(histogram * np.arange(len(histogram))**2) + rms = np.sqrt(float(sum_of_squares) / num_values) + return rms + + +def verify_result_image(input_image, expected_image, **plot_kwargs): + # These tests were written assuming uint8 inputs. + assert input_image.dtype == np.uint8 + assert expected_image.dtype == np.uint8 + image_result = rendered_image_result(input_image, **plot_kwargs) + rms = calculate_rms(image_result, expected_image) + print "RMS =", rms + assert rms < MAX_RMS_ERROR + + +def plot_comparison(input_image, expected_image, **plot_kwargs): + import matplotlib.pyplot as plt + + image_result = rendered_image_result(input_image, **plot_kwargs) + diff = np.int64(expected_image) - np.int64(image_result) + max_diff = max(abs(diff.min()), abs(diff.max()), 1) + + fig, (ax0, ax1, ax2) = plt.subplots(ncols=3, sharex='all', sharey='all') + ax0.imshow(expected_image) + ax1.imshow(image_result) + im_plot = ax2.imshow(diff, vmin=-max_diff, vmax=max_diff, cmap=plt.cm.bwr) + fig.colorbar(im_plot) + plt.show() + + +def test_horizontal_top_left(): + # Horizontal orientation with top left origin renders original image. + verify_result_image(RGB, IMAGE, origin='top left') + + +def test_horizontal_bottom_left(): + # Horizontal orientation with bottom left origin renders a vertically + # flipped image. + verify_result_image(RGB, IMAGE[::-1], origin='bottom left') + + +def test_horizontal_top_right(): + # Horizontal orientation with top right origin renders a horizontally + # flipped image. + verify_result_image(RGB, IMAGE[:, ::-1], origin='top right') + + +def test_horizontal_bottom_right(): + # Horizontal orientation with top right origin renders an image flipped + # horizontally and vertically. + verify_result_image(RGB, IMAGE[::-1, ::-1], origin='bottom right') + + +def test_vertical_top_left(): + # Vertical orientation with top left origin renders transposed image. + verify_result_image(RGB, IMAGE.T, origin='top left', orientation='v') + + +def test_vertical_bottom_left(): + # Vertical orientation with bottom left origin renders transposed image + # that is vertically flipped. + verify_result_image(RGB, (IMAGE.T)[::-1], + origin='bottom left', orientation='v') + + +def test_vertical_top_right(): + # Vertical orientation with top right origin renders transposed image + # that is horizontally flipped. + verify_result_image(RGB, (IMAGE.T)[:, ::-1], + origin='top right', orientation='v') + + +def test_vertical_bottom_right(): + # Vertical orientation with bottom right origin renders transposed image + # that is flipped vertically and horizontally. + verify_result_image(RGB, (IMAGE.T)[::-1, ::-1], + origin='bottom right', orientation='v') + + +if __name__ == "__main__": + np.testing.run_module_suite() From a6091420faffc490ad721fbf86bd7b76002d5f16 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 12:05:24 -0600 Subject: [PATCH 03/15] Add demo of origin and orientation values --- .../demo/image_plot_origin_and_orientation.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 examples/demo/image_plot_origin_and_orientation.py diff --git a/examples/demo/image_plot_origin_and_orientation.py b/examples/demo/image_plot_origin_and_orientation.py new file mode 100644 index 000000000..a4c3330b8 --- /dev/null +++ b/examples/demo/image_plot_origin_and_orientation.py @@ -0,0 +1,79 @@ +""" +Demonstration of altering a plot's origin and orientation. + +The origin parameter sets a plot's default origin to the specified corner +of the plot window. These positions has the following behavior: + * 'left' : index increases left to right + * 'right' : index increases right to left + * 'top' : index increases top to bottom + * 'bottom' : index increases bottom to top + +The orientation parameter switches the x- and y-axes. Alternatively, you can +think of this as a transpose about the origin. +""" + +# Major library imports +from scipy.misc import lena + +# Enthought library imports +from enable.api import Component, ComponentEditor +from traits.api import HasTraits, Instance +from traitsui.api import UItem, Group, View + +# Chaco imports +from chaco.api import ArrayPlotData, GridContainer, Plot +from chaco.tools.api import PanTool, ZoomTool + + +class Demo(HasTraits): + plot = Instance(Component) + + traits_view = View( + Group( + UItem('plot', editor=ComponentEditor(size=(1000, 500))), + orientation = "vertical" + ), + resizable=True, title="Demo of image origin and orientation" + ) + + def _plot_default(self): + # Create a GridContainer to hold all of our plots: 2 rows, 4 columns: + container = GridContainer(fill_padding=True, + bgcolor="lightgray", use_backbuffer=True, + shape=(2, 4)) + + arrangements = [('top left', 'h'), + ('top right', 'h'), + ('top left', 'v'), + ('top right', 'v'), + ('bottom left', 'h'), + ('bottom right', 'h'), + ('bottom left', 'v'), + ('bottom right', 'v')] + orientation_name = {'h': 'horizontal', 'v': 'vertical'} + + pd = ArrayPlotData(image=lena()) + # Plot some bessel functions and add the plots to our container + for origin, orientation in arrangements: + plot = Plot(pd, default_origin=origin, orientation=orientation) + plot.img_plot('image') + + # Attach some tools to the plot + plot.tools.append(PanTool(plot)) + zoom = ZoomTool(plot, tool_mode="box", always_on=False) + plot.overlays.append(zoom) + + title = '{}, {}' + plot.title = title.format(orientation_name[orientation], + origin.replace(' ', '-')) + + # Add to the grid container + container.add(plot) + + return container + + +demo = Demo() + +if __name__ == "__main__": + demo.configure_traits() From 43f12803ac48a8a006c53b86d0a1dc8e097c7bbc Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 13:10:51 -0600 Subject: [PATCH 04/15] Use Traits properties instead of plain Python properties --- chaco/image_plot.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/chaco/image_plot.py b/chaco/image_plot.py index 3ae67df07..af43162e5 100644 --- a/chaco/image_plot.py +++ b/chaco/image_plot.py @@ -18,7 +18,8 @@ import numpy as np # Enthought library imports. -from traits.api import Bool, Either, Enum, Instance, List, Range, Trait, Tuple +from traits.api import (Bool, Either, Enum, Instance, List, Range, Trait, + Tuple, Property, cached_property) from kiva.agg import GraphicsContextArray # Local relative imports @@ -51,6 +52,12 @@ class ImagePlot(Base2DPlot): # The interpolation method to use when rendering an image onto the GC. interpolation = Enum("nearest", "bilinear", "bicubic") + # Bool indicating whether x-axis is flipped. + x_axis_is_flipped = Property(depends_on=['orientation', 'origin']) + + # Bool indicating whether y-axis is flipped. + y_axis_is_flipped = Property(depends_on=['orientation', 'origin']) + #------------------------------------------------------------------------ # Private traits #------------------------------------------------------------------------ @@ -65,17 +72,21 @@ class ImagePlot(Base2DPlot): # **_cached_image** is to be drawn. _cached_dest_rect = Either(Tuple, List) + # Bool indicating whether the origin is top-left or bottom-right. + # The name "principal diagonal" is borrowed from linear algebra. + _origin_on_principal_diagonal = Property(depends_on='origin') + #------------------------------------------------------------------------ # Properties #------------------------------------------------------------------------ - @property - def x_axis_is_flipped(self): + @cached_property + def _get_x_axis_is_flipped(self): return ((self.orientation == 'h' and 'right' in self.origin) or (self.orientation == 'v' and 'top' in self.origin)) - @property - def y_axis_is_flipped(self): + @cached_property + def _get_y_axis_is_flipped(self): return ((self.orientation == 'h' and 'top' in self.origin) or (self.orientation == 'v' and 'right' in self.origin)) @@ -152,9 +163,8 @@ def map_index(self, screen_pt, threshold=0.0, outside_returns_none=True, # Private methods #------------------------------------------------------------------------ - @property - def _origin_on_principal_diagonal(self): - # The name "principal diagonal" comes from linear algebra. + @cached_property + def _get__origin_on_principal_diagonal(self): bottom_right = 'bottom' in self.origin and 'right' in self.origin top_left = 'top' in self.origin and 'left' in self.origin return bottom_right or top_left From e4a1a0f0bafdb96afe0e7afcae9980b75513c522 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 13:28:59 -0600 Subject: [PATCH 05/15] Remove skimage dependency from test --- chaco/tests/test_image_plot.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/chaco/tests/test_image_plot.py b/chaco/tests/test_image_plot.py index 6cc0db6f6..9bce60f44 100644 --- a/chaco/tests/test_image_plot.py +++ b/chaco/tests/test_image_plot.py @@ -3,9 +3,7 @@ from contextlib import contextmanager import numpy as np -from skimage import io -from skimage.data import coins -from skimage.color import gray2rgb +from scipy.misc import lena from traits.etsconfig.api import ETSConfig from chaco.api import (PlotGraphicsContext, GridDataSource, GridMapper, @@ -15,8 +13,8 @@ # The Quartz backend rescales pixel values, so use a higher threshold. MAX_RMS_ERROR = 16 if ETSConfig.kiva_backend == 'quartz' else 1 -IMAGE = coins() -RGB = gray2rgb(IMAGE) +IMAGE = lena().astype(np.uint8) +RGB = np.dstack([IMAGE] * 3) # Rendering adds rows and columns for some reason. TRIM_RENDERED = (slice(2, None), slice(0, -2), 0) @@ -56,7 +54,7 @@ def image_from_renderer(renderer, orientation): with temp_image_file() as filename: save_renderer_result(renderer, filename) - rendered_image = io.imread(filename)[TRIM_RENDERED] + rendered_image = ImageData.fromfile(filename).data[TRIM_RENDERED] return rendered_image From 25a2ec78c1ebfc09e1bc575464719fe1e92ab31e Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 14:14:23 -0600 Subject: [PATCH 06/15] Fix for off-screen images on quartz --- chaco/image_plot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/chaco/image_plot.py b/chaco/image_plot.py index af43162e5..e25a1ab61 100644 --- a/chaco/image_plot.py +++ b/chaco/image_plot.py @@ -122,6 +122,9 @@ def _render(self, gc): scale_y = 1 if self.y_axis_is_flipped else -1 x, y, w, h = self._cached_dest_rect + if w <= 0 or h <= 0: + return + x_center = x + w / 2 y_center = y + h / 2 with gc: From 28c8a3748d1ebd93be09820a307302ce67f4ab78 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 14:31:53 -0600 Subject: [PATCH 07/15] Remove scipy test dependency --- chaco/tests/test_image_plot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/chaco/tests/test_image_plot.py b/chaco/tests/test_image_plot.py index 9bce60f44..0ae38676d 100644 --- a/chaco/tests/test_image_plot.py +++ b/chaco/tests/test_image_plot.py @@ -3,7 +3,6 @@ from contextlib import contextmanager import numpy as np -from scipy.misc import lena from traits.etsconfig.api import ETSConfig from chaco.api import (PlotGraphicsContext, GridDataSource, GridMapper, @@ -13,7 +12,7 @@ # The Quartz backend rescales pixel values, so use a higher threshold. MAX_RMS_ERROR = 16 if ETSConfig.kiva_backend == 'quartz' else 1 -IMAGE = lena().astype(np.uint8) +IMAGE = np.random.random_integers(0, 255, size=(100, 200)).astype(np.uint8) RGB = np.dstack([IMAGE] * 3) # Rendering adds rows and columns for some reason. TRIM_RENDERED = (slice(2, None), slice(0, -2), 0) From 847c6cc2ea744fc71e31f5c4929912d00683891a Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 7 Nov 2013 14:41:54 -0600 Subject: [PATCH 08/15] Try work-around for compatibility with Travis CI's PIL --- chaco/tests/test_image_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chaco/tests/test_image_plot.py b/chaco/tests/test_image_plot.py index 0ae38676d..4d7df2767 100644 --- a/chaco/tests/test_image_plot.py +++ b/chaco/tests/test_image_plot.py @@ -19,7 +19,7 @@ @contextmanager -def temp_image_file(suffix='.png', prefix='test', dir=None): +def temp_image_file(suffix='.tif', prefix='test', dir=None): fd, filename = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=dir) try: yield filename From 1686160e5b11b341919ab502dfbcca777a12ab55 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Fri, 8 Nov 2013 08:40:07 -0600 Subject: [PATCH 09/15] Clarify imporrt and fix Python 2.6 compatibility --- chaco/image_plot.py | 11 ++++++----- examples/demo/image_plot_origin_and_orientation.py | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/chaco/image_plot.py b/chaco/image_plot.py index e25a1ab61..d66568e63 100644 --- a/chaco/image_plot.py +++ b/chaco/image_plot.py @@ -26,13 +26,14 @@ from base_2d_plot import Base2DPlot try: + # InterpolationQuality required for Quartz backend only (requires OSX). from kiva.quartz.ABCGI import InterpolationQuality except ImportError: pass else: - KIVA_INTERP_QUALITY = {"nearest": InterpolationQuality.none, - "bilinear": InterpolationQuality.low, - "bicubic": InterpolationQuality.high} + QUARTZ_INTERP_QUALITY = {"nearest": InterpolationQuality.none, + "bilinear": InterpolationQuality.low, + "bicubic": InterpolationQuality.high} KIVA_DEPTH_MAP = {3: "rgb24", 4: "rgba32"} @@ -183,7 +184,7 @@ def _transpose_about_origin(self, gc): def _temporary_interp_setting(self, gc): if hasattr(gc, "set_interpolation_quality"): # Quartz uses interpolation setting on the destination GC. - interp_quality = KIVA_INTERP_QUALITY[self.interpolation] + interp_quality = QUARTZ_INTERP_QUALITY[self.interpolation] gc.set_interpolation_quality(interp_quality) yield elif hasattr(gc, "set_image_interpolation"): @@ -250,7 +251,7 @@ def _compute_cached_image(self, data=None, mapper=None): if len(data.shape) != 3: raise RuntimeError("`ImagePlot` requires color images.") elif data.shape[2] not in KIVA_DEPTH_MAP: - msg = "Unknown colormap depth value: {}" + msg = "Unknown colormap depth value: {0}" raise RuntimeError(msg.format(data.shape[2])) kiva_depth = KIVA_DEPTH_MAP[data.shape[2]] diff --git a/examples/demo/image_plot_origin_and_orientation.py b/examples/demo/image_plot_origin_and_orientation.py index a4c3330b8..e8c8ec5e7 100644 --- a/examples/demo/image_plot_origin_and_orientation.py +++ b/examples/demo/image_plot_origin_and_orientation.py @@ -31,7 +31,7 @@ class Demo(HasTraits): traits_view = View( Group( UItem('plot', editor=ComponentEditor(size=(1000, 500))), - orientation = "vertical" + orientation="vertical" ), resizable=True, title="Demo of image origin and orientation" ) @@ -63,7 +63,7 @@ def _plot_default(self): zoom = ZoomTool(plot, tool_mode="box", always_on=False) plot.overlays.append(zoom) - title = '{}, {}' + title = '{0}, {1}' plot.title = title.format(orientation_name[orientation], origin.replace(' ', '-')) @@ -75,5 +75,6 @@ def _plot_default(self): demo = Demo() + if __name__ == "__main__": demo.configure_traits() From c4d405eca9bd922306b4baf0814a12577d6678d8 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Tue, 14 Jan 2014 23:21:36 -0600 Subject: [PATCH 10/15] Fix grid setup. As suggested by @corranwebster --- chaco/tests/test_image_plot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chaco/tests/test_image_plot.py b/chaco/tests/test_image_plot.py index 4d7df2767..7df384285 100644 --- a/chaco/tests/test_image_plot.py +++ b/chaco/tests/test_image_plot.py @@ -30,8 +30,8 @@ def temp_image_file(suffix='.tif', prefix='test', dir=None): def get_image_index_and_mapper(image): h, w = image.shape[:2] - index = GridDataSource(np.arange(h), np.arange(w)) - index_mapper = GridMapper(range=DataRange2D(low=(0, 0), high=(h-1, w-1))) + index = GridDataSource(np.arange(h+1), np.arange(w+1)) + index_mapper = GridMapper(range=DataRange2D(low=(0, 0), high=(h, w))) return index, index_mapper From d0a5e6d3d1c03c57f605048d9b7970d7b61a92a1 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Tue, 14 Jan 2014 23:55:50 -0600 Subject: [PATCH 11/15] Factor out kiva-array creation --- chaco/image_plot.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/chaco/image_plot.py b/chaco/image_plot.py index d66568e63..3a56fb8cd 100644 --- a/chaco/image_plot.py +++ b/chaco/image_plot.py @@ -250,16 +250,21 @@ def _compute_cached_image(self, data=None, mapper=None): if len(data.shape) != 3: raise RuntimeError("`ImagePlot` requires color images.") - elif data.shape[2] not in KIVA_DEPTH_MAP: - msg = "Unknown colormap depth value: {0}" + + # 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 + + def _kiva_array_from_numpy_array(self, data): + if data.shape[2] not in KIVA_DEPTH_MAP: + msg = "Unknown colormap depth value: {}" raise RuntimeError(msg.format(data.shape[2])) kiva_depth = KIVA_DEPTH_MAP[data.shape[2]] # Data presented to the GraphicsContextArray needs to be contiguous. data = np.ascontiguousarray(data) - self._cached_image = GraphicsContextArray(data, pix_format=kiva_depth) - self._cached_dest_rect = screen_rect - self._image_cache_valid = True + return GraphicsContextArray(data, pix_format=kiva_depth) def _calc_zoom_coords(self, image_rect): """ Calculates the coordinates of a zoomed sub-image. From 1c84e6e5b2113174ebb12e7638648d316f50cbfb Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Tue, 3 Jun 2014 16:07:13 -0500 Subject: [PATCH 12/15] Add back optimization of image rendering at high zoom --- chaco/image_plot.py | 7 +++- chaco/image_utils.py | 36 +++++++++++++++++++ chaco/tests/test_image_utils.py | 61 +++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 chaco/image_utils.py create mode 100644 chaco/tests/test_image_utils.py diff --git a/chaco/image_plot.py b/chaco/image_plot.py index 3a56fb8cd..98378cce3 100644 --- a/chaco/image_plot.py +++ b/chaco/image_plot.py @@ -24,6 +24,7 @@ # Local relative imports from base_2d_plot import Base2DPlot +from image_utils import trim_screen_rect try: # InterpolationQuality required for Quartz backend only (requires OSX). @@ -241,8 +242,12 @@ def _compute_cached_image(self, data=None, mapper=None): virtual_rect = self._calc_virtual_screen_bbox() index_bounds, screen_rect = self._calc_zoom_coords(virtual_rect) - 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 mapper is not None: diff --git a/chaco/image_utils.py b/chaco/image_utils.py new file mode 100644 index 000000000..c639c02de --- /dev/null +++ b/chaco/image_utils.py @@ -0,0 +1,36 @@ + +X_PARAMS = (0, 2) # index for x-position and width +Y_PARAMS = (1, 3) # index for y-position and height + + +def trim_screen_rect(screen_rect, view_rect, sub_array_size): + """ Trim sub-image screen rectangle for highly zoomed in states. + + When zoomed into one or two pixels (in any dimension), the screen rectangle + for those pixels can extend without bound outside of the plot area. This + function will return altered bounds to remove unnecessary rendering. + """ + screen_rect = list(screen_rect) # Copy values that we'll be modifying. + + for n_px, (i_pos, i_length) in zip(sub_array_size, (X_PARAMS, Y_PARAMS)): + if n_px == 1: + screen_max = screen_rect[i_pos] + screen_rect[i_length] + view_max = view_rect[i_pos] + view_rect[i_length] + # Viewer bounds shows single pixel, so we can clip the actual pixel + # bounds to the viewer bounds. + new_min = max(screen_rect[i_pos], view_rect[i_pos]) + new_max = min(screen_max, view_max) + screen_rect[i_pos] = new_min + screen_rect[i_length] = new_max - new_min + elif n_px == 2: + image_length = screen_rect[i_length] + # Viewer displays 2 pixels; if we scale down the sub-image's screen + # size to twice the viewer size, we can be sure that any offset + # will cover the entire screen. + scale = 2 * view_rect[i_length] / float(image_length) + if scale < 1: + # Sub-image is two pixels wide, so pixel size is half the image + pixel_length = image_length/2 + screen_rect[i_length] *= scale + screen_rect[i_pos] += (1-scale) * pixel_length + return screen_rect diff --git a/chaco/tests/test_image_utils.py b/chaco/tests/test_image_utils.py new file mode 100644 index 000000000..62ae83418 --- /dev/null +++ b/chaco/tests/test_image_utils.py @@ -0,0 +1,61 @@ +from numpy.testing import assert_allclose, assert_equal + +from chaco.image_utils import trim_screen_rect, X_PARAMS, Y_PARAMS + + +SINGLE_PIXEL = (1, 1) +FOUR_PIXELS = (2, 2) + + +def midpoint(x, length): + return x + length / 2.0 + + +def assert_midpoints_equal(bounds_list): + + for i_pos, i_length in (X_PARAMS, Y_PARAMS): + x_mid = [midpoint(bnd[i_pos], bnd[i_length]) for bnd in bounds_list] + assert_equal(x_mid[0], x_mid[1]) + + +def test_viewer_zoomed_into_single_pixel(): + screen_rect = [0, 0, 100, 100] + view_rect = [10, 11, 1, 2] + new_rect = trim_screen_rect(screen_rect, view_rect, SINGLE_PIXEL) + assert_allclose(new_rect, view_rect) + + +def test_viewer_at_corner_of_single_image(): + offset = 0.2 + screen_rect = [1, 1, 1, 1] + new_size = [1-offset, 1-offset] + + down_right = [1+offset, 1+offset, 1, 1] + new_rect = trim_screen_rect(screen_rect, down_right, SINGLE_PIXEL) + expected_rect = down_right[:2] + new_size + assert_midpoints_equal((new_rect, expected_rect)) + + up_left = [1-offset, 1-offset, 1, 1] + new_rect = trim_screen_rect(screen_rect, up_left, SINGLE_PIXEL) + expected_rect = [1, 1] + new_size + assert_midpoints_equal((new_rect, expected_rect)) + + +def test_viewer_zoomed_into_four_pixel_intersection(): + screen_rect = [0, 0, 100, 100] # 4-pixel intersection at (50, 50) + view_rectangles = ([49, 49, 2, 2], # Centered pixel intersection + [49, 49, 3, 3], # Intersection at 1/3 of view + [49, 49, 2, 3]) # Intersection at 1/2, 1/3 of view + for view_rect in view_rectangles: + new_rect = trim_screen_rect(screen_rect, view_rect, FOUR_PIXELS) + yield assert_midpoints_equal, (new_rect, screen_rect) + + +def test_viewer_at_corner_of_four_pixel_image(): + offset = 0.2 + screen_rect = [1, 1, 1, 1] + view_rectangles = ([1+offset, 1+offset, 1, 1], # Shifted down and right + [1-offset, 1-offset, 1, 1]) # Shifted up and left + for view_rect in view_rectangles: + new_rect = trim_screen_rect(screen_rect, view_rect, FOUR_PIXELS) + yield assert_equal, new_rect, screen_rect From f209299bef0b0ee7e483ccfbaa077565313e9242 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Wed, 5 Nov 2014 13:16:19 -0600 Subject: [PATCH 13/15] Fix tests to match changes in PR #219 --- chaco/tests/test_image_plot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chaco/tests/test_image_plot.py b/chaco/tests/test_image_plot.py index 7df384285..dba9d3105 100644 --- a/chaco/tests/test_image_plot.py +++ b/chaco/tests/test_image_plot.py @@ -15,7 +15,7 @@ IMAGE = np.random.random_integers(0, 255, size=(100, 200)).astype(np.uint8) RGB = np.dstack([IMAGE] * 3) # Rendering adds rows and columns for some reason. -TRIM_RENDERED = (slice(2, None), slice(0, -2), 0) +TRIM_RENDERED = (slice(1, -1), slice(1, -1), 0) @contextmanager @@ -49,7 +49,7 @@ def image_from_renderer(renderer, orientation): renderer.bounds = (data.get_width() + 1, data.get_height() + 1) if orientation == 'v': renderer.bounds = renderer.bounds[::-1] - renderer.position = 0.5, 0.5 + renderer.position = 0, 0 with temp_image_file() as filename: save_renderer_result(renderer, filename) From 93e131f95eb7c2e2bf04d50468226f0e70729203 Mon Sep 17 00:00:00 2001 From: Tony S Yu Date: Thu, 8 Jan 2015 08:08:16 -0600 Subject: [PATCH 14/15] Add comment about ImagePlot refactor to the change log --- CHANGES.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 112ee15e0..1f07cb561 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -9,6 +9,8 @@ New features/Improvements * Replaced chaco.base.bin_search by numpy.searchsorted-based routine for 5x speedup and remove use of zip in chaco.base.arg_find_runs in favour of column_stack for 10x speedup in bad cases. (PR #263) + * `ImagePlot` refactored to clarify transformation applied to images and allow + easier reuse of transformations in subclasses. Fixes From 4a93e666e188b0e5e92abc9488e73eb4b220c692 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 8 Jan 2015 14:43:29 +0000 Subject: [PATCH 15/15] Add PR number to CHANGES.txt [skip ci] --- CHANGES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.txt b/CHANGES.txt index 1f07cb561..13ba7f98c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -10,7 +10,7 @@ New features/Improvements 5x speedup and remove use of zip in chaco.base.arg_find_runs in favour of column_stack for 10x speedup in bad cases. (PR #263) * `ImagePlot` refactored to clarify transformation applied to images and allow - easier reuse of transformations in subclasses. + easier reuse of transformations in subclasses. (PR #147) Fixes