fix(image): prevent log-scale crash from non-finite color levels

This commit is contained in:
2026-07-14 12:53:04 +02:00
committed by Jan Wyzula
parent a749992530
commit aca13fb305
4 changed files with 102 additions and 14 deletions
@@ -954,6 +954,11 @@ class ImageBase(PlotBase):
vmin, vmax = value.x(), value.y()
# Non-finite levels would crash pyqtgraph's colorbar
if not (np.isfinite(vmin) and np.isfinite(vmax)):
logger.warning(f"Ignoring non-finite v_range ({vmin}, {vmax}).")
return
for layer in self.layer_manager:
if not layer.sync.v_range:
continue
+10 -1
View File
@@ -142,6 +142,13 @@ class ImageItem(BECConnector, pg.ImageItem):
self.set_v_range(vrange, disable_autorange=True)
def set_v_range(self, vrange: tuple[float, float], disable_autorange=True):
vmin, vmax = vrange
if not (np.isfinite(vmin) and np.isfinite(vmax)):
logger.warning(f"Ignoring non-finite v_range ({vmin}, {vmax}).")
return
if vmin > vmax:
vmin, vmax = vmax, vmin
vrange = (float(vmin), float(vmax))
if disable_autorange:
self.config.autorange = False
self.vRangeChangedManually.emit(vrange)
@@ -200,8 +207,10 @@ class ImageItem(BECConnector, pg.ImageItem):
"""Update the v_range based on the stats of the image."""
fumble_factor = 2
if self.config.autorange_mode == "mean":
vmin = max(stats.mean - fumble_factor * stats.std, 0)
vmin = stats.mean - fumble_factor * stats.std
vmax = stats.mean + fumble_factor * stats.std
if stats.minimum >= 0:
vmin = max(vmin, 0)
elif self.config.autorange_mode == "max":
vmin, vmax = stats.minimum, stats.maximum
else:
@@ -28,10 +28,10 @@ class ImageStats:
ImageStats: The statistics of the image data.
"""
return cls(
maximum=np.nanmax(data),
minimum=np.nanmin(data),
mean=np.nanmean(data),
std=np.nanstd(data),
maximum=float(np.nanmax(data)),
minimum=float(np.nanmin(data)),
mean=float(np.nanmean(data)),
std=float(np.nanstd(data)),
)
@@ -123,11 +123,11 @@ class ImageProcessor(QObject):
Returns:
np.ndarray: The processed data.
"""
# TODO this is not final solution -> data should stay as int16
data = data.astype(np.float32)
offset = 1e-6
data_offset = data + offset
return np.log10(data_offset)
data = np.asarray(data, dtype=np.float32)
# Detector data is counts (>= 0): clipping at 0.1 maps zero-count pixels
# to -1 and single counts to 0, keeping the log image finite without
# stretching the display range down to the float epsilon.
return np.log10(np.clip(data, 0.1, None))
def update_image_stats(self, data: np.ndarray) -> None:
"""Get the statistics of the image data.
@@ -136,10 +136,10 @@ class ImageProcessor(QObject):
data(np.ndarray): The image data.
"""
self.config.stats.maximum = np.max(data)
self.config.stats.minimum = np.min(data)
self.config.stats.mean = np.mean(data)
self.config.stats.std = np.std(data)
self.config.stats.maximum = float(np.nanmax(data))
self.config.stats.minimum = float(np.nanmin(data))
self.config.stats.mean = float(np.nanmean(data))
self.config.stats.std = float(np.nanstd(data))
def process_image(self, data: np.ndarray) -> np.ndarray:
"""Core processing logic without threading overhead."""
@@ -5,6 +5,7 @@ from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QPointF, Qt
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.image_processor import ImageProcessor, ProcessingConfig
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@@ -1366,3 +1367,76 @@ def test_signal_syncs_from_toolbar(qtbot, mocked_client):
view._sync_signal_from_toolbar()
assert view.signal == "img_b"
##############################################
# Log scale / non-finite data crash regression
##############################################
@pytest.mark.parametrize(
"data",
[
-np.abs(np.random.rand(20, 30)).astype(np.float32) - 1.0, # all negative -> all NaN
np.zeros((20, 30), dtype=np.uint16), # all zeros -> uniform floor
np.where(np.eye(20, 30) > 0, -5.0, 3.0).astype(np.float32), # mixed sign
],
ids=["all_negative", "all_zeros", "mixed_sign"],
)
def test_log_scale_does_not_crash_with_full_colorbar(qtbot, mocked_client, data):
"""Log scaling of non-positive data must not produce NaN levels that crash
the full (HistogramLUTItem) colorbar. Regression for fix/image-crashing."""
view = create_widget(qtbot, Image, client=mocked_client)
view.enable_full_colorbar = True
assert isinstance(view._color_bar, pg.HistogramLUTItem)
view.log = True
# This is the exact path that used to raise "Cannot set range [nan, nan]".
view.on_image_update_2d({"data": data}, {})
vmin, vmax = view.main_image.v_range
assert np.isfinite(vmin) and np.isfinite(vmax)
assert vmin <= vmax
# Colorbar levels stay finite as well.
low, high = view._color_bar.getLevels()
assert np.isfinite(low) and np.isfinite(high)
def test_log_scale_all_negative_keeps_finite_image(qtbot, mocked_client):
"""All-negative input must still yield a finite, displayed image under log."""
view = create_widget(qtbot, Image, client=mocked_client)
view.log = True
data = (-np.abs(np.random.rand(15, 15)) - 1.0).astype(np.float32)
view.on_image_update_2d({"data": data}, {})
assert view.main_image.image is not None
assert np.all(np.isfinite(view.main_image.image))
def test_set_v_range_ignores_non_finite_levels(qtbot, mocked_client):
"""Non-finite v_range requests are rejected rather than forwarded to pg."""
view = create_widget(qtbot, Image, client=mocked_client)
view.on_image_update_2d({"data": np.random.rand(10, 10)}, {})
good = view.main_image.v_range
view.main_image.set_v_range((np.nan, np.nan))
assert view.main_image.v_range == good # unchanged
# Inverted ranges are normalised, not propagated as-is.
view.main_image.set_v_range((10.0, 2.0))
lo, hi = view.main_image.v_range
assert (lo, hi) == (2.0, 10.0)
def test_image_processor_log_is_finite_for_non_positive():
"""ImageProcessor.log must never emit NaN/inf for zero/negative input."""
proc = ImageProcessor(config=ProcessingConfig(log=True))
data = np.array([[-100.0, -1.0], [0.0, 1000.0]], dtype=np.float32)
out = proc.log(data)
assert np.all(np.isfinite(out))
# Non-positive pixels collapse to the floor log10(0.1) == -1; a count of 1
# maps to 0.
assert out[0, 0] == pytest.approx(-1.0)
assert out[1, 0] == pytest.approx(-1.0)
assert proc.log(np.array([[1.0]]))[0, 0] == pytest.approx(0.0)