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 872510a..27492f6 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 @@ -79,8 +79,8 @@ msgstr "**Coordonnées d'ellipse** : `EllipseCoordinates` (`sigima.objects.shape 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, 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, 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 and radial profiles are sampled in steps of the smallest distance between pixel centers, so that the radial profile is circular for anisotropic pixels and non-uniform coordinates; they are refused when the X and Y units of the image differ. Coordinates outside the image are refused instead of being clamped. On images without calibration (origin 0, pixel size 1), results are unchanged. 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 et radiaux sont échantillonnés par pas égaux à la plus petite distance entre centres de pixels, de sorte que le profil radial est circulaire pour des pixels anisotropes et des coordonnées non uniformes ; ils sont refusés lorsque les unités X et Y de l'image diffèrent. 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 résultats sont inchangés. Les contrats de `line_profile`, `segment_profile`, `average_profile` et `radial_profile` sont 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. 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. 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." msgid "🛠️ Bug Fixes since version 1.3.0" msgstr "🛠️ Corrections de bugs depuis la version 1.3.0" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 0c5443d..97db99e 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -27,7 +27,7 @@ The [user guide](../user_guide/overview.rst) shows how readers and connectors at * **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, 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 and radial profiles are sampled in steps of the smallest distance between pixel centers, so that the radial profile is circular for anisotropic pixels and non-uniform coordinates; they are refused when the X and Y units of the image differ. Coordinates outside the image are refused instead of being clamped. On images without calibration (origin 0, pixel size 1), results are unchanged. The contracts of `line_profile`, `segment_profile`, `average_profile` and `radial_profile` are 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. 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 ### diff --git a/sigima/proc/image/extraction.py b/sigima/proc/image/extraction.py index 3484ca0..d71ca51 100644 --- a/sigima/proc/image/extraction.py +++ b/sigima/proc/image/extraction.py @@ -532,11 +532,14 @@ def csline(data: np.ndarray, row0, col0, row1, col1) -> tuple[np.ndarray, np.nda def segment_profile(src: ImageObj, p: SegmentProfileParam) -> SignalObj: """Compute segment profile - The segment is sampled at regular steps, equal to the smallest distance between - the centers of neighboring pixels, from (X1, Y1) to (X2, Y2): each sample takes - the value of the pixel whose center is the nearest (no interpolation). The X - axis of the profile is the distance from the start point, in the units of the - image. + The segment is sampled at regular steps from (X1, Y1) to (X2, Y2), both end + points included, with one sample per pixel along the axis where it spans the + most pixels (as many samples as pixels from the pixel nearest to its start + point to the pixel nearest to its end point, along this axis): each sample + takes the value of the pixel whose center is the nearest (no interpolation). + The number of samples thus never exceeds the number of rows or columns of the + image, whatever the pixel sizes. The X axis of the profile is the distance + from the start point, in the units of the image. Args: src: input image object @@ -550,15 +553,14 @@ def segment_profile(src: ImageObj, p: SegmentProfileParam) -> SignalObj: of the image differ """ _check_same_units(src, "Segment profile") - _nearest_pixels(src, [p.x1, p.x2], "x", "X") - _nearest_pixels(src, [p.y1, p.y2], "y", "Y") - length = float(np.hypot(p.x2 - p.x1, p.y2 - p.y1)) - step = _pixel_spacing(src) - count = int(np.floor(length / step + PIXEL_INDEX_TOLERANCE)) + 1 - dist = np.arange(count) * step - fraction = dist / length if length > 0 else np.zeros(count) - cols = _nearest_pixels(src, p.x1 + (p.x2 - p.x1) * fraction, "x", "X") - rows = _nearest_pixels(src, p.y1 + (p.y2 - p.y1) * fraction, "y", "Y") + col1, col2 = _nearest_pixels(src, [p.x1, p.x2], "x", "X") + row1, row2 = _nearest_pixels(src, [p.y1, p.y2], "y", "Y") + count = int(max(abs(col2 - col1), abs(row2 - row1))) + 1 + fraction = np.linspace(0.0, 1.0, count) + # Weighted form: the first and last samples are exactly the end points + cols = _nearest_pixels(src, p.x1 * (1.0 - fraction) + p.x2 * fraction, "x", "X") + rows = _nearest_pixels(src, p.y1 * (1.0 - fraction) + p.y2 * fraction, "y", "Y") + dist = fraction * float(np.hypot(p.x2 - p.x1, p.y2 - p.y1)) values = src.get_masked_view()[rows, cols] keep = ~ma.getmaskarray(values) suffix = f"({p.x1:g}, {p.y1:g})-({p.x2:g}, {p.y2:g})" diff --git a/sigima/tests/image/profile_physical_unit_test.py b/sigima/tests/image/profile_physical_unit_test.py index d361c65..c762bc8 100644 --- a/sigima/tests/image/profile_physical_unit_test.py +++ b/sigima/tests/image/profile_physical_unit_test.py @@ -107,3 +107,91 @@ def test_default_parameters_from_image() -> None: radial = sigima.params.RadialProfileParam() radial.update_from_obj(obj) assert (radial.xc, radial.yc) == (obj.xc, obj.yc) + + +SEGMENT_DATA = np.arange(100.0).reshape(10, 10) + + +def _segment(x1, y1, x2, y2, obj=None) -> sigima.objects.SignalObj: + """Return the segment profile from (x1, y1) to (x2, y2).""" + obj = _image(SEGMENT_DATA) if obj is None else obj + param = sigima.params.SegmentProfileParam.create(x1=x1, y1=y1, x2=x2, y2=y2) + return sipi.segment_profile(obj, param) + + +def _segment_profile_1_3(data, row0, col0, row1, col1) -> np.ndarray: + """Segment profile of Sigima 1.3 (``csline``, in pixel indices), without its + reordering of rows and columns, which mirrored anti-diagonal segments.""" + count = max(abs(row1 - row0), abs(col1 - col0)) + 1 + rows = np.linspace(row0, row1, count).astype(int) + cols = np.linspace(col0, col1, count).astype(int) + return data[rows, cols].astype(float) + + +def test_segment_profile_end_points_included() -> None: + """Both end points are sampled (regression: the diagonal of a 10 x 10 image + was sampled in steps of the pixel size, up to pixel (8, 8), duplicating some + pixels and omitting the last one).""" + sig = _segment(0, 0, 9, 9) + assert np.array_equal(sig.y, np.diag(SEGMENT_DATA)) + assert sig.y[-1] == 99.0 + assert np.allclose(sig.x, np.linspace(0.0, 9.0 * np.sqrt(2.0), 10)) + # Oblique segment: one sample per column (the axis spanning the most pixels), + # each taking the nearest pixel, the end pixel included + sig = _segment(0, 0, 9, 4) + rows = np.floor(4 * np.arange(10) / 9 + 0.5).astype(int) + assert np.array_equal(sig.y, SEGMENT_DATA[rows, np.arange(10)]) + assert sig.y[-1] == SEGMENT_DATA[4, 9] + assert np.allclose(sig.x, np.linspace(0.0, np.hypot(9.0, 4.0), 10)) + + +@pytest.mark.parametrize( + "start, end", + [ + ((0, 0), (9, 9)), # diagonal + ((9, 0), (0, 9)), # anti-diagonal + ((8, 1), (2, 7)), # anti-diagonal, inside the image + ((0, 4), (9, 4)), # horizontal + ((9, 4), (0, 4)), # horizontal, reversed + ((3, 0), (3, 9)), # vertical + ], +) +def test_segment_profile_like_sigima_1_3(start, end) -> None: + """On images without calibration, axis-aligned and diagonal segments give the + profiles of Sigima 1.3.""" + (x1, y1), (x2, y2) = start, end + expected = _segment_profile_1_3(SEGMENT_DATA, y1, x1, y2, x2) + assert np.array_equal(_segment(x1, y1, x2, y2).y, expected) + + +@pytest.mark.parametrize( + "x1, y1, x2, y2, expected", + [ + (0, 0, 0.6, 0, [0.0, 1.0]), # shorter than a pixel: end pixel sampled + (0, 0, 0.4, 0.3, [0.0]), # both end points in the same pixel + (2, 3, 2, 3, [32.0]), # zero length + ], +) +def test_segment_profile_short_segments(x1, y1, x2, y2, expected) -> None: + """Segments shorter than a pixel sample the pixels of both end points.""" + sig = _segment(x1, y1, x2, y2) + assert np.array_equal(sig.y, expected) + assert np.allclose(sig.x, np.linspace(0, np.hypot(x2 - x1, y2 - y1), len(sig.x))) + + +def test_segment_profile_tiny_spacings() -> None: + """The number of samples does not depend on the pixel spacings (regression: + a tiny spacing gave hundreds of millions of samples for a few pixels).""" + data = np.arange(25.0).reshape(5, 5) + # Non-uniform coordinates with a tiny spacing + obj = _image(data) + obj.set_coords(np.array([0.0, 1e-8, 1.0, 2.0, 3.0]), np.arange(5.0)) + sig = _segment(0, 0, 3, 4, obj) + assert sig.y.size == 5 + assert sig.y[0] == data[0, 0] and sig.y[-1] == data[4, 4] + # Strongly anisotropic pixels + obj = _image(data) + obj.set_uniform_coords(1e-8, 1.0, 0.0, 0.0) + sig = _segment(0, 0, 4e-8, 4, obj) + assert np.array_equal(sig.y, np.diag(data)) + assert np.allclose(sig.x, np.linspace(0.0, np.hypot(4e-8, 4.0), 5)) diff --git a/sigima/tests/image/profile_unit_test.py b/sigima/tests/image/profile_unit_test.py index 22e7300..d0cc45b 100644 --- a/sigima/tests/image/profile_unit_test.py +++ b/sigima/tests/image/profile_unit_test.py @@ -86,18 +86,20 @@ def test_segment_profile() -> None: assert sig.xunit == "mm" # Anti-diagonal segment: sampled from its start point (row 0, column 30) to its - # end point (row 30, column 0), each sample taking the nearest pixel + # end point (row 30, column 0) included, one sample per pixel along the + # diagonal, each sample taking the nearest pixel param = sigima.params.SegmentProfileParam.create( x1=X0 + 30 * 0.5, y1=Y0, x2=X0, y2=Y0 + 30 * 0.5 ) sig = sigima.proc.image.segment_profile(ima, param) - fraction = np.arange(43) * 0.5 / np.hypot(15.0, 15.0) - rows = np.floor(30 * fraction + 0.5).astype(int) - cols = np.floor(30 - 30 * fraction + 0.5).astype(int) + rows = np.arange(31) check_array_result( - "Anti-diagonal segment", sig.y, ima.data[rows, cols].astype(float) + "Anti-diagonal segment", sig.y, ima.data[rows, 30 - rows].astype(float) ) - assert sig.y[0] == ima.data[0, 30] + check_array_result( + "Anti-diagonal distances", sig.x, np.linspace(0, np.hypot(15.0, 15.0), 31) + ) + assert sig.y[0] == ima.data[0, 30] and sig.y[-1] == ima.data[30, 0] # End points outside the image and different X/Y units are refused param = sigima.params.SegmentProfileParam.create(x1=X0, y1=Y0, x2=X0, y2=Y0 + 1e3)