Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions doc/locale/fr/LC_MESSAGES/user_guide/features.po
Original file line number Diff line number Diff line change
Expand Up @@ -1080,8 +1080,8 @@ msgstr "Détection de pics 2D"
msgid ":func:`contour_shape <sigima.proc.image.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 <sigima.proc.image.hough_circle_peaks>`"
msgstr ""
Expand Down
2 changes: 1 addition & 1 deletion doc/user_guide/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ Feature Detection
* - :func:`peak_detection <sigima.proc.image.peak_detection>`
- 2D peak detection
* - :func:`contour_shape <sigima.proc.image.contour_shape>`
- Contour shape analysis
- Contour shape analysis (with optional ROI creation from detected contours)
* - :func:`hough_circle_peaks <sigima.proc.image.hough_circle_peaks>`
- Circular Hough transform

Expand Down
39 changes: 26 additions & 13 deletions sigima/locale/fr/LC_MESSAGES/sigima.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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."

Expand Down
2 changes: 1 addition & 1 deletion sigima/objects/image/roi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions sigima/proc/image/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
contour_shape,
hough_circle_peaks,
peak_detection,
store_contour_roi_metadata,
)
from sigima.proc.image.edges import (
CannyParam,
Expand Down Expand Up @@ -481,6 +482,7 @@
"sobel_v",
"standard_deviation",
"stats",
"store_contour_roi_metadata",
"threshold",
"threshold_isodata",
"threshold_li",
Expand Down
142 changes: 140 additions & 2 deletions sigima/proc/image/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from __future__ import annotations

import guidata.dataset as gds
import numpy as np

import sigima.enums
import sigima.tools.image
Expand All @@ -33,6 +34,7 @@
GeometryResult,
ImageObj,
KindShape,
create_image_roi,
create_image_roi_around_points,
)
from sigima.proc.decorator import computation_function
Expand Down Expand Up @@ -61,6 +63,7 @@
"contour_shape",
"hough_circle_peaks",
"peak_detection",
"store_contour_roi_metadata",
"store_roi_creation_metadata",
]

Expand Down Expand Up @@ -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
Expand All @@ -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"""

Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand Down
Loading
Loading