Skip to content

Commit 1c2e4ec

Browse files
committed
fix(image): keep LUT range in data units with Z-axis log scale
With the Z-axis logarithmic scale, the LUT range of image items was expressed in base-10 logarithms: the color bar showed logarithms of the image values, the contrast adjustment panel mixed a histogram of the data with a range of logarithms (so that "Full range" or "Eliminate %" showed the image with a single color), and cross sections or exported images with the LUT applied mixed data with log coefficients. The LUT range now always uses data units: only the color mapping is logarithmic (like matplotlib's LogNorm). - `set_lut_range` stores data units and computes the LUT coefficients from the logarithms of the bounds; `get_lut_range_full` and `set_data` follow the same convention - Values lower than or equal to zero are replaced by the smallest positive value of the data instead of 1 (`clip(1)`), so that data lower than 1 is no longer shown with a single color - Enabling the log scale sets the full range of the positive values, disabling it restores the previous LUT range; enabling it twice no longer overwrites the saved range - The color bar uses a logarithmic scale engine, with bounds in data units, and the new `LogScaleColorMap` so that its colors match the image - Cross sections and `export_roi` with `apply_lut=True` use the log data; the anti-aliasing kernel follows the data type of the data drawn - New `BaseImageItem.supports_zaxis_log`: RGB images, 2D histograms, quadrilateral grids and image filters, which do not draw the log data, are ignored by `ZAxisLogTool` and `set_zaxis_log_state` - `ZAxisLogTool` notifies the LUT change, so that the contrast panel and other listeners get the new range Closes #63 Assisted-by: Claude Opus 5.5
1 parent eb2a4d6 commit 1c2e4ec

10 files changed

Lines changed: 568 additions & 74 deletions

File tree

‎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)