From aca13fb305e73c4138581c15a6d728aecd471197 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 9 Jul 2026 14:26:32 +0200 Subject: [PATCH] fix(image): prevent log-scale crash from non-finite color levels --- bec_widgets/widgets/plots/image/image_base.py | 5 ++ bec_widgets/widgets/plots/image/image_item.py | 11 ++- .../widgets/plots/image/image_processor.py | 26 +++---- tests/unit_tests/test_image_view_next_gen.py | 74 +++++++++++++++++++ 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index d3f96193..2c80baea 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -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 diff --git a/bec_widgets/widgets/plots/image/image_item.py b/bec_widgets/widgets/plots/image/image_item.py index 66ee0926..3b20d896 100644 --- a/bec_widgets/widgets/plots/image/image_item.py +++ b/bec_widgets/widgets/plots/image/image_item.py @@ -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: diff --git a/bec_widgets/widgets/plots/image/image_processor.py b/bec_widgets/widgets/plots/image/image_processor.py index 0bc796e6..099f17c9 100644 --- a/bec_widgets/widgets/plots/image/image_processor.py +++ b/bec_widgets/widgets/plots/image/image_processor.py @@ -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.""" diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 884e36bb..4b2c8411 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -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) \ No newline at end of file