Skip to content

Commit b62c67d

Browse files
Merge pull request #86 from PlotPyStack/fix/63-zaxis-log-data-units
Keep the LUT range in data units with the Z-axis log scale (color bar, contrast panel)
2 parents eb2a4d6 + 78efb10 commit b62c67d

11 files changed

Lines changed: 569 additions & 74 deletions

File tree

‎doc/release_notes/release_2.12.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
🛠️ Bug fixes:
1010

11+
* **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))
1112
* **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
1213
* **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))
1314
* **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

‎plotpy/items/image/base.py‎

Lines changed: 128 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ class BaseImageItem(QwtPlotItem):
188188
_readonly = False
189189
_private = False
190190
_icon_name = "image.png"
191+
#: True if the item supports the Z-axis logarithmic scale, i.e. if it draws
192+
#: its data through :meth:`_get_display_data` (see :meth:`supports_zaxis_log`)
193+
_ZAXIS_LOG_SUPPORTED = False
191194

192195
def __init__(
193196
self, data: np.ndarray | None = None, param: Any | None = None
@@ -211,6 +214,13 @@ def __init__(
211214

212215
self.data: np.ndarray | None = None
213216

217+
# Z-axis logarithmic scale support: the LUT range (`min`, `max`) is always
218+
# expressed in data units, only the color mapping is logarithmic
219+
self._log_data: np.ndarray | None = None
220+
self._log_floor = 1.0
221+
self._lin_lut_range: tuple[float, float] | None = None
222+
self._is_zaxis_log = False
223+
214224
self.min = 0.0
215225
self.max = 1.0
216226
self.cmap_table = None
@@ -238,11 +248,6 @@ def __init__(
238248

239249
self.histogram_cache = None
240250

241-
# Z-axis logarithmic scale support
242-
self._log_data: np.ndarray | None = None
243-
self._lin_lut_range: tuple[float, float] | None = None
244-
self._is_zaxis_log = False
245-
246251
if data is not None:
247252
self.set_data(data)
248253
self.param.update_item(self)
@@ -451,8 +456,47 @@ def _recompute_log_data(self) -> None:
451456
Used both when toggling the Z-axis log scale on and when the underlying
452457
data is replaced (e.g. via :meth:`set_data`) while the log scale is
453458
already active.
459+
460+
Values lower than or equal to zero have no logarithm: they are replaced
461+
by the smallest positive value of the data (the "log floor", or 1.0 if
462+
there is no positive value), so that they are shown with the lowest
463+
color of the colormap. NaN values are kept.
464+
"""
465+
data = np.asarray(self.data)
466+
with np.errstate(invalid="ignore"):
467+
positive = data[data > 0]
468+
self._log_floor = float(positive.min()) if positive.size else 1.0
469+
with np.errstate(invalid="ignore", divide="ignore"):
470+
self._log_data = np.log10(
471+
np.maximum(data, self._log_floor, dtype=np.float64)
472+
)
473+
474+
def _get_display_data(self) -> np.ndarray:
475+
"""Return the data to which the LUT is applied when drawing the image
476+
477+
Returns:
478+
Base-10 logarithm of the data if the Z-axis log scale is enabled,
479+
the data itself otherwise
480+
"""
481+
if self._is_zaxis_log:
482+
return self._log_data
483+
return self.data
484+
485+
def _to_lut_space(self, values: np.ndarray) -> np.ndarray:
486+
"""Convert data values to the space in which the LUT is applied
487+
488+
Args:
489+
values: Data values
490+
491+
Returns:
492+
Base-10 logarithm of the values (see :meth:`_recompute_log_data` for
493+
values lower than or equal to zero) if the Z-axis log scale is enabled,
494+
the values themselves otherwise
454495
"""
455-
self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64)
496+
if self._is_zaxis_log:
497+
with np.errstate(invalid="ignore", divide="ignore"):
498+
return np.log10(np.maximum(values, self._log_floor, dtype=np.float64))
499+
return values
456500

457501
def set_data(
458502
self, data: np.ndarray, lut_range: tuple[float, float] | None = None
@@ -461,24 +505,27 @@ def set_data(
461505
462506
Args:
463507
data: 2D NumPy array
464-
lut_range: LUT range -- tuple (levelmin, levelmax) (Default value = None)
508+
lut_range: LUT range -- tuple (levelmin, levelmax), in data units, even
509+
when the Z-axis log scale is enabled (Default value = None)
465510
"""
466511
self.data = data
467512
self.histogram_cache = None
468513
self.update_bounds()
469514
self.update_border()
470515
# Refresh the cached log10 data when log scale is active, otherwise the
471516
# display would keep using the previous (now stale) log data.
472-
if self.get_zaxis_log_state():
517+
if self._is_zaxis_log:
473518
self._recompute_log_data()
474519
if not self.param.keep_lut_range:
475-
if lut_range is not None:
476-
_min, _max = lut_range
477-
elif self.get_zaxis_log_state():
478-
_min, _max = get_nan_range(self._log_data)
479-
else:
480-
_min, _max = get_nan_range(data)
481-
self.set_lut_range((_min, _max))
520+
if self._is_zaxis_log:
521+
# The linear LUT range, restored when the log scale is disabled,
522+
# is updated as it would have been without the log scale
523+
self._lin_lut_range = (
524+
get_nan_range(data) if lut_range is None else tuple(lut_range)
525+
)
526+
if lut_range is None:
527+
lut_range = self.get_lut_range_full()
528+
self.set_lut_range(lut_range)
482529

483530
def get_data(
484531
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:
598645
if interp_mode in (INTERP_NEAREST, INTERP_LINEAR):
599646
self.interpolate = (interp_mode,)
600647
if interp_mode == INTERP_AA:
601-
aa = np.ones((size, size), self.data.dtype)
648+
# The kernel must have the same data type as the data drawn
649+
aa = np.ones((size, size), self._get_display_data().dtype)
602650
self.interpolate = (interp_mode, aa)
603651

604652
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:
610658
Set the current active lut range
611659
612660
Args:
613-
lut_range: Lut range, tuple(min, max)
661+
lut_range: Lut range, tuple(min, max), in data units (even when the
662+
Z-axis log scale is enabled: values lower than or equal to zero are
663+
then replaced by the smallest positive value of the data)
614664
615665
Example:
616666
>>> item.set_lut_range((0.0, 1.0))
617667
"""
618-
self.min, self.max = lut_range
668+
vmin, vmax = lut_range
669+
if self._is_zaxis_log:
670+
if vmax <= 0:
671+
vmax = self._log_floor
672+
if vmin <= 0:
673+
vmin = min(self._log_floor, vmax)
674+
self.min, self.max = vmin, vmax
619675
_a, _b, bg, cmap = self.lut
620-
if self.max == self.min:
621-
self.lut = (LUT_MAX, self.min, bg, cmap)
676+
if self._is_zaxis_log:
677+
lmin, lmax = np.log10(float(vmin)), np.log10(float(vmax))
678+
else:
679+
lmin, lmax = self.min, self.max
680+
if lmax == lmin:
681+
self.lut = (LUT_MAX, lmin, bg, cmap)
622682
else:
623-
fmin, fmax = float(self.min), float(self.max) # avoid overflows
683+
fmin, fmax = float(lmin), float(lmax) # avoid overflows
624684
self.lut = (
625685
LUT_MAX / (fmax - fmin),
626686
-LUT_MAX * fmin / (fmax - fmin),
@@ -632,7 +692,7 @@ def get_lut_range(self) -> tuple[float, float]:
632692
"""Get the current active lut range
633693
634694
Returns:
635-
tuple[float, float]: Lut range, tuple(min, max)
695+
tuple[float, float]: Lut range, tuple(min, max), in data units
636696
"""
637697
return self.min, self.max
638698

@@ -648,35 +708,61 @@ def get_lut_range_full(self) -> tuple[float, float]:
648708
"""Return full dynamic range
649709
650710
Returns:
651-
tuple[float, float]: Lut range, tuple(min, max)
711+
tuple[float, float]: Lut range, tuple(min, max), in data units. When the
712+
Z-axis log scale is enabled, the minimum is the smallest positive value
713+
of the data.
652714
"""
653-
return get_nan_range(self.data)
715+
vmin, vmax = get_nan_range(self.data)
716+
if self._is_zaxis_log:
717+
return self._log_floor, max(vmax, self._log_floor)
718+
return vmin, vmax
654719

655720
# ---- Z-axis logarithmic scale --------------------------------------------
721+
def supports_zaxis_log(self) -> bool:
722+
"""Return True if the item supports the Z-axis logarithmic scale
723+
724+
Items which do not support it (e.g. RGB images, which have no colormap)
725+
are ignored by :class:`.ZAxisLogTool`, and :meth:`set_zaxis_log_state`
726+
has no effect on them.
727+
"""
728+
return self._ZAXIS_LOG_SUPPORTED
729+
656730
def get_zaxis_log_state(self) -> bool:
657731
"""Return True if Z-axis is in logarithmic scale"""
658732
return self._is_zaxis_log
659733

660734
def set_zaxis_log_state(self, state: bool) -> None:
661735
"""Set Z-axis logarithmic scale state
662736
737+
With the logarithmic scale, the colors are mapped to the base-10 logarithm
738+
of the data, whereas the LUT range (see :meth:`get_lut_range`), the color
739+
bar, the contrast adjustment panel and the cursor values remain in data
740+
units. Values lower than or equal to zero are shown with the lowest color
741+
of the colormap.
742+
743+
Enabling the logarithmic scale sets the LUT range to the full range of the
744+
positive values of the data; disabling it restores the previous LUT range.
745+
663746
Args:
664747
state: True to enable logarithmic scale, False otherwise
665748
"""
666-
self._is_zaxis_log = state
667-
plot = self.plot()
749+
state = bool(state)
750+
if not self.supports_zaxis_log() or state == self._is_zaxis_log:
751+
return
668752
if state:
669753
self._lin_lut_range = self.get_lut_range()
670-
if self._log_data is None:
754+
self._is_zaxis_log = True
755+
if self.data is not None:
671756
self._recompute_log_data()
672-
self.set_lut_range(get_nan_range(self._log_data))
673-
dtype = self._log_data.dtype
757+
self.set_lut_range(self.get_lut_range_full())
674758
else:
759+
self._is_zaxis_log = False
675760
self._log_data = None
676761
self.set_lut_range(self._lin_lut_range)
677-
dtype = self.data.dtype
678-
if self.interpolate[0] == INTERP_AA:
762+
if self.data is not None and self.interpolate[0] == INTERP_AA:
763+
dtype = self._get_display_data().dtype
679764
self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype))
765+
plot = self.plot()
680766
if plot is not None:
681767
plot.update_colormap_axis(self)
682768

@@ -766,7 +852,12 @@ def draw_image(
766852
if self.warn_if_non_linear_scale(painter, canvasRect):
767853
return
768854
dest = _scale_rect(
769-
self.data, src_rect, self._offscreen, dst_rect, self.lut, self.interpolate
855+
self._get_display_data(),
856+
src_rect,
857+
self._offscreen,
858+
dst_rect,
859+
self.lut,
860+
self.interpolate,
770861
)
771862
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
772863
painter.drawImage(qrect, self._image, qrect)
@@ -797,10 +888,12 @@ def export_roi(
797888
"""
798889
if apply_lut:
799890
a, b, _bg, _cmap = self.lut
891+
data = self._get_display_data()
800892
else:
801893
a, b = 1.0, 0.0
894+
data = self.data
802895
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
803-
_scale_rect(self.data, src_rect, dst_image, dst_rect, (a, b, None), interp)
896+
_scale_rect(data, src_rect, dst_image, dst_rect, (a, b, None), interp)
804897

805898
# ---- QwtPlotItem API -----------------------------------------------------
806899
def draw(
@@ -1170,7 +1263,7 @@ def get_histogram(
11701263
def __process_cross_section(self, ydata, apply_lut):
11711264
if apply_lut:
11721265
a, b, _bg, _cmap = self.lut
1173-
return (ydata * a + b).clip(0, LUT_MAX)
1266+
return (self._to_lut_space(ydata) * a + b).clip(0, LUT_MAX)
11741267
else:
11751268
return ydata
11761269

@@ -1283,6 +1376,7 @@ class RawImageItem(BaseImageItem):
12831376
IVoiImageItemType,
12841377
ISerializableType,
12851378
)
1379+
_ZAXIS_LOG_SUPPORTED = True
12861380

12871381
# ---- BaseImageItem API ---------------------------------------------------
12881382
def get_default_param(self) -> RawImageParam:

‎plotpy/items/image/misc.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ class QuadGridItem(RawImageItem):
7171
"""
7272

7373
__implements__ = (IBasePlotItem, IBaseImageItem, IHistDataSource, IVoiImageItemType)
74+
# Quadrilaterals are drawn from the data itself, without the log10 data
75+
_ZAXIS_LOG_SUPPORTED = False
7476

7577
def __init__(
7678
self,

‎plotpy/items/image/standard.py‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -359,17 +359,14 @@ def draw_image(
359359
src2 = self._rescale_src_rect(src_rect)
360360
dst_rect = tuple([int(i) for i in dst_rect])
361361

362-
# Not the most efficient way to do it, but it works...
363-
# --------------------------------------------------------------------------
364-
if self.get_zaxis_log_state():
365-
data = self._log_data
366-
else:
367-
data = self.data
368-
# --------------------------------------------------------------------------
369-
370362
try:
371363
dest = _scale_rect(
372-
data, src2, self._offscreen, dst_rect, self.lut, self.interpolate
364+
self._get_display_data(),
365+
src2,
366+
self._offscreen,
367+
dst_rect,
368+
self.lut,
369+
self.interpolate,
373370
)
374371
except ValueError:
375372
# This exception is raised when zooming unreasonably inside a pixel
@@ -403,12 +400,14 @@ def export_roi(
403400
"""
404401
if apply_lut:
405402
a, b, _bg, _cmap = self.lut
403+
data = self._get_display_data()
406404
else:
407405
a, b = 1.0, 0.0
406+
data = self.data
408407
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
409408
rescaled_src = self._rescale_src_rect(src_rect)
410409
_scale_rect(
411-
self.data,
410+
data,
412411
rescaled_src,
413412
dst_image,
414413
dst_rect,
@@ -657,12 +656,13 @@ def draw_image(
657656
return
658657
xytr = self.x, self.y, src_rect
659658
dst_rect = tuple([int(i) for i in dst_rect])
660-
if self.get_zaxis_log_state():
661-
data = self._log_data
662-
else:
663-
data = self.data
664659
dest = _scale_xy(
665-
data, xytr, self._offscreen, dst_rect, self.lut, self.interpolate
660+
self._get_display_data(),
661+
xytr,
662+
self._offscreen,
663+
dst_rect,
664+
self.lut,
665+
self.interpolate,
666666
)
667667
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
668668
painter.drawImage(qrect, self._image, qrect)
@@ -692,12 +692,14 @@ def export_roi(
692692
"""
693693
if apply_lut:
694694
lut = self.lut
695+
data = self._get_display_data()
695696
else:
696697
a, b = 1.0, 0.0
697698
lut = (a, b, None, np.zeros((1,), np.uint32))
699+
data = self.data
698700
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
699701
xytr = self.x, self.y, src_rect
700-
_scale_xy(self.data, xytr, dst_image, dst_rect, lut, interp)
702+
_scale_xy(data, xytr, dst_image, dst_rect, lut, interp)
701703

702704
def get_pixel_coordinates(self, xplot: float, yplot: float) -> tuple[float, float]:
703705
"""Get pixel coordinates from plot coordinates
@@ -835,6 +837,8 @@ class RGBImageItem(ImageItem):
835837
"""
836838

837839
__implements__ = (IBasePlotItem, IBaseImageItem, ISerializableType)
840+
# RGB images have no colormap
841+
_ZAXIS_LOG_SUPPORTED = False
838842

839843
def __init__(
840844
self, data: np.ndarray | None = None, param: RGBImageParam | None = None

0 commit comments

Comments
 (0)