diff --git a/datalab/adapters_plotpy/objects/signal.py b/datalab/adapters_plotpy/objects/signal.py
index 2a2dd937..69a1e3de 100644
--- a/datalab/adapters_plotpy/objects/signal.py
+++ b/datalab/adapters_plotpy/objects/signal.py
@@ -15,7 +15,7 @@
import numpy as np
from guidata.dataset import restore_dataset, update_dataset
from plotpy.builder import make
-from plotpy.items import CurveItem
+from plotpy.items import CurveItem, ErrorBarCurveItem
from sigima.objects import SignalObj
from datalab.adapters_plotpy.objects.base import (
@@ -245,7 +245,12 @@ def update_item(self, item: CurveItem, data_changed: bool = True) -> None:
and isinstance(dx, np.ndarray)
and isinstance(dy, np.ndarray)
)
- item.set_data(x.real, y.real, dx.real, dy.real)
+ if isinstance(item, ErrorBarCurveItem):
+ item.set_data(x.real, y.real, dx.real, dy.real)
+ else:
+ # xydata has 4 rows but dx/dy are all NaN (no real
+ # error bars) — the plot item is a plain CurveItem
+ item.set_data(x.real, y.real)
item.param.label = o.title
apply_downsampling(item)
# Reapply linewidth with smart clamping (data size may have changed)
diff --git a/datalab/data/icons/processing/replace_nan.svg b/datalab/data/icons/processing/replace_nan.svg
new file mode 100644
index 00000000..dea21bb3
--- /dev/null
+++ b/datalab/data/icons/processing/replace_nan.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/datalab/gui/actionhandler.py b/datalab/gui/actionhandler.py
index 6a3cc0e9..3ca8827d 100644
--- a/datalab/gui/actionhandler.py
+++ b/datalab/gui/actionhandler.py
@@ -1086,6 +1086,7 @@ def create_first_actions(self):
with self.new_menu(_("Level adjustment"), icon_name="level_adjustment.svg"):
self.action_for("normalize")
self.action_for("clip")
+ self.action_for("replace_special_values")
self.new_action(
_("Offset correction"),
triggered=self.panel.processor.compute_offset_correction,
diff --git a/datalab/gui/processor/image.py b/datalab/gui/processor/image.py
index 8e4bbbca..ece5824f 100644
--- a/datalab/gui/processor/image.py
+++ b/datalab/gui/processor/image.py
@@ -36,6 +36,9 @@
from datalab.objectmodel import get_uuid
from datalab.utils.qthelpers import create_progress_bar, qt_try_except
from datalab.widgets import imagebackground
+from datalab.widgets.replacespecialvalues import (
+ ReplaceSpecialValuesImageParamDL,
+)
class ImageProcessor(BaseProcessor[ImageROI, ROI2DParam]):
@@ -279,6 +282,12 @@ def register_processing(self) -> None:
icon_name="normalize.svg",
)
self.register_1_to_1(sipi.clip, _("Clipping"), sipb.ClipParam, "clip.svg")
+ self.register_1_to_1(
+ sipi.replace_special_values,
+ _("Replace special values"),
+ ReplaceSpecialValuesImageParamDL,
+ "replace_nan.svg",
+ )
self.register_1_to_1(
sipi.offset_correction,
_("Offset correction"),
diff --git a/datalab/gui/processor/signal.py b/datalab/gui/processor/signal.py
index 60f71e3b..1d6e025c 100644
--- a/datalab/gui/processor/signal.py
+++ b/datalab/gui/processor/signal.py
@@ -40,6 +40,9 @@
signaldeltax,
signalpeak,
)
+from datalab.widgets.replacespecialvalues import (
+ ReplaceSpecialValuesSignalParamDL,
+)
class SignalProcessor(BaseProcessor[SignalROI, ROI1DParam]):
@@ -236,6 +239,12 @@ def register_processing(self) -> None:
self.register_1_to_1(
sips.clip, _("Clipping"), sigima_base.ClipParam, "clip.svg"
)
+ self.register_1_to_1(
+ sips.replace_special_values,
+ _("Replace special values"),
+ ReplaceSpecialValuesSignalParamDL,
+ "replace_nan.svg",
+ )
self.register_1_to_1(
sips.offset_correction,
_("Offset correction"),
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 35af47c3..29991dbb 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -1677,7 +1677,6 @@ msgstr "Enregistrer l'historique dans un fichier HDF5..."
msgid "Save history to a standalone .dlhist file"
msgstr "Enregistrer l'historique dans un fichier .dlhist autonome"
-#, python-format
msgid "Duplicate selected history action/session"
msgstr "Dupliquer l'objet %s sélectionné"
@@ -2169,6 +2168,9 @@ msgstr "Normaliser"
msgid "Clipping"
msgstr "Écrêtage"
+msgid "Replace special values"
+msgstr "Remplacer les valeurs spéciales"
+
msgid "Add Gaussian noise"
msgstr "Ajouter du bruit gaussien"
@@ -3772,6 +3774,21 @@ msgstr "Des journaux de bord ont été générés lors de la dernière session."
msgid "Log files are currently empty."
msgstr "Les journaux de bord sont vides."
+msgid "Kernel / Mask preview"
+msgstr "Aperçu du noyau / masque"
+
+#, python-brace-format
+msgid "{label} — Kernel / Mask preview"
+msgstr "{label} — Aperçu du noyau / masque"
+
+#, python-brace-format
+msgid "±{n} points"
+msgstr "±{n} points"
+
+#, python-brace-format
+msgid "±{n} rows × ±{n} columns"
+msgstr "±{n} lignes × ±{n} colonnes"
+
msgid "Signal baseline selection"
msgstr "Sélection de la ligne de base du signal"
@@ -4113,6 +4130,9 @@ msgstr "Image"
msgid "Dimensions"
msgstr "Dimensions"
+msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable."
+msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable."
+
msgid "Minimum value"
msgstr "Valeur minimum"
@@ -4160,3 +4180,4 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
+
diff --git a/datalab/tests/features/image/replace_special_values_app_test.py b/datalab/tests/features/image/replace_special_values_app_test.py
new file mode 100644
index 00000000..b70d4b14
--- /dev/null
+++ b/datalab/tests/features/image/replace_special_values_app_test.py
@@ -0,0 +1,108 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Replace special values application test for images.
+
+This test verifies DataLab processor integration for ``replace_special_values``.
+Algorithm correctness (including Inf handling) is covered by Sigima unit tests.
+Only NaN values are injected here to avoid Qt plot instability with Inf data.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+from __future__ import annotations
+
+import numpy as np
+import sigima.params
+from qtpy import QtWidgets as QW
+from sigima.enums import ReplacementStrategyImage as S
+from sigima.objects import ImageObj, create_image
+
+from datalab.tests import datalab_test_app_context
+from datalab.widgets.replacespecialvalues import ReplaceSpecialValuesImageParamDL
+
+
+def test_replace_special_values_image_app():
+ """Test image replace special values through DataLab processor."""
+ with datalab_test_app_context(console=False) as win:
+ panel = win.imagepanel
+
+ # Use NaN-only data: Inf values destabilize Qt image plot rendering/teardown
+ data = np.array(
+ [
+ [1.0, np.nan, 3.0],
+ [4.0, 5.0, np.nan],
+ [7.0, 8.0, 9.0],
+ ]
+ )
+ image = create_image("Image with NaN values", data)
+ panel.add_object(image)
+ panel.objview.select_objects([image])
+
+ param = sigima.params.ReplaceSpecialValuesImageParam.create(
+ nan_strategy=S.CONSTANT,
+ nan_constant_value=10.0,
+ )
+ panel.processor.run_feature("replace_special_values", param, edit=False)
+
+ result_objects = panel.objview.get_sel_objects()
+ assert len(result_objects) == 1
+ result = result_objects[0]
+ assert isinstance(result, ImageObj)
+ assert not np.isnan(result.data).any()
+ np.testing.assert_array_equal(
+ result.data,
+ np.array(
+ [
+ [1.0, 10.0, 3.0],
+ [4.0, 5.0, 10.0],
+ [7.0, 8.0, 9.0],
+ ]
+ ),
+ )
+
+
+def test_replace_special_values_integer_image_app_noop():
+ """Integer images should keep their data unchanged and emit a warning path."""
+ with datalab_test_app_context(console=False) as win:
+ panel = win.imagepanel
+
+ data = np.arange(9, dtype=np.uint16).reshape(3, 3)
+ image = create_image("Integer image", data)
+ panel.add_object(image)
+ panel.objview.select_objects([image])
+
+ param = sigima.params.ReplaceSpecialValuesImageParam.create(
+ nan_strategy=S.CONSTANT,
+ nan_constant_value=10.0,
+ )
+ panel.processor.run_feature("replace_special_values", param, edit=False)
+
+ result_objects = panel.objview.get_sel_objects()
+ assert len(result_objects) == 1
+ result = result_objects[0]
+ assert isinstance(result, ImageObj)
+ assert result is not image
+ assert result.data.dtype == np.uint16
+ np.testing.assert_array_equal(result.data, data)
+
+
+def test_replace_special_values_integer_image_dialog_disabled():
+ """The custom dialog should inform the user and disable validation."""
+ with datalab_test_app_context(console=False):
+ data = np.arange(9, dtype=np.uint16).reshape(3, 3)
+ image = create_image("Integer image", data)
+
+ param = ReplaceSpecialValuesImageParamDL.create()
+ param.update_from_obj(image)
+
+ dlg = param.create_dialog()
+ ok_button = dlg.findChild(QW.QDialogButtonBox).button(QW.QDialogButtonBox.Ok)
+ assert ok_button is not None
+ assert not ok_button.isEnabled()
+ dlg.close()
+
+
+if __name__ == "__main__":
+ test_replace_special_values_image_app()
diff --git a/datalab/tests/features/signal/replace_special_values_app_test.py b/datalab/tests/features/signal/replace_special_values_app_test.py
new file mode 100644
index 00000000..e63c0cfa
--- /dev/null
+++ b/datalab/tests/features/signal/replace_special_values_app_test.py
@@ -0,0 +1,51 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Replace special values application test for signals.
+
+This test verifies DataLab processor integration for ``replace_special_values``.
+Algorithm correctness (including Inf handling) is covered by Sigima unit tests.
+Only NaN values are injected here to avoid Qt plot instability with Inf data.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+from __future__ import annotations
+
+import numpy as np
+import sigima.params
+from sigima.enums import ReplacementStrategySignal as S
+from sigima.objects import SignalObj, create_signal
+
+from datalab.tests import datalab_test_app_context
+
+
+def test_replace_special_values_signal_app():
+ """Test signal replace special values through DataLab processor."""
+ with datalab_test_app_context(console=False) as win:
+ panel = win.signalpanel
+
+ # Use NaN-only data: Inf values destabilize Qt signal plot rendering
+ x = np.arange(5, dtype=float)
+ y = np.array([1.0, np.nan, 3.0, np.nan, 5.0])
+ sig = create_signal("Signal with NaN values", x, y)
+ panel.add_object(sig)
+ panel.objview.select_objects([sig])
+
+ param = sigima.params.ReplaceSpecialValuesSignalParam.create(
+ nan_strategy=S.CONSTANT,
+ nan_constant_value=10.0,
+ )
+ panel.processor.run_feature("replace_special_values", param, edit=False)
+
+ result_objects = panel.objview.get_sel_objects()
+ assert len(result_objects) == 1
+ result = result_objects[0]
+ assert isinstance(result, SignalObj)
+ assert not np.isnan(result.y).any()
+ np.testing.assert_array_equal(result.y, [1.0, 10.0, 3.0, 10.0, 5.0])
+
+
+if __name__ == "__main__":
+ test_replace_special_values_signal_app()
diff --git a/datalab/widgets/replacespecialvalues.py b/datalab/widgets/replacespecialvalues.py
new file mode 100644
index 00000000..880a9350
--- /dev/null
+++ b/datalab/widgets/replacespecialvalues.py
@@ -0,0 +1,458 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Replace special values dialog
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Custom dialog widget for the "Replace special values" processing feature.
+
+The dialog extends the standard guidata DataSet edition layout with:
+
+- **Count display**: colored badges showing the number (and percentage) of
+ NaN, +Inf and -Inf samples present in the source signal or image.
+- **Kernel preview**: visual grid of the active neighbor mask, displayed
+ whenever a neighbor-based strategy is selected for any of the three targets.
+- **Live preview update**: the kernel preview is refreshed automatically
+ whenever a parameter value changes.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+from guidata.dataset.qtwidgets import DataSetEditLayout
+from guidata.qthelpers import exec_dialog
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+from sigima.enums import ReplacementStrategyImage, ReplacementStrategySignal
+from sigima.proc.base import (
+ ReplaceSpecialValuesImageParam,
+ ReplaceSpecialValuesSignalParam,
+)
+from sigima.tools.image.replace_values import count_special_values_2d
+from sigima.tools.signal.replace_values import count_special_values
+
+from datalab.config import _
+
+if TYPE_CHECKING:
+ from qtpy.QtWidgets import QWidget
+
+# -- Badge configuration --------------------------------------------------------
+
+_BADGE_COLORS: dict[str, str] = {
+ "nan": "#e74c3c",
+ "posinf": "#e67e22",
+ "neginf": "#3498db",
+}
+
+_BADGE_LABELS: dict[str, str] = {
+ "nan": "NaN",
+ "posinf": "+\u221e",
+ "neginf": "\u2212\u221e",
+}
+
+# -- Helper widgets -------------------------------------------------------------
+
+
+def _make_count_badge(key: str, count: int, total: int) -> QW.QLabel:
+ """Create a rich-text label with a colored dot and count information."""
+ color = _BADGE_COLORS[key]
+ label = _BADGE_LABELS[key]
+ if total > 0 and count > 0:
+ pct = count / total * 100
+ text = (
+ f'\u25cf '
+ f"{label}: {count} ({pct:.1f}%)"
+ )
+ else:
+ text = f'\u25cf {label}: {count}'
+ lbl = QW.QLabel(text)
+ lbl.setTextFormat(QC.Qt.RichText)
+ return lbl
+
+
+class _KernelPreviewWidget(QW.QGroupBox):
+ """Visual grid showing kernel/mask weights.
+
+ The center cell is highlighted in red, other cells use a blue intensity
+ proportional to their weight.
+ """
+
+ def __init__(self, title: str = "", parent: QWidget | None = None) -> None:
+ super().__init__(title or _("Kernel / Mask preview"), parent)
+ layout = QW.QVBoxLayout(self)
+ self._info = QW.QLabel()
+ layout.addWidget(self._info)
+ self._table = QW.QTableWidget()
+ self._table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
+ self._table.setSelectionMode(QW.QAbstractItemView.NoSelection)
+ self._table.setMaximumHeight(140)
+ self._table.verticalHeader().setVisible(False)
+ self._table.horizontalHeader().setVisible(False)
+ layout.addWidget(self._table)
+ self.setVisible(False)
+
+ # -- public API -------------------------------------------------------------
+
+ def show_1d(
+ self, kernel: np.ndarray, info: str = "", show_values: bool = True
+ ) -> None:
+ """Display a 1-D kernel as a single-row table.
+
+ Args:
+ kernel: 1-D weight array.
+ info: label text shown above the table.
+ show_values: if False, cells show only the coloured background
+ (useful for non-weighted stat filters like min/max/median).
+ """
+ self._info.setText(info)
+ n = len(kernel)
+ self._table.setRowCount(1)
+ self._table.setColumnCount(n)
+ center = n // 2
+ vmax = float(np.max(np.abs(kernel))) or 1.0
+ for c in range(n):
+ val = kernel[c]
+ text = f"{val:.3g}" if show_values else ""
+ item = QW.QTableWidgetItem(text)
+ item.setTextAlignment(QC.Qt.AlignCenter)
+ if c == center:
+ item.setBackground(QG.QColor(255, 180, 180, 200))
+ else:
+ alpha = int(abs(val) / vmax * 180) if show_values else 120
+ item.setBackground(QG.QColor(180, 180, 255, alpha))
+ self._table.setItem(0, c, item)
+ self._table.resizeColumnsToContents()
+ self._table.resizeRowsToContents()
+ self.setVisible(True)
+
+ def show_2d(
+ self, kernel: np.ndarray, info: str = "", show_values: bool = True
+ ) -> None:
+ """Display a 2-D kernel as a coloured grid.
+
+ Args:
+ kernel: 2-D weight array.
+ info: label text shown above the table.
+ show_values: if False, cells show only the coloured background
+ (useful for non-weighted stat filters like min/max/median).
+ """
+ self._info.setText(info)
+ rows, cols = kernel.shape
+ self._table.setRowCount(rows)
+ self._table.setColumnCount(cols)
+ cr, cc = rows // 2, cols // 2
+ vmax = float(np.max(np.abs(kernel))) or 1.0
+ for r in range(rows):
+ for c in range(cols):
+ val = kernel[r, c]
+ text = f"{val:.3g}" if show_values else ""
+ item = QW.QTableWidgetItem(text)
+ item.setTextAlignment(QC.Qt.AlignCenter)
+ if r == cr and c == cc:
+ item.setBackground(QG.QColor(255, 180, 180, 200))
+ else:
+ alpha = int(abs(val) / vmax * 180) if show_values else 120
+ item.setBackground(QG.QColor(180, 180, 255, alpha))
+ self._table.setItem(r, c, item)
+ self._table.resizeColumnsToContents()
+ self._table.resizeRowsToContents()
+ self.setVisible(True)
+
+ def hide_preview(self) -> None:
+ """Clear and hide the preview group."""
+ self._table.setRowCount(0)
+ self._table.setColumnCount(0)
+ self.setVisible(False)
+
+
+# -- Main dialog ----------------------------------------------------------------
+
+
+class ReplaceSpecialValuesDialog(QW.QDialog):
+ """Custom dialog for the *Replace special values* feature.
+
+ Shows coloured count badges (NaN, +Inf, -Inf) at the top, the standard
+ DataSet parameter editing form in the middle, and a kernel/mask preview
+ at the bottom when a neighbor-based strategy is selected.
+
+ Args:
+ instance: DataSet parameter instance to edit.
+ counts: dictionary with keys ``"nan"``, ``"posinf"``, ``"neginf"``.
+ total_size: total number of data points.
+ is_image: ``True`` for image parameters, ``False`` for signals.
+ parent: parent widget.
+ """
+
+ def __init__(
+ self,
+ instance: ReplaceSpecialValuesSignalParam | ReplaceSpecialValuesImageParam,
+ counts: dict[str, int],
+ total_size: int,
+ is_image: bool,
+ info_message: str | None = None,
+ can_apply: bool = True,
+ parent: QWidget | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self.instance = instance
+ self._is_image = is_image
+ self._info_message = info_message
+ self._can_apply = can_apply
+ self.setWindowTitle(instance.get_title())
+ self.setMinimumWidth(480)
+
+ main_layout = QW.QVBoxLayout(self)
+
+ # --- Count badges ---
+ count_row = QW.QHBoxLayout()
+ for key in ("nan", "posinf", "neginf"):
+ count_row.addWidget(_make_count_badge(key, counts[key], total_size))
+ count_row.addStretch()
+ main_layout.addLayout(count_row)
+
+ line = QW.QFrame()
+ line.setFrameShape(QW.QFrame.HLine)
+ line.setFrameShadow(QW.QFrame.Sunken)
+ main_layout.addWidget(line)
+
+ if info_message:
+ info_label = QW.QLabel(info_message)
+ info_label.setWordWrap(True)
+ info_label.setStyleSheet(
+ "background-color: #eef6ff; border: 1px solid #8fb7e8; "
+ "padding: 8px; border-radius: 4px;"
+ )
+ main_layout.addWidget(info_label)
+
+ # --- DataSet editing ---
+ grid = QW.QGridLayout()
+ self.edit_layout: DataSetEditLayout | None = None
+ edit_layout = DataSetEditLayout(
+ self, instance, grid, change_callback=self._on_change
+ )
+ self.edit_layout = edit_layout
+ main_layout.addLayout(grid)
+
+ # --- Kernel previews (one per group) ---
+ self._kernel_previews: dict[str, _KernelPreviewWidget] = {}
+ for key in ("nan", "posinf", "neginf"):
+ label = _BADGE_LABELS[key]
+ color = _BADGE_COLORS[key]
+ preview = _KernelPreviewWidget(
+ title=_("{label} — Kernel / Mask preview").format(label=label),
+ )
+ preview.setStyleSheet(
+ f"_KernelPreviewWidget {{ border: 1px solid {color}; }}"
+ )
+ self._kernel_previews[key] = preview
+ main_layout.addWidget(preview)
+
+ # --- Buttons ---
+ bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel)
+ bbox.accepted.connect(self.accept)
+ bbox.rejected.connect(self.reject)
+ ok_button = bbox.button(QW.QDialogButtonBox.Ok)
+ if ok_button is not None:
+ ok_button.setEnabled(can_apply)
+ main_layout.addWidget(bbox)
+
+ # Initial preview
+ self._refresh_preview()
+
+ # -- internal helpers -------------------------------------------------------
+
+ def _on_change(self) -> None:
+ """Slot called whenever a DataSet widget value changes."""
+ if self.edit_layout is None:
+ return
+ # Sync current widget values → DataSet so the preview reads live data
+ self.edit_layout.accept_changes()
+ self._refresh_preview()
+
+ def _refresh_preview(self) -> None:
+ """Update every kernel preview independently."""
+ p = self.instance
+ for prefix in ("nan", "posinf", "neginf"):
+ strategy = getattr(p, f"{prefix}_strategy")
+ preview = self._kernel_previews[prefix]
+ shown = (
+ self._try_show_image_kernel(strategy, prefix, preview)
+ if self._is_image
+ else self._try_show_signal_kernel(strategy, prefix, preview)
+ )
+ if not shown:
+ preview.hide_preview()
+
+ def _try_show_signal_kernel(
+ self,
+ strategy: ReplacementStrategySignal,
+ prefix: str,
+ preview: _KernelPreviewWidget,
+ ) -> bool:
+ """Show kernel for signal neighbor strategies. Returns True if shown."""
+ _S = ReplacementStrategySignal
+ p = self.instance
+ if strategy in (
+ _S.NEIGHBOR_MIN,
+ _S.NEIGHBOR_MAX,
+ _S.NEIGHBOR_MEAN,
+ _S.NEIGHBOR_MEDIAN,
+ ):
+ n = getattr(p, f"{prefix}_neighbor_size", 3)
+ size = 2 * n + 1
+ kernel = np.ones(size) / size
+ show_values = strategy == _S.NEIGHBOR_MEAN
+ preview.show_1d(
+ kernel,
+ _("±{n} points").format(n=n),
+ show_values=show_values,
+ )
+ return True
+ return False
+
+ def _try_show_image_kernel(
+ self,
+ strategy: ReplacementStrategyImage,
+ prefix: str,
+ preview: _KernelPreviewWidget,
+ ) -> bool:
+ """Show kernel for image neighbor strategies. Returns True if shown."""
+ _S = ReplacementStrategyImage
+ p = self.instance
+ if strategy in (
+ _S.NEIGHBOR_MIN,
+ _S.NEIGHBOR_MAX,
+ _S.NEIGHBOR_MEAN,
+ _S.NEIGHBOR_MEDIAN,
+ ):
+ n = getattr(p, f"{prefix}_neighbor_size", 3)
+ size = 2 * n + 1
+ kernel = np.ones((size, size)) / (size * size)
+ show_values = strategy == _S.NEIGHBOR_MEAN
+ preview.show_2d(
+ kernel,
+ _("±{n} rows × ±{n} columns").format(n=n),
+ show_values=show_values,
+ )
+ return True
+ return False
+
+ # -- public interface -------------------------------------------------------
+
+ def accept(self) -> None:
+ """Validate all widget values, then commit to the DataSet."""
+ if not self._can_apply:
+ return
+ if self.edit_layout is not None:
+ if not self.edit_layout.check_all_values():
+ return
+ self.edit_layout.accept_changes()
+ super().accept()
+
+
+# -- DataLab-specific parameter subclasses --------------------------------------
+
+
+class ReplaceSpecialValuesSignalParamDL(ReplaceSpecialValuesSignalParam):
+ """Signal parameter subclass with counts and custom dialog.
+
+ Overrides :meth:`edit` to display :class:`ReplaceSpecialValuesDialog`.
+ """
+
+ _counts: dict[str, int]
+ _total_size: int
+
+ def update_from_obj(self, obj: object) -> None:
+ """Pre-analyse the signal to compute special-value counts."""
+ _, y = obj.get_data() # type: ignore[union-attr]
+ self._counts = count_special_values(y)
+ self._total_size = int(y.size)
+
+ def create_dialog(
+ self,
+ parent: QWidget | None = None,
+ object_name: str | None = None,
+ ) -> ReplaceSpecialValuesDialog:
+ """Create the custom replace-special-values dialog."""
+ counts = getattr(self, "_counts", {"nan": 0, "posinf": 0, "neginf": 0})
+ total = getattr(self, "_total_size", 0)
+ dlg = ReplaceSpecialValuesDialog(
+ self, counts, total, is_image=False, parent=parent
+ )
+ dlg.setObjectName(object_name or self.__class__.__name__ + "Dialog")
+ return dlg
+
+ def edit(
+ self,
+ parent: QWidget | None = None,
+ apply: object = None,
+ wordwrap: bool = True,
+ size: object = None,
+ object_name: str | None = None,
+ ) -> int:
+ """Open the custom replace-special-values dialog."""
+ dlg = self.create_dialog(parent=parent, object_name=object_name)
+ return exec_dialog(dlg)
+
+
+class ReplaceSpecialValuesImageParamDL(ReplaceSpecialValuesImageParam):
+ """Image parameter subclass with counts and custom dialog.
+
+ Overrides :meth:`edit` to display :class:`ReplaceSpecialValuesDialog`.
+ """
+
+ _counts: dict[str, int]
+ _total_size: int
+ _can_apply: bool
+ _info_message: str | None
+
+ def update_from_obj(self, obj: object) -> None:
+ """Pre-analyse the image to compute special-value counts."""
+ data = obj.data # type: ignore[union-attr]
+ self._counts = count_special_values_2d(data)
+ self._total_size = int(data.size)
+ if np.issubdtype(data.dtype, np.integer):
+ self._can_apply = False
+ self._info_message = _(
+ "This image uses an integer data type, so it cannot contain NaN "
+ "or infinite values. Replace special values is therefore not "
+ "applicable."
+ )
+ else:
+ self._can_apply = True
+ self._info_message = None
+
+ def create_dialog(
+ self,
+ parent: QWidget | None = None,
+ object_name: str | None = None,
+ ) -> ReplaceSpecialValuesDialog:
+ """Create the custom replace-special-values dialog."""
+ counts = getattr(self, "_counts", {"nan": 0, "posinf": 0, "neginf": 0})
+ total = getattr(self, "_total_size", 0)
+ dlg = ReplaceSpecialValuesDialog(
+ self,
+ counts,
+ total,
+ is_image=True,
+ info_message=getattr(self, "_info_message", None),
+ can_apply=getattr(self, "_can_apply", True),
+ parent=parent,
+ )
+ dlg.setObjectName(object_name or self.__class__.__name__ + "Dialog")
+ return dlg
+
+ def edit(
+ self,
+ parent: QWidget | None = None,
+ apply: object = None,
+ wordwrap: bool = True,
+ size: object = None,
+ object_name: str | None = None,
+ ) -> int:
+ """Open the custom replace-special-values dialog."""
+ dlg = self.create_dialog(parent=parent, object_name=object_name)
+ return exec_dialog(dlg)
diff --git a/doc/features/image/menu_processing.rst b/doc/features/image/menu_processing.rst
index ac499947..41d45964 100644
--- a/doc/features/image/menu_processing.rst
+++ b/doc/features/image/menu_processing.rst
@@ -124,6 +124,45 @@ Clipping
Apply the clipping to each selected image.
+Replace special values
+~~~~~~~~~~~~~~~~~~~~~~
+
+Create a new image where ``NaN``, ``+Inf`` and ``-Inf`` pixels are replaced
+using a configurable strategy per target value.
+
+Each of the three targets (``NaN``, ``+Inf``, ``-Inf``) is handled
+independently. For each target the user selects one of the following
+strategies (or *Do nothing* to leave the target untouched):
+
+.. list-table::
+ :header-rows: 1
+ :widths: 35, 65
+
+ * - Strategy
+ - Description
+ * - Do nothing
+ - Leave the target values untouched.
+ * - Replace with zero
+ - Replace by ``0``.
+ * - Replace with constant
+ - Replace by a user-defined constant value.
+ * - Replace with minimum / maximum
+ - Replace by the minimum / maximum of the finite pixels.
+ * - Replace with mean / median
+ - Replace by the mean / median of the finite pixels.
+ * - N-neighbor minimum / maximum / mean / median
+ - Replace by the corresponding statistic computed on the
+ ``(2N+1) × (2N+1)`` neighborhood around each affected pixel.
+
+Before opening the standard parameter form, the dialog displays a count of
+each target found in the image (e.g. *NaN: 124 (0.5 %)*). When a neighbor
+strategy is selected, a small preview shows the 2D kernel that will be used.
+
+.. note::
+
+ Unlike for signals, no interpolation strategy is currently available
+ for images.
+
Offset correction
~~~~~~~~~~~~~~~~~
diff --git a/doc/features/signal/menu_processing.rst b/doc/features/signal/menu_processing.rst
index f45a56a3..19e153fa 100644
--- a/doc/features/signal/menu_processing.rst
+++ b/doc/features/signal/menu_processing.rst
@@ -141,6 +141,48 @@ Clipping
Create a new signal which is the result of clipping each selected signal.
+Replace special values
+~~~~~~~~~~~~~~~~~~~~~~
+
+Create a new signal where ``NaN``, ``+Inf`` and ``-Inf`` samples are replaced
+using a configurable strategy per target value.
+
+Each of the three targets (``NaN``, ``+Inf``, ``-Inf``) is handled
+independently. For each target the user selects one of the following
+strategies (or *Do nothing* to leave the target untouched):
+
+.. list-table::
+ :header-rows: 1
+ :widths: 35, 65
+
+ * - Strategy
+ - Description
+ * - Do nothing
+ - Leave the target values untouched.
+ * - Replace with zero
+ - Replace by ``0``.
+ * - Replace with constant
+ - Replace by a user-defined constant value.
+ * - Replace with minimum / maximum
+ - Replace by the minimum / maximum of the finite samples.
+ * - Replace with mean / median
+ - Replace by the mean / median of the finite samples.
+ * - Delete points
+ - Remove the affected samples from the signal (the X array is also
+ shrunk). A warning is emitted if the signal was uniformly sampled.
+ * - Forward fill / Backward fill
+ - Propagate the last (resp. next) valid value.
+ * - Interpolation: Linear / Spline / Quadratic / Cubic / PCHIP
+ - Reconstruct missing samples by interpolating the surrounding valid
+ samples.
+ * - N-neighbor minimum / maximum / mean / median
+ - Replace by the corresponding statistic computed on the ``2N+1``
+ neighboring samples around each affected position.
+
+Before opening the standard parameter form, the dialog displays a count of
+each target found in the signal (e.g. *NaN: 17 (1.7 %)*). When a neighbor
+strategy is selected, a small preview shows the kernel that will be used.
+
Offset correction
~~~~~~~~~~~~~~~~~
diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po
index 697cc219..5e004582 100644
--- a/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po
+++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po
@@ -174,6 +174,60 @@ msgstr "Ecrêtage"
msgid "Apply the clipping to each selected image."
msgstr "Applique un écrêtage sur chaque image sélectionnée."
+msgid "Replace special values"
+msgstr "Remplacer les valeurs spéciales"
+
+msgid "Create a new image where ``NaN``, ``+Inf`` and ``-Inf`` pixels are replaced using a configurable strategy per target value."
+msgstr "Crée une nouvelle image où les pixels ``NaN``, ``+Inf`` et ``-Inf`` sont remplacés à l'aide d'une stratégie configurable pour chaque valeur cible."
+
+msgid "Each of the three targets (``NaN``, ``+Inf``, ``-Inf``) is handled independently. For each target the user selects one of the following strategies (or *Do nothing* to leave the target untouched):"
+msgstr "Chacune des trois cibles (``NaN``, ``+Inf``, ``-Inf``) est traitée indépendamment. Pour chaque cible, l'utilisateur sélectionne l'une des stratégies suivantes (ou *Ne rien faire* pour laisser la cible inchangée) :"
+
+msgid "Strategy"
+msgstr "Stratégie"
+
+msgid "Do nothing"
+msgstr "Ne rien faire"
+
+msgid "Leave the target values untouched."
+msgstr "Laisser les valeurs cibles inchangées."
+
+msgid "Replace with zero"
+msgstr "Remplacer par zéro"
+
+msgid "Replace by ``0``."
+msgstr "Remplacer par ``0``."
+
+msgid "Replace with constant"
+msgstr "Remplacer par une constante"
+
+msgid "Replace by a user-defined constant value."
+msgstr "Remplacer par une valeur constante définie par l'utilisateur."
+
+msgid "Replace with minimum / maximum"
+msgstr "Remplacer par le minimum / maximum"
+
+msgid "Replace by the minimum / maximum of the finite pixels."
+msgstr "Remplacer par le minimum / maximum des pixels finis."
+
+msgid "Replace with mean / median"
+msgstr "Remplacer par la moyenne / médiane"
+
+msgid "Replace by the mean / median of the finite pixels."
+msgstr "Remplacer par la moyenne / médiane des pixels finis."
+
+msgid "N-neighbor minimum / maximum / mean / median"
+msgstr "Minimum / maximum / moyenne / médiane sur N voisins"
+
+msgid "Replace by the corresponding statistic computed on the ``(2N+1) × (2N+1)`` neighborhood around each affected pixel."
+msgstr "Remplacer par la statistique correspondante calculée sur le voisinage ``(2N+1) × (2N+1)`` autour de chaque pixel affecté."
+
+msgid "Before opening the standard parameter form, the dialog displays a count of each target found in the image (e.g. *NaN: 124 (0.5 %)*). When a neighbor strategy is selected, a small preview shows the 2D kernel that will be used."
+msgstr "Avant d'ouvrir le formulaire de paramètres standard, la boîte de dialogue affiche le nombre de chaque cible trouvée dans l'image (par exemple *NaN : 124 (0,5 %)*). Lorsqu'une stratégie fondée sur les voisins est sélectionnée, un petit aperçu montre le noyau 2D qui sera utilisé."
+
+msgid "Unlike for signals, no interpolation strategy is currently available for images."
+msgstr "Contrairement aux signaux, aucune stratégie d'interpolation n'est actuellement disponible pour les images."
+
msgid "Offset correction"
msgstr "Soustraction d'offset"
@@ -261,9 +315,6 @@ msgstr "Les paramètres suivants sont disponibles :"
msgid "Parameter"
msgstr "Paramètre"
-msgid "Strategy"
-msgstr "Stratégie"
-
msgid "Zero padding strategy (see below)"
msgstr "Stratégie de complément de zéros (voir ci-dessous)"
diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po
index e6667974..179aab61 100644
--- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po
+++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po
@@ -194,6 +194,75 @@ msgstr "Ecrêtage"
msgid "Create a new signal which is the result of clipping each selected signal."
msgstr "Crée un signal à partir de l'écrêtage de chaque signal sélectionné."
+msgid "Replace special values"
+msgstr "Remplacer les valeurs spéciales"
+
+msgid "Create a new signal where ``NaN``, ``+Inf`` and ``-Inf`` samples are replaced using a configurable strategy per target value."
+msgstr "Crée un nouveau signal où les échantillons ``NaN``, ``+Inf`` et ``-Inf`` sont remplacés à l'aide d'une stratégie configurable pour chaque valeur cible."
+
+msgid "Each of the three targets (``NaN``, ``+Inf``, ``-Inf``) is handled independently. For each target the user selects one of the following strategies (or *Do nothing* to leave the target untouched):"
+msgstr "Chacune des trois cibles (``NaN``, ``+Inf``, ``-Inf``) est traitée indépendamment. Pour chaque cible, l'utilisateur sélectionne l'une des stratégies suivantes (ou *Ne rien faire* pour laisser la cible inchangée) :"
+
+msgid "Strategy"
+msgstr "Stratégie"
+
+msgid "Do nothing"
+msgstr "Ne rien faire"
+
+msgid "Leave the target values untouched."
+msgstr "Laisser les valeurs cibles inchangées."
+
+msgid "Replace with zero"
+msgstr "Remplacer par zéro"
+
+msgid "Replace by ``0``."
+msgstr "Remplacer par ``0``."
+
+msgid "Replace with constant"
+msgstr "Remplacer par une constante"
+
+msgid "Replace by a user-defined constant value."
+msgstr "Remplacer par une valeur constante définie par l'utilisateur."
+
+msgid "Replace with minimum / maximum"
+msgstr "Remplacer par le minimum / maximum"
+
+msgid "Replace by the minimum / maximum of the finite samples."
+msgstr "Remplacer par le minimum / maximum des échantillons finis."
+
+msgid "Replace with mean / median"
+msgstr "Remplacer par la moyenne / médiane"
+
+msgid "Replace by the mean / median of the finite samples."
+msgstr "Remplacer par la moyenne / médiane des échantillons finis."
+
+msgid "Delete points"
+msgstr "Supprimer les points"
+
+msgid "Remove the affected samples from the signal (the X array is also shrunk). A warning is emitted if the signal was uniformly sampled."
+msgstr "Supprimer les échantillons affectés du signal (le tableau X est également réduit). Un avertissement est émis si le signal était échantillonné uniformément."
+
+msgid "Forward fill / Backward fill"
+msgstr "Remplissage avant / remplissage arrière"
+
+msgid "Propagate the last (resp. next) valid value."
+msgstr "Propager la dernière valeur valide (respectivement la suivante)."
+
+msgid "Interpolation: Linear / Spline / Quadratic / Cubic / PCHIP"
+msgstr "Interpolation : linéaire / spline / quadratique / cubique / PCHIP"
+
+msgid "Reconstruct missing samples by interpolating the surrounding valid samples."
+msgstr "Reconstruire les échantillons manquants en interpolant les échantillons valides environnants."
+
+msgid "N-neighbor minimum / maximum / mean / median"
+msgstr "Minimum / maximum / moyenne / médiane sur N voisins"
+
+msgid "Replace by the corresponding statistic computed on the ``2N+1`` neighboring samples around each affected position."
+msgstr "Remplacer par la statistique correspondante calculée sur les ``2N+1`` échantillons voisins autour de chaque position affectée."
+
+msgid "Before opening the standard parameter form, the dialog displays a count of each target found in the signal (e.g. *NaN: 17 (1.7 %)*). When a neighbor strategy is selected, a small preview shows the kernel that will be used."
+msgstr "Avant d'ouvrir le formulaire de paramètres standard, la boîte de dialogue affiche le nombre de chaque cible trouvée dans le signal (par exemple *NaN : 17 (1,7 %)*). Lorsqu'une stratégie fondée sur les voisins est sélectionnée, un petit aperçu montre le noyau qui sera utilisé."
+
msgid "Offset correction"
msgstr "Soustraction d'offset"
@@ -278,9 +347,6 @@ msgstr "Crée un signal à partir du complément de zéro de chaque signal séle
msgid "The following parameters are available:"
msgstr "Les paramètres suivants sont disponibles :"
-msgid "Strategy"
-msgstr "Stratégie"
-
msgid "Zero padding strategy (see below)"
msgstr "Stratégie de complément de zéro (voir ci-dessous)"
diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
index 6019a429..4e57afc5 100644
--- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
+++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
@@ -34,3 +34,27 @@ msgstr "Les répertoires listés sont ajoutés aux chemins de recherche existant
msgid "Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting"
msgstr "Les répertoires inexistants sont ignorés silencieusement (un avertissement est consigné dans le fichier journal), ainsi une variable d'environnement obsolète sur une autre machine n'empêchera pas le démarrage de DataLab"
+
+msgid "**Replace special values processing (signal and image):**"
+msgstr "**Traitement de remplacement des valeurs spéciales (signal et image) :**"
+
+msgid "DataLab now provides a dedicated **\"Replace special values\"** processing function that detects and replaces `NaN`, `+Inf` and `-Inf` values in both signals and images. The feature is available under **Processing → Level adjustment → Replace special values** in both the Signal and Image panels."
+msgstr "DataLab fournit désormais une fonction de traitement dédiée **\"Remplacer les valeurs spéciales\"** qui détecte et remplace les valeurs `NaN`, `+Inf` et `-Inf` dans les signaux et les images. La fonctionnalité est disponible sous **Traitement → Ajustement des niveaux → Remplacer les valeurs spéciales** dans les panneaux Signal et Image."
+
+msgid "Each target (`NaN`, `+Inf`, `-Inf`) can be processed independently with its own strategy"
+msgstr "Chaque cible (`NaN`, `+Inf`, `-Inf`) peut être traitée indépendamment avec sa propre stratégie"
+
+msgid "Signal strategies: do nothing, replace with zero / constant / minimum / maximum / mean / median, delete affected points, forward fill, backward fill, interpolation (linear, spline, quadratic, cubic, PCHIP), N-neighbor minimum / maximum / mean / median"
+msgstr "Stratégies pour les signaux : ne rien faire, remplacer par zéro / constante / minimum / maximum / moyenne / médiane, supprimer les points affectés, remplissage avant, remplissage arrière, interpolation (linéaire, spline, quadratique, cubique, PCHIP), minimum / maximum / moyenne / médiane des N voisins"
+
+msgid "Image strategies: do nothing, replace with zero / constant / minimum / maximum / mean / median, N-neighbor minimum / maximum / mean / median"
+msgstr "Stratégies pour les images : ne rien faire, remplacer par zéro / constante / minimum / maximum / moyenne / médiane, minimum / maximum / moyenne / médiane des N voisins"
+
+msgid "The parameter dialog displays **colored badges** showing the number (and percentage) of `NaN`, `+Inf` and `-Inf` samples found in the source object, giving immediate visibility on what will be modified"
+msgstr "La boîte de dialogue des paramètres affiche des **badges colorés** indiquant le nombre (et le pourcentage) d'échantillons `NaN`, `+Inf` et `-Inf` trouvés dans l'objet source, offrant une visibilité immédiate sur ce qui sera modifié"
+
+msgid "When a neighbor strategy is selected, a **live kernel preview** shows the shape of the neighborhood that will be used for the replacement"
+msgstr "Lorsqu'une stratégie de voisinage est sélectionnée, un **aperçu en direct du noyau** montre la forme du voisinage qui sera utilisé pour le remplacement"
+
+msgid "Integer images are handled explicitly: because `NaN` and infinite values cannot exist in integer data, the dialog explains that the operation is not applicable and prevents accidental processing, while preserving the original image data type without unnecessary float conversion"
+msgstr "Les images entières sont traitées explicitement : comme les valeurs `NaN` et infinies ne peuvent pas exister dans les données entières, la boîte de dialogue explique que l'opération n'est pas applicable et empêche un traitement accidentel, tout en préservant le type de données d'image d'origine sans conversion inutile en flottant"
diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md
index 87c809b1..a4350ccc 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -10,3 +10,29 @@
* Multiple directories can be listed using the OS path separator (`;` on Windows, `:` on Linux/macOS), following the same convention as `PYTHONPATH`
* Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism
* Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting
+
+**Replace special values processing (signal and image):**
+
+DataLab now provides a dedicated **"Replace special values"** processing
+function that detects and replaces `NaN`, `+Inf` and `-Inf` values in both
+signals and images. The feature is available under
+**Processing → Level adjustment → Replace special values** in both the Signal
+and Image panels.
+
+* Each target (`NaN`, `+Inf`, `-Inf`) can be processed independently with its
+ own strategy
+* Signal strategies: do nothing, replace with zero / constant / minimum /
+ maximum / mean / median, delete affected points, forward fill, backward
+ fill, interpolation (linear, spline, quadratic, cubic, PCHIP), N-neighbor
+ minimum / maximum / mean / median
+* Image strategies: do nothing, replace with zero / constant / minimum /
+ maximum / mean / median, N-neighbor minimum / maximum / mean / median
+* The parameter dialog displays **colored badges** showing the number (and
+ percentage) of `NaN`, `+Inf` and `-Inf` samples found in the source object,
+ giving immediate visibility on what will be modified
+* When a neighbor strategy is selected, a **live kernel preview** shows the
+ shape of the neighborhood that will be used for the replacement
+* Integer images are handled explicitly: because `NaN` and infinite values
+ cannot exist in integer data, the dialog explains that the operation is not
+ applicable and prevents accidental processing, while preserving the original
+ image data type without unnecessary float conversion
\ No newline at end of file