diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index 0890a1d..810de25 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -25,6 +25,9 @@ msgstr "Prérequis" msgid "Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release." msgstr "Python 3.10 ou ultérieur est désormais requis. Les utilisateurs de Python 3.9 doivent conserver une version antérieure compatible." +msgid "guidata 3.17.0 or later is now required: importing Sigima no longer fails when a translation catalog is corrupted (messages fall back to English)." +msgstr "guidata 3.17.0 ou ultérieur est désormais requis : l'import de Sigima n'échoue plus lorsqu'un catalogue de traduction est corrompu (les messages reviennent à l'anglais)." + msgid "Version 1.4" msgstr "Version 1.4" @@ -76,8 +79,8 @@ msgstr "**Centre de l'image** : `ImageObj.xc` et `ImageObj.yc` sont désormais l msgid "**Ellipse coordinates**: `EllipseCoordinates` (`sigima.objects.shape`) now holds the 5 values `(x, y, a, b, theta)` of ellipse geometry results, which can thus be transformed." msgstr "**Coordonnées d'ellipse** : `EllipseCoordinates` (`sigima.objects.shape`) contient désormais les 5 valeurs `(x, y, a, b, theta)` des résultats géométriques d'ellipse, qui peuvent ainsi être transformés." -msgid "**Operation contract versions**: The operation contracts keep version 1 in this release, although the result of some operations changed since 1.3.0 (for example `resampling`, `extract_roi` and the product of signals): the contracts had just been published with Sigima 1.3.0 and were not used yet. This one-off exception does not apply to later changes: from now on, the version of a contract is increased whenever a change of the computation changes its result, whatever the releases of the applications using Sigima, and calls recorded with an earlier version are refused instead of being replayed (see [Operation contracts and versions](../user_guide/contracts.rst)). The image profiles and the product of signals (`product`), changed afterwards (see below), are thus at version 2." -msgstr "**Versions des contrats d'opération** : les contrats d'opération conservent la version 1 dans cette version, bien que le résultat de certaines opérations ait changé depuis la 1.3.0 (par exemple `resampling`, `extract_roi` et le produit de signaux) : les contrats venaient d'être publiés avec Sigima 1.3.0 et n'étaient pas encore exploités. Cette exception ponctuelle ne s'applique pas aux modifications ultérieures : désormais, la version d'un contrat est augmentée dès qu'une modification du calcul change son résultat, indépendamment des publications des applications utilisant Sigima, et les appels enregistrés avec une version antérieure sont refusés au lieu d'être rejoués (voir [Contrats d'opération et versions](../user_guide/contracts.rst)). Les profils d'image et le produit de signaux (`product`), modifiés ensuite (voir ci-dessous), sont donc en version 2." +msgid "**Operation contract versions**: The operation contracts keep version 1 in this release, although the result of some operations changed since 1.3.0 (for example `resampling`, `extract_roi` and the product of signals): the contracts had just been published with Sigima 1.3.0 and were not used yet. This one-off exception does not apply to later changes: from now on, the version of a contract is increased whenever a change of the computation changes its result, whatever the releases of the applications using Sigima, and calls recorded with an earlier version are refused instead of being replayed (see [Operation contracts and versions](../user_guide/contracts.rst)). The image profiles, the product of signals (`product`), the extraction of image ROIs (`extract_roi`, `extract_rois`) and the image analyses giving geometric results (centroid, enclosing circle, peaks, contours, blobs and Hough circles), changed afterwards (see below), are thus at version 2." +msgstr "**Versions des contrats d'opération** : les contrats d'opération conservent la version 1 dans cette version, bien que le résultat de certaines opérations ait changé depuis la 1.3.0 (par exemple `resampling`, `extract_roi` et le produit de signaux) : les contrats venaient d'être publiés avec Sigima 1.3.0 et n'étaient pas encore exploités. Cette exception ponctuelle ne s'applique pas aux modifications ultérieures : désormais, la version d'un contrat est augmentée dès qu'une modification du calcul change son résultat, indépendamment des publications des applications utilisant Sigima, et les appels enregistrés avec une version antérieure sont refusés au lieu d'être rejoués (voir [Contrats d'opération et versions](../user_guide/contracts.rst)). Les profils d'image, le produit de signaux (`product`), l'extraction de ROI d'image (`extract_roi`, `extract_rois`) et les analyses d'image donnant des résultats géométriques (centroïde, cercle englobant, pics, contours, blobs et cercles de Hough), modifiés ensuite (voir ci-dessous), sont donc en version 2." msgid "**Image profiles in physical coordinates**: The line, segment, average and radial profiles of images are now defined and returned in the physical coordinates of the image, like regions of interest and analysis results, instead of pixel indices. Their parameters were renamed, so that scripts using the former names fail explicitly instead of silently using another meaning: `LineProfileParam.row`/`col` became `y`/`x` (the row or column whose pixel centers are the nearest), `SegmentProfileParam.row1`/`col1`/`row2`/`col2` and `AverageProfileParam.row1`/`col1`/`row2`/`col2` became `x1`/`y1`/`x2`/`y2` (for the average profile, `None` stands for the edge of the image instead of `-1`, and the pixels are those whose centers lie in the rectangle, like rectangular ROIs), and `RadialProfileParam.x0`/`y0` became `xc`/`yc`. The X axis of the profiles holds physical coordinates or distances, with the unit and label of the corresponding axis of the image. Segment profiles are sampled at regular steps from their start point to their end point included, with one sample per pixel along the axis where the segment spans the most pixels (as in Sigima 1.3), each sample taking the value of the nearest pixel. Radial profiles are binned in steps of the smallest distance between pixel centers, so that they are circular for anisotropic pixels and non-uniform coordinates. Segment and radial profiles are refused when the X and Y units of the image differ (and radial profiles when the centers of neighboring pixels coincide). Coordinates outside the image are refused instead of being clamped. On images without calibration (origin 0, pixel size 1), line, average and radial profiles are unchanged, and segment profiles take the same pixels for horizontal, vertical and diagonal segments: other oblique segments now take the nearest pixel of each sample instead of truncating its indices. The contracts of `line_profile`, `segment_profile`, `average_profile` and `radial_profile` are at version 2." msgstr "**Profils d'image en coordonnées physiques** : les profils ligne, segment, moyen et radial des images sont désormais définis et renvoyés dans les coordonnées physiques de l'image, comme les régions d'intérêt et les résultats d'analyse, au lieu d'indices de pixels. Leurs paramètres ont été renommés, afin que les scripts utilisant les anciens noms échouent explicitement au lieu d'utiliser silencieusement un autre sens : `LineProfileParam.row`/`col` deviennent `y`/`x` (la ligne ou la colonne dont les centres de pixels sont les plus proches), `SegmentProfileParam.row1`/`col1`/`row2`/`col2` et `AverageProfileParam.row1`/`col1`/`row2`/`col2` deviennent `x1`/`y1`/`x2`/`y2` (pour le profil moyen, `None` désigne le bord de l'image au lieu de `-1`, et les pixels retenus sont ceux dont le centre est dans le rectangle, comme pour les ROI rectangulaires), et `RadialProfileParam.x0`/`y0` deviennent `xc`/`yc`. L'axe X des profils contient des coordonnées ou des distances physiques, avec l'unité et le libellé de l'axe correspondant de l'image. Les profils de segment sont échantillonnés à pas réguliers de leur point de départ à leur point d'arrivée inclus, avec un échantillon par pixel le long de l'axe sur lequel le segment couvre le plus de pixels (comme dans Sigima 1.3), chaque échantillon prenant la valeur du pixel le plus proche. Les profils radiaux sont regroupés par pas égaux à la plus petite distance entre centres de pixels, de sorte qu'ils sont circulaires pour des pixels anisotropes et des coordonnées non uniformes. Les profils de segment et radiaux sont refusés lorsque les unités X et Y de l'image diffèrent (et les profils radiaux lorsque les centres de pixels voisins sont confondus). Les coordonnées hors de l'image sont refusées au lieu d'être ramenées au bord. Sur les images sans calibration (origine 0, taille de pixel 1), les profils ligne, moyen et radial sont inchangés, et les profils de segment prennent les mêmes pixels pour les segments horizontaux, verticaux et diagonaux : les autres segments obliques prennent désormais le pixel le plus proche de chaque échantillon au lieu de tronquer ses indices. Les contrats de `line_profile`, `segment_profile`, `average_profile` et `radial_profile` sont en version 2." @@ -85,8 +88,8 @@ msgstr "**Profils d'image en coordonnées physiques** : les profils ligne, segme msgid "🛠️ Bug Fixes since version 1.3.0" msgstr "🛠️ Corrections de bugs depuis la version 1.3.0" -msgid "**Image profiles**: The radial profile centered on the image center was computed around a wrong point on calibrated images (the physical center was used as pixel indices), and could even contain undefined values. Vertical profiles were labeled with the unit of the X axis instead of the Y axis, and all profiles with the unit of the image although their X axis was in pixels. Anti-diagonal segment profiles (e.g. from the top-right to the bottom-left corner) were extracted along the other diagonal." -msgstr "**Profils d'image** : le profil radial centré sur le centre de l'image était calculé autour d'un mauvais point sur les images calibrées (le centre physique était utilisé comme indices de pixels), et pouvait même contenir des valeurs indéfinies. Les profils verticaux portaient l'unité de l'axe X au lieu de l'axe Y, et tous les profils l'unité de l'image alors que leur axe X était en pixels. Les profils de segment anti-diagonaux (par exemple du coin supérieur droit au coin inférieur gauche) étaient extraits le long de l'autre diagonale." +msgid "**Image profiles**: The radial profile centered on the image center was computed around a wrong point on calibrated images (the physical center was used as pixel indices), and could even contain undefined values. Vertical profiles were labeled with the unit of the X axis instead of the Y axis, and all profiles with the unit of the image although their X axis was in pixels. Anti-diagonal segment profiles (e.g. from the top-right to the bottom-left corner) were extracted along the other diagonal. The radial profile of an image holding no pixel is now refused with an explicit error, instead of an obscure NumPy error." +msgstr "**Profils d'image** : le profil radial centré sur le centre de l'image était calculé autour d'un mauvais point sur les images calibrées (le centre physique était utilisé comme indices de pixels), et pouvait même contenir des valeurs indéfinies. Les profils verticaux portaient l'unité de l'axe X au lieu de l'axe Y, et tous les profils l'unité de l'image alors que leur axe X était en pixels. Les profils de segment anti-diagonaux (par exemple du coin supérieur droit au coin inférieur gauche) étaient extraits le long de l'autre diagonale. Le profil radial d'une image ne contenant aucun pixel est désormais refusé avec une erreur explicite, au lieu d'une erreur NumPy obscure." msgid "**Exact integer import**: Integer data that does not fit any integer type supported by the destination object (for example `uint32` counts above 2 147 483 647 in an image, or large integers in a signal) is now converted to a floating-point type that represents every value exactly. Such data was previously converted to `float32` when the source used 4-byte integers, which rounded counts above 16 777 216. A warning is now emitted when no supported floating-point type is exact (integers beyond 2⁵³)." msgstr "**Import exact des entiers** : les données entières qui ne tiennent dans aucun type entier pris en charge par l'objet de destination (par exemple des comptages `uint32` supérieurs à 2 147 483 647 dans une image, ou de grands entiers dans un signal) sont désormais converties vers un type flottant représentant chaque valeur exactement. Ces données étaient auparavant converties en `float32` lorsque la source utilisait des entiers sur 4 octets, ce qui arrondissait les comptages au-delà de 16 777 216. Un avertissement est désormais émis lorsqu'aucun type flottant pris en charge n'est exact (entiers au-delà de 2⁵³)." @@ -121,5 +124,11 @@ msgstr "**ROI et annotations après les retournements et les rotations** : les r msgid "**Coordinates of an extracted ROI**: `extract_roi` now gives the extracted image the coordinates of the extracted pixels. The origin of the source image was counted twice, and the coordinates of non-uniform images were shifted instead of being cut to the extracted pixels." msgstr "**Coordonnées d'une ROI extraite** : `extract_roi` donne désormais à l'image extraite les coordonnées des pixels extraits. L'origine de l'image source était comptée deux fois, et les coordonnées des images non uniformes étaient décalées au lieu d'être réduites aux pixels extraits." +msgid "**Extraction of ROIs holding no pixel**: Extracting regions of interest that hold no pixel of the image (for example the cells of a ROI grid narrower than a pixel, a rectangle outside the image or with no width, or a circle passing between pixel centers) is now refused with an explicit error: an empty image was created silently. The contracts of `extract_roi` and `extract_rois` are at version 2." +msgstr "**Extraction de ROI ne contenant aucun pixel** : l'extraction de régions d'intérêt ne contenant aucun pixel de l'image (par exemple les cellules d'une grille de ROI plus étroites qu'un pixel, un rectangle hors de l'image ou de largeur nulle, ou un cercle passant entre les centres des pixels) est désormais refusée avec une erreur explicite : une image vide était créée silencieusement. Les contrats de `extract_roi` et `extract_rois` sont en version 2." + msgid "**Analysis results in ROIs**: The coordinates of the analysis results computed in a ROI (centroid, peaks, contours, etc.) are now offset by the position of the first extracted pixel: they could be off by up to half a pixel when the ROI bounds did not lie on pixel centers." msgstr "**Résultats d'analyse dans les ROI** : les coordonnées des résultats d'analyse calculés dans une ROI (centroïde, pics, contours, etc.) sont désormais décalées de la position du premier pixel extrait : elles pouvaient être fausses jusqu'à un demi-pixel lorsque les bornes de la ROI n'étaient pas situées sur des centres de pixels." + +msgid "**Analysis results of non-uniform images**: The analysis results of images with non-uniform coordinates (centroid, enclosing circle, peaks, contours, blobs and Hough circles) are now given in physical coordinates, following the pixel centers: they were given in pixel indices, or according to a former uniform calibration left after switching to non-uniform coordinates. Radii and semi-axes are scaled by the local pixel size. The results of images with uniform coordinates are unchanged. The contracts of `centroid`, `enclosing_circle`, `peak_detection`, `contour_shape`, `blob_dog`, `blob_doh`, `blob_log`, `blob_opencv` and `hough_circle_peaks` are at version 2." +msgstr "**Résultats d'analyse des images non uniformes** : les résultats d'analyse des images à coordonnées non uniformes (centroïde, cercle englobant, pics, contours, blobs et cercles de Hough) sont désormais donnés en coordonnées physiques, en suivant les centres des pixels : ils étaient donnés en indices de pixels, ou selon un ancien étalonnage uniforme conservé après le passage en coordonnées non uniformes. Les rayons et demi-axes sont mis à l'échelle de la taille locale des pixels. Les résultats des images à coordonnées uniformes sont inchangés. Les contrats de `centroid`, `enclosing_circle`, `peak_detection`, `contour_shape`, `blob_dog`, `blob_doh`, `blob_log`, `blob_opencv` et `hough_circle_peaks` sont en version 2." diff --git a/doc/locale/fr/LC_MESSAGES/requirements.po b/doc/locale/fr/LC_MESSAGES/requirements.po index b6f3a5e..d85648e 100644 --- a/doc/locale/fr/LC_MESSAGES/requirements.po +++ b/doc/locale/fr/LC_MESSAGES/requirements.po @@ -36,8 +36,8 @@ msgstr "Langage de programmation Python" msgid "guidata" msgstr "guidata" -msgid ">= 3.16.0" -msgstr ">= 3.16.0" +msgid ">= 3.17.0" +msgstr ">= 3.17.0" msgid "Automatic GUI generation for easy dataset editing and display" msgstr "Génération automatique d'interface graphique pour une édition et un affichage faciles des ensembles de données" diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/installation.po b/doc/locale/fr/LC_MESSAGES/user_guide/installation.po index 4685168..687cead 100644 --- a/doc/locale/fr/LC_MESSAGES/user_guide/installation.po +++ b/doc/locale/fr/LC_MESSAGES/user_guide/installation.po @@ -120,8 +120,8 @@ msgstr "Langage de programmation Python" msgid "guidata" msgstr "guidata" -msgid ">= 3.16.0" -msgstr ">= 3.16.0" +msgid ">= 3.17.0" +msgstr ">= 3.17.0" msgid "Automatic GUI generation for easy dataset editing and display" msgstr "Génération automatique d'interfaces graphiques pour l'édition et l'affichage de jeux de données" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index af16211..2803d26 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -5,6 +5,7 @@ ### Requirements ### * Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release. +* guidata 3.17.0 or later is now required: importing Sigima no longer fails when a translation catalog is corrupted (messages fall back to English). ### ✨ New features since version 1.3.0 ### @@ -27,12 +28,12 @@ The [user guide](../user_guide/overview.rst) shows how readers and connectors at * **Replay of processing in image ROIs**: The selection of image pixels by regions of interest is now recorded with each call on images carrying regions of interest, and with each call of the operations taking image regions of interest as parameters (`extract_roi`, `extract_rois`, `erase` and `offset_correction`), whatever their input (rule `sigima.image.roi.pixel_centers`, `sigima.proc.contracts.IMAGE_ROI_CONVENTION`, in the execution context described by `describe_call` and `prepare_execution`). Applications that replay recorded processing (e.g. DataLab capsules) refuse to replay such calls recorded with earlier versions, which selected other pixels, instead of reporting a different result. Other calls on images without regions of interest are not concerned. * **Image center**: `ImageObj.xc` and `ImageObj.yc` are now the center of the pixel grid (halfway between the centers of the first and last pixels), for uniform and non-uniform coordinates: they were half a pixel off for uniform coordinates. `width` and `height` are unchanged. * **Ellipse coordinates**: `EllipseCoordinates` (`sigima.objects.shape`) now holds the 5 values `(x, y, a, b, theta)` of ellipse geometry results, which can thus be transformed. -* **Operation contract versions**: The operation contracts keep version 1 in this release, although the result of some operations changed since 1.3.0 (for example `resampling`, `extract_roi` and the product of signals): the contracts had just been published with Sigima 1.3.0 and were not used yet. This one-off exception does not apply to later changes: from now on, the version of a contract is increased whenever a change of the computation changes its result, whatever the releases of the applications using Sigima, and calls recorded with an earlier version are refused instead of being replayed (see [Operation contracts and versions](../user_guide/contracts.rst)). The image profiles and the product of signals (`product`), changed afterwards (see below), are thus at version 2. +* **Operation contract versions**: The operation contracts keep version 1 in this release, although the result of some operations changed since 1.3.0 (for example `resampling`, `extract_roi` and the product of signals): the contracts had just been published with Sigima 1.3.0 and were not used yet. This one-off exception does not apply to later changes: from now on, the version of a contract is increased whenever a change of the computation changes its result, whatever the releases of the applications using Sigima, and calls recorded with an earlier version are refused instead of being replayed (see [Operation contracts and versions](../user_guide/contracts.rst)). The image profiles, the product of signals (`product`), the extraction of image ROIs (`extract_roi`, `extract_rois`) and the image analyses giving geometric results (centroid, enclosing circle, peaks, contours, blobs and Hough circles), changed afterwards (see below), are thus at version 2. * **Image profiles in physical coordinates**: The line, segment, average and radial profiles of images are now defined and returned in the physical coordinates of the image, like regions of interest and analysis results, instead of pixel indices. Their parameters were renamed, so that scripts using the former names fail explicitly instead of silently using another meaning: `LineProfileParam.row`/`col` became `y`/`x` (the row or column whose pixel centers are the nearest), `SegmentProfileParam.row1`/`col1`/`row2`/`col2` and `AverageProfileParam.row1`/`col1`/`row2`/`col2` became `x1`/`y1`/`x2`/`y2` (for the average profile, `None` stands for the edge of the image instead of `-1`, and the pixels are those whose centers lie in the rectangle, like rectangular ROIs), and `RadialProfileParam.x0`/`y0` became `xc`/`yc`. The X axis of the profiles holds physical coordinates or distances, with the unit and label of the corresponding axis of the image. Segment profiles are sampled at regular steps from their start point to their end point included, with one sample per pixel along the axis where the segment spans the most pixels (as in Sigima 1.3), each sample taking the value of the nearest pixel. Radial profiles are binned in steps of the smallest distance between pixel centers, so that they are circular for anisotropic pixels and non-uniform coordinates. Segment and radial profiles are refused when the X and Y units of the image differ (and radial profiles when the centers of neighboring pixels coincide). Coordinates outside the image are refused instead of being clamped. On images without calibration (origin 0, pixel size 1), line, average and radial profiles are unchanged, and segment profiles take the same pixels for horizontal, vertical and diagonal segments: other oblique segments now take the nearest pixel of each sample instead of truncating its indices. The contracts of `line_profile`, `segment_profile`, `average_profile` and `radial_profile` are at version 2. ### 🛠️ Bug Fixes since version 1.3.0 ### -* **Image profiles**: The radial profile centered on the image center was computed around a wrong point on calibrated images (the physical center was used as pixel indices), and could even contain undefined values. Vertical profiles were labeled with the unit of the X axis instead of the Y axis, and all profiles with the unit of the image although their X axis was in pixels. Anti-diagonal segment profiles (e.g. from the top-right to the bottom-left corner) were extracted along the other diagonal. +* **Image profiles**: The radial profile centered on the image center was computed around a wrong point on calibrated images (the physical center was used as pixel indices), and could even contain undefined values. Vertical profiles were labeled with the unit of the X axis instead of the Y axis, and all profiles with the unit of the image although their X axis was in pixels. Anti-diagonal segment profiles (e.g. from the top-right to the bottom-left corner) were extracted along the other diagonal. The radial profile of an image holding no pixel is now refused with an explicit error, instead of an obscure NumPy error. * **Exact integer import**: Integer data that does not fit any integer type supported by the destination object (for example `uint32` counts above 2 147 483 647 in an image, or large integers in a signal) is now converted to a floating-point type that represents every value exactly. Such data was previously converted to `float32` when the source used 4-byte integers, which rounded counts above 16 777 216. A warning is now emitted when no supported floating-point type is exact (integers beyond 2⁵³). * **Image resampling**: Resampling an image onto its own grid now gives back the same image. The output pixels were sampled half a pixel away from the requested positions, so that every value was shifted and the last row and column were filled with NaN, while the output origin claimed the requested position. The `xmin`/`xmax` and `ymin`/`ymax` parameters of `resampling` are now the coordinates of the centers of the first and last output pixels, like the `xmin`/`xmax` of an image, and default to the source grid. In pixel size mode, the requested pixel size is now kept exactly (the last pixel is the last one not beyond `xmax`/`ymax`). * **Real and complex signals**: The sum, average, product and standard deviation of several signals, and their conversion into an image, no longer depend on the order of the signals: when a real signal came first, the imaginary parts of the complex signals were discarded. @@ -44,4 +45,6 @@ The [user guide](../user_guide/overview.rst) shows how readers and connectors at * **Uncertainty of a product of signals**: The uncertainty of the product of signals is now propagated where a factor is zero: the result had no uncertainty at all there. Complex factors now give a real uncertainty, computed from their moduli. The uncertainty no longer becomes undefined (NaN) when the factors span a very wide range of magnitudes (e.g. 10³⁰⁸ × 1 × 10⁻³⁰⁸) or when the product itself is too small to be represented, and no longer vanishes when many signals are multiplied (it was wrong, then zero, from about 150 `float32` or 1 000 `float64` signals): it is defined wherever its value can be represented, whatever the number and the order of the signals. The contract of `product` is at version 2. * **ROIs and annotations after flips and rotations**: Horizontal and vertical flips and 90° and 270° rotations now move the ROIs and annotations of an image with its pixels: they were mirrored about a point half a pixel away from the center of the pixel grid, hence shifted by one pixel, and non-uniform coordinates were ignored. The move is exact for uniform coordinates. For non-uniform coordinates, reversing the pixels along a non-uniform axis is not an affine mapping: points, bounds and unrotated rectangles are moved exactly, while circles, ellipses and polygons are moved approximately (a circle does not stay a circle). ROIs defined in pixel indices were moved as if they were physical coordinates, and inverse ROIs (which mask the inside of their shape) became normal ROIs after these operations, the transposition and the translation. * **Coordinates of an extracted ROI**: `extract_roi` now gives the extracted image the coordinates of the extracted pixels. The origin of the source image was counted twice, and the coordinates of non-uniform images were shifted instead of being cut to the extracted pixels. +* **Extraction of ROIs holding no pixel**: Extracting regions of interest that hold no pixel of the image (for example the cells of a ROI grid narrower than a pixel, a rectangle outside the image or with no width, or a circle passing between pixel centers) is now refused with an explicit error: an empty image was created silently. The contracts of `extract_roi` and `extract_rois` are at version 2. * **Analysis results in ROIs**: The coordinates of the analysis results computed in a ROI (centroid, peaks, contours, etc.) are now offset by the position of the first extracted pixel: they could be off by up to half a pixel when the ROI bounds did not lie on pixel centers. +* **Analysis results of non-uniform images**: The analysis results of images with non-uniform coordinates (centroid, enclosing circle, peaks, contours, blobs and Hough circles) are now given in physical coordinates, following the pixel centers: they were given in pixel indices, or according to a former uniform calibration left after switching to non-uniform coordinates. Radii and semi-axes are scaled by the local pixel size. The results of images with uniform coordinates are unchanged. The contracts of `centroid`, `enclosing_circle`, `peak_detection`, `contour_shape`, `blob_dog`, `blob_doh`, `blob_log`, `blob_opencv` and `hough_circle_peaks` are at version 2. diff --git a/doc/requirements.rst b/doc/requirements.rst index d375f17..0d97b09 100644 --- a/doc/requirements.rst +++ b/doc/requirements.rst @@ -11,7 +11,7 @@ The `sigima` package requires the following Python modules: - >=3.10, <4 - Python programming language * - guidata - - >= 3.16.0 + - >= 3.17.0 - Automatic GUI generation for easy dataset editing and display * - NumPy - >= 1.22, < 2.6 diff --git a/requirements.txt b/requirements.txt index 4822b98..c70df25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ PyWavelets >= 1.2, < 2.0 SciPy >= 1.10.1, < 1.19 babel build -guidata >= 3.16.0 +guidata >= 3.17.0 imagecodecs >= 2024.12.30; sys_platform != 'emscripten' jsonschema >= 4 makefun >= 1.13.1 diff --git a/sigima/proc/image/base.py b/sigima/proc/image/base.py index a1abac4..27d3f09 100644 --- a/sigima/proc/image/base.py +++ b/sigima/proc/image/base.py @@ -213,6 +213,49 @@ def dst_1_to_1_signal(src: ImageObj, name: str, suffix: str | None = None) -> Si ) +def _indices_to_nonuniform_physical( + obj: ImageObj, + coords: np.ndarray, + colx: slice | int, + coly: slice | int, + ix0: int, + iy0: int, +) -> None: + """Convert geometry coordinates from pixel indices to physical coordinates, in + place, for an image with non-uniform coordinates. + + Points are interpolated linearly between the pixel centers (see + :meth:`~sigima.objects.ImageObj.axis_to_physical`). Distance-like values + (radius, semi-axes) are scaled by the local pixel size at the shape center, + i.e. the mean of the X and Y distances between the edges of the pixel there. + + Args: + obj: image object with non-uniform coordinates + coords: coordinates in pixel indices, relative to the part of the image + starting at column ``ix0`` and row ``iy0`` (NaN padding is kept) + colx: X columns of *coords* + coly: Y columns of *coords* + ix0: first column of the part of the image + iy0: first row of the part of the image + """ + xi = coords[:, colx] + ix0 + yi = coords[:, coly] + iy0 + if coords.shape[1] % 2 != 0: + # Circle [x0, y0, r] or ellipse [x0, y0, a, b, theta] (theta is an angle) + size_x = np.abs( + obj.axis_to_physical(xi + 0.5, "x") - obj.axis_to_physical(xi - 0.5, "x") + ) + size_y = np.abs( + obj.axis_to_physical(yi + 0.5, "y") - obj.axis_to_physical(yi - 0.5, "y") + ) + pixel_scale = (size_x + size_y) / 2 + coords[:, 2] *= pixel_scale + if coords.shape[1] >= 4: + coords[:, 3] *= pixel_scale + coords[:, colx] = obj.axis_to_physical(xi, "x") + coords[:, coly] = obj.axis_to_physical(yi, "y") + + def compute_geometry_from_obj( title: str, shape: KindShape, @@ -243,7 +286,11 @@ def compute_geometry_from_obj( The conversion is performed using the image's calibration parameters: ``physical_x = obj.dx * pixel_x + obj.x0`` and - ``physical_y = obj.dy * pixel_y + obj.y0`` + ``physical_y = obj.dy * pixel_y + obj.y0`` for uniform coordinates, and a + linear interpolation between the pixel centers (``obj.xcoords``, + ``obj.ycoords``) for non-uniform coordinates. Distance-like values (radius, + semi-axes) are scaled by the mean pixel size: ``(obj.dx + obj.dy) / 2``, or + the local pixel size at the shape center for non-uniform coordinates. .. warning:: @@ -306,25 +353,30 @@ def compute_geometry_from_obj( else: # Circle [x0, y0, r] or ellipse coordinates [x0, y0, a, b, theta] colx, coly = 0, 1 - coords[:, colx] = obj.dx * coords[:, colx] + obj.x0 - coords[:, coly] = obj.dy * coords[:, coly] + obj.y0 - if coords.shape[1] % 2 != 0: - # Scale distance-like values (radius, semi-axes) from pixel to - # physical units. Use average pixel size for isotropic scaling. - pixel_scale = (obj.dx + obj.dy) / 2 - if coords.shape[1] >= 3: - # Column 2: radius (circle) or semi-axis a (ellipse) - coords[:, 2] *= pixel_scale - if coords.shape[1] >= 4: - # Column 3: semi-axis b (ellipse) - coords[:, 3] *= pixel_scale - # Column 4 (theta) is an angle: no scaling needed + ix0 = iy0 = 0 if obj.roi is not None: # Coordinates are relative to the part of the image holding the # ROI (see `ImageObj.get_data`): offset them by its first pixel ix0, iy0, _ix1, _iy1 = obj.get_roi_crop_indices(i_roi) - coords[:, colx] += ix0 * obj.dx - coords[:, coly] += iy0 * obj.dy + if obj.is_uniform_coords: + coords[:, colx] = obj.dx * coords[:, colx] + obj.x0 + coords[:, coly] = obj.dy * coords[:, coly] + obj.y0 + if coords.shape[1] % 2 != 0: + # Scale distance-like values (radius, semi-axes) from pixel to + # physical units. Use average pixel size for isotropic scaling. + pixel_scale = (obj.dx + obj.dy) / 2 + if coords.shape[1] >= 3: + # Column 2: radius (circle) or semi-axis a (ellipse) + coords[:, 2] *= pixel_scale + if coords.shape[1] >= 4: + # Column 3: semi-axis b (ellipse) + coords[:, 3] *= pixel_scale + # Column 4 (theta) is an angle: no scaling needed + if obj.roi is not None: + coords[:, colx] += ix0 * obj.dx + coords[:, coly] += iy0 * obj.dy + else: + _indices_to_nonuniform_physical(obj, coords, colx, coly, ix0, iy0) rows.append(coords) num_cols.append(coords.shape[1]) diff --git a/sigima/proc/image/detection.py b/sigima/proc/image/detection.py index 502b1bd..e735faa 100644 --- a/sigima/proc/image/detection.py +++ b/sigima/proc/image/detection.py @@ -341,8 +341,8 @@ class Peak2DDetectionParam(DetectionROIParam, GenericDetectionParam): @computation_function( operation_id="sigima.image.peak_detection", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def peak_detection(obj: ImageObj, p: Peak2DDetectionParam) -> GeometryResult | None: """Compute 2D peak detection @@ -389,9 +389,9 @@ class ContourShapeParam(GenericDetectionParam): @computation_function( operation_id="sigima.image.contour_shape", - contract_version=1, + contract_version=2, qualification=ReplayQualification( - contract_version=1, + contract_version=2, comparison=tolerance( ( "Floating-point reductions and linear algebra (NumPy, SciPy, scikit-" @@ -480,8 +480,8 @@ class BlobDOGParam(DetectionROIParam, BaseBlobParam): @computation_function( operation_id="sigima.image.blob_dog", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def blob_dog(image: ImageObj, p: BlobDOGParam) -> GeometryResult | None: """Compute blobs using Difference of Gaussian method @@ -524,8 +524,8 @@ class BlobDOHParam(DetectionROIParam, BaseBlobParam): @computation_function( operation_id="sigima.image.blob_doh", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def blob_doh(image: ImageObj, p: BlobDOHParam) -> GeometryResult | None: """Compute blobs using Determinant of Hessian method @@ -564,8 +564,8 @@ class BlobLOGParam(BlobDOHParam): @computation_function( operation_id="sigima.image.blob_log", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def blob_log(image: ImageObj, p: BlobLOGParam) -> GeometryResult | None: """Compute blobs using Laplacian of Gaussian method @@ -768,8 +768,8 @@ def validate_parameters(self, *context: object) -> None: @computation_function( operation_id="sigima.image.blob_opencv", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def blob_opencv(image: ImageObj, p: BlobOpenCVParam) -> GeometryResult | None: """Compute blobs using OpenCV @@ -829,8 +829,8 @@ def validate_parameters(self, *context: object) -> None: @computation_function( operation_id="sigima.image.hough_circle_peaks", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def hough_circle_peaks(image: ImageObj, p: HoughCircleParam) -> GeometryResult | None: """Compute Hough circles diff --git a/sigima/proc/image/extraction.py b/sigima/proc/image/extraction.py index c834160..9d4ce20 100644 --- a/sigima/proc/image/extraction.py +++ b/sigima/proc/image/extraction.py @@ -65,8 +65,8 @@ @computation_function( operation_id="sigima.image.extract_rois", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def extract_rois(src: ImageObj, params: list[ROI2DParam]) -> ImageObj: """Extract multiple regions of interest from data @@ -77,7 +77,15 @@ def extract_rois(src: ImageObj, params: list[ROI2DParam]) -> ImageObj: Returns: Output image object + + Raises: + ValueError: if the regions of interest hold no pixel of the image """ + mask = ImageROI.from_params(src, params).to_mask(src) + if mask.all(): + raise ValueError( + "The regions of interest hold no pixel of the image: nothing to extract" + ) # Initialize ix0, iy0 with maximum values: iy0, ix0 = iymax, ixmax = src.data.shape # Initialize ix1, iy1 with minimum values: @@ -101,17 +109,16 @@ def extract_rois(src: ImageObj, params: list[ROI2DParam]) -> ImageObj: dst.set_coords(src.xcoords[ix0:ix1].copy(), src.ycoords[iy0:iy1].copy()) dst.roi = None - src2 = src.copy() - src2.roi = ImageROI.from_params(src2, params) - src2.data[src2.maskdata] = 0 - dst.data = src2.data[iy0:iy1, ix0:ix1] + data = np.array(src.data, copy=True) + data[mask] = 0 + dst.data = data[iy0:iy1, ix0:ix1] return dst @computation_function( operation_id="sigima.image.extract_roi", - contract_version=1, - qualification=ReplayQualification(contract_version=1, comparison=EXACT), + contract_version=2, + qualification=ReplayQualification(contract_version=2, comparison=EXACT), ) def extract_roi(src: ImageObj, p: ROI2DParam) -> ImageObj: """Extract single ROI @@ -122,7 +129,14 @@ def extract_roi(src: ImageObj, p: ROI2DParam) -> ImageObj: Returns: Output image object + + Raises: + ValueError: if the region of interest holds no pixel of the image """ + if p.to_single_roi(src).to_mask(src).all(): + raise ValueError( + "The region of interest holds no pixel of the image: nothing to extract" + ) dst = dst_1_to_1(src, "extract_roi", p.get_suffix()) dst.data = p.get_data(src).copy() dst.roi = p.get_extracted_roi(src) diff --git a/sigima/proc/image/measurement.py b/sigima/proc/image/measurement.py index d93e834..b73b371 100644 --- a/sigima/proc/image/measurement.py +++ b/sigima/proc/image/measurement.py @@ -75,9 +75,9 @@ def get_centroid_coords(data: np.ndarray) -> np.ndarray: @computation_function( operation_id="sigima.image.centroid", - contract_version=1, + contract_version=2, qualification=ReplayQualification( - contract_version=1, + contract_version=2, comparison=tolerance( ( "Floating-point reductions and linear algebra (NumPy, SciPy, scikit-" @@ -120,9 +120,9 @@ def get_enclosing_circle_coords(data: np.ndarray) -> np.ndarray: @computation_function( operation_id="sigima.image.enclosing_circle", - contract_version=1, + contract_version=2, qualification=ReplayQualification( - contract_version=1, + contract_version=2, comparison=tolerance( ( "The fitted circle center is truncated to whole pixels: " diff --git a/sigima/tests/image/geometry_nonuniform_unit_test.py b/sigima/tests/image/geometry_nonuniform_unit_test.py new file mode 100644 index 0000000..2de888c --- /dev/null +++ b/sigima/tests/image/geometry_nonuniform_unit_test.py @@ -0,0 +1,187 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Geometry results of images with non-uniform coordinates +------------------------------------------------------- + +Analysis results (centroid, peaks, blobs, contours...) are computed in pixel +indices and converted to physical coordinates by +:func:`sigima.proc.image.base.compute_geometry_from_obj`: for non-uniform +coordinates, the conversion follows the pixel centers (``xcoords``, ``ycoords``) +instead of ``dx``, ``dy``, ``x0`` and ``y0``, which do not describe the image. +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +import numpy as np +import pytest + +import sigima.objects +import sigima.params +import sigima.proc.image as sipi +from sigima.objects import KindShape +from sigima.proc.image.base import compute_geometry_from_obj + +NROWS, NCOLS = 32, 40 +#: Irregular, strictly increasing pixel centers +XCOORDS = 10.0 + np.cumsum(np.linspace(0.5, 2.0, NCOLS)) +YCOORDS = -5.0 + np.cumsum(np.linspace(1.5, 0.25, NROWS)) +#: Uniform coordinates (anisotropic pixels, offset origin) +DX, DY, X0, Y0 = 2.0, 0.5, 10.0, -5.0 + + +def _blob_image() -> np.ndarray: + """Return an image holding a 3x3 square centered on row 20, column 25.""" + data = np.zeros((NROWS, NCOLS)) + data[19:22, 24:27] = 1.0 + return data + + +def _nonuniform_image(data: np.ndarray) -> sigima.objects.ImageObj: + """Return an image with irregular non-uniform coordinates.""" + obj = sigima.objects.create_image("nonuniform", data) + obj.set_coords(XCOORDS.copy(), YCOORDS.copy()) + return obj + + +def _uniform_pair( + data: np.ndarray, +) -> tuple[sigima.objects.ImageObj, sigima.objects.ImageObj]: + """Return an image with uniform coordinates, and the same image with the + equivalent non-uniform coordinates.""" + uniform = sigima.objects.create_image("uniform", data) + uniform.set_uniform_coords(DX, DY, X0, Y0) + nonuniform = uniform.copy() + nonuniform.switch_coords_to("non-uniform") + return uniform, nonuniform + + +@pytest.mark.parametrize("with_roi", [False, True]) +def test_centroid_follows_nonuniform_coordinates(with_roi: bool) -> None: + """The centroid is given at the physical position of the pixel holding it. + + Regression: it was given in pixel indices (``dx``, ``dy``, ``x0`` and ``y0`` + keep their default values for non-uniform coordinates). + """ + obj = _nonuniform_image(_blob_image()) + if with_roi: + obj.roi = sigima.objects.create_image_roi( + "rectangle", + [ + XCOORDS[20] + 0.1, + YCOORDS[15] + 0.1, + XCOORDS[30] - XCOORDS[20], + YCOORDS[25] - YCOORDS[15], + ], + ) + result = sipi.centroid(obj) + assert result.coords[0] == pytest.approx((XCOORDS[25], YCOORDS[20])) + + +def test_centroid_ignores_stale_uniform_calibration() -> None: + """Uniform parameters left over by a switch to non-uniform coordinates are + not used (regression: the result followed the former calibration).""" + obj = sigima.objects.create_image("stale", _blob_image()) + obj.set_uniform_coords(0.5, 0.25, 10.0, 20.0) + obj.switch_coords_to("non-uniform") + obj.set_coords(obj.xcoords**1.2, obj.ycoords**1.1) + result = sipi.centroid(obj) + assert result.coords[0] == pytest.approx((obj.xcoords[25], obj.ycoords[20])) + + +def test_peak_detection_follows_nonuniform_coordinates() -> None: + """Detected peaks are given at the physical positions of their pixels.""" + yy, xx = np.mgrid[0:NROWS, 0:NCOLS] + data = np.zeros((NROWS, NCOLS)) + for row, col in ((8, 10), (22, 30)): + data += np.exp(-((xx - col) ** 2 + (yy - row) ** 2) / 4.0) + param = sigima.params.Peak2DDetectionParam.create(size=5) + indices = sipi.peak_detection(sigima.objects.create_image("i", data), param) + result = sipi.peak_detection(_nonuniform_image(data), param) + assert len(indices.coords) == 2 + expected = [(XCOORDS[int(col)], YCOORDS[int(row)]) for col, row in indices.coords] + np.testing.assert_allclose(result.coords, expected) + + +#: Results in pixel indices of each kind of shape (polygons padded with NaN) +SHAPES = { + KindShape.POINT: np.array([[3.0, 4.5], [10.25, 7.0]]), + KindShape.SEGMENT: np.array([[1.0, 2.0, 6.5, 3.5]]), + KindShape.CIRCLE: np.array([[5.0, 6.0, 2.5]]), + KindShape.ELLIPSE: np.array([[5.0, 6.0, 2.5, 1.5, 0.3]]), + KindShape.POLYGON: np.array([[1.0, 2.0, 5.0, 2.0, 5.0, 6.0, np.nan, np.nan]]), +} + + +@pytest.mark.parametrize("with_roi", [False, True]) +@pytest.mark.parametrize("kind", list(SHAPES)) +def test_nonuniform_conversion_matches_uniform(kind: KindShape, with_roi: bool): + """Non-uniform coordinates describing a uniform grid give the same results as + the uniform coordinates, for every kind of shape, also in a ROI (whose first + pixel offsets the coordinates).""" + data = np.zeros((NROWS, NCOLS)) + images = _uniform_pair(data) + if with_roi: + for obj in images: + obj.roi = sigima.objects.create_image_roi( + "rectangle", [X0 + 3.3 * DX, Y0 + 2.2 * DY, 10.0 * DX, 8.0 * DY] + ) + assert obj.get_roi_crop_indices(0)[:2] == (4, 3) + results = [ + compute_geometry_from_obj("t", kind, obj, lambda _data: SHAPES[kind].copy()) + for obj in images + ] + np.testing.assert_allclose( + results[1].coords, results[0].coords, rtol=1e-12, atol=1e-12, equal_nan=True + ) + if kind == KindShape.POLYGON: + assert np.isnan(results[1].coords[0, -2:]).all() + + +def test_nonuniform_distances_use_the_local_pixel_size() -> None: + """Radii and semi-axes are scaled by the local pixel size at the shape center, + and angles are kept.""" + obj = _nonuniform_image(np.zeros((NROWS, NCOLS))) + col, row = 10, 12 + result = compute_geometry_from_obj( + "ellipse", + KindShape.ELLIPSE, + obj, + lambda _data: np.array([[float(col), float(row), 3.0, 2.0, 0.4]]), + ) + size_x = (XCOORDS[col + 1] - XCOORDS[col - 1]) / 2 + size_y = (YCOORDS[row + 1] - YCOORDS[row - 1]) / 2 + scale = (size_x + size_y) / 2 + expected = [XCOORDS[col], YCOORDS[row], 3.0 * scale, 2.0 * scale, 0.4] + np.testing.assert_allclose(result.coords[0], expected) + + +def test_uniform_conversion_is_unchanged() -> None: + """Results of images with uniform coordinates are exactly those of the former + conversion (pixel size and origin, then offset of the first pixel of the + ROI).""" + dx, dy, x0, y0 = 0.3, 0.7, -1.1, 2.9 + obj = sigima.objects.create_image("u", np.zeros((NROWS, NCOLS))) + obj.set_uniform_coords(dx, dy, x0, y0) + obj.roi = sigima.objects.create_image_roi( + "rectangle", [x0 + 2.6 * dx, y0 + 5.2 * dy, 9.0 * dx, 7.0 * dy] + ) + pixels = np.array([[3.3, 4.7, 1.9]]) + result = compute_geometry_from_obj( + "circle", KindShape.CIRCLE, obj, lambda _data: pixels.copy() + ) + expected = pixels.copy() + expected[:, 0] = dx * expected[:, 0] + x0 + expected[:, 1] = dy * expected[:, 1] + y0 + expected[:, 2] *= (dx + dy) / 2 + ix0, iy0, _ix1, _iy1 = obj.get_roi_crop_indices(0) + assert (ix0, iy0) != (0, 0) + expected[:, 0] += ix0 * dx + expected[:, 1] += iy0 * dy + assert np.array_equal(result.coords, expected) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/sigima/tests/image/pixel_convention_unit_test.py b/sigima/tests/image/pixel_convention_unit_test.py index 4c5c38a..73fa43a 100644 --- a/sigima/tests/image/pixel_convention_unit_test.py +++ b/sigima/tests/image/pixel_convention_unit_test.py @@ -209,6 +209,56 @@ def test_extract_roi_keeps_pixel_positions(uniform: bool) -> None: assert pixel_center(dst, 2, 3) == pytest.approx(pixel_center(src, 3, 5)) +@pytest.mark.parametrize("uniform", [True, False]) +def test_extract_roi_holding_no_pixel_is_refused(uniform: bool) -> None: + """Extracting regions of interest holding no pixel is refused explicitly. + + Regression: an empty image was created silently. + """ + src = make_image(uniform) + (xa, ya), (xb, yb) = pixel_center(src, 1, 2), pixel_center(src, 2, 3) + hx, hy = abs(xb - xa) / 2, abs(yb - ya) / 2 + create = sigima.objects.ROI2DParam.create + no_pixel = [ + create(geometry="rectangle", x0=1e6, y0=1e6, dx=1.0, dy=1.0), + # Between the centers of two columns + create(geometry="rectangle", x0=xa + hx / 2, y0=ya - hy, dx=hx, dy=2 * hy), + # No width, on a pixel center + create(geometry="rectangle", x0=xa, y0=ya - hy, dx=0.0, dy=2 * hy), + # Bounding box holding pixel centers, circle holding none + create( + geometry="circle", + xc=xa + hx, + yc=ya + hy, + r=(max(hx, hy) + np.hypot(hx, hy)) / 2, + ), + ] + for param in no_pixel: + with pytest.raises(ValueError, match="holds no pixel"): + sipi.extract_roi(src, param) + with pytest.raises(ValueError, match="hold no pixel"): + sipi.extract_rois(src, [param]) + # Regions of interest holding pixels are extracted with those holding none + with_pixels = create(geometry="rectangle", x0=xa, y0=ya, dx=2 * hx, dy=2 * hy) + assert sipi.extract_rois(src, no_pixel + [with_pixels]).data.size > 0 + + +def test_extract_roi_grid_cells_holding_no_pixel() -> None: + """The cells of a ROI grid narrower than a pixel hold no pixel: extracting + them is refused, the other cells are extracted.""" + src = sigima.objects.create_image("i", np.arange(16.0).reshape(4, 4)) + grid = sipi.generate_image_grid_roi(src, sigima.params.ROIGridParam()) + params = grid.to_params(src) + empty = [param.to_single_roi(src).to_mask(src).all() for param in params] + assert any(empty) and not all(empty) + for param, is_empty in zip(params, empty): + if is_empty: + with pytest.raises(ValueError, match="holds no pixel"): + sipi.extract_roi(src, param) + else: + assert sipi.extract_roi(src, param).data.size > 0 + + def test_roi_grid_selects_every_pixel_once() -> None: """The cells of a full-size ROI grid tile the image exactly.""" src = make_image() diff --git a/sigima/tests/image/profile_physical_unit_test.py b/sigima/tests/image/profile_physical_unit_test.py index cc99563..516298d 100644 --- a/sigima/tests/image/profile_physical_unit_test.py +++ b/sigima/tests/image/profile_physical_unit_test.py @@ -192,6 +192,14 @@ def test_radial_profile_refusals() -> None: sigima.tools.image.get_radial_profile(data, center) +@pytest.mark.parametrize("shape", [(0, 0), (3, 0), (0, 4)]) +def test_radial_profile_empty_image_refused(shape) -> None: + """An image holding no pixel is refused explicitly (regression: obscure + NumPy error on a zero-size reduction).""" + with pytest.raises(ValueError, match="holds no pixel"): + sigima.tools.image.get_radial_profile(np.zeros(shape), (0.0, 0.0)) + + @pytest.mark.parametrize( "paramclass, kwargs", [ diff --git a/sigima/tests/image/roi_advanced_unit_test.py b/sigima/tests/image/roi_advanced_unit_test.py index 72e4813..f071da3 100644 --- a/sigima/tests/image/roi_advanced_unit_test.py +++ b/sigima/tests/image/roi_advanced_unit_test.py @@ -198,9 +198,9 @@ def test_empty_image_roi() -> None: f"Source object ROI should be empty or None{context}" ) if src.roi is not None: - # No ROI has been set in the source image - im1 = sigima.proc.image.extract_roi(src, src.roi.to_params(src)) - assert im1.data.shape == (0, 0), f"Extracted image should be empty{context}" + # No ROI has been set in the source image: nothing to extract + with pytest.raises(ValueError, match="holds no pixel"): + sigima.proc.image.extract_roi(src, src.roi.to_params(src)) @pytest.mark.validation diff --git a/sigima/tools/image/extraction.py b/sigima/tools/image/extraction.py index 3f70a9a..cba9741 100644 --- a/sigima/tools/image/extraction.py +++ b/sigima/tools/image/extraction.py @@ -50,11 +50,16 @@ def get_radial_profile( pixels at this distance (1D array) Raises: - ValueError: if *step* is not positive and finite, or if the center or the - pixel coordinates are not finite + ValueError: if *step* is not positive and finite, if the image holds no + pixel, or if the center or the pixel coordinates are not finite """ if not (np.isfinite(step) and step > 0): raise ValueError(f"The bin width must be positive and finite (got {step})") + if data.size == 0: + raise ValueError( + f"The image holds no pixel (shape {data.shape}): its radial profile " + f"is undefined" + ) if xcoords is None: xcoords = np.arange(data.shape[1], dtype=float) if ycoords is None: