Skip to content

Contour to ROI (Auto Mask) #294

@PierreRaybaut

Description

@PierreRaybaut

Summary

Add a new image processing feature that converts detected contours into polygon ROIs,
enabling automatic mask generation from threshold-based contour detection. This bridges
the existing contour detection capability (contour_shape) with the ROI system
(PolygonalROI), allowing users to automatically define regions of interest from image
intensity contours.

Motivation

Currently, DataLab can:

  • Detect contours via contour_shape() → produces GeometryResult for visualization
  • Define polygon ROIs manually via PolygonalROI with arbitrary vertices
  • Create ROIs from detection for blobs/peaks → uniform-sized rectangles/circles
    around detected points

However, there is no way to automatically create ROIs from contour outlines. Users who
want to isolate regions based on intensity thresholds must manually draw polygon ROIs,
which is tedious and imprecise.

Use cases:

  • Isolating bright/dark regions in microscopy images for further analysis
  • Creating masks from thresholded features (e.g., particles, defects)
  • Automatic segmentation-based ROI definition for batch processing
  • Extracting regions of interest based on intensity level curves

Current State Analysis

What exists

Component Status Location
skimage.measure.find_contours() Available Used in get_contour_shapes()
get_contour_shapes(data, shape=POLYGON, level) Exists sigima/tools/image/detection.py
PolygonalROI class Exists sigima/objects/image/roi.py
create_image_roi("polygon", coords) Exists sigima/objects/image/roi.py
contour_shape() proc function Exists sigima/proc/image/detection.py
Contour → ROI bridge Missing
Mask → ROI conversion Missing
contour_shape with ROI output Missing No DetectionROIParam support

Key insight

The POLYGON branch in get_contour_shapes() already extracts raw contour coordinates
from find_contours() and returns them as flattened [x0, y0, x1, y1, ...] arrays
(NaN-padded). The PolygonalROI class accepts exactly this format. The gap is purely a
bridge function connecting the two.

Implementation Plan

Phase 1: Sigima — Low-level contour-to-polygon extraction

File: sigima/tools/image/detection.py

Add a new function that extracts contours as polygon coordinate arrays suitable for
direct PolygonalROI construction:

def find_contour_polygons(
    data: np.ndarray | ma.MaskedArray,
    level: float = 0.5,
    min_area: float = 0.0,
    min_vertices: int = 3,
) -> list[np.ndarray]:
    """Find contours at a given level and return polygon coordinate arrays.

    Args:
        data: 2D image array
        level: Relative threshold level (0.0 to 1.0, mapped to data range)
        min_area: Minimum contour area in pixels (filters small contours)
        min_vertices: Minimum number of vertices per contour

    Returns:
        List of 1D arrays, each containing flattened [x0, y0, x1, y1, ...] pairs
         in physical coordinates (compatible with PolygonalROI)
    """

Key decisions:

  • Use skimage.measure.find_contours() directly (like existing code)
  • Convert relative level (0–1) to absolute level using data min/max
  • Filter by minimum area (using skimage.measure.regionprops or shoelace formula)
    and minimum vertex count to discard noise
  • Return coordinates in pixel indices (to be converted to physical coordinates at the
    proc layer when attaching to an object)

Phase 2: Sigima — High-level computation function

File: sigima/proc/image/detection.py

Add a new @computation_function that wraps the low-level tool and creates ROIs on
the image object:

class ContourToROIParam(gds.DataSet):
    """Parameters for contour-to-ROI conversion."""

    level = gds.FloatItem(
        _("Threshold level"),
        default=0.5, min=0.0, max=1.0,
        help=_("Relative threshold (0=min, 1=max)")
    )
    min_area = gds.FloatItem(
        _("Min. contour area (pixels)"),
        default=100.0, min=0.0,
        help=_("Minimum enclosed area to keep a contour")
    )
    min_vertices = gds.IntItem(
        _("Min. vertices"),
        default=5, min=3,
        help=_("Minimum vertex count per contour polygon")
    )
    max_contours = gds.IntItem(
        _("Max. contours"),
        default=10, min=1, max=100,
        help=_("Maximum number of contours to convert to ROIs")
    )
    inverse = gds.BoolItem(
        _("Inverse mask"),
        default=False,
        help=_("If True, ROI masks the inside of contours (focus outside)")
    )

@computation_function()
def contour_to_roi(image: ImageObj, p: ContourToROIParam) -> ImageObj:
    """Create polygon ROIs from image contours at a threshold level.

    Detects contours at the specified threshold and converts each contour
    into a PolygonalROI, setting them as the image's ROI.

    Args:
        image: Input image object
        p: Contour-to-ROI parameters

    Returns:
        Image object with polygon ROIs set from detected contours
    """

Processing type: This is a 1-to-1 operation (input image → same image with ROIs
attached). The image data is not modified; only the ROI metadata changes.

Coordinate conversion: Contour vertices from find_contours() are in pixel
indices (row, col). They must be converted to physical coordinates using the image's
x0, y0, dx, dy attributes before storing in PolygonalROI (which uses physical
coordinates).

Sorting: Contours should be sorted by area (largest first) and truncated to
max_contours.

Phase 3: Sigima — Exports and parameters

  1. Export function in sigima/proc/image/__init__.py:

    from sigima.proc.image.detection import contour_to_roi, ContourToROIParam
  2. Export parameter in sigima/params.py:

    from sigima.proc.image import ContourToROIParam
  3. Export tool function in sigima/tools/image/__init__.py (if not already
    re-exported):

    from sigima.tools.image.detection import find_contour_polygons

Phase 4: DataLab — Processor registration

File: datalab/gui/processor/image.py

Register in ImageProcessor:

