diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index eede5b21..e932697f 100644 --- a/doc/release_notes/release_2.12.md +++ b/doc/release_notes/release_2.12.md @@ -8,6 +8,7 @@ 🛠️ Bug fixes: +* **Z-axis logarithmic scale** — With the Z-axis logarithmic scale enabled (`ZAxisLogTool`), the color bar, the contrast adjustment panel and the LUT range used logarithms of the image values instead of the image values themselves. The color bar now shows the original image values on a logarithmic scale, with the same colors as the image, consistently with the cursor values. In the contrast adjustment panel, the range now matches the histogram, so that the "Full range", "Eliminate %", "Min." and "Max." actions no longer show the image with a single color. Cross sections and exported images with the LUT applied now follow the logarithmic color mapping. Images with values lower than 1 (e.g. between 0 and 1) are no longer shown with a single color: values lower than or equal to zero are now shown with the lowest color of the colormap. Enabling the logarithmic scale sets the LUT range to the full range of the positive values, and disabling it restores the previous LUT range. The Z-axis log tool is now disabled for image items which do not support it (RGB images, 2D histograms, quadrilateral grids and image filters). API change: with the logarithmic scale, `get_lut_range`, `set_lut_range`, `get_lut_range_full` and the `lut_range` argument of `set_data` now use data units instead of base-10 logarithms, and the new `BaseImageItem.supports_zaxis_log` method tells whether an image item supports the logarithmic scale (closes [Issue #63](https://github.com/PlotPyStack/PlotPy/issues/63)) * **Editing points of downsampled curves** — With downsampling enabled, moving a curve point modified another point of the curve data than the one shown, since the handle of a drawn point was used as an index into the full data * **Curve fitting dialog** — Running the automatic fit no longer raises `OverflowError: argument 1 overflowed` errors when a fitted value ends up far outside the range of its slider (e.g. with the Powell method on a fit range restricted to the tail of a peak). These errors left the slider of the parameter unresponsive, and the values of the following parameters were not displayed. Such values are now shown with a warning icon, whose tooltip gives the slider range, while the slider stays at its nearest end. Typing `nan` or `inf` in a parameter field now restores the previous value instead of raising errors, and the number of slider steps must be at least 2 in the parameter properties dialog (closes [Issue #76](https://github.com/PlotPyStack/PlotPy/issues/76)) * **Cross-section panels with many hidden images** — X/Y cross-section panels kept one curve for every image ever displayed, even after it was hidden, so plots containing hundreds of hidden images became progressively slower to refresh. Curves are now only kept for visible images, and are recreated when a hidden image is shown again diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py index e3f80a7a..6b25ac8c 100644 --- a/plotpy/items/image/base.py +++ b/plotpy/items/image/base.py @@ -188,6 +188,9 @@ class BaseImageItem(QwtPlotItem): _readonly = False _private = False _icon_name = "image.png" + #: True if the item supports the Z-axis logarithmic scale, i.e. if it draws + #: its data through :meth:`_get_display_data` (see :meth:`supports_zaxis_log`) + _ZAXIS_LOG_SUPPORTED = False def __init__( self, data: np.ndarray | None = None, param: Any | None = None @@ -211,6 +214,13 @@ def __init__( self.data: np.ndarray | None = None + # Z-axis logarithmic scale support: the LUT range (`min`, `max`) is always + # expressed in data units, only the color mapping is logarithmic + self._log_data: np.ndarray | None = None + self._log_floor = 1.0 + self._lin_lut_range: tuple[float, float] | None = None + self._is_zaxis_log = False + self.min = 0.0 self.max = 1.0 self.cmap_table = None @@ -238,11 +248,6 @@ def __init__( self.histogram_cache = None - # Z-axis logarithmic scale support - self._log_data: np.ndarray | None = None - self._lin_lut_range: tuple[float, float] | None = None - self._is_zaxis_log = False - if data is not None: self.set_data(data) self.param.update_item(self) @@ -451,8 +456,47 @@ def _recompute_log_data(self) -> None: Used both when toggling the Z-axis log scale on and when the underlying data is replaced (e.g. via :meth:`set_data`) while the log scale is already active. + + Values lower than or equal to zero have no logarithm: they are replaced + by the smallest positive value of the data (the "log floor", or 1.0 if + there is no positive value), so that they are shown with the lowest + color of the colormap. NaN values are kept. + """ + data = np.asarray(self.data) + with np.errstate(invalid="ignore"): + positive = data[data > 0] + self._log_floor = float(positive.min()) if positive.size else 1.0 + with np.errstate(invalid="ignore", divide="ignore"): + self._log_data = np.log10( + np.maximum(data, self._log_floor, dtype=np.float64) + ) + + def _get_display_data(self) -> np.ndarray: + """Return the data to which the LUT is applied when drawing the image + + Returns: + Base-10 logarithm of the data if the Z-axis log scale is enabled, + the data itself otherwise + """ + if self._is_zaxis_log: + return self._log_data + return self.data + + def _to_lut_space(self, values: np.ndarray) -> np.ndarray: + """Convert data values to the space in which the LUT is applied + + Args: + values: Data values + + Returns: + Base-10 logarithm of the values (see :meth:`_recompute_log_data` for + values lower than or equal to zero) if the Z-axis log scale is enabled, + the values themselves otherwise """ - self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64) + if self._is_zaxis_log: + with np.errstate(invalid="ignore", divide="ignore"): + return np.log10(np.maximum(values, self._log_floor, dtype=np.float64)) + return values def set_data( self, data: np.ndarray, lut_range: tuple[float, float] | None = None @@ -461,7 +505,8 @@ def set_data( Args: data: 2D NumPy array - lut_range: LUT range -- tuple (levelmin, levelmax) (Default value = None) + lut_range: LUT range -- tuple (levelmin, levelmax), in data units, even + when the Z-axis log scale is enabled (Default value = None) """ self.data = data self.histogram_cache = None @@ -469,16 +514,18 @@ def set_data( self.update_border() # Refresh the cached log10 data when log scale is active, otherwise the # display would keep using the previous (now stale) log data. - if self.get_zaxis_log_state(): + if self._is_zaxis_log: self._recompute_log_data() if not self.param.keep_lut_range: - if lut_range is not None: - _min, _max = lut_range - elif self.get_zaxis_log_state(): - _min, _max = get_nan_range(self._log_data) - else: - _min, _max = get_nan_range(data) - self.set_lut_range((_min, _max)) + if self._is_zaxis_log: + # The linear LUT range, restored when the log scale is disabled, + # is updated as it would have been without the log scale + self._lin_lut_range = ( + get_nan_range(data) if lut_range is None else tuple(lut_range) + ) + if lut_range is None: + lut_range = self.get_lut_range_full() + self.set_lut_range(lut_range) def get_data( self, x0: float, y0: float, x1: float | None = None, y1: float | None = None @@ -598,7 +645,8 @@ def set_interpolation(self, interp_mode: int, size: int | None = None) -> None: if interp_mode in (INTERP_NEAREST, INTERP_LINEAR): self.interpolate = (interp_mode,) if interp_mode == INTERP_AA: - aa = np.ones((size, size), self.data.dtype) + # The kernel must have the same data type as the data drawn + aa = np.ones((size, size), self._get_display_data().dtype) self.interpolate = (interp_mode, aa) def get_interpolation(self) -> tuple[int] | tuple[int, np.ndarray]: @@ -610,17 +658,29 @@ def set_lut_range(self, lut_range: tuple[float, float]) -> None: Set the current active lut range Args: - lut_range: Lut range, tuple(min, max) + lut_range: Lut range, tuple(min, max), in data units (even when the + Z-axis log scale is enabled: values lower than or equal to zero are + then replaced by the smallest positive value of the data) Example: >>> item.set_lut_range((0.0, 1.0)) """ - self.min, self.max = lut_range + vmin, vmax = lut_range + if self._is_zaxis_log: + if vmax <= 0: + vmax = self._log_floor + if vmin <= 0: + vmin = min(self._log_floor, vmax) + self.min, self.max = vmin, vmax _a, _b, bg, cmap = self.lut - if self.max == self.min: - self.lut = (LUT_MAX, self.min, bg, cmap) + if self._is_zaxis_log: + lmin, lmax = np.log10(float(vmin)), np.log10(float(vmax)) + else: + lmin, lmax = self.min, self.max + if lmax == lmin: + self.lut = (LUT_MAX, lmin, bg, cmap) else: - fmin, fmax = float(self.min), float(self.max) # avoid overflows + fmin, fmax = float(lmin), float(lmax) # avoid overflows self.lut = ( LUT_MAX / (fmax - fmin), -LUT_MAX * fmin / (fmax - fmin), @@ -632,7 +692,7 @@ def get_lut_range(self) -> tuple[float, float]: """Get the current active lut range Returns: - tuple[float, float]: Lut range, tuple(min, max) + tuple[float, float]: Lut range, tuple(min, max), in data units """ return self.min, self.max @@ -648,11 +708,25 @@ def get_lut_range_full(self) -> tuple[float, float]: """Return full dynamic range Returns: - tuple[float, float]: Lut range, tuple(min, max) + tuple[float, float]: Lut range, tuple(min, max), in data units. When the + Z-axis log scale is enabled, the minimum is the smallest positive value + of the data. """ - return get_nan_range(self.data) + vmin, vmax = get_nan_range(self.data) + if self._is_zaxis_log: + return self._log_floor, max(vmax, self._log_floor) + return vmin, vmax # ---- Z-axis logarithmic scale -------------------------------------------- + def supports_zaxis_log(self) -> bool: + """Return True if the item supports the Z-axis logarithmic scale + + Items which do not support it (e.g. RGB images, which have no colormap) + are ignored by :class:`.ZAxisLogTool`, and :meth:`set_zaxis_log_state` + has no effect on them. + """ + return self._ZAXIS_LOG_SUPPORTED + def get_zaxis_log_state(self) -> bool: """Return True if Z-axis is in logarithmic scale""" return self._is_zaxis_log @@ -660,23 +734,35 @@ def get_zaxis_log_state(self) -> bool: def set_zaxis_log_state(self, state: bool) -> None: """Set Z-axis logarithmic scale state + With the logarithmic scale, the colors are mapped to the base-10 logarithm + of the data, whereas the LUT range (see :meth:`get_lut_range`), the color + bar, the contrast adjustment panel and the cursor values remain in data + units. Values lower than or equal to zero are shown with the lowest color + of the colormap. + + Enabling the logarithmic scale sets the LUT range to the full range of the + positive values of the data; disabling it restores the previous LUT range. + Args: state: True to enable logarithmic scale, False otherwise """ - self._is_zaxis_log = state - plot = self.plot() + state = bool(state) + if not self.supports_zaxis_log() or state == self._is_zaxis_log: + return if state: self._lin_lut_range = self.get_lut_range() - if self._log_data is None: + self._is_zaxis_log = True + if self.data is not None: self._recompute_log_data() - self.set_lut_range(get_nan_range(self._log_data)) - dtype = self._log_data.dtype + self.set_lut_range(self.get_lut_range_full()) else: + self._is_zaxis_log = False self._log_data = None self.set_lut_range(self._lin_lut_range) - dtype = self.data.dtype - if self.interpolate[0] == INTERP_AA: + if self.data is not None and self.interpolate[0] == INTERP_AA: + dtype = self._get_display_data().dtype self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype)) + plot = self.plot() if plot is not None: plot.update_colormap_axis(self) @@ -766,7 +852,12 @@ def draw_image( if self.warn_if_non_linear_scale(painter, canvasRect): return dest = _scale_rect( - self.data, src_rect, self._offscreen, dst_rect, self.lut, self.interpolate + self._get_display_data(), + src_rect, + self._offscreen, + dst_rect, + self.lut, + self.interpolate, ) qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])) painter.drawImage(qrect, self._image, qrect) @@ -797,10 +888,12 @@ def export_roi( """ if apply_lut: a, b, _bg, _cmap = self.lut + data = self._get_display_data() else: a, b = 1.0, 0.0 + data = self.data interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,) - _scale_rect(self.data, src_rect, dst_image, dst_rect, (a, b, None), interp) + _scale_rect(data, src_rect, dst_image, dst_rect, (a, b, None), interp) # ---- QwtPlotItem API ----------------------------------------------------- def draw( @@ -1170,7 +1263,7 @@ def get_histogram( def __process_cross_section(self, ydata, apply_lut): if apply_lut: a, b, _bg, _cmap = self.lut - return (ydata * a + b).clip(0, LUT_MAX) + return (self._to_lut_space(ydata) * a + b).clip(0, LUT_MAX) else: return ydata @@ -1283,6 +1376,7 @@ class RawImageItem(BaseImageItem): IVoiImageItemType, ISerializableType, ) + _ZAXIS_LOG_SUPPORTED = True # ---- BaseImageItem API --------------------------------------------------- def get_default_param(self) -> RawImageParam: diff --git a/plotpy/items/image/misc.py b/plotpy/items/image/misc.py index 1853671d..bf739f9e 100644 --- a/plotpy/items/image/misc.py +++ b/plotpy/items/image/misc.py @@ -71,6 +71,8 @@ class QuadGridItem(RawImageItem): """ __implements__ = (IBasePlotItem, IBaseImageItem, IHistDataSource, IVoiImageItemType) + # Quadrilaterals are drawn from the data itself, without the log10 data + _ZAXIS_LOG_SUPPORTED = False def __init__( self, diff --git a/plotpy/items/image/standard.py b/plotpy/items/image/standard.py index c900e1ab..7c65b4e9 100644 --- a/plotpy/items/image/standard.py +++ b/plotpy/items/image/standard.py @@ -359,17 +359,14 @@ def draw_image( src2 = self._rescale_src_rect(src_rect) dst_rect = tuple([int(i) for i in dst_rect]) - # Not the most efficient way to do it, but it works... - # -------------------------------------------------------------------------- - if self.get_zaxis_log_state(): - data = self._log_data - else: - data = self.data - # -------------------------------------------------------------------------- - try: dest = _scale_rect( - data, src2, self._offscreen, dst_rect, self.lut, self.interpolate + self._get_display_data(), + src2, + self._offscreen, + dst_rect, + self.lut, + self.interpolate, ) except ValueError: # This exception is raised when zooming unreasonably inside a pixel @@ -403,12 +400,14 @@ def export_roi( """ if apply_lut: a, b, _bg, _cmap = self.lut + data = self._get_display_data() else: a, b = 1.0, 0.0 + data = self.data interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,) rescaled_src = self._rescale_src_rect(src_rect) _scale_rect( - self.data, + data, rescaled_src, dst_image, dst_rect, @@ -657,12 +656,13 @@ def draw_image( return xytr = self.x, self.y, src_rect dst_rect = tuple([int(i) for i in dst_rect]) - if self.get_zaxis_log_state(): - data = self._log_data - else: - data = self.data dest = _scale_xy( - data, xytr, self._offscreen, dst_rect, self.lut, self.interpolate + self._get_display_data(), + xytr, + self._offscreen, + dst_rect, + self.lut, + self.interpolate, ) qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])) painter.drawImage(qrect, self._image, qrect) @@ -692,12 +692,14 @@ def export_roi( """ if apply_lut: lut = self.lut + data = self._get_display_data() else: a, b = 1.0, 0.0 lut = (a, b, None, np.zeros((1,), np.uint32)) + data = self.data interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,) xytr = self.x, self.y, src_rect - _scale_xy(self.data, xytr, dst_image, dst_rect, lut, interp) + _scale_xy(data, xytr, dst_image, dst_rect, lut, interp) def get_pixel_coordinates(self, xplot: float, yplot: float) -> tuple[float, float]: """Get pixel coordinates from plot coordinates @@ -835,6 +837,8 @@ class RGBImageItem(ImageItem): """ __implements__ = (IBasePlotItem, IBaseImageItem, ISerializableType) + # RGB images have no colormap + _ZAXIS_LOG_SUPPORTED = False def __init__( self, data: np.ndarray | None = None, param: RGBImageParam | None = None diff --git a/plotpy/items/image/transform.py b/plotpy/items/image/transform.py index 4309fe18..c76dc2d2 100644 --- a/plotpy/items/image/transform.py +++ b/plotpy/items/image/transform.py @@ -274,12 +274,13 @@ def draw_image( mat = self.tr @ tr dst_rect = tuple([int(i) for i in dst_rect]) - if self.get_zaxis_log_state(): - data = self._log_data - else: - data = self.data dest = _scale_tr( - data, mat, self._offscreen, dst_rect, self.lut, self.interpolate + self._get_display_data(), + mat, + self._offscreen, + dst_rect, + self.lut, + self.interpolate, ) qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])) painter.drawImage(qrect, self._image, qrect) @@ -326,8 +327,10 @@ def export_roi( """ if apply_lut: a, b, _bg, _cmap = self.lut + data = self._get_display_data() else: a, b = 1.0, 0.0 + data = self.data xs0, ys0, xs1, ys1 = src_rect xd0, yd0, xd1, yd1 = dst_rect @@ -354,15 +357,13 @@ def export_roi( if force_interp_mode in (INTERP_NEAREST, INTERP_LINEAR): interp = (force_interp_mode,) else: # INTERP_AA - aa = np.ones( - (force_interp_size, force_interp_size), self.data.dtype - ) + aa = np.ones((force_interp_size, force_interp_size), data.dtype) interp = (force_interp_mode, aa) else: # don't force interpolation, keep current image interpolation interp = self.interpolate else: # don't apply interpolation --> INTERP_NEAREST interp = (INTERP_NEAREST,) - _scale_tr(self.data, mat, dst_image, dst_rect, (a, b, None), interp) + _scale_tr(data, mat, dst_image, dst_rect, (a, b, None), interp) # ---- IBasePlotItem API --------------------------------------------------- def move_local_point_to(self, handle: int, pos: QPointF, ctrl: bool = None) -> None: diff --git a/plotpy/mathutils/colormap.py b/plotpy/mathutils/colormap.py index d9719237..fece04bf 100644 --- a/plotpy/mathutils/colormap.py +++ b/plotpy/mathutils/colormap.py @@ -22,6 +22,10 @@ * :py:func:`.build_icon_from_cmap_name`: build an icon representing the colormap from its name +The :py:class:`.LogScaleColorMap` class maps values to colors on a base-10 +logarithmic scale (e.g. for the color bar of images shown with a logarithmic +Z-axis). + Reference ^^^^^^^^^ @@ -30,11 +34,14 @@ .. autofunction:: add_cmap .. autofunction:: build_icon_from_cmap .. autofunction:: build_icon_from_cmap_name +.. autoclass:: LogScaleColorMap + :members: """ from __future__ import annotations import json +import math import os from typing import Dict, Literal, Sequence @@ -43,6 +50,7 @@ import qtpy.QtGui as QG from guidata.configtools import get_module_data_path from qwt import QwtInterval, toQImage +from qwt.color_map import QwtColorMap from plotpy.config import CONF from plotpy.widgets.colormap.widget import EditableColormap @@ -301,3 +309,65 @@ def delete_cmap(cmap: EditableColormap) -> None: if CUSTOM_COLORMAPS.pop(cmap.name.lower(), None) is not None: del ALL_COLORMAPS[cmap.name.lower()] save_colormaps(CUSTOM_COLORMAPS_PATH, CUSTOM_COLORMAPS) + + +class LogScaleColorMap(QwtColorMap): + """Color map applying another color map on a base-10 logarithmic scale + + The value interval and the values are given in data units: the wrapped color + map is applied to their base-10 logarithm. Values lower than or equal to zero + get the color of the lower bound of the interval. + + This is used to draw the color bar of images shown with a logarithmic Z-axis, + so that the colors of the color bar match those of the image. + + Args: + colormap: Wrapped color map + """ + + def __init__(self, colormap: QwtColorMap) -> None: + super().__init__(colormap.format()) + self.colormap = colormap + + @staticmethod + def _to_log(interval: QwtInterval, value: float) -> tuple[QwtInterval, float]: + """Convert interval and value to base-10 logarithmic scale + + Args: + interval: Value interval, in data units + value: Value, in data units + + Returns: + Interval and value, on logarithmic scale (unchanged if the interval + is not strictly positive) + """ + vmin, vmax = interval.minValue(), interval.maxValue() + if not (vmin > 0 and vmax > 0): + return interval, value + lmin = math.log10(vmin) + lvalue = math.log10(value) if value > 0 else lmin + return QwtInterval(lmin, math.log10(vmax)), lvalue + + def rgb(self, interval: QwtInterval, value: float) -> int: + """Return the color of a value + + Args: + interval: Value interval, in data units + value: Value, in data units + + Returns: + RGB value of the color + """ + return self.colormap.rgb(*self._to_log(interval, value)) + + def colorIndex(self, interval: QwtInterval, value: float) -> int: + """Return the color index of a value + + Args: + interval: Value interval, in data units + value: Value, in data units + + Returns: + Index of the color in the color table + """ + return self.colormap.colorIndex(*self._to_log(interval, value)) diff --git a/plotpy/plot/base.py b/plotpy/plot/base.py index df6ed662..3b3c3d96 100644 --- a/plotpy/plot/base.py +++ b/plotpy/plot/base.py @@ -55,6 +55,7 @@ PolygonShape, QuiverItem, ) +from plotpy.mathutils.colormap import LogScaleColorMap from plotpy.styles.axes import AxesParam, AxeStyleParam, AxisParam, ImageAxesParam from plotpy.styles.base import GridParam, ItemParameters @@ -2597,6 +2598,10 @@ def update_colormap_axis(self, item: itf.IColormapImageItemType) -> None: """ Update colormap axis + When the Z-axis logarithmic scale of the item is enabled, the colormap + axis uses a logarithmic scale, with bounds in data units, and its color + bar is drawn with the colormap applied on a logarithmic scale. + Args: item (IColormapImageItemType): the item """ @@ -2604,12 +2609,18 @@ def update_colormap_axis(self, item: itf.IColormapImageItemType) -> None: return zaxis = self.colormap_axis axiswidget: QwtScaleWidget = self.axisWidget(zaxis) + is_log = item.get_zaxis_log_state() + if is_log != (self.get_axis_scale(zaxis) == "log"): + self.setAxisScaleEngine( + zaxis, self.AXIS_TYPES["log" if is_log else "lin"]() + ) self.setAxisScale(zaxis, item.min, item.max) + colormap = item.get_color_map() + if is_log: + colormap = LogScaleColorMap(colormap) # XXX: The colormap can't be displayed if min>max, to fix this # we should pass an inverted colormap along with _max, _min values - axiswidget.setColorMap( - qwt.QwtInterval(item.min, item.max), item.get_color_map() - ) + axiswidget.setColorMap(qwt.QwtInterval(item.min, item.max), colormap) self.updateAxes() # # Keep this around to debug too many replots diff --git a/plotpy/tests/tools/test_zaxislog.py b/plotpy/tests/tools/test_zaxislog.py index 62a4a530..a76123f5 100644 --- a/plotpy/tests/tools/test_zaxislog.py +++ b/plotpy/tests/tools/test_zaxislog.py @@ -38,6 +38,7 @@ def setup_items(self) -> None: plot.add_item(item) plot.set_active_item(item) item.unselect() + self.item = item def test_zaxislogtool() -> None: @@ -45,10 +46,21 @@ def test_zaxislogtool() -> None: with qt_app_context(exec_loop=True): win = MyPlotDialog() win.show() + plot = win.get_plot() + item = win.item + zaxis = plot.colormap_axis tool = win.manager.get_tool(ZAxisLogTool) qt_wait(1, except_unattended=True) for _index in range(2): tool.activate() + if item.get_zaxis_log_state(): + # The color bar shows data values, on a logarithmic scale + assert plot.get_axis_scale(zaxis) == "log" + scalediv = plot.axisScaleDiv(zaxis) + assert scalediv.lowerBound() >= 1.0 + assert np.isclose(scalediv.upperBound(), item.data.max()) + else: + assert plot.get_axis_scale(zaxis) == "lin" qt_wait(1, except_unattended=True) diff --git a/plotpy/tests/unit/test_image_log_set_data.py b/plotpy/tests/unit/test_image_log_set_data.py index cc26cb10..147c6391 100644 --- a/plotpy/tests/unit/test_image_log_set_data.py +++ b/plotpy/tests/unit/test_image_log_set_data.py @@ -30,17 +30,15 @@ def test_set_data_refreshes_log_data_when_log_scale_enabled() -> None: first = np.array([[1.0, 10.0], [100.0, 1000.0]]) item = _make_item(first) item.set_zaxis_log_state(True) - np.testing.assert_array_almost_equal(item._log_data, np.log10(first.clip(1))) + np.testing.assert_array_almost_equal(item._log_data, np.log10(first)) second = np.array([[10.0, 100.0], [1000.0, 10000.0]]) item.set_data(second) # The cache must reflect the new data, not the previous one. - np.testing.assert_array_almost_equal(item._log_data, np.log10(second.clip(1))) - # And the LUT range must be derived from the refreshed log data. - lut_min, lut_max = item.get_lut_range() - assert lut_min == np.log10(second.clip(1)).min() - assert lut_max == np.log10(second.clip(1)).max() + np.testing.assert_array_almost_equal(item._log_data, np.log10(second)) + # And the LUT range must be the range of the new data, in data units. + assert item.get_lut_range() == (10.0, 10000.0) def test_set_data_keeps_lut_range_in_log_mode() -> None: @@ -49,16 +47,16 @@ def test_set_data_keeps_lut_range_in_log_mode() -> None: first = np.array([[1.0, 10.0], [100.0, 1000.0]]) item = _make_item(first) item.set_zaxis_log_state(True) - item.set_lut_range((0.5, 2.5)) + item.set_lut_range((5.0, 500.0)) item.param.keep_lut_range = True second = np.array([[10.0, 100.0], [1000.0, 10000.0]]) item.set_data(second) # Cache must still be refreshed (display correctness)… - np.testing.assert_array_almost_equal(item._log_data, np.log10(second.clip(1))) + np.testing.assert_array_almost_equal(item._log_data, np.log10(second)) # …but the LUT range must remain frozen as requested by the user. - assert item.get_lut_range() == (0.5, 2.5) + assert item.get_lut_range() == (5.0, 500.0) def test_set_data_does_not_create_log_data_when_log_scale_disabled() -> None: diff --git a/plotpy/tests/unit/test_image_zaxis_log.py b/plotpy/tests/unit/test_image_zaxis_log.py new file mode 100644 index 00000000..8c4f57a0 --- /dev/null +++ b/plotpy/tests/unit/test_image_zaxis_log.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Z-axis logarithmic scale of image items (Issue #63). + +With the Z-axis logarithmic scale, the colors are mapped to the base-10 logarithm +of the data, whereas the LUT range, the color bar, the contrast adjustment panel +and the cursor values remain in data units. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtCore as QC +from qwt import QwtInterval + +from plotpy.builder import make +from plotpy.constants import LUT_MAX +from plotpy.items import assemble_imageitems +from plotpy.lutrange import lut_range_threshold +from plotpy.mathutils.colormap import LogScaleColorMap, get_cmap +from plotpy.tools import ZAxisLogTool + +#: Data ranging from 1 to 10000, on a logarithmic scale +DATA = np.logspace(0, 4, 6 * 8).reshape(6, 8) + +SUPPORTED_KINDS = ("image", "xyimage", "trimage", "maskedimage", "maskedxyimage") + + +def _make_item(kind: str, data: np.ndarray = DATA): + """Return an image item of the given kind, without interpolation""" + ny, nx = data.shape + x, y = np.arange(nx, dtype=float), np.arange(ny, dtype=float) + mask = np.zeros(data.shape, dtype=bool) + builders = { + "image": lambda: make.image(data, interpolation="nearest"), + "xyimage": lambda: make.xyimage(x, y, data, interpolation="nearest"), + "trimage": lambda: make.trimage(data, interpolation="nearest"), + "maskedimage": lambda: make.maskedimage(data, mask, interpolation="nearest"), + "maskedxyimage": lambda: make.maskedxyimage( + x, y, data, mask, interpolation="nearest" + ), + } + return builders[kind]() + + +def _expected_lut_values(item, values: np.ndarray) -> np.ndarray: + """Return the LUT indexes expected for data values with the log scale""" + lmin, lmax = np.log10(item.get_lut_range()) + lvalues = np.log10(np.maximum(values, item._log_floor)) + return (LUT_MAX * (lvalues - lmin) / (lmax - lmin)).clip(0, LUT_MAX) + + +@pytest.mark.parametrize("kind", SUPPORTED_KINDS) +def test_toggle_keeps_lut_range_in_data_units(kind: str) -> None: + """Enabling the log scale sets the full positive range, in data units, and + disabling it restores the previous LUT range""" + with qt_app_context(exec_loop=False): + item = _make_item(kind) + assert item.supports_zaxis_log() + item.set_lut_range((100.0, 5000.0)) + item.set_zaxis_log_state(True) + assert item.get_zaxis_log_state() + assert item.get_lut_range() == pytest.approx((1.0, 10000.0)) + assert item.get_lut_range_full() == pytest.approx((1.0, 10000.0)) + # Enabling it twice must not overwrite the saved linear range + item.set_zaxis_log_state(True) + item.set_zaxis_log_state(False) + assert not item.get_zaxis_log_state() + assert item.get_lut_range() == (100.0, 5000.0) + + +@pytest.mark.parametrize("kind", SUPPORTED_KINDS) +def test_cross_sections_apply_log_lut(kind: str) -> None: + """Cross sections with `apply_lut=True` use the log color mapping""" + with qt_app_context(exec_loop=False): + item = _make_item(kind) + item.set_zaxis_log_state(True) + item.set_lut_range((2.0, 9000.0)) + iy = 3 + _x, raw = item.get_xsection(iy) + _x, ydata = item.get_xsection(iy, apply_lut=True) + np.testing.assert_allclose(ydata, _expected_lut_values(item, raw)) + assert len(np.unique(ydata)) > 1 + item.set_zaxis_log_state(False) + _x, ydata = item.get_xsection(iy, apply_lut=True) + a, b, _bg, _cmap = item.lut + np.testing.assert_allclose(ydata, (raw * a + b).clip(0, LUT_MAX)) + + +@pytest.mark.parametrize("kind", ("image", "maskedimage", "trimage")) +def test_export_roi_applies_log_lut(kind: str) -> None: + """Exporting an image area with `apply_lut=True` uses the log color mapping""" + with qt_app_context(exec_loop=False): + item = _make_item(kind) + item.set_zaxis_log_state(True) + ny, nx = DATA.shape + rect = QC.QRectF(0, 0, nx, ny) + raw = assemble_imageitems([item], rect, nx, ny) + output = assemble_imageitems([item], rect, nx, ny, apply_lut=True) + inside = raw > 0 # Pixels outside the image are not exported + assert np.count_nonzero(inside) > nx + a, b, _bg, _cmap = item.lut + np.testing.assert_allclose( + output[inside], a * np.log10(raw[inside]) + b, rtol=1e-5, atol=1e-3 + ) + + +def test_non_positive_values() -> None: + """Values lower than or equal to zero get the lowest color, and the LUT range + is clamped to the smallest positive value""" + with qt_app_context(exec_loop=False): + data = np.array([[-5.0, 0.0, np.nan], [0.5, 5.0, 50.0]]) + item = make.image(data, interpolation="nearest") + item.set_zaxis_log_state(True) + assert item.get_lut_range() == (0.5, 50.0) + np.testing.assert_allclose( + item._log_data, + [[np.log10(0.5), np.log10(0.5), np.nan], np.log10([0.5, 5.0, 50.0])], + ) + item.set_lut_range((-1.0, 5.0)) + assert item.get_lut_range() == (0.5, 5.0) + item.set_lut_range((-2.0, -1.0)) + assert item.get_lut_range() == (0.5, 0.5) + _x, ydata = item.get_xsection(0, apply_lut=True) + assert ydata[0] == ydata[1] + + +def test_data_between_zero_and_one() -> None: + """Data lower than 1 is no longer shown with a single color""" + with qt_app_context(exec_loop=False): + data = np.linspace(0.0, 1.0, 101).reshape(1, 101) + item = make.image(data, interpolation="nearest") + item.set_zaxis_log_state(True) + assert item.get_lut_range() == (0.01, 1.0) + _x, ydata = item.get_xsection(0, apply_lut=True) + assert ydata[0] == ydata[1] == 0 + assert ydata[-1] == LUT_MAX + assert np.all(np.diff(ydata[1:]) > 0) + + +def test_contrast_functions_in_data_units() -> None: + """Full range and outliers elimination give LUT ranges in data units""" + with qt_app_context(exec_loop=False): + item = make.image(DATA, interpolation="nearest") + item.set_zaxis_log_state(True) + vmin, vmax = lut_range_threshold(item, 256, 1.0) + assert 1.0 <= vmin < vmax <= 10000.0 + item.set_lut_threshold(1.0) + assert item.get_lut_range() == pytest.approx((vmin, vmax)) + item.set_lut_range(item.get_lut_range_full()) + assert item.get_lut_range() == pytest.approx((1.0, 10000.0)) + + +@pytest.mark.parametrize("kind", ("image", "maskedxyimage")) +def test_set_data_in_log_mode(kind: str) -> None: + """`set_data` LUT ranges are in data units with the log scale""" + with qt_app_context(exec_loop=False): + item = _make_item(kind) + item.set_zaxis_log_state(True) + item.set_data(DATA * 10.0, lut_range=(50.0, 500.0)) + assert item.get_lut_range() == (50.0, 500.0) + item.set_data(DATA * 10.0) + assert item.get_lut_range() == pytest.approx((10.0, 100000.0)) + # Disabling the log scale gives the range that the data would have had + item.set_zaxis_log_state(False) + assert item.get_lut_range() == pytest.approx((10.0, 100000.0)) + + +def test_anti_aliasing_kernel_follows_displayed_data() -> None: + """The anti-aliasing kernel has the data type of the data drawn""" + with qt_app_context(exec_loop=False): + item = make.image(DATA.astype(np.uint16), interpolation="nearest") + item.set_zaxis_log_state(True) + item.set_interpolation(2, 3) # INTERP_AA + assert item.interpolate[1].dtype == np.float64 + item.set_zaxis_log_state(False) + assert item.interpolate[1].dtype == np.uint16 + + +def test_colorbar_in_data_units() -> None: + """The color bar uses a log scale in data units, with the colors of the image""" + with qt_app_context(exec_loop=False): + win = make.dialog(type="image", toolbar=True) + plot = win.manager.get_plot() + item = make.image(DATA, colormap="jet", interpolation="nearest") + plot.add_item(item) + zaxis = plot.colormap_axis + item.set_zaxis_log_state(True) + assert plot.get_axis_scale(zaxis) == "log" + scalediv = plot.axisScaleDiv(zaxis) + assert (scalediv.lowerBound(), scalediv.upperBound()) == pytest.approx( + (1.0, 10000.0) + ) + widget = plot.axisWidget(zaxis) + colormap = widget.colorMap() + assert isinstance(colormap, LogScaleColorMap) + assert colormap.colormap is item.get_color_map() + interval = widget.colorBarInterval() + assert (interval.minValue(), interval.maxValue()) == pytest.approx( + (1.0, 10000.0) + ) + # Switching to another item without log scale restores a linear color bar + other = make.image(DATA, interpolation="nearest") + plot.add_item(other) + plot.set_active_item(other) + assert plot.get_axis_scale(zaxis) == "lin" + assert widget.colorMap() is other.get_color_map() + plot.set_active_item(item) + assert plot.get_axis_scale(zaxis) == "log" + item.set_zaxis_log_state(False) + assert plot.get_axis_scale(zaxis) == "lin" + assert widget.colorMap() is item.get_color_map() + win.close() + + +@pytest.mark.parametrize("invert", (False, True)) +def test_log_scale_colormap_colors(invert: bool) -> None: + """`LogScaleColorMap` applies the wrapped colormap to the log of values""" + with qt_app_context(exec_loop=False): + cmap = get_cmap("jet") + old_invert = cmap.invert + cmap.invert = invert + try: + logcmap = LogScaleColorMap(cmap) + assert logcmap.format() == cmap.format() + interval = QwtInterval(1.0, 10000.0) + loginterval = QwtInterval(0.0, 4.0) + for exponent in range(5): + value = 10.0**exponent + assert logcmap.rgb(interval, value) == cmap.rgb(loginterval, exponent) + assert logcmap.colorIndex(interval, value) == cmap.colorIndex( + loginterval, exponent + ) + assert logcmap.rgb(interval, 0.0) == cmap.rgb(loginterval, 0.0) + assert logcmap.rgb(interval, -1.0) == cmap.rgb(loginterval, 0.0) + # Not strictly positive intervals are left unchanged + interval = QwtInterval(-1.0, 1.0) + assert logcmap.rgb(interval, 0.5) == cmap.rgb(interval, 0.5) + finally: + cmap.invert = old_invert + + +def test_unsupported_items() -> None: + """Items without log color mapping are ignored by the Z-axis log tool""" + with qt_app_context(exec_loop=False): + win = make.dialog(type="image", toolbar=True) + plot = win.manager.get_plot() + tool = win.manager.get_tool(ZAxisLogTool) + rgb = make.rgbimage(np.zeros((6, 8, 3), dtype=np.uint8)) + rng = np.random.default_rng(0) + hist2d = make.histogram2D(rng.normal(size=100), rng.normal(size=100), 8, 8) + for item in (rgb, hist2d): + assert not item.supports_zaxis_log() + plot.add_item(item) + item.set_zaxis_log_state(True) + assert not item.get_zaxis_log_state() + tool.update_status(plot) + assert not tool.get_supported_items(plot) + assert not tool.action.isEnabled() + image = make.image(DATA) + plot.add_item(image) + tool.update_status(plot) + assert tool.get_supported_items(plot) == [image] + assert tool.action.isEnabled() + win.close() + + +def test_tool_notifies_lut_change() -> None: + """Toggling the Z-axis log scale updates the contrast panel range""" + with qt_app_context(exec_loop=False): + win = make.dialog(type="image", toolbar=True, show_contrast=True) + plot = win.manager.get_plot() + contrast = win.manager.get_contrast_panel() + item = make.image(DATA, interpolation="nearest") + plot.add_item(item) + plot.set_active_item(item) + plot.select_item(item) + item.set_lut_range((100.0, 5000.0)) + emitted = [] + plot.SIG_LUT_CHANGED.connect(lambda _plot: emitted.append(True)) + tool = win.manager.get_tool(ZAxisLogTool) + tool.activate() + assert item.get_zaxis_log_state() + assert emitted + assert contrast.histogram.range.get_range() == pytest.approx((1.0, 10000.0)) + tool.activate() + assert not item.get_zaxis_log_state() + assert contrast.histogram.range.get_range() == pytest.approx((100.0, 5000.0)) + win.close() diff --git a/plotpy/tools/image.py b/plotpy/tools/image.py index 614764a6..a176c61e 100644 --- a/plotpy/tools/image.py +++ b/plotpy/tools/image.py @@ -388,7 +388,13 @@ class ReverseYAxisTool(BaseReverseAxisTool): class ZAxisLogTool(ToggleTool): - """Patched tools.ToggleTool""" + """Toggle the Z-axis logarithmic scale of image items + + Colors are then mapped to the base-10 logarithm of the data, whereas the color + bar, the contrast adjustment panel and the cursor values remain in data units + (see :meth:`.BaseImageItem.set_zaxis_log_state`). Image items which do not + support it (see :meth:`.BaseImageItem.supports_zaxis_log`) are ignored. + """ def __init__(self, manager: PlotManager) -> None: title = _("Base-10 logarithmic Z axis") @@ -403,7 +409,9 @@ def activate_command(self, plot: BasePlot, checked: bool) -> None: """Reimplement tools.ToggleTool method""" for item in self.get_supported_items(plot): item.set_zaxis_log_state(not item.get_zaxis_log_state()) - plot.replot() + # The LUT range of the items has changed: notify the contrast adjustment + # panel and the other listeners, update the color bar and replot + plot.notify_colormap_changed() self.update_status(plot) def get_supported_items(self, plot: BasePlot) -> list[BaseImageItem]: @@ -413,7 +421,7 @@ def get_supported_items(self, plot: BasePlot) -> list[BaseImageItem]: for item in plot.get_items() if isinstance(item, BaseImageItem) and not item.is_empty() - and hasattr(item, "get_zaxis_log_state") + and item.supports_zaxis_log() ] if len(items) > 1: selected = plot.get_selected_items()