@@ -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 :
0 commit comments