def register_detection(self) -> None:
    # ... existing registrations ...
    self.register_1_to_1(
        sipi.contour_to_roi,
        _("Contour to ROI"),
        paramclass=sipi.ContourToROIParam,
        # No icon initially, can be added later
    )

Processing type choice: register_1_to_1 is appropriate because:

  • Input: 1 image → Output: 1 image (same data, ROIs added)
  • Multi-selection: each selected image gets its own contour-based ROIs
  • The result is visible immediately (ROI overlay on the image plot)

Phase 5: DataLab — Menu integration

File: datalab/gui/actionhandler.py

Add the action to the Analysis menu, near the existing contour detection:

# In ImageActionHandler, within the Analysis menu setup
act = self.action_for("contour_to_roi")
# Add near existing contour_shape action

Refer to scripts/datalab_menus.txt for exact menu placement.

Phase 6: Tests

Sigima unit tests

File: sigima/tests/image/detection_unit_test.py (or new file)

def test_find_contour_polygons():
    """Test low-level contour polygon extraction."""
    # Create synthetic image with known shapes (circle, rectangle)
    data = np.zeros((100, 100))
    rr, cc = skimage.draw.disk((50, 50), 20)
    data[rr, cc] = 1.0

    polygons = find_contour_polygons(data, level=0.5)
    assert len(polygons) >= 1
    assert all(len(p) >= 6 for p in polygons)  # At least 3 vertices


def test_contour_to_roi():
    """Test high-level contour-to-ROI function."""
    image = create_test_image_with_blobs()
    p = ContourToROIParam.create(level=0.5, min_area=50)
    result = contour_to_roi(image, p)

    assert result.roi is not None
    assert len(result.roi.single_rois) > 0
    assert all(isinstance(r, PolygonalROI) for r in result.roi.single_rois)


def test_contour_to_roi_empty():
    """Test with uniform image (no contours)."""
    image = create_uniform_image()
    p = ContourToROIParam.create(level=0.5)
    result = contour_to_roi(image, p)
    assert result.roi is None or len(result.roi.single_rois) == 0

DataLab integration tests

File: datalab/tests/features/image/contour_to_roi_test.py

Test the full GUI pipeline: create image → run contour_to_roi → verify ROIs on result.

Phase 7: Documentation

  1. Sphinx docs (doc/features/image/menu_analysis.rst): Add section describing
    the "Contour to ROI" feature with screenshots and parameter descriptions
  2. Release notes (doc/release_notes/): Add user-focused entry
  3. Translations: Run guidata.utils.translations scan for both Sigima and DataLab

Design Decisions & Alternatives

Why PolygonalROI and not a new MaskROI?

  • PolygonalROI already supports arbitrary shapes and produces masks via
    skimage.draw.polygon
  • All existing ROI infrastructure (serialization, visualization, mask computation,
    inverse logic) works with PolygonalROI
  • A bitmap-based MaskROI would require new serialization, new visualization code,
    and could not leverage the existing ROI combination logic
  • Polygon ROIs are editable by the user after creation; bitmap masks would not be

Trade-off: Very complex contours with many vertices may be slow to render or edit.
The min_vertices and max_contours parameters mitigate this. A vertex decimation
step (e.g., Douglas-Peucker simplification via skimage.measure.approximate_polygon)
could be added as an optional parameter.

Why a new function instead of extending contour_shape()?

  • contour_shape() returns GeometryResult (scalar analysis) — fundamentally
    different from ROI creation
  • Adding ROI creation to contour_shape would mix two concerns (analysis vs. ROI
    definition)
  • contour_shape fits shapes (circles, ellipses) to contours; this feature preserves
    raw contour geometry
  • A separate function is cleaner and follows the single-responsibility principle

Why register_1_to_1 instead of register_1_to_0?

  • The output is the same image with ROIs attached, not a scalar result
  • 1_to_0 is for analysis functions producing GeometryResult/TableResult
  • 1_to_1 allows the ROI to be immediately visible on the image plot
  • Alternative: a dedicated ROI-setting mechanism, but 1_to_1 returning the same
    image with ROIs is the simplest approach

Vertex simplification

Consider adding an optional Douglas-Peucker tolerance parameter:

tolerance = gds.FloatItem(
    _("Simplification tolerance"),
    default=0.0, min=0.0,
    help=_("Douglas-Peucker tolerance for vertex reduction (0=no simplification)")
)

This would use skimage.measure.approximate_polygon(contour, tolerance) to reduce
vertex count on complex contours, improving performance and editability.

Open Questions

  1. Should existing ROIs be replaced or appended? When contour_to_roi is applied
    to an image that already has ROIs, should the new contour-based ROIs replace or be
    added to the existing ones? (Suggested default: replace, with an option to append.)

  2. Should the function support ROI from a separate threshold image? For example,
    applying a threshold on channel A of a multi-channel image and using the resulting
    contours as ROI on the original image. This could be a future extension.

  3. What about closed vs. open contours? find_contours may return open contours
    at image boundaries. Should these be closed automatically or filtered out?
    (Suggested: close them by connecting first and last points.)

  4. Maximum vertex count per polygon? Very detailed contours can have thousands of
    vertices. Should there be a maximum, with automatic decimation?
    (Suggested: add optional simplification via Douglas-Peucker.)

Acceptance Criteria

  • User can select one or more images and run "Contour to ROI" from the Analysis menu
  • Contours at the specified threshold level are converted to PolygonalROI objects
  • ROIs are immediately visible on the image plot after the operation
  • Small/noisy contours are filtered by minimum area and vertex count
  • The inverse flag correctly inverts the mask logic
  • The feature works with ROI-aware processing (e.g., ROI extraction, statistics)
  • Unit tests cover: normal case, empty image, multi-contour, parameter variations
  • Integration test covers full GUI workflow
  • Documentation and translations are updated

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions