diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/features.po b/doc/locale/fr/LC_MESSAGES/user_guide/features.po index 1611882..e0b3463 100644 --- a/doc/locale/fr/LC_MESSAGES/user_guide/features.po +++ b/doc/locale/fr/LC_MESSAGES/user_guide/features.po @@ -1080,8 +1080,8 @@ msgstr "Détection de pics 2D" msgid ":func:`contour_shape `" msgstr "" -msgid "Contour shape analysis" -msgstr "Analyse de forme de contour" +msgid "Contour shape analysis (with optional ROI creation from detected contours)" +msgstr "Analyse de forme de contour (avec création optionnelle de ROI à partir des contours détectés)" msgid ":func:`hough_circle_peaks `" msgstr "" diff --git a/doc/user_guide/features.rst b/doc/user_guide/features.rst index 299aaf1..2c1a48d 100644 --- a/doc/user_guide/features.rst +++ b/doc/user_guide/features.rst @@ -600,7 +600,7 @@ Feature Detection * - :func:`peak_detection ` - 2D peak detection * - :func:`contour_shape ` - - Contour shape analysis + - Contour shape analysis (with optional ROI creation from detected contours) * - :func:`hough_circle_peaks ` - Circular Hough transform diff --git a/sigima/locale/fr/LC_MESSAGES/sigima.po b/sigima/locale/fr/LC_MESSAGES/sigima.po index b300bcb..5353cfe 100644 --- a/sigima/locale/fr/LC_MESSAGES/sigima.po +++ b/sigima/locale/fr/LC_MESSAGES/sigima.po @@ -315,18 +315,18 @@ msgstr "Image sans titre" msgid "Title" msgstr "Titre" -msgid "Height" -msgstr "Hauteur" - msgid "Image height: number of rows" msgstr "Hauteur de l'image : nombre de lignes" -msgid "Width" -msgstr "Largeur" +msgid "Height" +msgstr "Hauteur" msgid "Image width: number of columns" msgstr "Largeur de l'image : nombre de colonnes" +msgid "Width" +msgstr "Largeur" + msgid "Type" msgstr "Type" @@ -372,18 +372,18 @@ msgstr "Décalage X" msgid "Y offset" msgstr "Décalage Y" -msgid "Minimum value" -msgstr "Minimum" - msgid "Value for dark squares" msgstr "Valeur des carrés foncés" -msgid "Maximum value" -msgstr "Maximum" +msgid "Minimum value" +msgstr "Minimum" msgid "Value for light squares" msgstr "Valeur des carrés clairs" +msgid "Maximum value" +msgstr "Maximum" + msgid "Amplitude and offset" msgstr "Amplitude et décalage" @@ -489,12 +489,12 @@ msgstr "Coordonnées Y" msgid "Titles / Units" msgstr "Titres / Unités" -msgid "Untitled" -msgstr "Sans titre" - msgid "Image title" msgstr "Titre de l'image" +msgid "Untitled" +msgstr "Sans titre" + msgid "X-axis" msgstr "Axe des X" @@ -893,6 +893,19 @@ msgstr "Taille de la fenêtre glissante utilisée dans l'algorithme de filtrage msgid "Shape" msgstr "Forme" +msgid "" +"Regions of interest will be created from detected contours.\n" +"ROI geometry is determined by the selected contour shape:\n" +" • Polygon → polygon ROIs\n" +" • Ellipse → polygon ROIs (approximated)\n" +" • Circle → circular ROIs" +msgstr "" +"Des régions d'intérêt seront créées à partir des contours détectés.\n" +"La géométrie de la ROI est déterminée par la forme de contour sélectionnée :\n" +" • Polygone → ROIs polygonales\n" +" • Ellipse → ROIs polygonales (approximées)\n" +" • Cercle → ROIs circulaires" + msgid "The minimum standard deviation for Gaussian Kernel. Keep this low to detect smaller blobs." msgstr "L'écart-type minimal pour le noyau gaussien. Cette valeur doit être faible pour détecter de petites taches." diff --git a/sigima/objects/image/roi.py b/sigima/objects/image/roi.py index 750e8cd..4b1f642 100644 --- a/sigima/objects/image/roi.py +++ b/sigima/objects/image/roi.py @@ -1011,4 +1011,4 @@ def create_image_roi_around_points( roi_coords.append([x0, y0, dx, dy]) else: # circle roi_coords.append([x, y, radius]) - return create_image_roi(roi_geometry, roi_coords, indices=True) + return create_image_roi(roi_geometry, roi_coords, indices=False) diff --git a/sigima/proc/image/__init__.py b/sigima/proc/image/__init__.py index f503f3b..dad92b2 100644 --- a/sigima/proc/image/__init__.py +++ b/sigima/proc/image/__init__.py @@ -172,6 +172,7 @@ contour_shape, hough_circle_peaks, peak_detection, + store_contour_roi_metadata, ) from sigima.proc.image.edges import ( CannyParam, @@ -481,6 +482,7 @@ "sobel_v", "standard_deviation", "stats", + "store_contour_roi_metadata", "threshold", "threshold_isodata", "threshold_li", diff --git a/sigima/proc/image/detection.py b/sigima/proc/image/detection.py index aea453c..1969206 100644 --- a/sigima/proc/image/detection.py +++ b/sigima/proc/image/detection.py @@ -25,6 +25,7 @@ from __future__ import annotations import guidata.dataset as gds +import numpy as np import sigima.enums import sigima.tools.image @@ -33,6 +34,7 @@ GeometryResult, ImageObj, KindShape, + create_image_roi, create_image_roi_around_points, ) from sigima.proc.decorator import computation_function @@ -61,6 +63,7 @@ "contour_shape", "hough_circle_peaks", "peak_detection", + "store_contour_roi_metadata", "store_roi_creation_metadata", ] @@ -157,7 +160,15 @@ def apply_detection_rois( # Check if ROI creation was requested (or forced) create_rois = force or geometry.attrs.get("create_rois", False) - if not create_rois or len(geometry) < 2: + if not create_rois: + return False + + # Handle contour-based ROIs (polygon, ellipse, circle shapes from contour_shape). + # These bypass the len >= 2 check: each contour is already a complete shape. + if geometry.attrs.get("contour_rois", False): + return _apply_contour_rois(obj, geometry) + + if len(geometry) < 2: return False # Get ROI geometry from parameter, attrs, or use default @@ -177,6 +188,122 @@ def apply_detection_rois( return False +def _ellipse_to_polygon( + xc: float, yc: float, a: float, b: float, theta: float, n_points: int = 64 +) -> np.ndarray: + """Convert ellipse parameters to polygon vertex coordinates. + + The ``(xc, yc, a, b, theta)`` parameters come from + :func:`sigima.tools.image.preprocessing.fit_ellipse_model`, which fits the + contour in scikit-image ``(row, col)`` space and then swaps axes back to + ``(x, y)``. Because of that swap, the angle is measured relative to the + ``y`` axis and ``a``/``b`` are the semi-axes along ``col``/``row``. Sampling + the ellipse therefore uses orthogonal semi-axis vectors + ``u = (b*sin(theta), b*cos(theta))`` (cos term) and + ``v = (a*cos(theta), -a*sin(theta))`` (sin term), so each vertex is + ``center + cos(t)*u + sin(t)*v``. This reproduces the actual fitted ellipse + (the polygon ROI follows the contour data), unlike a naive rotation matrix + which would shear it for rotated contours. + + Args: + xc: center x + yc: center y + a: semi-axis along the x (col) direction returned by the fit + b: semi-axis along the y (row) direction returned by the fit + theta: ellipse angle returned by the fit (relative to the y axis) + n_points: number of vertices for the polygon approximation + + Returns: + 1D array [x0, y0, x1, y1, ...] of polygon vertices + """ + t = np.linspace(0, 2 * np.pi, n_points, endpoint=False) + cos_th, sin_th = np.cos(theta), np.sin(theta) + x = xc + b * np.cos(t) * sin_th + a * np.sin(t) * cos_th + y = yc + b * np.cos(t) * cos_th - a * np.sin(t) * sin_th + return np.column_stack((x, y)).flatten() + + +def _apply_contour_rois(obj: ImageObj, geometry: GeometryResult) -> bool: + # Keep the early returns here: each shape branch is intentionally small and + # symmetric, and splitting this dispatch into helper functions would add + # indirection without making the code clearer. + # pylint: disable=too-many-return-statements + """Apply ROI creation from contour-based geometry results. + + Converts contour detection results into ROIs: + - POLYGON contours → polygon ROIs + - ELLIPSE contours → polygon ROIs (sampled approximation) + - CIRCLE contours → circle ROIs + + Args: + obj: Image object to modify + geometry: Geometry result from contour_shape + + Returns: + True if ROIs were created, False otherwise + """ + kind = geometry.kind + coords = geometry.coords + + if kind == KindShape.POLYGON: + # Each row is [x0, y0, x1, y1, ...] possibly NaN-padded + polygon_coords = [] + for row in coords: + # Strip NaN padding + valid = row[~np.isnan(row)] + if len(valid) >= 6: # At least 3 vertices + polygon_coords.append(valid.tolist()) + if not polygon_coords: + return False + obj.roi = create_image_roi("polygon", polygon_coords) + return True + + if kind == KindShape.ELLIPSE: + # Each row is [xc, yc, a, b, theta] → approximate as polygon + polygon_coords = [] + for row in coords: + xc, yc, a, b, theta = row + poly = _ellipse_to_polygon(xc, yc, a, b, theta) + polygon_coords.append(poly.tolist()) + if not polygon_coords: + return False + obj.roi = create_image_roi("polygon", polygon_coords) + return True + + if kind == KindShape.CIRCLE: + # Each row is [xc, yc, r] + circle_coords = coords.tolist() + if not circle_coords: + return False + obj.roi = create_image_roi("circle", circle_coords) + return True + + return False + + +def store_contour_roi_metadata( + geometry: GeometryResult | None, + create_rois: bool, +) -> GeometryResult | None: + """Store ROI creation metadata for contour detection results. + + Unlike the standard store_roi_creation_metadata (which stores ROI geometry for + point-based detections), this marks the result for contour-specific ROI conversion + where the contour shape itself determines the ROI geometry. + + Args: + geometry: Geometry result from contour detection + create_rois: Whether to create ROIs from the contours + + Returns: + The same geometry object (for chaining), or None if geometry is None + """ + if geometry is not None and create_rois and len(geometry) >= 1: + geometry.attrs["create_rois"] = True + geometry.attrs["contour_rois"] = True + return geometry + + class GenericDetectionParam(gds.DataSet): """Generic detection parameters""" @@ -240,6 +367,17 @@ class ContourShapeParam(GenericDetectionParam): set(item.name for item in KindShape) ) shape = gds.ChoiceItem(_("Shape"), sigima.enums.ContourShape) + create_rois = gds.BoolItem( + _("Create regions of interest"), + default=False, + help=_( + "Regions of interest will be created from detected contours.\n" + "ROI geometry is determined by the selected contour shape:\n" + " • Polygon → polygon ROIs\n" + " • Ellipse → polygon ROIs (approximated)\n" + " • Circle → circular ROIs" + ), + ) @computation_function() @@ -255,7 +393,7 @@ def contour_shape(image: ImageObj, p: ContourShapeParam) -> GeometryResult | Non shape, p.threshold, ) - return geometry + return store_contour_roi_metadata(geometry, p.create_rois) class BaseBlobParam(gds.DataSet): diff --git a/sigima/tests/image/contour_unit_test.py b/sigima/tests/image/contour_unit_test.py index 529ad0e..ef488ae 100644 --- a/sigima/tests/image/contour_unit_test.py +++ b/sigima/tests/image/contour_unit_test.py @@ -17,6 +17,10 @@ import sigima.params import sigima.proc.image from sigima.enums import ContourShape +from sigima.objects import KindShape +from sigima.objects.image.roi import CircularROI, PolygonalROI +from sigima.objects.scalar import GeometryResult +from sigima.proc.image import apply_detection_rois, store_contour_roi_metadata from sigima.tests import guiutils from sigima.tests.data import get_peak2d_data from sigima.tests.env import execenv @@ -144,6 +148,110 @@ def test_contour_shape() -> None: execenv.print("All contour shape tests passed!") +def test_contour_roi_polygon() -> None: + """Test contour detection with polygon shape creates polygon ROIs.""" + data, _coords = get_peak2d_data() + image = sigima.objects.create_image("Test", data=data) + param = sigima.params.ContourShapeParam.create( + shape=ContourShape.POLYGON, create_rois=True + ) + result = sigima.proc.image.contour_shape(image, param) + assert result is not None + assert sigima.proc.image.apply_detection_rois(image, result) + assert image.roi is not None + for roi in image.roi.single_rois: + assert isinstance(roi, PolygonalROI) + execenv.print(f"Polygon ROIs created: {len(image.roi.single_rois)}") + + +def test_contour_roi_ellipse() -> None: + """Test contour detection with ellipse shape creates polygon ROIs.""" + data, _coords = get_peak2d_data() + image = sigima.objects.create_image("Test", data=data) + param = sigima.params.ContourShapeParam.create( + shape=ContourShape.ELLIPSE, create_rois=True + ) + result = sigima.proc.image.contour_shape(image, param) + assert result is not None + assert sigima.proc.image.apply_detection_rois(image, result) + assert image.roi is not None + # Ellipses are approximated as polygon ROIs + for roi in image.roi.single_rois: + assert isinstance(roi, PolygonalROI) + execenv.print(f"Polygon ROIs from ellipses: {len(image.roi.single_rois)}") + + +def test_contour_roi_circle() -> None: + """Test contour detection with circle shape creates circle ROIs.""" + data, _coords = get_peak2d_data() + image = sigima.objects.create_image("Test", data=data) + param = sigima.params.ContourShapeParam.create( + shape=ContourShape.CIRCLE, create_rois=True + ) + result = sigima.proc.image.contour_shape(image, param) + assert result is not None + assert sigima.proc.image.apply_detection_rois(image, result) + assert image.roi is not None + for roi in image.roi.single_rois: + assert isinstance(roi, CircularROI) + execenv.print(f"Circle ROIs created: {len(image.roi.single_rois)}") + + +def test_contour_roi_disabled() -> None: + """Test contour detection with create_rois=False does not create ROIs.""" + data, _coords = get_peak2d_data() + image = sigima.objects.create_image("Test", data=data) + param = sigima.params.ContourShapeParam.create( + shape=ContourShape.POLYGON, create_rois=False + ) + result = sigima.proc.image.contour_shape(image, param) + assert not sigima.proc.image.apply_detection_rois(image, result) + assert image.roi is None + + +def test_store_contour_roi_metadata_none_geometry() -> None: + """store_contour_roi_metadata must return None when geometry is None.""" + result = store_contour_roi_metadata(None, create_rois=True) + assert result is None + + +def test_store_contour_roi_metadata_empty_geometry() -> None: + """store_contour_roi_metadata must not set attrs when geometry has 0 rows.""" + geometry = GeometryResult("test", KindShape.CIRCLE, np.empty((0, 3))) + result = store_contour_roi_metadata(geometry, create_rois=True) + # The geometry object is returned but the attrs must NOT be populated because + # len(geometry) == 0 < 1 (the guard in store_contour_roi_metadata) + assert result is geometry + assert not result.attrs.get("contour_rois", False) + assert not result.attrs.get("create_rois", False) + + +def test_apply_contour_rois_no_detections_returns_false() -> None: + """apply_detection_rois returns False when contour detection found nothing. + + Regression guard: calling apply_detection_rois with a contour-flagged + GeometryResult that has no rows must not crash and must return False. + """ + data, _coords = get_peak2d_data() + image = sigima.objects.create_image("Test", data=data) + + # Build a geometry that carries the contour_rois flag but has no rows + geometry = GeometryResult("test", KindShape.POLYGON, np.empty((0, 6))) + geometry.attrs["create_rois"] = True + geometry.attrs["contour_rois"] = True + + result = apply_detection_rois(image, geometry) + assert result is False + assert image.roi is None + + if __name__ == "__main__": test_contour_interactive() test_contour_shape() + test_contour_roi_polygon() + test_contour_roi_ellipse() + test_contour_roi_circle() + test_contour_roi_disabled() + test_store_contour_roi_metadata_none_geometry() + test_store_contour_roi_metadata_empty_geometry() + test_apply_contour_rois_no_detections_returns_false() diff --git a/sigima/tests/image/detection_roi_scaling_unit_test.py b/sigima/tests/image/detection_roi_scaling_unit_test.py new file mode 100644 index 0000000..b6ed834 --- /dev/null +++ b/sigima/tests/image/detection_roi_scaling_unit_test.py @@ -0,0 +1,192 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Regression test: detection ROIs on images with non-unit axis scaling + +When an image has non-default pixel spacing (dx, dy ≠ 1.0), ROIs created by +detection algorithms (peak detection, blob detection, …) must be positioned in +**physical** coordinates, not pixel indices. +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +import numpy as np +from skimage.draw import disk + +import sigima.enums +import sigima.objects +import sigima.params +import sigima.proc.image +from sigima.enums import ContourShape +from sigima.objects.image.roi import CircularROI +from sigima.tests.data import get_peak2d_data +from sigima.tests.helpers import validate_detection_rois + + +def _make_scaled_image(factor: float = 2.0) -> sigima.objects.ImageObj: + """Return a standard peak-detection test image with a non-unit pixel spacing. + + The underlying pixel data is identical to the default test image, but the + image's pixel spacing is set to *factor* so that + ``physical_coord = factor × pixel_index``. + + Args: + factor: Pixel spacing to apply (default 2.0). + + Returns: + An ImageObj whose calibration uses *factor* as pixel spacing. + """ + data, _ = get_peak2d_data(seed=1, multi=False) + obj = sigima.objects.create_image("scaled_peak_test", data=data) + obj.set_uniform_coords(dx=float(factor), dy=float(factor), x0=0.0, y0=0.0) + return obj + + +def test_peak_detection_rois_with_non_unit_pixel_spacing() -> None: + """ROIs created by peak detection must be correctly positioned on a scaled image. + + Regression test for the ``indices=True`` bug in + ``create_image_roi_around_points``: with a pixel spacing of 2.0, the ROI + bounding boxes must still enclose the physical coordinates of the detected + peaks. + """ + obj_base = _make_scaled_image(factor=2.0) + + for roi_geometry in sigima.enums.DetectionROIGeometry: + obj = obj_base.copy() + param = sigima.params.Peak2DDetectionParam.create( + create_rois=True, + roi_geometry=roi_geometry, + ) + geometry = sigima.proc.image.peak_detection(obj, param) + if geometry is None or len(geometry) < 2: + continue + + sigima.proc.image.apply_detection_rois(obj, geometry) + + # validate_detection_rois checks that each ROI bbox (in physical coords) + # contains the corresponding detected peak center (also physical coords). + validate_detection_rois( + obj, + geometry.coords, + create_rois=True, + roi_geometry=roi_geometry, + ) + + +def test_peak_detection_rois_with_unit_pixel_spacing() -> None: + """With default pixel spacing (dx = dy = 1), behaviour is unchanged. + + This test acts as a baseline: the fix must not regress the standard case. + """ + obj_base = _make_scaled_image(factor=1.0) + + for roi_geometry in sigima.enums.DetectionROIGeometry: + obj = obj_base.copy() + param = sigima.params.Peak2DDetectionParam.create( + create_rois=True, + roi_geometry=roi_geometry, + ) + geometry = sigima.proc.image.peak_detection(obj, param) + if geometry is None or len(geometry) < 2: + continue + + sigima.proc.image.apply_detection_rois(obj, geometry) + validate_detection_rois( + obj, + geometry.coords, + create_rois=True, + roi_geometry=roi_geometry, + ) + + +def test_roi_center_matches_physical_peak_for_various_spacings() -> None: + """ROI center must track the physical peak position for several dx values. + + For each spacing factor *s*, a peak detected at pixel index *p* has the + physical coordinate *s × p*. The ROI bounding box (in physical coords) + must contain *s × p*, regardless of *s*. + """ + for factor in (1.0, 2.0, 0.5, 3.0): + obj_base = _make_scaled_image(factor=factor) + obj = obj_base.copy() + param = sigima.params.Peak2DDetectionParam.create( + create_rois=True, + roi_geometry=sigima.enums.DetectionROIGeometry.RECTANGLE, + ) + geometry = sigima.proc.image.peak_detection(obj, param) + if geometry is None or len(geometry) < 2: + continue + + sigima.proc.image.apply_detection_rois(obj, geometry) + validate_detection_rois( + obj, + geometry.coords, + create_rois=True, + roi_geometry=sigima.enums.DetectionROIGeometry.RECTANGLE, + ) + + +def test_contour_roi_circle_with_non_unit_pixel_spacing() -> None: + """Contour circle ROIs must use physical coordinates on a scaled image. + + Two disks are drawn at pixel centres (50, 50) and (150, 150), each with + pixel radius 25. With dx = dy = 2.0 the expected physical coordinates are: + - circle 0: xc ≈ 100, yc ≈ 100, r ≈ 50 + - circle 1: xc ≈ 300, yc ≈ 300, r ≈ 50 + not the raw pixel values (50/25 and 150/25). + """ + # Build a synthetic image: two well-separated white disks, dx = dy = 2.0 + data = np.zeros((200, 200), dtype=np.uint8) + rr0, cc0 = disk((50, 50), 25, shape=data.shape) + rr1, cc1 = disk((150, 150), 25, shape=data.shape) + data[rr0, cc0] = 255 + data[rr1, cc1] = 255 + obj = sigima.objects.create_image("circle_scaled", data=data) + obj.set_uniform_coords(dx=2.0, dy=2.0, x0=0.0, y0=0.0) + + # ContourShapeParam.create_rois is a ValueProp-managed item; set it manually. + param = sigima.params.ContourShapeParam.create(shape=ContourShape.CIRCLE) + param.create_rois = True + + geometry = sigima.proc.image.contour_shape(obj, param) + assert geometry is not None, "contour_shape must detect the circles" + assert len(geometry) == 2, f"Expected exactly 2 circles, got {len(geometry)}" + + ok = sigima.proc.image.apply_detection_rois(obj, geometry) + assert ok, "apply_detection_rois must return True when contours are detected" + assert obj.roi is not None, "ROIs must be created on the image" + + # The ROIs must be CircularROIs in physical coordinates. + rois = obj.roi.single_rois + assert len(rois) == 2, f"Expected exactly 2 ROIs, got {len(rois)}" + + for roi in rois: + assert isinstance(roi, CircularROI), ( + f"Expected CircularROI, got {type(roi).__name__}" + ) + + # Sort by xc so the order is deterministic. + sorted_rois = sorted(rois, key=lambda roi: roi.coords[0]) + + # coords = [xc, yc, r] already in physical units. + # pixel (50, 50) × dx=2 → physical (100, 100), r=25×2=50 + xc0, yc0, r0 = sorted_rois[0].coords + assert abs(xc0 - 100.0) < 5.0, f"xc0={xc0} must be near 100 (physical coords)" + assert abs(yc0 - 100.0) < 5.0, f"yc0={yc0} must be near 100 (physical coords)" + assert abs(r0 - 50.0) < 5.0, f"r0={r0} must be near 50 (physical units)" + + # pixel (150, 150) × dx=2 → physical (300, 300), r=25×2=50 + xc1, yc1, r1 = sorted_rois[1].coords + assert abs(xc1 - 300.0) < 5.0, f"xc1={xc1} must be near 300 (physical coords)" + assert abs(yc1 - 300.0) < 5.0, f"yc1={yc1} must be near 300 (physical coords)" + assert abs(r1 - 50.0) < 5.0, f"r1={r1} must be near 50 (physical units)" + + +if __name__ == "__main__": + test_peak_detection_rois_with_non_unit_pixel_spacing() + test_peak_detection_rois_with_unit_pixel_spacing() + test_roi_center_matches_physical_peak_for_various_spacings() + test_contour_roi_circle_with_non_unit_pixel_spacing() diff --git a/sigima/tests/image/ellipse_roi_polygon_unit_test.py b/sigima/tests/image/ellipse_roi_polygon_unit_test.py new file mode 100644 index 0000000..4478c44 --- /dev/null +++ b/sigima/tests/image/ellipse_roi_polygon_unit_test.py @@ -0,0 +1,100 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Non-regression test for ellipse contour -> polygon ROI conversion. + +Background +---------- +``contour_shape`` detects ellipses and, when ROI creation is requested, converts +each fitted ellipse ``(xc, yc, a, b, theta)`` into a polygon ROI through +:func:`sigima.proc.image.detection._ellipse_to_polygon`. + +A previous bug made the polygon ROI appear sheared/rotated with respect to the +actual ellipse contour: for rotated contours the polygon could drift by more +than 10 pixels from the data. The root cause was that the polygon sampling did +not account for the scikit-image ``(row, col)`` fitting space (where the fitted +angle is measured relative to the ``y`` axis with swapped semi-axes), producing +non-orthogonal (sheared) axes. + +This test pins the correct behaviour: the polygon ROI must +1. have orthogonal semi-axes (a true, non-sheared ellipse), and +2. follow the actual ellipse contour extracted from a rasterized ellipse. +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +import numpy as np +import pytest +from skimage import measure +from skimage.draw import polygon as sk_polygon # pylint: disable=no-name-in-module + +from sigima.proc.image.detection import _ellipse_to_polygon +from sigima.tools.image.preprocessing import fit_ellipse_model + + +def _make_ellipse_contour( + xc: float, yc: float, a: float, b: float, theta: float, shape: tuple[int, int] +) -> np.ndarray: + """Rasterize a true rotated ellipse and return its contour in (row, col).""" + t = np.linspace(0, 2 * np.pi, 600, endpoint=False) + x = xc + a * np.cos(t) * np.cos(theta) - b * np.sin(t) * np.sin(theta) + y = yc + a * np.cos(t) * np.sin(theta) + b * np.sin(t) * np.cos(theta) + img = np.zeros(shape) + rr, cc = sk_polygon(y, x, shape=shape) + img[rr, cc] = 1.0 + return measure.find_contours(img, 0.5)[0] # (row, col) = (y, x) + + +def _min_distance_max(poly_xy: np.ndarray, contour_xy: np.ndarray) -> float: + """Max over polygon vertices of the nearest distance to the contour.""" + dist = np.sqrt(((poly_xy[:, None, :] - contour_xy[None, :, :]) ** 2).sum(axis=-1)) + return float(dist.min(axis=1).max()) + + +@pytest.mark.parametrize("theta_deg", [0, 30, 45, 60, 90, 120, 150]) +def test_ellipse_polygon_axes_are_orthogonal(theta_deg: int) -> None: + """The sampled polygon must be a true (non-sheared) ellipse. + + Pins the fix for the sheared-ROI bug: the two semi-axis vectors used to + sample the polygon must be orthogonal for any orientation. + """ + xc, yc, a, b = 100.0, 50.0, 40.0, 15.0 + theta = np.deg2rad(theta_deg) + vertices = _ellipse_to_polygon(xc, yc, a, b, theta, n_points=64).reshape(-1, 2) + center = np.array([xc, yc]) + + # cos-term semi-axis vector is vertices[0] - center (t = 0); + # sin-term semi-axis vector is vertices[16] - center (t = pi/2, n=64). + u = vertices[0] - center + v = vertices[16] - center + assert abs(np.dot(u, v)) < 1e-9, "Ellipse polygon axes are not orthogonal" + + +@pytest.mark.parametrize("theta_deg", [0, 30, 45, 60, 120, 150]) +def test_ellipse_polygon_follows_contour(theta_deg: int) -> None: + """The polygon ROI must overlay the actual ellipse contour. + + Full pipeline: true ellipse -> raster -> find_contours -> fit_ellipse_model + -> _ellipse_to_polygon. Each polygon vertex must lie close to the contour + (within rasterization noise), which would fail for the previous sheared + formula (drift > 7 px for rotated contours). + """ + xc0, yc0, A, B = 100.0, 80.0, 40.0, 15.0 + shape = (200, 220) + theta = np.deg2rad(theta_deg) + + contour_rc = _make_ellipse_contour(xc0, yc0, A, B, theta, shape) + contour_xy = contour_rc[:, ::-1] # (x, y) + + fit = fit_ellipse_model(contour_rc) + assert fit is not None + xc, yc, a, b, theta_fit = fit + + poly = _ellipse_to_polygon(xc, yc, a, b, theta_fit, n_points=200).reshape(-1, 2) + max_drift = _min_distance_max(poly, contour_xy) + assert max_drift < 1.5, ( + f"Polygon ROI drifts {max_drift:.2f} px from the ellipse contour " + f"(theta={theta_deg} deg)" + )