diff --git a/doc/locale/fr/LC_MESSAGES/api/index.po b/doc/locale/fr/LC_MESSAGES/api/index.po index b5effb5..1d8a574 100644 --- a/doc/locale/fr/LC_MESSAGES/api/index.po +++ b/doc/locale/fr/LC_MESSAGES/api/index.po @@ -45,6 +45,12 @@ msgstr ":mod:`sigima.objects`" msgid "Object model for signals and images (:class:`sigima.objects.SignalObj` and :class:`sigima.objects.ImageObj`), scalar results (:class:`sigima.objects.GeometryResult` and :class:`sigima.objects.TableResult`), and related functions" msgstr "Modèle d'objet pour les signaux et les images (:class:`sigima.objects.SignalObj` et :class:`sigima.objects.ImageObj`), résultats scalaires (:class:`sigima.objects.GeometryResult` et :class:`sigima.objects.TableResult`), et fonctions associées" +msgid ":mod:`sigima.objects.annotations`" +msgstr ":mod:`sigima.objects.annotations`" + +msgid "Renderer-independent graphical annotation model, serialization, and transformations (see :doc:`annotations`)" +msgstr "Modèle d'annotation graphique indépendant du moteur de rendu, sérialisation et transformations (voir :doc:`annotations`)" + msgid ":mod:`sigima.proc`" msgstr ":mod:`sigima.proc`" 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 new file mode 100644 index 0000000..51376dd --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po @@ -0,0 +1,34 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, DataLab Platform Developers +# This file is distributed under the same license as the Sigima package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Version 1.3" +msgstr "Version 1.3" + +msgid "Sigima Version 1.3.0" +msgstr "Sigima Version 1.3.0" + +msgid "✨ New features since version 1.2.0" +msgstr "✨ Nouvelles fonctionnalités depuis la version 1.2.0" + +msgid "**Brightness and contrast adjustment**: A new ROI-aware image processing performs numerically stable clipped linear remapping from persistent minimum and maximum bounds, preserves the source dtype and non-finite values, and exposes portable histogram context for native and browser interfaces. Tiny floating-point ranges and finite IEEE-754 extremes are supported without coupling headless computation to histogram rendering. The low-level helpers support floating-point arrays and integers up to 32 bits; they explicitly reject `int64` and `uint64` instead of risking precision loss at integer endpoints." +msgstr "**Ajustement de la luminosité et du contraste** : Un nouveau traitement d'image conscient de la ROI effectue un remappage linéaire tronqué numériquement stable à partir de limites minimales et maximales persistantes, préserve le type de données source et les valeurs non finies, et expose un contexte d'histogramme portable pour les interfaces natives et navigateur. Les plages de flottants minuscules et les extrêmes finis IEEE-754 sont pris en charge sans coupler le calcul sans tête au rendu de l'histogramme. Les helpers bas niveau prennent en charge les tableaux flottants et les entiers jusqu'à 32 bits ; ils rejettent explicitement `int64` et `uint64` au lieu de risquer une perte de précision aux extrémités des entiers." + +msgid "**Portable graphical annotations**: Signals and images may now carry versioned, renderer-independent points, shapes, text, cursors and axis ranges. Annotations survive Sigima file round trips and supported image transformations, and are displayed consistently by the PlotPy and Matplotlib visualization backends. Existing PlotPy annotations remain readable and can be migrated explicitly while unknown application data is preserved. This implements [Issue #53](https://github.com/DataLab-Platform/Sigima/issues/53)." +msgstr "**Annotations graphiques portables** : Les signaux et les images peuvent désormais transporter des points, des formes, du texte, des curseurs et des plages d'axes versionnés et indépendants du moteur de rendu. Les annotations survivent aux allers-retours des fichiers Sigima et aux transformations d'images prises en charge, et sont affichées de manière cohérente par les backends de visualisation PlotPy et Matplotlib. Les annotations PlotPy existantes restent lisibles et peuvent être migrées explicitement tandis que les données d'application inconnues sont préservées. Cela implémente [Issue #53](https://github.com/DataLab-Platform/Sigima/issues/53)." + +msgid "**Interactive Plotly visualization**: Signals and images can now be inspected in a browser with zoom, pan and hover through the optional Plotly backend. Sigima also exposes dependency-free Plotly JSON builders for applications and notebooks, including portable annotation, ROI and geometry-result overlays." +msgstr "**Visualisation interactive Plotly** : Les signaux et les images peuvent désormais être inspectés dans un navigateur avec zoom, panoramique et survol grâce au backend Plotly optionnel. Sigima expose également des constructeurs JSON Plotly sans dépendance pour les applications et les notebooks, y compris les superpositions d'annotations portables, de ROI et de résultats géométriques." + +msgid "**Parameter validation**: Numeric creation and processing parameters now expose precise unconditional bounds, while mode-dependent and source-dependent constraints are checked before computation. Signed coordinate and grid conventions, as well as point-like or descending signal domains, remain supported." +msgstr "**Validation des paramètres** : Les paramètres de création et de traitement numériques exposent désormais des limites inconditionnelles précises, tandis que les contraintes dépendantes du mode et de la source sont vérifiées avant le calcul. Les conventions de coordonnées et de grille signées, ainsi que les domaines de signaux ponctuels ou décroissants, restent prises en charge." diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/features.po b/doc/locale/fr/LC_MESSAGES/user_guide/features.po index 47f03bf..50bb714 100644 --- a/doc/locale/fr/LC_MESSAGES/user_guide/features.po +++ b/doc/locale/fr/LC_MESSAGES/user_guide/features.po @@ -1182,6 +1182,12 @@ msgstr "Seuillage minimal" msgid "Exposure and Intensity Correction" msgstr "Correction d'exposition et d'intensité" +msgid ":func:`adjust_brightness_contrast `" +msgstr "" + +msgid "Histogram-driven brightness and contrast adjustment" +msgstr "Réglage de la luminosité et du contraste piloté par histogramme" + msgid ":func:`histogram `" msgstr "" diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md index c2515c2..c22f4ad 100644 --- a/doc/release_notes/release_1.03.md +++ b/doc/release_notes/release_1.03.md @@ -4,5 +4,7 @@ ### ✨ New features since version 1.2.0 ### +* **Brightness and contrast adjustment**: A new ROI-aware image processing performs numerically stable clipped linear remapping from persistent minimum and maximum bounds, preserves the source dtype and non-finite values, and exposes portable histogram context for native and browser interfaces. Tiny floating-point ranges and finite IEEE-754 extremes are supported without coupling headless computation to histogram rendering. The low-level helpers support floating-point arrays and integers up to 32 bits; they explicitly reject `int64` and `uint64` instead of risking precision loss at integer endpoints. * **Portable graphical annotations**: Signals and images may now carry versioned, renderer-independent points, shapes, text, cursors and axis ranges. Annotations survive Sigima file round trips and supported image transformations, and are displayed consistently by the PlotPy and Matplotlib visualization backends. Existing PlotPy annotations remain readable and can be migrated explicitly while unknown application data is preserved. This implements [Issue #53](https://github.com/DataLab-Platform/Sigima/issues/53). -* **Interactive Plotly visualization**: Signals and images can now be inspected in a browser with zoom, pan and hover through the optional Plotly backend. Sigima also exposes dependency-free Plotly JSON builders for applications and notebooks, including portable annotation, ROI and geometry-result overlays. \ No newline at end of file +* **Interactive Plotly visualization**: Signals and images can now be inspected in a browser with zoom, pan and hover through the optional Plotly backend. Sigima also exposes dependency-free Plotly JSON builders for applications and notebooks, including portable annotation, ROI and geometry-result overlays. +* **Parameter validation**: Numeric creation and processing parameters now expose precise unconditional bounds, while mode-dependent and source-dependent constraints are checked before computation. Signed coordinate and grid conventions, as well as point-like or descending signal domains, remain supported. diff --git a/doc/user_guide/features.rst b/doc/user_guide/features.rst index 2c1a48d..bf4ed33 100644 --- a/doc/user_guide/features.rst +++ b/doc/user_guide/features.rst @@ -661,6 +661,8 @@ Exposure and Intensity Correction * - Function - Description + * - :func:`adjust_brightness_contrast ` + - Histogram-driven brightness and contrast adjustment * - :func:`histogram ` - Compute image histogram * - :func:`equalize_hist ` diff --git a/sigima/locale/fr/LC_MESSAGES/sigima.po b/sigima/locale/fr/LC_MESSAGES/sigima.po index 23223f5..84b1f4f 100644 --- a/sigima/locale/fr/LC_MESSAGES/sigima.po +++ b/sigima/locale/fr/LC_MESSAGES/sigima.po @@ -317,18 +317,18 @@ msgstr "Image sans titre" msgid "Title" msgstr "Titre" -msgid "Image height: number of rows" -msgstr "Hauteur de l'image : nombre de lignes" - msgid "Height" msgstr "Hauteur" -msgid "Image width: number of columns" -msgstr "Largeur de l'image : nombre de colonnes" +msgid "Image height: number of rows" +msgstr "Hauteur de l'image : nombre de lignes" msgid "Width" msgstr "Largeur" +msgid "Image width: number of columns" +msgstr "Largeur de l'image : nombre de colonnes" + msgid "Type" msgstr "Type" @@ -374,18 +374,18 @@ msgstr "Décalage X" msgid "Y offset" msgstr "Décalage Y" -msgid "Value for dark squares" -msgstr "Valeur des carrés foncés" - msgid "Minimum value" msgstr "Minimum" -msgid "Value for light squares" -msgstr "Valeur des carrés clairs" +msgid "Value for dark squares" +msgstr "Valeur des carrés foncés" msgid "Maximum value" msgstr "Maximum" +msgid "Value for light squares" +msgstr "Valeur des carrés clairs" + msgid "Amplitude and offset" msgstr "Amplitude et décalage" @@ -843,6 +843,9 @@ msgstr "- Infini" msgid "Replace special values (image)" msgstr "Remplacer les valeurs spéciales (image)" +msgid "rows" +msgstr "lignes" + msgid "columns" msgstr "colonnes" @@ -1073,6 +1076,15 @@ msgstr "Interprète les seuils bas et haut en tant que quantiles des niveaux des msgid "Value to fill past edges of input if mode is constant." msgstr "Valeur de remplissage si le mode est constant." +msgid "Brightness and contrast" +msgstr "Luminosité et contraste" + +msgid "Minimum" +msgstr "Minimum" + +msgid "Adjust the intensity window using the histogram." +msgstr "Ajuster la fenêtre d'intensité à l'aide de l'histogramme." + msgid "Gamma" msgstr "Gamma" @@ -1436,9 +1448,6 @@ msgstr "Manuel" msgid "Mean" msgstr "Moyenne" -msgid "Minimum" -msgstr "Minimum" - msgid "Threshold method" msgstr "Méthode de seuillage" @@ -1712,3 +1721,4 @@ msgstr "Barycentre" msgid "Plot dialog" msgstr "Fenêtre de tracé" + diff --git a/sigima/objects/base.py b/sigima/objects/base.py index 01660af..f2a6d83 100644 --- a/sigima/objects/base.py +++ b/sigima/objects/base.py @@ -27,6 +27,7 @@ annotation_to_dict, is_graphical_annotation_dict, ) +from sigima.validation import validate_dataset if sys.version_info >= (3, 11): # Use Self from typing module in Python 3.11+ @@ -123,6 +124,12 @@ class BaseRandomParam(BaseProcParam): class UniformDistributionParam(BaseRandomParam): """Uniform-distribution signal/image parameters.""" + def validate_parameters(self, *context: object) -> None: + """Validate uniform-distribution bounds.""" + validate_dataset(super(), *context) + if self.vmin > self.vmax: + raise ValueError("vmin must be less than or equal to vmax") + def apply_integer_range(self, vmin, vmax): """Do something in case of integer min-max range.""" self.vmin, self.vmax = float(vmin), float(vmax) diff --git a/sigima/objects/image/creation.py b/sigima/objects/image/creation.py index d6fdb52..4f329d2 100644 --- a/sigima/objects/image/creation.py +++ b/sigima/objects/image/creation.py @@ -34,6 +34,7 @@ from sigima.objects import base from sigima.objects.image.object import ImageObj from sigima.tools.image import scale_data_to_min_max +from sigima.validation import validate_dataset def create_image( @@ -404,7 +405,7 @@ class Gauss2DParam( a = gds.FloatItem("A", default=None, check=False) xmin = gds.FloatItem("xmin", default=-10.0).set_pos(col=1) - sigma = gds.FloatItem("σ", default=1.0) + sigma = gds.FloatItem("σ", default=1.0, min=0.0, nonzero=True) xmax = gds.FloatItem("xmax", default=10.0).set_pos(col=1) mu = gds.FloatItem("μ", default=0.0) ymin = gds.FloatItem("ymin", default=-10.0).set_pos(col=1) @@ -719,6 +720,12 @@ class SiemensStar2DParam( ymax = gds.FloatItem("ymax", default=100.0).set_pos(col=1) _g2_end = gds.EndGroup("") + def validate_parameters(self, *context: object) -> None: + """Validate radial limits.""" + validate_dataset(super(), *context) + if self.inner_radius > self.outer_radius: + raise ValueError("inner_radius must be less than or equal to outer_radius") + def generate_title(self) -> str: """Generate a title based on current parameters.""" return f"Siemens(n={self.n_spokes})" @@ -864,6 +871,7 @@ def create_image_from_param(param: NewImageParam) -> ImageObj: param.width = 1024 if param.dtype is None: param.dtype = ImageDatatypes.UINT16 + validate_dataset(param) # Generate data first, as some `generate_title()` methods may depend on it: shape = (param.height, param.width) data = param.generate_2d_data(shape) diff --git a/sigima/objects/image/roi.py b/sigima/objects/image/roi.py index 25df169..eeb5423 100644 --- a/sigima/objects/image/roi.py +++ b/sigima/objects/image/roi.py @@ -40,6 +40,7 @@ import sigima.tools.image from sigima.config import _ from sigima.objects import base +from sigima.validation import validate_dataset if TYPE_CHECKING: from sigima.objects.image.object import ImageObj @@ -175,6 +176,7 @@ def to_single_roi( Returns: Single ROI """ + validate_dataset(self, obj) if self.geometry == "rectangle": return RectangularROI.from_param(obj, self) if self.geometry == "circle": diff --git a/sigima/objects/signal/creation.py b/sigima/objects/signal/creation.py index 24df39a..a825f85 100644 --- a/sigima/objects/signal/creation.py +++ b/sigima/objects/signal/creation.py @@ -49,6 +49,7 @@ PulseFitModel, VoigtModel, ) +from sigima.validation import validate_dataset def create_signal( @@ -277,6 +278,10 @@ def generate_title(self) -> str: # or when the NewSignalParam class is used alone). sep = gds.SeparatorItem() + def validate_parameters(self, *context: object) -> None: + """Run validators provided by cooperative base classes.""" + validate_dataset(super(), *context) + def generate_x_data(self) -> np.ndarray: """Generate x data based on current parameters.""" return np.linspace(self.xmin, self.xmax, self.size) @@ -479,7 +484,7 @@ class BaseGaussLorentzVoigtParam(NewSignalParam): ).set_prop("display", hide=True) amplitude = gds.FloatItem("A", default=1.0) y0 = gds.FloatItem("y0", default=0.0).set_pos(col=1) - sigma = gds.FloatItem("σ", default=1.0) + sigma = gds.FloatItem("σ", default=1.0, min=0.0, nonzero=True) mu = gds.FloatItem("μ", default=0.0).set_pos(col=1) @classmethod @@ -1064,6 +1069,12 @@ class PulseParam(NewSignalParam, title=_("Pulse")): offset = gds.FloatItem(_("Offset"), default=10.0) stop = gds.FloatItem(_("End"), default=5.0).set_pos(col=1) + def validate_parameters(self, *context: object) -> None: + """Validate pulse chronology.""" + super().validate_parameters(*context) + if self.start > self.stop: + raise ValueError("start must be less than or equal to stop") + def generate_title(self) -> str: """Generate a title based on current parameters.""" return ( @@ -1131,7 +1142,7 @@ class BasePulseParam(NewSignalParam): offset = gds.FloatItem(_("Initial value"), default=0.0) amplitude = gds.FloatItem(_("Amplitude"), default=5.0).set_pos(col=1) noise_amplitude = gds.FloatItem(_("Noise amplitude"), default=0.2, min=0.0) - x_rise_start = gds.FloatItem(_("Rise start time"), default=3.0, min=0.0) + x_rise_start = gds.FloatItem(_("Rise start time"), default=3.0) total_rise_time = gds.FloatItem(_("Total rise time"), default=2.0, min=0.0).set_pos( col=1 ) @@ -1260,6 +1271,12 @@ def square_duration(self) -> float: """Calculate the square duration from FWHM and total rise/fall times.""" return self.fwhm - 0.5 * self.total_rise_time - 0.5 * self.total_fall_time + def validate_parameters(self, *context: object) -> None: + """Validate square-pulse timing.""" + super().validate_parameters(*context) + if self.square_duration < 0.0: + raise ValueError("square pulse plateau duration must be non-negative") + def get_plateau_range(self) -> tuple[float, float]: """Get the theoretical plateau range (start, end) for the square signal. @@ -1547,6 +1564,7 @@ def create_signal_from_param(param: NewSignalParam) -> SignalObj: Raises: NotImplementedError: if the signal type is not supported """ + validate_dataset(param) # Generate data first, as some `generate_title()` methods may depend on it: x, y = param.generate_1d_data() # Check if user has customized the title or left it as default/empty diff --git a/sigima/objects/signal/roi.py b/sigima/objects/signal/roi.py index d6bc8c1..174fb10 100644 --- a/sigima/objects/signal/roi.py +++ b/sigima/objects/signal/roi.py @@ -29,6 +29,7 @@ from sigima.config import _ from sigima.objects import base +from sigima.validation import validate_dataset if TYPE_CHECKING: from sigima.objects.signal.object import SignalObj @@ -52,6 +53,7 @@ def to_single_roi(self, obj: SignalObj) -> SegmentROI: Returns: Single ROI """ + validate_dataset(self, obj) assert isinstance(self.xmin, float) and isinstance(self.xmax, float) return SegmentROI([self.xmin, self.xmax], False, title=self.title) diff --git a/sigima/params.py b/sigima/params.py index 88a5bec..5898130 100644 --- a/sigima/params.py +++ b/sigima/params.py @@ -320,6 +320,7 @@ "BlobDOHParam", "BlobLOGParam", "BlobOpenCVParam", + "BrightnessContrastParam", "ButterworthParam", "CannyParam", "ClipParam", @@ -411,6 +412,7 @@ BlobDOHParam, BlobLOGParam, BlobOpenCVParam, + BrightnessContrastParam, ButterworthParam, CannyParam, ContourShapeParam, diff --git a/sigima/proc/base.py b/sigima/proc/base.py index e9899bd..2d0dd3a 100644 --- a/sigima/proc/base.py +++ b/sigima/proc/base.py @@ -146,6 +146,16 @@ class NormalizeParam(gds.DataSet, title=_("Normalize")): class HistogramParam(gds.DataSet, title=_("Histogram")): """Histogram parameters""" + def validate_parameters(self, *context: object) -> None: + """Validate optional histogram limits.""" + del context + if ( + self.lower is not None + and self.upper is not None + and self.lower > self.upper + ): + raise ValueError("lower must be less than or equal to upper") + def get_suffix(self, data: np.ndarray) -> str: """Return suffix for the histogram computation diff --git a/sigima/proc/decorator.py b/sigima/proc/decorator.py index 0c1b2be..3dc32d4 100644 --- a/sigima/proc/decorator.py +++ b/sigima/proc/decorator.py @@ -22,6 +22,7 @@ from sigima.objects.scalar.geometry import GeometryResult from sigima.objects.scalar.table import TableResult +from sigima.validation import validate_dataset if sys.version_info >= (3, 10): # Use ParamSpec from typing module in Python 3.10+ @@ -124,11 +125,16 @@ def wrapper(*args, **kwargs): # Build the final positional argument list for the original function final_args = [] + validation_context = [] for p in params: if p is ds_param: final_args.append(ds_obj) else: - final_args.append(ba.arguments.get(p.name, None)) + value = ba.arguments.get(p.name, None) + final_args.append(value) + validation_context.append(value) + + validate_dataset(ds_obj, *validation_context) # Call the original function result = f(*final_args) @@ -188,7 +194,14 @@ def decorator(f: Callable[P, R]) -> Callable[P, R]: try: type_hints = typing.get_type_hints(f) except Exception: # pylint: disable=broad-except - type_hints = {} + # Python 3.9 cannot evaluate stringized PEP 604 return annotations. + # Preserve direct parameter-class references resolved from module globals. + type_hints = { + p.name: f.__globals__.get(p.annotation, p.annotation) + if isinstance(p.annotation, str) + else p.annotation + for p in params + } # Find DataSet parameter if any ds_param = None @@ -208,7 +221,11 @@ def decorator(f: Callable[P, R]) -> Callable[P, R]: # If a DataSet param is present, expand signature and docstring if ds_cls is not None: # Build signature exposing all DataSet items as keyword-only parameters - ds_items: list[gds.DataItem] = ds_cls._items # pylint: disable=W0212 + ds_items: list[gds.DataItem] = [ + item + for item in ds_cls._items # pylint: disable=W0212 + if not item.get_prop("data", "transient", False) + ] item_names = [item.get_name() for item in ds_items] items = [] for item in ds_items: diff --git a/sigima/proc/image/__init__.py b/sigima/proc/image/__init__.py index bfa9ac1..40e3b84 100644 --- a/sigima/proc/image/__init__.py +++ b/sigima/proc/image/__init__.py @@ -196,11 +196,13 @@ AdjustGammaParam, AdjustLogParam, AdjustSigmoidParam, + BrightnessContrastParam, EqualizeAdaptHistParam, EqualizeHistParam, FlatFieldParam, NormalizeParam, RescaleIntensityParam, + adjust_brightness_contrast, adjust_gamma, adjust_log, adjust_sigmoid, @@ -342,6 +344,7 @@ "BlobDOHParam", "BlobLOGParam", "BlobOpenCVParam", + "BrightnessContrastParam", "ButterworthParam", "CannyParam", "ContourShapeParam", @@ -382,6 +385,7 @@ "add_uniform_noise", "addition", "addition_constant", + "adjust_brightness_contrast", "adjust_gamma", "adjust_log", "adjust_sigmoid", diff --git a/sigima/proc/image/detection.py b/sigima/proc/image/detection.py index e0a31c4..d4850ba 100644 --- a/sigima/proc/image/detection.py +++ b/sigima/proc/image/detection.py @@ -39,6 +39,7 @@ ) from sigima.proc.decorator import computation_function from sigima.proc.image.base import compute_geometry_from_obj +from sigima.validation import validate_dataset # NOTE: Only parameter classes DEFINED in this module should be included in __all__. # Parameter classes imported from other modules (like sigima.proc.base) should NOT @@ -311,8 +312,8 @@ class GenericDetectionParam(gds.DataSet): threshold = gds.FloatItem( _("Relative threshold"), default=0.5, - min=0.1, - max=0.9, + min=0.0, + max=1.0, help=_( "Detection threshold, relative to difference between " "data maximum and minimum" @@ -400,6 +401,12 @@ def contour_shape(image: ImageObj, p: ContourShapeParam) -> GeometryResult | Non class BaseBlobParam(gds.DataSet): """Base class for blob detection parameters""" + def validate_parameters(self, *context: object) -> None: + """Validate the Gaussian scale interval.""" + validate_dataset(super(), *context) + if self.min_sigma > self.max_sigma: + raise ValueError("min_sigma must be less than or equal to max_sigma") + min_sigma = gds.FloatItem( "σmin", default=10.0, @@ -557,6 +564,40 @@ def blob_log(image: ImageObj, p: BlobLOGParam) -> GeometryResult | None: class BlobOpenCVParam(DetectionROIParam, gds.DataSet): """Blob detection using OpenCV""" + def validate_parameters(self, *context: object) -> None: + """Validate threshold and enabled filter intervals.""" + del context + if self.min_threshold > self.max_threshold: + raise ValueError( + "min_threshold must be less than or equal to max_threshold" + ) + filter_intervals = ( + (self.filter_by_area, self.min_area, self.max_area, "area"), + ( + self.filter_by_circularity, + self.min_circularity, + self.max_circularity, + "circularity", + ), + ( + self.filter_by_inertia, + self.min_inertia_ratio, + self.max_inertia_ratio, + "inertia", + ), + ( + self.filter_by_convexity, + self.min_convexity, + self.max_convexity, + "convexity", + ), + ) + for enabled, lower, upper, name in filter_intervals: + if enabled and lower > upper: + raise ValueError( + f"minimum {name} must be less than or equal to maximum {name}" + ) + min_threshold = gds.FloatItem( _("Min. threshold"), default=10.0, @@ -607,6 +648,8 @@ class BlobOpenCVParam(DetectionROIParam, gds.DataSet): blob_color = gds.IntItem( _("Blob color"), default=0, + min=0, + max=255, help=_( "The color of the blobs to detect (0 for dark blobs, 255 for light blobs)." ), @@ -733,6 +776,12 @@ def blob_opencv(image: ImageObj, p: BlobOpenCVParam) -> GeometryResult | None: class HoughCircleParam(DetectionROIParam, gds.DataSet): """Circle Hough transform parameters""" + def validate_parameters(self, *context: object) -> None: + """Validate the radius interval.""" + del context + if self.min_radius >= self.max_radius: + raise ValueError("min_radius must be strictly less than max_radius") + min_radius = gds.IntItem( _("Radiusmin"), unit="pixels", min=0, nonzero=True ) diff --git a/sigima/proc/image/edges.py b/sigima/proc/image/edges.py index 516f448..1d74a60 100644 --- a/sigima/proc/image/edges.py +++ b/sigima/proc/image/edges.py @@ -76,6 +76,18 @@ class CannyParam(gds.DataSet): """Canny filter parameters.""" + def validate_parameters(self, *context: object) -> None: + """Validate the hysteresis threshold interval.""" + del context + if self.low_threshold > self.high_threshold: + raise ValueError( + "low_threshold must be less than or equal to high_threshold" + ) + if self.use_quantiles and ( + self.low_threshold > 1.0 or self.high_threshold > 1.0 + ): + raise ValueError("quantile thresholds must be less than or equal to 1") + sigma = gds.FloatItem( "Sigma", default=1.0, diff --git a/sigima/proc/image/exposure.py b/sigima/proc/image/exposure.py index 9293718..01b66fb 100644 --- a/sigima/proc/image/exposure.py +++ b/sigima/proc/image/exposure.py @@ -63,10 +63,12 @@ "AdjustGammaParam", "AdjustLogParam", "AdjustSigmoidParam", + "BrightnessContrastParam", "EqualizeAdaptHistParam", "EqualizeHistParam", "FlatFieldParam", "RescaleIntensityParam", + "adjust_brightness_contrast", "adjust_gamma", "adjust_log", "adjust_sigmoid", @@ -82,6 +84,86 @@ ] +class BrightnessContrastParam(gds.DataSet, title=_("Brightness and contrast")): + """Brightness and contrast adjustment parameters.""" + + minimum = gds.FloatItem(_("Minimum"), default=0.0, check=False).set_prop( + "display", hide=True + ) + maximum = gds.FloatItem(_("Maximum"), default=1.0, check=False).set_prop( + "display", hide=True + ) + histogram = gds.HistogramRangeItem( + _("Brightness and contrast"), + "minimum", + "maximum", + help=_("Adjust the intensity window using the histogram."), + ).set_prop("display", presentation="brightness_contrast") + + def update_from_obj(self, obj: ImageObj) -> None: + """Initialize the range and histogram from an image object.""" + self.update_editor_context(obj) + self.minimum, self.maximum = self.histogram["reset_range"] + + def update_editor_context(self, obj: ImageObj | None) -> None: + """Refresh transient editor context without changing saved parameters.""" + if obj is None: + self.histogram = {} + return + if np.issubdtype(obj.data.dtype, np.complexfloating): + raise ValueError("Brightness and contrast require a real image") + data = obj.get_masked_view().compressed() + self.histogram = sigima.tools.image.brightness_contrast_context( + data, obj.data.dtype + ) + + def validate_parameters(self, *context: object) -> None: + """Validate the source dtype and canonical input range.""" + if not context or not isinstance(context[0], ImageObj): + raise ValueError( + "brightness and contrast validation requires a source ImageObj" + ) + if np.issubdtype(context[0].data.dtype, np.complexfloating): + raise ValueError("Brightness and contrast require a real image") + if ( + not np.isfinite(self.minimum) + or not np.isfinite(self.maximum) + or self.minimum >= self.maximum + ): + raise ValueError("minimum must be finite and strictly less than maximum") + + +@computation_function() +def adjust_brightness_contrast(src: ImageObj, p: BrightnessContrastParam) -> ImageObj: + """Apply a clipped linear brightness and contrast adjustment. + + Args: + src: Input image object + p: Brightness and contrast parameters + + Returns: + Adjusted image object + """ + dst = dst_1_to_1( + src, + "adjust_brightness_contrast", + f"min={p.minimum:.6g}, max={p.maximum:.6g}", + ) + data = src.get_masked_view().compressed() + observed_range, output_range = sigima.tools.image.brightness_contrast_ranges( + data, src.data.dtype + ) + if observed_range is not None and observed_range[0] < observed_range[1]: + dst.data = sigima.tools.image.adjust_brightness_contrast( + src.data, + p.minimum, + p.maximum, + output_range, + ) + restore_data_outside_roi(dst, src) + return dst + + class AdjustGammaParam(gds.DataSet): """Gamma adjustment parameters""" diff --git a/sigima/proc/image/extraction.py b/sigima/proc/image/extraction.py index 169292d..3c97255 100644 --- a/sigima/proc/image/extraction.py +++ b/sigima/proc/image/extraction.py @@ -146,11 +146,11 @@ def geometry_changed(self, item, value) -> None: self.on_geometry_changed() # pylint: disable=not-callable _b_group0 = gds.BeginGroup(_("Geometry")) - ny = gds.IntItem(f"Ny ({_('rows')})", default=3, nonzero=True).set_prop( + ny = gds.IntItem(f"Ny ({_('rows')})", default=3, min=1).set_prop( "display", callback=geometry_changed ) nx = ( - gds.IntItem(f"Nx ({_('columns')})", default=3, nonzero=True) + gds.IntItem(f"Nx ({_('columns')})", default=3, min=1) .set_prop("display", callback=geometry_changed) .set_pos(col=1) ) diff --git a/sigima/proc/image/filtering.py b/sigima/proc/image/filtering.py index 819bd1c..bc78842 100644 --- a/sigima/proc/image/filtering.py +++ b/sigima/proc/image/filtering.py @@ -168,13 +168,6 @@ def butterworth(src: ImageObj, p: ButterworthParam) -> ImageObj: class GaussianFreqFilterParam(GaussianParam): """Parameters for Gaussian filter applied in the frequency domain.""" - sigma = gds.FloatItem( - "σ", - default=1.0, - unit="pixel⁻¹", - min=0.0, - help=_("Standard deviation of the Gaussian filter"), - ) f0 = gds.FloatItem( _("Center frequency"), default=1.0, @@ -187,6 +180,7 @@ class GaussianFreqFilterParam(GaussianParam): default=0.5, unit="pixels⁻¹", min=0.0, + nonzero=True, help=_("Standard deviation of the Gaussian filter"), ) ifft_result_type = gds.ChoiceItem( diff --git a/sigima/proc/image/geometry.py b/sigima/proc/image/geometry.py index 2fa56af..9f6a135 100644 --- a/sigima/proc/image/geometry.py +++ b/sigima/proc/image/geometry.py @@ -259,7 +259,7 @@ class ResizeParam(gds.DataSet): prop = gds.ValueProp(False) - zoom = gds.FloatItem(_("Zoom"), default=1.0) + zoom = gds.FloatItem(_("Zoom"), default=1.0, min=0.0, nonzero=True) mode = gds.ChoiceItem(_("Mode"), BorderMode, default=BorderMode.CONSTANT) cval = gds.FloatItem( _("cval"), @@ -369,10 +369,16 @@ class Resampling2DParam(gds.DataSet): # Pixel size mode parameters dx = gds.FloatItem( - "ΔX", default=None, allow_none=True, help=_("Pixel size in X direction") + "ΔX", + default=None, + allow_none=True, + help=_("Pixel size in X direction"), ).set_prop("display", active=gds.FuncProp(_prop, lambda x: x == "dxy")) dy = gds.FloatItem( - "ΔY", default=None, allow_none=True, help=_("Pixel size in Y direction") + "ΔY", + default=None, + allow_none=True, + help=_("Pixel size in Y direction"), ).set_prop("display", active=gds.FuncProp(_prop, lambda x: x == "dxy")) # Shape mode parameters @@ -405,6 +411,41 @@ class Resampling2DParam(gds.DataSet): check=False, ) + def validate_parameters(self, *context: object) -> None: + """Validate output bounds and the active sampling mode.""" + if not context or not isinstance(context[0], ImageObj): + raise ValueError("resampling validation requires a source ImageObj") + src = context[0] + xmin = self.xmin if self.xmin is not None else src.x0 + xmax = self.xmax if self.xmax is not None else src.x0 + src.width + ymin = self.ymin if self.ymin is not None else src.y0 + ymax = self.ymax if self.ymax is not None else src.y0 + src.height + x_extent = xmax - xmin + y_extent = ymax - ymin + if x_extent == 0.0 or y_extent == 0.0: + raise ValueError("resampling coordinate extents must be nonzero") + + if self.mode == "dxy": + if self.dx is None or self.dy is None: + raise ValueError("dx and dy must be specified in pixel size mode") + if self.dx == 0.0 or self.dy == 0.0: + raise ValueError("dx and dy must be nonzero in pixel size mode") + if ( + x_extent > 0.0 + and self.dx < 0.0 + or x_extent < 0.0 + and self.dx > 0.0 + or y_extent > 0.0 + and self.dy < 0.0 + or y_extent < 0.0 + and self.dy > 0.0 + ): + raise ValueError("pixel sizes must have the same sign as their extents") + elif self.width is None or self.height is None: + raise ValueError("width and height must be specified in shape mode") + elif self.width < 1 or self.height < 1: + raise ValueError("width and height must be at least 1 in shape mode") + def update_from_obj(self, obj: ImageObj) -> None: """Update parameters from an image object.""" if self.xmin is None: @@ -544,8 +585,12 @@ class UniformCoordsParam(gds.DataSet): x0 = gds.FloatItem("X0", default=0.0, help=_("Origin X-axis coordinate")) y0 = gds.FloatItem("Y0", default=0.0, help=_("Origin Y-axis coordinate")) - dx = gds.FloatItem("Δx", default=1.0, help=_("Pixel size along X-axis")) - dy = gds.FloatItem("Δy", default=1.0, help=_("Pixel size along Y-axis")) + dx = gds.FloatItem( + "Δx", default=1.0, nonzero=True, help=_("Pixel size along X-axis") + ) + dy = gds.FloatItem( + "Δy", default=1.0, nonzero=True, help=_("Pixel size along Y-axis") + ) def update_from_obj(self, obj: ImageObj) -> None: """Update default values from image object's non-uniform coordinates. diff --git a/sigima/proc/image/preprocessing.py b/sigima/proc/image/preprocessing.py index 2405b3c..6a7d998 100644 --- a/sigima/proc/image/preprocessing.py +++ b/sigima/proc/image/preprocessing.py @@ -35,13 +35,13 @@ class BinningParam(gds.DataSet): sx = gds.IntItem( _("Cluster size (X)"), default=2, - min=2, + min=1, help=_("Number of adjacent pixels to be combined together along X-axis."), ) sy = gds.IntItem( _("Cluster size (Y)"), default=2, - min=2, + min=1, help=_("Number of adjacent pixels to be combined together along Y-axis."), ) operation = gds.ChoiceItem( diff --git a/sigima/proc/signal/analysis.py b/sigima/proc/signal/analysis.py index 1d122b3..e0f391a 100644 --- a/sigima/proc/signal/analysis.py +++ b/sigima/proc/signal/analysis.py @@ -116,6 +116,14 @@ class PulseFeaturesParam(gds.DataSet, title=_("Pulse features")): ), ) + def validate_parameters(self, *context: object) -> None: + """Validate baseline intervals.""" + del context + if self.xstartmin > self.xstartmax: + raise ValueError("xstartmin must be less than or equal to xstartmax") + if self.xendmin > self.xendmax: + raise ValueError("xendmin must be less than or equal to xendmax") + def update_from_obj(self, obj: SignalObj) -> None: """Update parameters from a signal object.""" self.xstartmin, self.xstartmax = pulse.get_start_range(obj.x) diff --git a/sigima/proc/signal/features.py b/sigima/proc/signal/features.py index 5577437..7a9a776 100644 --- a/sigima/proc/signal/features.py +++ b/sigima/proc/signal/features.py @@ -49,7 +49,7 @@ class PeakDetectionParam(gds.DataSet, title=_("Peak detection")): """Peak detection parameters""" - threshold = gds.FloatItem(_("Threshold"), default=0.1, min=0.0) + threshold = gds.FloatItem(_("Threshold"), default=0.1, min=0.0, max=100.0) min_dist = gds.IntItem(_("Minimum distance"), default=1, min=1) @@ -273,6 +273,12 @@ class FWHMParam( help=_("Upper X boundary (empty for no limit, i.e. end of the signal)"), ).set_prop("display", col=1) + def validate_parameters(self, *context: object) -> None: + """Validate optional measurement boundaries.""" + del context + if self.xmin is not None and self.xmax is not None and self.xmin >= self.xmax: + raise ValueError("xmin must be strictly less than xmax") + @computation_function() def fwhm(obj: SignalObj, param: FWHMParam) -> GeometryResult | None: @@ -452,7 +458,9 @@ def bandwidth_3db(obj: SignalObj) -> GeometryResult | None: class DynamicParam(gds.DataSet, title=_("Dynamic parameters")): """Parameters for dynamic range computation (ENOB, SNR, SINAD, THD, SFDR)""" - full_scale = gds.FloatItem(_("Full scale"), default=0.16, min=0.0, unit="V") + full_scale = gds.FloatItem( + _("Full scale"), default=0.16, min=0.0, nonzero=True, unit="V" + ) unit = gds.ChoiceItem( _("Unit"), [(PowerUnit.DBC, "dBc"), (PowerUnit.DBFS, "dBFS")], diff --git a/sigima/proc/signal/filtering.py b/sigima/proc/signal/filtering.py index c6fbc63..7960bca 100644 --- a/sigima/proc/signal/filtering.py +++ b/sigima/proc/signal/filtering.py @@ -217,6 +217,33 @@ def get_filter_func(self) -> Callable: ), ) + def validate_parameters(self, *context: object) -> None: + """Validate active cutoff frequencies against the filter method.""" + if not context or not isinstance(context[0], SignalObj): + raise ValueError("filter validation requires a source SignalObj") + if self.cut0 is None or self.cut0 <= 0.0: + raise ValueError("cut0 must be strictly positive") + + is_band_filter = self.TYPE in (FilterType.BANDPASS, FilterType.BANDSTOP) + if is_band_filter: + if self.cut1 is None or self.cut1 <= 0.0: + raise ValueError("cut1 must be strictly positive for band filters") + if self.method == FrequencyFilterMethod.BRICKWALL: + if self.cut0 > self.cut1: + raise ValueError("cut0 must be less than or equal to cut1") + elif self.cut0 >= self.cut1: + raise ValueError("cut0 must be strictly less than cut1") + + if self.method != FrequencyFilterMethod.BRICKWALL: + f_nyquist = get_nyquist_frequency(context[0]) + active_cutoffs = [self.cut0] + if is_band_filter: + active_cutoffs.append(self.cut1) + if any(cutoff >= f_nyquist for cutoff in active_cutoffs): + raise ValueError( + "IIR cutoff frequencies must be strictly below Nyquist" + ) + def update_from_obj(self, obj: SignalObj) -> None: """Update the filter parameters from a signal object diff --git a/sigima/proc/signal/processing.py b/sigima/proc/signal/processing.py index e3d905c..69a02a6 100644 --- a/sigima/proc/signal/processing.py +++ b/sigima/proc/signal/processing.py @@ -48,6 +48,7 @@ from sigima.proc.decorator import computation_function from sigima.tools.signal import fourier, interpolation, scaling, windowing from sigima.tools.signal import replace_values as rv +from sigima.validation import validate_dataset from .base import dst_1_to_1, is_uncertainty_data_available, restore_data_outside_roi @@ -122,6 +123,21 @@ class Resampling1DParam(InterpolationParam): "display", active=FuncProp(_prop, lambda x: x == "nbpts") ) + def validate_parameters(self, *context: object) -> None: + """Validate the target domain and active sampling mode.""" + validate_dataset(super(), *context) + if self.xmin is None or self.xmax is None: + raise ValueError("xmin and xmax must be specified") + if self.mode == "dx": + if self.dx is None or self.dx == 0.0: + raise ValueError("dx must be non-zero in step size mode") + if (self.xmax > self.xmin and self.dx < 0.0) or ( + self.xmax < self.xmin and self.dx > 0.0 + ): + raise ValueError("dx sign must match the target domain orientation") + elif self.mode == "nbpts" and (self.nbpts is None or self.nbpts < 1): + raise ValueError("nbpts must be at least 1 in number of points mode") + def update_from_obj(self, obj: SignalObj) -> None: """Update parameters from a signal object.""" if self.xmin is None: @@ -474,6 +490,12 @@ class WindowingParam(gds.DataSet, title=_("Windowing")): active=gds.FuncProp(_meth_prop, lambda x: x == WindowingMethod.GAUSSIAN), ) + def validate_parameters(self, *context: object) -> None: + """Validate parameters used by the selected window method.""" + del context + if self.method == WindowingMethod.GAUSSIAN and self.sigma == 0.0: + raise ValueError("sigma must be non-zero for Gaussian windowing") + @computation_function() def apply_window(src: SignalObj, p: WindowingParam) -> SignalObj: diff --git a/sigima/tests/common/dataset_validation_unit_test.py b/sigima/tests/common/dataset_validation_unit_test.py new file mode 100644 index 0000000..eff63f7 --- /dev/null +++ b/sigima/tests/common/dataset_validation_unit_test.py @@ -0,0 +1,129 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for parameter DataSet validation helpers.""" + +from __future__ import annotations + +import guidata.dataset as gds +import pytest +from guidata.config import ValidationMode, temporary_validation_mode + +from sigima.validation import validate_dataset + + +class PlainParam(gds.DataSet): + """DataSet without relational validation.""" + + value = gds.FloatItem("Value", default=1.0) + + +class RecordingParam(gds.DataSet): + """DataSet recording relational validation context.""" + + value = gds.FloatItem("Value", default=1.0) + + def validate_parameters(self, *context: object) -> None: + """Record validation context.""" + self.validation_context = context + + +class RejectingParam(gds.DataSet): + """DataSet rejecting an invalid relation.""" + + lower = gds.FloatItem("Lower", default=1.0) + upper = gds.FloatItem("Upper", default=0.0) + + def validate_parameters(self, *context: object) -> None: + """Reject reversed bounds.""" + del context + if self.lower > self.upper: + raise ValueError("lower must be less than or equal to upper") + + +def test_validate_dataset_without_hook() -> None: + """DataSets without a validation hook are accepted.""" + validate_dataset(PlainParam()) + + +def test_validate_dataset_passes_context() -> None: + """Execution context is forwarded unchanged to the validation hook.""" + param = RecordingParam() + context = object() + + validate_dataset(param, context, "extra") + + assert param.validation_context == (context, "extra") + + +def test_validate_dataset_propagates_value_error() -> None: + """Relational validation errors propagate to the caller.""" + with pytest.raises(ValueError, match="lower must be less"): + validate_dataset(RejectingParam()) + + +def test_inactive_parameter_values_survive_json_round_trip() -> None: + """Conditional values remain unchanged through DataSet JSON conversion.""" + from sigima.proc.image.geometry import Resampling2DParam + from sigima.proc.signal.processing import Resampling1DParam, WindowingParam + + params_and_values = ( + ( + Resampling1DParam.create(mode="nbpts", xmin=0.0, xmax=1.0, nbpts=3, dx=0.0), + ("mode", "nbpts", "dx", 0.0), + ), + ( + Resampling2DParam.create(mode="dxy", dx=1.0, dy=1.0, width=0, height=-1), + ("mode", "dxy", "width", 0, "height", -1), + ), + ( + WindowingParam.create(method="hamming", sigma=0.0), + ("sigma", 0.0), + ), + ) + + for param, expected in params_and_values: + restored = gds.json_to_dataset(gds.dataset_to_json(param)) + for name, value in zip(expected[::2], expected[1::2]): + assert getattr(restored, name) == value + + +@pytest.mark.gui +@pytest.mark.parametrize("validation_mode", list(ValidationMode)) +def test_parameter_bounds_qt_forms(validation_mode: ValidationMode) -> None: + """Signed ROI and grid values remain editable in real DataSet dialogs.""" + import numpy as np + from guidata.dataset.qtwidgets import DataSetEditDialog + + from sigima.objects import ImageObj + from sigima.objects.image.roi import RectangularROI + from sigima.proc.image import GridParam + from sigima.tests import guiutils + + image = ImageObj(title="Reversed axes") + image.data = np.zeros((12, 12), dtype=float) + image.set_uniform_coords(-1.0, -2.0, 10.0, 20.0) + + with temporary_validation_mode(validation_mode): + roi_param = RectangularROI([2, 3, 4, 2], indices=True, inverse=True).to_param( + image, 0 + ) + grid_param = GridParam.create(direction="col", cols=-3) + + values_before = ( + (roi_param.dx, roi_param.dy, roi_param.inverse), + (grid_param.cols, grid_param.direction), + ) + with guiutils.lazy_qt_app_context(force=True) as app: + assert app is not None + dialogs = (DataSetEditDialog(roi_param), DataSetEditDialog(grid_param)) + assert all(dialog.edit_layout for dialog in dialogs) + for dialog in dialogs: + assert all(layout.check_all_values() for layout in dialog.edit_layout) + for layout in dialog.edit_layout: + layout.accept_changes() + dialog.close() + + assert values_before == ( + (roi_param.dx, roi_param.dy, roi_param.inverse), + (grid_param.cols, grid_param.direction), + ) diff --git a/sigima/tests/common/decorator_unit_test.py b/sigima/tests/common/decorator_unit_test.py index de4e212..88a569a 100644 --- a/sigima/tests/common/decorator_unit_test.py +++ b/sigima/tests/common/decorator_unit_test.py @@ -9,11 +9,17 @@ - The metadata is correctly set and can be introspected """ +# `@computation_function` rewrites the decorated signature at import time, making the +# DataSet parameter optional. Pylint only sees the source signature, so it wrongly +# reports a missing argument on the expanded-keyword calls exercised below. +# pylint: disable=no-value-for-parameter + from __future__ import annotations import guidata.dataset as gds import numpy as np import pytest +from guidata.config import ValidationMode, temporary_validation_mode from sigima.objects import ImageObj, SignalObj, create_image, create_signal from sigima.proc.base import dst_1_to_1 @@ -44,6 +50,12 @@ class DummySignalParam(gds.DataSet): methods = (("linear", "Linear"), ("quadratic", "Quadratic")) method = gds.ChoiceItem("Method", choices=methods, default="linear") + def validate_parameters(self, *context: object) -> None: + """Validate parameter ordering and record execution context.""" + self.validation_context = context + if self.a > self.b: + raise ValueError("a must be less than or equal to b") + SCF_NAME = "dummy_signal_func" SCF_DESCRIPTION = "A dummy signal function" @@ -68,6 +80,12 @@ def dummy_signal_func(src: SignalObj, p: DummySignalParam) -> SignalObj: return dst +@computation_function() +def dummy_optional_signal_func(src: SignalObj, p: DummySignalParam) -> SignalObj | None: + """Return a signal through a function with a PEP 604 return annotation.""" + return src + + class DummyImageParam(gds.DataSet): """Dummy DataSet for testing purposes""" @@ -116,12 +134,12 @@ def test_signal_decorator_signature() -> None: # Call the function with a DataSet parameter p = DummySignalParam.create(a=3.0, b=4.0, method="quadratic") res_ds = dummy_signal_func(orig, p) + assert p.validation_context == (orig,) name = "Signal[DataSet parameter]" check_array_result(f"{name} x", res_ds.x, orig.x) check_array_result(f"{name} y", res_ds.y, orig.y + orig.x**2 * 3.0 + 4.0) # Call the function with keyword arguments - # pylint: disable=no-value-for-parameter res_kw = dummy_signal_func(orig, a=3.0, b=4.0) name = "Signal[keyword arguments]" check_array_result(f"{name} x", res_kw.x, orig.x) @@ -141,6 +159,31 @@ def test_signal_decorator_signature() -> None: ) +def test_signal_decorator_relational_validation() -> None: + """Relational validation applies to both supported call styles.""" + x = np.linspace(0, 10, 100) + orig = create_signal("test_signal", x=x, y=x) + + with temporary_validation_mode(ValidationMode.DISABLED): + with pytest.raises(ValueError, match="a must be less"): + dummy_signal_func(orig, DummySignalParam.create(a=5.0, b=4.0)) + with pytest.raises(ValueError, match="a must be less"): + dummy_signal_func(orig, a=5.0, b=4.0) + + param = DummySignalParam.create(a=3.0, b=4.0) + dummy_signal_func(orig, param, a=100.0, b=0.0) + assert param.validation_context == (orig,) + + +def test_decorator_validates_dataset_with_optional_return_annotation() -> None: + """A failing return hint does not prevent DataSet parameter discovery.""" + orig = create_signal("test_signal", x=np.arange(2.0), y=np.arange(2.0)) + + with temporary_validation_mode(ValidationMode.DISABLED): + with pytest.raises(ValueError, match="a must be less"): + dummy_optional_signal_func(orig, DummySignalParam.create(a=2.0, b=1.0)) + + def test_image_decorator_marker() -> None: """Test the computation function decorator marker for images""" # Check if the function is marked as a computation function diff --git a/sigima/tests/image/exposure_unit_test.py b/sigima/tests/image/exposure_unit_test.py index 28a78f1..7c05c28 100644 --- a/sigima/tests/image/exposure_unit_test.py +++ b/sigima/tests/image/exposure_unit_test.py @@ -4,10 +4,19 @@ Unit tests for exposure computation functions. """ +# `@computation_function` rewrites the decorated signature at import time, making the +# DataSet parameter optional. Pylint only sees the source signature, so it wrongly +# reports a missing argument on the expanded-keyword calls exercised below. +# pylint: disable=no-value-for-parameter + from __future__ import annotations +import inspect +import json + import numpy as np import pytest +from guidata.dataset.jsonschema import dataset_to_schema from skimage import exposure import sigima.enums @@ -16,6 +25,198 @@ import sigima.proc.image from sigima.tests.data import get_test_image from sigima.tests.helpers import check_array_result, check_scalar_result +from sigima.tools.image import exposure as image_exposure + + +@pytest.mark.validation +def test_adjust_brightness_contrast() -> None: + """The source-derived window drives a clipped dtype-preserving remap.""" + data = np.array([[0, 64, 128, 255]], dtype=np.uint8) + src = sigima.objects.create_image("uint8", data) + p = sigima.params.BrightnessContrastParam() + p.update_from_obj(src) + + assert ( + dataset_to_schema(type(p))["properties"]["histogram"][ + "x-guidata-histogram-presentation" + ] + == "brightness_contrast" + ) + assert (p.minimum, p.maximum) == (0.0, 255.0) + assert p.histogram["domain"] == [0.0, 255.0] + assert len(p.histogram["counts"]) == 256 + assert ( + "histogram" + not in inspect.signature( + sigima.proc.image.adjust_brightness_contrast + ).parameters + ) + + p.minimum, p.maximum = 64.0, 192.0 + dst = sigima.proc.image.adjust_brightness_contrast(src, p) + np.testing.assert_array_equal(dst.data, [[0, 0, 128, 255]]) + assert dst.data.dtype == src.data.dtype + np.testing.assert_array_equal(src.data, data) + + +@pytest.mark.parametrize("dtype", [np.int64, np.uint64]) +@pytest.mark.parametrize("values", [[], [0, 1, 2]]) +@pytest.mark.parametrize("operation", ["ranges", "context", "remap", "identity"]) +def test_brightness_contrast_rejects_64bit_integers(dtype, values, operation): + """All tools reject unsupported types, including early-return paths.""" + data = np.array([values], dtype=dtype) + original = data.copy() + with np.errstate(all="raise"), pytest.raises(TypeError, match="32-bit"): + if operation == "ranges": + image_exposure.brightness_contrast_ranges(data, data.dtype) + elif operation == "context": + image_exposure.brightness_contrast_context(data, data.dtype) + else: + output = (0.0, 2.0) if operation == "identity" else (0.0, 10.0) + image_exposure.adjust_brightness_contrast(data, 0.0, 2.0, output) + np.testing.assert_array_equal(data, original) + + +@pytest.mark.parametrize( + "dtype", [np.int8, np.uint8, np.int16, np.uint16, np.int32, np.uint32] +) +def test_brightness_contrast_integer_endpoints(dtype): + """Supported integer types retain exact endpoints and nearest-even rounding.""" + data = np.array([[0, 1, 2]], dtype=dtype) + bounds = np.iinfo(dtype) + _, output = image_exposure.brightness_contrast_ranges(data, data.dtype) + with np.errstate(all="raise"): + result = image_exposure.adjust_brightness_contrast(data, 0.0, 2.0, output) + assert result.dtype == dtype + assert result[0, 0] == bounds.min + assert result[0, 2] == bounds.max + assert int(result[0, 1]) == round((int(bounds.min) + int(bounds.max)) / 2) + assert np.all(result[0, 1:] >= result[0, :-1]) + np.testing.assert_array_equal(data, [[0, 1, 2]]) + + +def test_adjust_brightness_contrast_roi() -> None: + """The ROI controls initialization and pixels outside it are restored.""" + data = np.arange(16, dtype=np.uint16).reshape(4, 4) + src = sigima.objects.create_image("roi", data) + src.roi = sigima.objects.create_image_roi("rectangle", [1, 1, 2, 2], indices=True) + p = sigima.params.BrightnessContrastParam() + p.update_from_obj(src) + assert (p.minimum, p.maximum) == (5.0, 10.0) + + dst = sigima.proc.image.adjust_brightness_contrast(src, p) + mask = src.maskdata + np.testing.assert_array_equal(dst.data[mask], src.data[mask]) + np.testing.assert_array_equal(dst.data[~mask], [0, 13107, 52428, 65535]) + + +def test_adjust_brightness_contrast_float_nonfinite() -> None: + """Non-finite values do not affect statistics and survive the remap.""" + data = np.array([[-1.0, 0.0, 1.0, np.nan, np.inf, -np.inf]]) + src = sigima.objects.create_image("float", data) + p = sigima.params.BrightnessContrastParam() + p.update_from_obj(src) + assert (p.minimum, p.maximum) == (-1.0, 1.0) + + p.minimum, p.maximum = 0.0, 1.0 + dst = sigima.proc.image.adjust_brightness_contrast(src, p) + np.testing.assert_array_equal(dst.data[0, :3], [-1.0, -1.0, 1.0]) + assert np.isnan(dst.data[0, 3]) + assert np.isposinf(dst.data[0, 4]) + assert np.isneginf(dst.data[0, 5]) + + +def test_brightness_contrast_editor_context_preserves_range() -> None: + """Refreshing transient context does not overwrite persisted bounds.""" + src = sigima.objects.create_image( + "source", np.array([[0, 64, 128, 255]], dtype=np.uint8) + ) + p = sigima.params.BrightnessContrastParam() + p.minimum, p.maximum = 64.0, 192.0 + + p.update_editor_context(src) + + assert (p.minimum, p.maximum) == (64.0, 192.0) + assert p.histogram["domain"] == [0.0, 255.0] + + +def test_adjust_brightness_contrast_tiny_float_reset_is_identity() -> None: + """Reset preserves a non-constant float image at sub-unit scales.""" + data = np.array([[0.0, 1e-9, 2e-9]], dtype=np.float32) + src = sigima.objects.create_image("tiny", data) + p = sigima.params.BrightnessContrastParam() + p.update_from_obj(src) + + assert (p.minimum, p.maximum) == (0.0, float(data.max())) + assert 0.0 < p.histogram["minimum_width"] < p.maximum + dst = sigima.proc.image.adjust_brightness_contrast(src, p) + np.testing.assert_array_equal(dst.data, data) + + +def test_brightness_contrast_float32_histogram_uses_safe_precision() -> None: + """Large finite float32 values all contribute to the histogram.""" + data = np.array([[-3e38, 0.0, 3e38]], dtype=np.float32) + src = sigima.objects.create_image("large-float32", data) + p = sigima.params.BrightnessContrastParam() + + with np.errstate(over="raise", invalid="raise", divide="raise"): + p.update_from_obj(src) + + counts = p.histogram["counts"] + assert sum(counts) == data.size + assert (counts[0], counts[128], counts[-1]) == (1, 1, 1) + assert p.histogram["auto_range"] == p.histogram["reset_range"] + dst = sigima.proc.image.adjust_brightness_contrast(src, p) + np.testing.assert_array_equal(dst.data, data) + + +@pytest.mark.parametrize( + "data", + [ + np.array([[-1e308, 0.0, 1e308]], dtype=np.float64), + np.array([[np.finfo(np.float64).max]], dtype=np.float64), + np.array([[-np.finfo(np.float64).max]], dtype=np.float64), + ], +) +def test_brightness_contrast_extreme_float_context_is_finite( + data: np.ndarray, +) -> None: + """Finite IEEE-754 extremes produce a JSON-safe context and no exception.""" + src = sigima.objects.create_image("extreme", data) + p = sigima.params.BrightnessContrastParam() + p.update_from_obj(src) + + json.dumps(p.histogram, allow_nan=False) + assert np.all(np.isfinite(p.histogram["domain"])) + assert np.all(np.isfinite(p.histogram["bin_edges"])) + assert np.isfinite(p.histogram["minimum_width"]) + assert p.histogram["minimum_width"] > 0.0 + assert sum(p.histogram["counts"]) == data.size + dst = sigima.proc.image.adjust_brightness_contrast(src, p) + np.testing.assert_array_equal(dst.data, data) + + +def test_adjust_brightness_contrast_degenerate_inputs() -> None: + """Constant images are no-ops and complex images are rejected.""" + constant = sigima.objects.create_image( + "constant", np.full((3, 3), 7.0, dtype=np.float32) + ) + p = sigima.params.BrightnessContrastParam() + p.update_from_obj(constant) + assert p.minimum < p.maximum + assert not p.histogram["active"] + dst = sigima.proc.image.adjust_brightness_contrast(constant, p) + np.testing.assert_array_equal(dst.data, constant.data) + + complex_image = sigima.objects.create_image( + "complex", np.ones((2, 2), dtype=np.complex128) + ) + with pytest.raises(ValueError, match="real image"): + p.update_from_obj(complex_image) + with pytest.raises(ValueError, match="real image"): + sigima.proc.image.adjust_brightness_contrast( + complex_image, minimum=0.0, maximum=1.0 + ) @pytest.mark.validation diff --git a/sigima/tests/image/imageobj_unit_test.py b/sigima/tests/image/imageobj_unit_test.py index a0baa5e..5468138 100644 --- a/sigima/tests/image/imageobj_unit_test.py +++ b/sigima/tests/image/imageobj_unit_test.py @@ -402,6 +402,38 @@ def test_create_image_from_param() -> None: execenv.print(f"{test_create_image_from_param.__doc__}: OK") +def test_image_creation_parameter_validation() -> None: + """Image generators enforce only their required parameter relations.""" + constant = sigima.objects.create_image_from_param( + sigima.objects.UniformDistribution2DParam.create( + height=2, width=2, vmin=3.0, vmax=3.0 + ) + ) + np.testing.assert_array_equal(constant.data, np.full((2, 2), 3.0)) + + with pytest.raises(ValueError, match="vmin must be less"): + sigima.objects.create_image_from_param( + sigima.objects.UniformDistribution2DParam.create( + height=2, width=2, vmin=2.0, vmax=1.0 + ) + ) + + equal_radii = SiemensStar2DParam.create( + height=2, width=2, inner_radius=1.0, outer_radius=1.0 + ) + assert sigima.objects.create_image_from_param(equal_radii).data.shape == (2, 2) + + with pytest.raises(ValueError, match="inner_radius must be less"): + sigima.objects.create_image_from_param( + SiemensStar2DParam.create( + height=2, width=2, inner_radius=2.0, outer_radius=1.0 + ) + ) + + with pytest.raises(ValueError, match="Zero is not"): + Gauss2DParam.create(sigma=0.0) + + def test_image_copy() -> None: """Test copying image objects with uniform and non-uniform coordinates""" execenv.print(f"{test_image_copy.__doc__}:") diff --git a/sigima/tests/image/parameter_validation_unit_test.py b/sigima/tests/image/parameter_validation_unit_test.py new file mode 100644 index 0000000..ddc3b0c --- /dev/null +++ b/sigima/tests/image/parameter_validation_unit_test.py @@ -0,0 +1,310 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for image processing parameter validation.""" + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.config import ValidationMode, temporary_validation_mode + +from sigima.objects import ImageObj, create_image +from sigima.proc.image import GridParam +from sigima.proc.image.detection import ( + BlobDOGParam, + BlobOpenCVParam, + GenericDetectionParam, + HoughCircleParam, + blob_opencv, + hough_circle_peaks, +) +from sigima.proc.image.edges import CannyParam +from sigima.proc.image.extraction import ROIGridParam +from sigima.proc.image.filtering import GaussianFreqFilterParam +from sigima.proc.image.geometry import ( + Resampling2DParam, + ResizeParam, + UniformCoordsParam, + resampling, +) +from sigima.proc.image.preprocessing import BinningParam, binning +from sigima.validation import validate_dataset + + +def create_test_image() -> ImageObj: + """Create a small image with uniform unit coordinates.""" + image = create_image("test", np.arange(24.0).reshape(4, 6)) + image.set_uniform_coords(1.0, 1.0, 0.0, 0.0) + return image + + +def test_grid_counts_and_zero_sized_roi_cells() -> None: + """Grid counts are signed and nonzero; zero-sized ROI cells remain valid.""" + assert GridParam.create(direction="col", cols=-3).cols == -3 + assert GridParam.create(direction="row", rows=-4).rows == -4 + assert ROIGridParam.create(nx=1, ny=1, xsize=0, ysize=0).xsize == 0 + + with pytest.raises(ValueError, match="Zero is not"): + GridParam.create(cols=0) + with pytest.raises(ValueError, match="lower than minimum"): + ROIGridParam.create(nx=0) + + +def test_relative_detection_threshold_endpoints() -> None: + """Relative detection thresholds span the full normalized interval.""" + assert GenericDetectionParam.create(threshold=0.0).threshold == 0.0 + assert GenericDetectionParam.create(threshold=1.0).threshold == 1.0 + with pytest.raises(ValueError, match="lower than minimum"): + GenericDetectionParam.create(threshold=-0.01) + with pytest.raises(ValueError, match="greater than maximum"): + GenericDetectionParam.create(threshold=1.01) + + +def test_blob_scale_and_hough_radius_intervals() -> None: + """Blob scales may be equal, while Hough radii must be strictly ordered.""" + validate_dataset(BlobDOGParam.create(min_sigma=2.0, max_sigma=2.0)) + with pytest.raises(ValueError, match="min_sigma must be less"): + validate_dataset(BlobDOGParam.create(min_sigma=3.0, max_sigma=2.0)) + + source = create_test_image() + with pytest.raises(ValueError, match="min_radius must be strictly less"): + hough_circle_peaks(source, HoughCircleParam.create(min_radius=2, max_radius=2)) + with pytest.raises(ValueError, match="min_radius must be strictly less"): + hough_circle_peaks(source, HoughCircleParam.create(min_radius=3, max_radius=2)) + + +@pytest.mark.parametrize( + ("enabled_field", "lower_field", "upper_field"), + ( + ("filter_by_area", "min_area", "max_area"), + ("filter_by_circularity", "min_circularity", "max_circularity"), + ("filter_by_inertia", "min_inertia_ratio", "max_inertia_ratio"), + ("filter_by_convexity", "min_convexity", "max_convexity"), + ), +) +def test_opencv_blob_enabled_filter_intervals( + enabled_field: str, lower_field: str, upper_field: str +) -> None: + """Sigima's hook validates an optional interval only when it is enabled.""" + param = BlobOpenCVParam() + setattr(param, enabled_field, True) + setattr(param, lower_field, 0.8) + setattr(param, upper_field, 0.2) + with pytest.raises(ValueError, match="must be less than or equal"): + validate_dataset(param) + + setattr(param, enabled_field, False) + validate_dataset(param) + + +def test_opencv_blob_ignores_valid_disabled_filter_interval() -> None: + """A disabled valid interval does not alter the public OpenCV result.""" + pytest.importorskip("cv2") + ycoords, xcoords = np.ogrid[:100, :100] + data = np.zeros((100, 100), dtype=float) + data[(xcoords - 50) ** 2 + (ycoords - 50) ** 2 < 10**2] = 1.0 + image = create_image("blob", data) + common = { + "min_threshold": 10.0, + "max_threshold": 200.0, + "min_repeatability": 2, + "min_dist_between_blobs": 10.0, + "filter_by_color": False, + "blob_color": 0, + "filter_by_area": True, + "min_area": 10.0, + "max_area": 1000.0, + "filter_by_circularity": False, + "filter_by_inertia": False, + "filter_by_convexity": False, + } + + first = blob_opencv( + image, + BlobOpenCVParam.create(**common, min_circularity=0.1, max_circularity=1.0), + ) + second = blob_opencv( + image, + BlobOpenCVParam.create(**common, min_circularity=0.2, max_circularity=0.9), + ) + + assert first is not None + assert second is not None + np.testing.assert_allclose(first.coords, second.coords) + + +def test_opencv_blob_threshold_and_color_bounds() -> None: + """Threshold ordering and byte colors apply even with color filtering off.""" + validate_dataset( + BlobOpenCVParam.create( + min_threshold=2.0, + max_threshold=2.0, + filter_by_color=False, + blob_color=255, + ) + ) + with pytest.raises(ValueError, match="min_threshold must be less"): + validate_dataset(BlobOpenCVParam.create(min_threshold=3.0, max_threshold=2.0)) + with pytest.raises(ValueError, match="greater than maximum"): + BlobOpenCVParam.create(filter_by_color=False, blob_color=256) + + +def test_canny_threshold_validation() -> None: + """Canny quantiles are normalized, while absolute thresholds are unbounded.""" + validate_dataset( + CannyParam.create(low_threshold=0.5, high_threshold=0.5, use_quantiles=True) + ) + validate_dataset( + CannyParam.create(low_threshold=2.0, high_threshold=3.0, use_quantiles=False) + ) + with pytest.raises(ValueError, match="low_threshold must be less"): + validate_dataset( + CannyParam.create(low_threshold=0.8, high_threshold=0.2, use_quantiles=True) + ) + with pytest.raises(ValueError, match="less than or equal to 1"): + validate_dataset( + CannyParam.create(low_threshold=0.5, high_threshold=1.1, use_quantiles=True) + ) + + +def test_positive_scale_and_identity_binning_bounds() -> None: + """Scale divisors are positive and one-pixel binning remains an identity.""" + with pytest.raises(ValueError, match="Zero is not"): + GaussianFreqFilterParam.create(sigma=0.0) + with pytest.raises(ValueError, match="Zero is not"): + ResizeParam.create(zoom=0.0) + + source = create_test_image() + result = binning(source, BinningParam.create(sx=1, sy=1)) + assert np.array_equal(result.data, source.data) + + +def test_signed_pixel_spacing_is_nonzero() -> None: + """Image axes may descend, but a zero pixel spacing is invalid.""" + param = UniformCoordsParam.create(dx=-1.0, dy=-2.0) + assert param.dx == -1.0 + assert param.dy == -2.0 + with pytest.raises(ValueError, match="Zero is not"): + UniformCoordsParam.create(dx=0.0) + + +def test_resampling_uses_source_bounds_without_mutation() -> None: + """Missing output bounds are resolved from the source without being stored.""" + source = create_test_image() + param = Resampling2DParam.create(mode="shape", width=6, height=4) + validate_dataset(param, source) + assert param.xmin is None + assert param.xmax is None + assert param.ymin is None + assert param.ymax is None + + +def test_resampling_accepts_descending_axes() -> None: + """Descending extents work in shape mode and with matching negative steps.""" + source = create_test_image() + shape_param = Resampling2DParam.create( + mode="shape", + xmin=6.0, + xmax=0.0, + ymin=4.0, + ymax=0.0, + dx=0.0, + dy=0.0, + width=6, + height=4, + ) + result = resampling(source, shape_param) + assert result.data.shape == (4, 6) + assert result.dx == -1.0 + assert result.dy == -1.0 + + dxy_param = Resampling2DParam.create( + mode="dxy", + xmin=6.0, + xmax=0.0, + ymin=4.0, + ymax=0.0, + dx=-1.0, + dy=-1.0, + width=0, + height=-1, + ) + dxy_result = resampling(source, dxy_param) + assert dxy_result.data.shape == (4, 6) + assert dxy_param.width == 0 + assert dxy_param.height == -1 + + +@pytest.mark.parametrize("step", [1e-200, -1e-200]) +def test_resampling_accepts_tiny_signed_pixel_sizes(step: float) -> None: + """Same-signed extents and steps remain valid when their product underflows.""" + source = create_test_image() + source.set_uniform_coords(step, step, 0.0, 0.0) + param = Resampling2DParam.create(mode="dxy", dx=step, dy=step, fill_value=0.0) + + result = resampling(source, param) + + assert result.data.shape == source.data.shape + assert np.isfinite(result.data).all() + assert result.dx == step + assert result.dy == step + + +def test_resampling_validates_fields_after_mode_switch() -> None: + """Inactive geometry values are preserved and checked only when activated.""" + source = create_test_image() + shape_param = Resampling2DParam.create( + mode="shape", width=6, height=4, dx=0.0, dy=0.0 + ) + resampling(source, shape_param) + shape_param.mode = "dxy" + with pytest.raises(ValueError, match="dx and dy must be nonzero"): + resampling(source, shape_param) + assert (shape_param.dx, shape_param.dy) == (0.0, 0.0) + + dxy_param = Resampling2DParam.create(mode="dxy", dx=1.0, dy=1.0, width=0, height=-1) + resampling(source, dxy_param) + dxy_param.mode = "shape" + with pytest.raises(ValueError, match="width and height must be at least 1"): + resampling(source, dxy_param) + assert (dxy_param.width, dxy_param.height) == (0, -1) + + with temporary_validation_mode(ValidationMode.DISABLED): + invalid = Resampling2DParam.create( + mode="dxy", dx=0.0, dy=1.0, width=6, height=4 + ) + with pytest.raises(ValueError, match="dx and dy must be nonzero"): + resampling(source, invalid) + + +def test_resampling_rejects_invalid_active_geometry() -> None: + """Resampling rejects empty extents, missing fields, and mismatched signs.""" + source = create_test_image() + with pytest.raises(ValueError, match="extents must be nonzero"): + validate_dataset( + Resampling2DParam.create( + mode="shape", + xmin=0.0, + xmax=0.0, + ymin=0.0, + ymax=4.0, + width=6, + height=4, + ), + source, + ) + with pytest.raises(ValueError, match="dx and dy must be specified"): + validate_dataset(Resampling2DParam.create(mode="dxy"), source) + with pytest.raises(ValueError, match="same sign"): + validate_dataset( + Resampling2DParam.create( + mode="dxy", + xmin=6.0, + xmax=0.0, + ymin=4.0, + ymax=0.0, + dx=1.0, + dy=-1.0, + ), + source, + ) diff --git a/sigima/tests/image/roi2dparam_unit_test.py b/sigima/tests/image/roi2dparam_unit_test.py index 1ff83d0..1def301 100644 --- a/sigima/tests/image/roi2dparam_unit_test.py +++ b/sigima/tests/image/roi2dparam_unit_test.py @@ -13,6 +13,7 @@ import pytest from sigima.objects import ImageObj, ROI2DParam +from sigima.objects.image.roi import CircularROI, ImageROI, RectangularROI from sigima.proc.image.extraction import extract_roi from sigima.tests import guiutils from sigima.tests.env import execenv @@ -40,6 +41,69 @@ def test_roi_2d_param_unit(): execenv.print(param) +@pytest.mark.parametrize( + ("roi_class", "coords"), + [ + (RectangularROI, [2, 3, 4, 2]), + (CircularROI, [5, 6, 2]), + ], + ids=("rectangle", "circle"), +) +@pytest.mark.parametrize("inverse", [False, True], ids=("normal", "inverse")) +@pytest.mark.parametrize( + ("xstep", "ystep"), + [(0.5, 2.0), (-0.5, 2.0), (0.5, -2.0), (-0.5, -2.0)], + ids=("ascending", "x-reversed", "y-reversed", "both-reversed"), +) +def test_roi_2d_param_preserves_signed_sizes_on_reversed_axes( + roi_class: type[RectangularROI] | type[CircularROI], + coords: list[int], + inverse: bool, + xstep: float, + ystep: float, +) -> None: + """Signed physical sizes survive the ROI parameter round trip.""" + obj = _create_image() + obj.set_uniform_coords(xstep, ystep, 10.0, 20.0) + roi = roi_class(coords, indices=True, inverse=inverse) + expected_physical = roi.get_physical_coords(obj) + expected_indices = roi.get_indices_coords(obj) + expected_mask = roi.to_mask(obj) + + param = roi.to_param(obj, 0) + restored = param.to_single_roi(obj) + + np.testing.assert_allclose(restored.get_physical_coords(obj), expected_physical) + np.testing.assert_allclose(restored.get_indices_coords(obj), expected_indices) + np.testing.assert_array_equal(restored.to_mask(obj), expected_mask) + + +def test_signed_roi_collection_round_trip_and_extraction() -> None: + """Signed ROI collections preserve masks and yield nonempty extracted data.""" + obj = _create_image() + obj.set_uniform_coords(-1.0, -2.0, 10.0, 20.0) + roi = ImageROI() + roi.add_roi(RectangularROI([2, 3, 4, 2], indices=True)) + roi.add_roi(CircularROI([8, 7, 2], indices=True)) + + restored = ImageROI.from_params(obj, roi.to_params(obj)) + + np.testing.assert_array_equal(restored.to_mask(obj), roi.to_mask(obj)) + extracted = roi.to_params(obj)[0].get_data(obj) + assert extracted.shape == (2, 4) + assert np.isfinite(extracted).all() + + +def test_roi_2d_param_accepts_zero_sizes() -> None: + """Degenerate rectangle and circle geometries remain representable.""" + obj = _create_image() + rectangle = ROI2DParam.create(geometry="rectangle", dx=0.0, dy=0.0) + circle = ROI2DParam.create(geometry="circle", r=0.0) + + assert rectangle.to_single_roi(obj).coords.tolist() == [0.0, 0.0, 0.0, 0.0] + assert circle.to_single_roi(obj).coords.tolist() == [0.0, 0.0, 0.0] + + @pytest.mark.gui def test_roi_2d_param_interactive(): """ROI parameters interactive test.""" diff --git a/sigima/tests/signal/creation_params_extra_unit_test.py b/sigima/tests/signal/creation_params_extra_unit_test.py index 84d3c99..4cabc76 100644 --- a/sigima/tests/signal/creation_params_extra_unit_test.py +++ b/sigima/tests/signal/creation_params_extra_unit_test.py @@ -20,7 +20,16 @@ import pytest from guidata.dataset import dataset_to_json -from sigima.objects import GaussParam, LorentzParam, SquarePulseParam, VoigtParam +from sigima.objects import ( + GaussParam, + LorentzParam, + NewSignalParam, + PulseParam, + SquarePulseParam, + UniformDistribution1DParam, + VoigtParam, + create_signal_from_param, +) from sigima.objects.signal.creation import ( CREATION_PARAMS_VERSION, convert_legacy_peak_creation_params, @@ -233,5 +242,83 @@ def test_square_pulse_get_crossing_time_branches() -> None: p.get_crossing_time("middle", 0.5) +def test_signal_creation_relational_validation() -> None: + """Signal creation rejects only relations required by the generators.""" + with pytest.raises(ValueError, match="vmin must be less"): + create_signal_from_param( + UniformDistribution1DParam.create( + size=2, xmin=1.0, xmax=0.0, vmin=2.0, vmax=1.0 + ) + ) + + with pytest.raises(ValueError, match="start must be less"): + create_signal_from_param( + PulseParam.create(size=2, xmin=0.0, xmax=1.0, start=1.0, stop=0.0) + ) + + with pytest.raises(ValueError, match="plateau duration"): + create_signal_from_param( + SquarePulseParam.create( + size=2, + xmin=0.0, + xmax=1.0, + fwhm=1.0, + total_rise_time=2.0, + total_fall_time=2.0, + ) + ) + + +@pytest.mark.parametrize( + ("size", "xmin", "xmax"), + [(4, 0.0, 1.0), (1, 0.5, 0.5), (3, 0.5, 0.5), (4, 1.0, 0.0)], +) +def test_signal_creation_preserves_supported_domains( + size: int, xmin: float, xmax: float +) -> None: + """Signal creation preserves ascending, point, repeated, and descending X.""" + signal = create_signal_from_param( + NewSignalParam.create(size=size, xmin=xmin, xmax=xmax) + ) + + np.testing.assert_array_equal(signal.x, np.linspace(xmin, xmax, size)) + np.testing.assert_array_equal(signal.y, np.zeros(size)) + + +def test_signal_creation_preserves_valid_degenerate_cases() -> None: + """Constant distributions and instantaneous pulse edges remain valid.""" + uniform = create_signal_from_param( + UniformDistribution1DParam.create( + size=3, xmin=0.0, xmax=1.0, vmin=2.0, vmax=2.0 + ) + ) + np.testing.assert_array_equal(uniform.y, np.full(3, 2.0)) + + pulse = create_signal_from_param( + PulseParam.create(size=3, xmin=0.0, xmax=1.0, start=0.5, stop=0.5) + ) + assert np.isfinite(pulse.y).all() + + square = create_signal_from_param( + SquarePulseParam.create( + size=3, + xmin=0.0, + xmax=1.0, + x_rise_start=-1.0, + fwhm=0.0, + total_rise_time=0.0, + total_fall_time=0.0, + noise_amplitude=0.0, + ) + ) + assert np.isfinite(square.y).all() + + +def test_peak_creation_sigma_is_strictly_positive() -> None: + """Analytical peak generators reject a zero standard deviation.""" + with pytest.raises(ValueError, match="Zero is not"): + GaussParam.create(sigma=0.0) + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/sigima/tests/signal/parameter_validation_unit_test.py b/sigima/tests/signal/parameter_validation_unit_test.py new file mode 100644 index 0000000..26dbda3 --- /dev/null +++ b/sigima/tests/signal/parameter_validation_unit_test.py @@ -0,0 +1,355 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for signal processing parameter validation.""" + +# `@computation_function` rewrites the decorated signature at import time, making the +# DataSet parameter optional. Pylint only sees the source signature, so it wrongly +# reports a missing argument on the expanded-keyword calls exercised below. +# pylint: disable=no-value-for-parameter + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.config import ValidationMode, temporary_validation_mode + +from sigima.enums import FrequencyFilterMethod, WindowingMethod +from sigima.objects import create_signal +from sigima.proc.base import HistogramParam +from sigima.proc.signal.analysis import ( + PulseFeaturesParam, + extract_pulse_features, + histogram, +) +from sigima.proc.signal.features import DynamicParam, FWHMParam, PeakDetectionParam +from sigima.proc.signal.filtering import ( + BandPassFilterParam, + LowPassFilterParam, + lowpass, +) +from sigima.proc.signal.processing import ( + Resampling1DParam, + WindowingParam, + apply_window, + resampling, +) +from sigima.validation import validate_dataset + + +def create_test_signal(): + """Create a uniformly sampled signal with a 50 Hz Nyquist frequency.""" + x = np.linspace(0.0, 1.0, 101) + return create_signal("test", x=x, y=np.sin(2.0 * np.pi * x)) + + +def test_histogram_range_validation() -> None: + """Equal histogram limits are valid, while reversed limits are rejected.""" + src = create_test_signal() + result = histogram(src, HistogramParam.create(bins=4, lower=0.0, upper=0.0)) + assert result.x.size == 4 + + with pytest.raises(ValueError, match="lower must be less"): + histogram(src, HistogramParam.create(lower=1.0, upper=0.0)) + + +@pytest.mark.parametrize( + ("xmin", "xmax", "nbpts"), + [(0.0, 1.0, 5), (1.0, 0.0, 5), (0.5, 0.5, 3), (0.5, 0.5, 1)], +) +def test_resampling_1d_nbpts_preserves_supported_domains( + xmin: float, xmax: float, nbpts: int +) -> None: + """Point-count mode supports ascending, descending, and repeated X.""" + src = create_test_signal() + src.y = 2.0 * src.x + 1.0 + param = Resampling1DParam.create( + mode="nbpts", xmin=xmin, xmax=xmax, nbpts=nbpts, dx=0.0 + ) + + result = resampling(src, param) + expected_x = np.linspace(xmin, xmax, nbpts) + + np.testing.assert_allclose(result.x, expected_x) + np.testing.assert_allclose(result.y, 2.0 * expected_x + 1.0) + assert param.dx == 0.0 + + +@pytest.mark.parametrize( + ("xmin", "xmax", "dx"), + [(0.0, 1.0, 0.25), (1.0, 0.0, -0.25), (0.5, 0.5, 0.1), (0.5, 0.5, -0.1)], +) +def test_resampling_1d_dx_supports_signed_and_point_domains( + xmin: float, xmax: float, dx: float +) -> None: + """Step mode supports both orientations and point-like domains.""" + src = create_test_signal() + src.y = 2.0 * src.x + 1.0 + param = Resampling1DParam.create(mode="dx", xmin=xmin, xmax=xmax, dx=dx, nbpts=0) + + result = resampling(src, param) + expected_x = np.arange(xmin, xmax + dx / 2, dx) + + np.testing.assert_allclose(result.x, expected_x) + np.testing.assert_allclose(result.y, 2.0 * expected_x + 1.0) + assert param.nbpts == 0 + + +def test_resampling_1d_expanded_call_supports_descending_domain() -> None: + """Expanded arguments use the same mode-aware validation contract.""" + src = create_test_signal() + src.y = 2.0 * src.x + 1.0 + + result = resampling(src, mode="nbpts", xmin=1.0, xmax=0.0, nbpts=5, dx=0.0) + + expected_x = np.linspace(1.0, 0.0, 5) + np.testing.assert_allclose(result.x, expected_x) + np.testing.assert_allclose(result.y, 2.0 * expected_x + 1.0) + + +def test_resampling_1d_rejects_only_invalid_active_fields() -> None: + """Mode changes preserve inactive values and validate them only when active.""" + src = create_test_signal() + + with pytest.raises(ValueError, match="xmin and xmax"): + resampling(src, Resampling1DParam.create(mode="nbpts", nbpts=10)) + with pytest.raises(ValueError, match="dx must be non-zero"): + resampling( + src, + Resampling1DParam.create(mode="dx", xmin=0.0, xmax=1.0, dx=0.0, nbpts=10), + ) + for xmin, xmax, dx in ((0.0, 1.0, -0.1), (1.0, 0.0, 0.1)): + with pytest.raises(ValueError, match="dx sign must match"): + resampling( + src, + Resampling1DParam.create( + mode="dx", xmin=xmin, xmax=xmax, dx=dx, nbpts=10 + ), + ) + + point_count_param = Resampling1DParam.create( + mode="nbpts", xmin=0.0, xmax=1.0, nbpts=3, dx=0.0 + ) + resampling(src, point_count_param) + point_count_param.mode = "dx" + with pytest.raises(ValueError, match="dx must be non-zero"): + resampling(src, point_count_param) + assert point_count_param.dx == 0.0 + + step_param = Resampling1DParam.create( + mode="dx", xmin=0.0, xmax=1.0, dx=0.5, nbpts=0 + ) + resampling(src, step_param) + step_param.mode = "nbpts" + with pytest.raises(ValueError, match="nbpts must be at least 1"): + resampling(src, step_param) + assert step_param.nbpts == 0 + + with temporary_validation_mode(ValidationMode.DISABLED): + invalid = Resampling1DParam.create( + mode="dx", xmin=0.0, xmax=1.0, dx=0.0, nbpts=3 + ) + with pytest.raises(ValueError, match="dx must be non-zero"): + resampling(src, invalid) + + +def test_windowing_sigma_validation_depends_on_method() -> None: + """Only Gaussian windowing requires a nonzero sigma.""" + src = create_test_signal() + with temporary_validation_mode(ValidationMode.STRICT): + param = WindowingParam.create(method=WindowingMethod.HAMMING, sigma=0.0) + result = apply_window(src, param) + np.testing.assert_allclose(result.y, src.y * np.hamming(src.y.size)) + + param.method = WindowingMethod.GAUSSIAN + with pytest.raises(ValueError, match="sigma must be non-zero"): + apply_window(src, param) + assert param.sigma == 0.0 + + with temporary_validation_mode(ValidationMode.DISABLED): + invalid = WindowingParam.create(method=WindowingMethod.GAUSSIAN, sigma=0.0) + with pytest.raises(ValueError, match="sigma must be non-zero"): + apply_window(src, invalid) + + validate_dataset( + WindowingParam.create(method=WindowingMethod.GAUSSIAN, sigma=-0.5), src + ) + + +def test_filter_validation_depends_on_method() -> None: + """Only IIR cutoffs are bounded by Nyquist and strictly ordered.""" + src = create_test_signal() + nyquist = 50.0 + + validate_dataset( + LowPassFilterParam.create( + method=FrequencyFilterMethod.BUTTERWORTH, cut0=nyquist - 1.0 + ), + src, + ) + with pytest.raises(ValueError, match="below Nyquist"): + validate_dataset( + LowPassFilterParam.create( + method=FrequencyFilterMethod.BUTTERWORTH, cut0=nyquist + ), + src, + ) + + validate_dataset( + LowPassFilterParam.create( + method=FrequencyFilterMethod.BRICKWALL, cut0=2.0 * nyquist + ), + src, + ) + validate_dataset( + BandPassFilterParam.create( + method=FrequencyFilterMethod.BRICKWALL, cut0=10.0, cut1=10.0 + ), + src, + ) + with pytest.raises(ValueError, match="strictly less than cut1"): + validate_dataset( + BandPassFilterParam.create( + method=FrequencyFilterMethod.BUTTERWORTH, cut0=10.0, cut1=10.0 + ), + src, + ) + + +def test_filter_validation_requires_source_context() -> None: + """Dynamic Nyquist validation requires its source signal.""" + param = LowPassFilterParam.create( + method=FrequencyFilterMethod.BUTTERWORTH, cut0=1.0 + ) + with pytest.raises(ValueError, match="source SignalObj"): + validate_dataset(param) + + +def test_filter_nfft_preserves_negative_minimum_semantics() -> None: + """Negative nfft values remain inert or equivalent to the source-size floor.""" + src = create_test_signal() + + iir = lowpass( + src, + LowPassFilterParam.create( + method=FrequencyFilterMethod.BUTTERWORTH, cut0=10.0, nfft=-1 + ), + ) + assert iir.y.size == src.y.size + + no_padding = lowpass( + src, + LowPassFilterParam.create( + method=FrequencyFilterMethod.BRICKWALL, + cut0=10.0, + zero_padding=False, + nfft=-1, + ), + ) + no_padding_large_nfft = lowpass( + src, + LowPassFilterParam.create( + method=FrequencyFilterMethod.BRICKWALL, + cut0=10.0, + zero_padding=False, + nfft=4096, + ), + ) + negative_nfft = lowpass( + src, + LowPassFilterParam.create( + method=FrequencyFilterMethod.BRICKWALL, + cut0=10.0, + zero_padding=True, + nfft=-1, + ), + ) + zero_nfft = lowpass( + src, + LowPassFilterParam.create( + method=FrequencyFilterMethod.BRICKWALL, + cut0=10.0, + zero_padding=True, + nfft=0, + ), + ) + + np.testing.assert_allclose(no_padding.y, no_padding_large_nfft.y) + np.testing.assert_allclose(negative_nfft.y, zero_nfft.y) + + +def test_analysis_range_validation() -> None: + """FWHM stays non-empty, while pulse baselines may be point-like.""" + with pytest.raises(ValueError, match="xmin must be strictly less"): + validate_dataset(FWHMParam.create(xmin=1.0, xmax=1.0)) + + validate_dataset(PulseFeaturesParam()) + with pytest.raises(ValueError, match="xstartmin must be less than or equal"): + validate_dataset( + PulseFeaturesParam.create( + xstartmin=1.0, xstartmax=0.0, xendmin=1.0, xendmax=2.0 + ) + ) + with pytest.raises(ValueError, match="xendmin must be less than or equal"): + validate_dataset( + PulseFeaturesParam.create( + xstartmin=0.0, xstartmax=1.0, xendmin=2.0, xendmax=1.0 + ) + ) + + +def test_pulse_features_accepts_point_baselines_in_public_calls() -> None: + """Default and explicit point baselines produce usable pulse features.""" + x = np.linspace(0.0, 1.0, 101) + y = np.clip((x - 0.4) / 0.2, 0.0, 1.0) + src = create_signal("step", x=x, y=y) + param = PulseFeaturesParam.create( + signal_shape="step", + xstartmin=0.0, + xstartmax=0.0, + xendmin=1.0, + xendmax=1.0, + ) + original_ranges = ( + param.xstartmin, + param.xstartmax, + param.xendmin, + param.xendmax, + ) + + results = ( + extract_pulse_features(src), + extract_pulse_features(src, param), + extract_pulse_features( + src, + signal_shape="step", + xstartmin=0.0, + xstartmax=0.0, + xendmin=1.0, + xendmax=1.0, + ), + ) + + assert original_ranges == ( + param.xstartmin, + param.xstartmax, + param.xendmin, + param.xendmax, + ) + for result in results: + row = result.to_dataframe().iloc[0] + assert row["polarity"] == 1 + metrics = np.asarray( + [row["amplitude"], row["offset"], row["rise_time"], row["x50"]], + dtype=float, + ) + assert np.isfinite(metrics).all() + assert row["x50"] == pytest.approx(0.5, abs=0.02) + + +def test_signal_scalar_bounds() -> None: + """Percentage and full-scale domains reject values outside their contracts.""" + assert PeakDetectionParam.create(threshold=100.0).threshold == 100.0 + with pytest.raises(ValueError, match="greater than maximum"): + PeakDetectionParam.create(threshold=100.1) + with pytest.raises(ValueError, match="Zero is not"): + DynamicParam.create(full_scale=0.0) diff --git a/sigima/tools/image/__init__.py b/sigima/tools/image/__init__.py index ba3a2de..6244786 100644 --- a/sigima/tools/image/__init__.py +++ b/sigima/tools/image/__init__.py @@ -37,7 +37,13 @@ get_hough_circle_peaks, remove_overlapping_disks, ) -from sigima.tools.image.exposure import flatfield, normalize +from sigima.tools.image.exposure import ( + adjust_brightness_contrast, + brightness_contrast_context, + brightness_contrast_ranges, + flatfield, + normalize, +) from sigima.tools.image.extraction import get_radial_profile from sigima.tools.image.fourier import ( convolve, @@ -66,7 +72,10 @@ # Define __all__ to specify what gets imported with # "from sigima.tools.image import *" __all__ = [ + "adjust_brightness_contrast", "binning", + "brightness_contrast_context", + "brightness_contrast_ranges", "convolve", "deconvolve", "distance_matrix", diff --git a/sigima/tools/image/exposure.py b/sigima/tools/image/exposure.py index 804eb4a..0dda24d 100644 --- a/sigima/tools/image/exposure.py +++ b/sigima/tools/image/exposure.py @@ -25,6 +25,244 @@ from sigima.tools.checks import check_2d_array from sigima.tools.image.preprocessing import scale_data_to_min_max +__all__ = [ + "adjust_brightness_contrast", + "brightness_contrast_context", + "brightness_contrast_ranges", + "flatfield", + "normalize", +] + + +def _real_dtype_range(dtype: np.dtype) -> tuple[float, float] | None: + """Return the finite range of a supported real dtype.""" + if np.issubdtype(dtype, np.integer): + if dtype.itemsize > 4: + raise TypeError("Brightness and contrast support at most 32-bit integers") + info = np.iinfo(dtype) + return float(info.min), float(info.max) + if np.issubdtype(dtype, np.floating): + return None + raise TypeError("Brightness and contrast adjustment requires a real image dtype") + + +def _minimum_width(dtype: np.dtype, lower: float, upper: float) -> float: + """Return a positive representable width for a range editor.""" + if np.issubdtype(dtype, np.integer): + return 1.0 + widths = [] + for bound in (lower, upper): + value = dtype.type(bound) + direction = dtype.type(0.0 if value else 1.0) + neighbor = np.nextafter(value, direction) + width = abs(float(value) - float(neighbor)) + if np.isfinite(width) and width > 0.0: + widths.append(width) + if widths: + return max(widths) + return float(np.nextafter(dtype.type(0.0), dtype.type(1.0))) + + +def _non_empty_range( + lower: float, upper: float, minimum_width: float +) -> tuple[float, float]: + """Return an ordered range with at least *minimum_width*.""" + if lower < upper: + return lower, upper + expanded_lower = lower - minimum_width + expanded_upper = upper + minimum_width + if np.isfinite(expanded_lower) and expanded_lower >= lower: + expanded_lower = float(np.nextafter(lower, -np.inf)) + if np.isfinite(expanded_upper) and expanded_upper <= upper: + expanded_upper = float(np.nextafter(upper, np.inf)) + if np.isfinite(expanded_lower) and expanded_lower < upper: + if np.isfinite(expanded_upper) and expanded_upper > lower: + return expanded_lower, expanded_upper + return expanded_lower, upper + if np.isfinite(expanded_upper) and expanded_upper > lower: + return lower, expanded_upper + raise ValueError("unable to construct a finite non-empty range") + + +def _normalize_range(values: np.ndarray, lower: float, upper: float) -> np.ndarray: + """Map finite values from a finite range to stable unit coordinates.""" + values = np.asarray(values, dtype=np.float64) + clipped = np.clip(values, lower, upper) + width = upper - lower + if np.isfinite(width): + return (clipped - lower) / width + scale = max(abs(lower), abs(upper)) + if scale == 0.0: + return np.zeros_like(values, dtype=np.float64) + scaled_lower = lower / scale + scaled_upper = upper / scale + normalized = (clipped / scale - scaled_lower) / (scaled_upper - scaled_lower) + return np.clip(normalized, 0.0, 1.0) + + +def _denormalize_range(values: np.ndarray, lower: float, upper: float) -> np.ndarray: + """Map unit coordinates to a finite range without overflowing its width.""" + width = upper - lower + if np.isfinite(width): + return lower + values * width + scale = max(abs(lower), abs(upper)) + if scale == 0.0: + return np.zeros_like(values, dtype=np.float64) + scaled_lower = lower / scale + scaled_upper = upper / scale + result = (scaled_lower + values * (scaled_upper - scaled_lower)) * scale + result = np.clip(result, lower, upper) + result[values <= 0.0] = lower + result[values >= 1.0] = upper + return result + + +def brightness_contrast_ranges( + data: np.ndarray, dtype: np.dtype | type +) -> tuple[tuple[float, float] | None, tuple[float, float]]: + """Return finite observed and output ranges without building a histogram. + + Supports floating-point and up to 32-bit integer dtypes. + + Raises: + TypeError: If dtype is complex, non-numeric, or an integer wider than 32 bits. + """ + dtype = np.dtype(dtype) + dtype_range = _real_dtype_range(dtype) + values = np.asarray(data).ravel() + finite = values[np.isfinite(values)] + if finite.size == 0: + return None, dtype_range or (0.0, 1.0) + observed = (float(np.min(finite)), float(np.max(finite))) + return observed, dtype_range or observed + + +def _histogram_auto_range( + counts: np.ndarray, bin_edges: np.ndarray, tail_fraction: float = 0.01 +) -> tuple[float, float]: + """Return histogram bounds after removing equal mass from both tails.""" + total = int(counts.sum()) + if total == 0: + return float(bin_edges[0]), float(bin_edges[-1]) + threshold = total * tail_fraction + lower_index = int(np.searchsorted(np.cumsum(counts), threshold, side="right")) + upper_index = ( + len(counts) + - 1 + - int(np.searchsorted(np.cumsum(counts[::-1]), threshold, side="right")) + ) + lower_index = min(max(lower_index, 0), len(counts) - 1) + upper_index = min(max(upper_index, lower_index), len(counts) - 1) + return float(bin_edges[lower_index]), float(bin_edges[upper_index + 1]) + + +def brightness_contrast_context( + data: np.ndarray, dtype: np.dtype | type +) -> dict[str, object]: + """Build the portable histogram/range payload for brightness adjustment. + + Args: + data: Values selected by the source ROI + dtype: Source image dtype + + Returns: + JSON-compatible renderer payload + + Raises: + TypeError: If dtype is not floating-point or an integer of at most 32 bits. + """ + dtype = np.dtype(dtype) + values = np.asarray(data).ravel() + finite = values[np.isfinite(values)] + observed, output_range = brightness_contrast_ranges(finite, dtype) + if observed is None: + domain = (0.0, 1.0) + minimum_width = _minimum_width(dtype, *domain) + return { + "counts": [0] * 256, + "bin_edges": np.linspace(*domain, 257).tolist(), + "domain": list(domain), + "y_max": 1, + "minimum_width": minimum_width, + "reset_range": list(domain), + "auto_range": list(domain), + "output_range": list(domain), + "active": False, + } + + dtype_range = _real_dtype_range(dtype) + domain = observed if dtype_range is None else dtype_range + minimum_width = _minimum_width(dtype, *domain) + histogram_domain = _non_empty_range(*domain, minimum_width) + normalized = _normalize_range(finite, *histogram_domain) + counts, normalized_edges = np.histogram(normalized, bins=256, range=(0.0, 1.0)) + bin_edges = _denormalize_range(normalized_edges, *histogram_domain) + second_highest = int(np.partition(counts, -2)[-2]) if len(counts) > 1 else 0 + mode = int(counts.max(initial=0)) + if second_highest and mode > 2 * second_highest: + y_max = int(1.5 * second_highest) + else: + y_max = mode + y_max = max(y_max, 1) + + reset_range = dtype_range if dtype == np.dtype(np.uint8) else observed + reset_range = _non_empty_range(*reset_range, minimum_width) + auto_range = _non_empty_range( + *_histogram_auto_range(counts, bin_edges), minimum_width + ) + return { + "counts": counts.tolist(), + "bin_edges": bin_edges.tolist(), + "domain": list(histogram_domain), + "y_max": y_max, + "minimum_width": minimum_width, + "reset_range": list(reset_range), + "auto_range": list(auto_range), + "output_range": list(output_range), + "active": observed[0] < observed[1], + } + + +def adjust_brightness_contrast( + data: np.ndarray, + minimum: float, + maximum: float, + output_range: tuple[float, float], +) -> np.ndarray: + """Apply a clipped linear intensity remapping while preserving dtype. + + Args: + data: Input image data + minimum: Input value mapped to the output minimum + maximum: Input value mapped to the output maximum + output_range: Output minimum and maximum + + Returns: + Remapped array with the input dtype + + Raises: + TypeError: If data is not floating-point or an integer of at most 32 bits. + """ + if not np.isfinite(minimum) or not np.isfinite(maximum) or minimum >= maximum: + raise ValueError("minimum must be finite and strictly less than maximum") + dtype = data.dtype + _real_dtype_range(dtype) + result = np.array(data, copy=True) + finite = np.isfinite(data) + if not np.any(finite): + return result + output_minimum, output_maximum = output_range + if minimum == output_minimum and maximum == output_maximum: + return result + normalized = _normalize_range( + np.asarray(data[finite], dtype=np.float64), minimum, maximum + ) + mapped = _denormalize_range(normalized, output_minimum, output_maximum) + if np.issubdtype(dtype, np.integer): + mapped = np.rint(mapped) + result[finite] = mapped.astype(dtype) + return result + @check_2d_array(non_constant=True) def normalize( diff --git a/sigima/validation.py b/sigima/validation.py new file mode 100644 index 0000000..b39f42f --- /dev/null +++ b/sigima/validation.py @@ -0,0 +1,35 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Validation helpers for parameter DataSets.""" + +from __future__ import annotations + +from typing import Protocol + +__all__ = ["validate_dataset"] + + +class ParameterValidator(Protocol): + """Structural contract for DataSets with relational validation.""" + + def validate_parameters(self, *context: object) -> None: + """Validate parameters, optionally using execution context.""" + + +def validate_dataset(dataset: object, *context: object) -> None: + """Run a DataSet's optional relational validation hook. + + Also accepts a ``super()`` proxy, so that cooperative subclasses can forward + to a parent validator that may not exist in their MRO:: + + def validate_parameters(self, *context: object) -> None: + validate_dataset(super(), *context) + ... + + Args: + dataset: DataSet-like object (or ``super()`` proxy) to validate. + *context: Execution objects required by contextual validation. + """ + validator = getattr(dataset, "validate_parameters", None) + if callable(validator): + validator(*context)