-
Notifications
You must be signed in to change notification settings - Fork 11
Description
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()→ producesGeometryResultfor visualization - Define polygon ROIs manually via
PolygonalROIwith 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.regionpropsor shoelace formula)
and minimum vertex count to discard noise - Return coordinates in pixel indices (to be converted to physical coordinates at the
proclayer 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
-
Export function in
sigima/proc/image/__init__.py:from sigima.proc.image.detection import contour_to_roi, ContourToROIParam
-
Export parameter in
sigima/params.py:from sigima.proc.image import ContourToROIParam
-
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 actionRefer 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) == 0DataLab 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
- Sphinx docs (
doc/features/image/menu_analysis.rst): Add section describing
the "Contour to ROI" feature with screenshots and parameter descriptions - Release notes (
doc/release_notes/): Add user-focused entry - Translations: Run
guidata.utils.translations scanfor both Sigima and DataLab
Design Decisions & Alternatives
Why PolygonalROI and not a new MaskROI?
PolygonalROIalready supports arbitrary shapes and produces masks via
skimage.draw.polygon- All existing ROI infrastructure (serialization, visualization, mask computation,
inverse logic) works withPolygonalROI - A bitmap-based
MaskROIwould 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()returnsGeometryResult(scalar analysis) — fundamentally
different from ROI creation- Adding ROI creation to
contour_shapewould mix two concerns (analysis vs. ROI
definition) contour_shapefits 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_0is for analysis functions producingGeometryResult/TableResult1_to_1allows the ROI to be immediately visible on the image plot- Alternative: a dedicated ROI-setting mechanism, but
1_to_1returning 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
-
Should existing ROIs be replaced or appended? When
contour_to_roiis 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.) -
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. -
What about closed vs. open contours?
find_contoursmay return open contours
at image boundaries. Should these be closed automatically or filtered out?
(Suggested: close them by connecting first and last points.) -
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
PolygonalROIobjects - 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