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
33 changes: 33 additions & 0 deletions docs/sub_mask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Sub Mask

Sample circular regions from a mask.

**plantcv.plantcv.sub_mask**(*img, mask, num_masks=1, radius=5*)

**returns** A `numpy.ndarray` labelled mask.

- **Parameters:**
- img - Grayscale or RGB image.
- mask - Binary mask of the image.
- num_masks - A number of circular regions to make masks of, defaults to 1. These spots cannot overlap, so specifying too many may trigger a warning that fewer masks could be placed than were specified.
- radius - Radius of the circular region(s) to select. These spots cannot overlap, so specifying too large of a radius may trigger a warning that fewer masks could be placed than were specified.


- **Context:**
- Used to downsample an image, particularly for spectral analysis.


- **Example use:**
- Below
Comment on lines +5 to +21
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page doesn’t follow the established docs pattern used elsewhere (e.g., docs/auto_crop.md): it uses plantcv.plantcv.sub_mask instead of plantcv.sub_mask, and several list items are indented with tabs which can render as code blocks in Markdown. Consider switching to the standard **plantcv.sub_mask**(...) heading and replacing tabs with spaces for consistent rendering.

Suggested change
**plantcv.plantcv.sub_mask**(*img, mask, num_masks=1, radius=5*)
**returns** A `numpy.ndarray` labelled mask.
- **Parameters:**
- img - Grayscale or RGB image.
- mask - Binary mask of the image.
- num_masks - A number of circular regions to make masks of, defaults to 1. These spots cannot overlap, so specifying too many may trigger a warning that fewer masks could be placed than were specified.
- radius - Radius of the circular region(s) to select. These spots cannot overlap, so specifying too large of a radius may trigger a warning that fewer masks could be placed than were specified.
- **Context:**
- Used to downsample an image, particularly for spectral analysis.
- **Example use:**
- Below
**plantcv.sub_mask**(*img, mask, num_masks=1, radius=5*)
**returns** A `numpy.ndarray` labelled mask.
- **Parameters:**
- img - Grayscale or RGB image.
- mask - Binary mask of the image.
- num_masks - A number of circular regions to make masks of, defaults to 1. These spots cannot overlap, so specifying too many may trigger a warning that fewer masks could be placed than were specified.
- radius - Radius of the circular region(s) to select. These spots cannot overlap, so specifying too large of a radius may trigger a warning that fewer masks could be placed than were specified.
- **Context:**
- Used to downsample an image, particularly for spectral analysis.
- **Example use:**
- Below

Copilot uses AI. Check for mistakes.



```python

from plantcv import plantcv as pcv

spot_masks = pcv.sub_mask(img, mask, num_masks=2, radius=5)

```

**Source Code:** [Here](https://github.com/danforthcenter/plantcv/blob/main/plantcv/plantcv/submask.py)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ nav:
- 'Auto Crop': auto_crop.md
- 'Background Subtraction': background_subtraction.md
- 'Canny Edge Detection': canny_edge_detect.md
- 'Circular Sub Masks': sub_mask.md
- 'Closing': closing.md
- 'Color Palette': color_palette.md
- 'Colorspace Conversion':
Expand Down
4 changes: 3 additions & 1 deletion plantcv/plantcv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
from plantcv.plantcv.process_results import process_results
from plantcv.plantcv.json2csv import json2csv
from plantcv.plantcv.masks2labels import masks2labels
from plantcv.plantcv.submask import sub_mask
# add new functions to end of lists

__all__ = [
Expand Down Expand Up @@ -167,5 +168,6 @@
"qc",
"process_results",
"json2csv",
"masks2labels"
"masks2labels",
"sub_mask"
]
106 changes: 106 additions & 0 deletions plantcv/plantcv/submask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from plantcv.plantcv.roi.roi_methods import circle
from plantcv.plantcv.roi.roi2mask import roi2mask
from plantcv.plantcv.warn import warn
import numpy as np
import random
import cv2


def sub_mask(img, mask, num_masks=1, radius=5):
"""
Make circular sub-masks inside the mask of an object

Parameters
----------
img = numpy.ndarray, input image
mask = numpy.ndarray, binary mask of object
num_masks = int, number of circular sub-masks to make
radius = int, radius of circular mask to make. Defaults to 5.

Returns
-------
labeled_mask = numpy.ndarray, labelled mask of circular masks each within complete mask.
"""
Comment on lines +10 to +23
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sub_mask docstring uses a one-off parameter format (img = numpy.ndarray, ...) that doesn’t match the NumPy-style name : type format used widely in this repo (e.g., plantcv/plantcv/masks2labels.py:10-27). Aligning the docstring style will make API docs more consistent and easier to parse.

Copilot uses AI. Check for mistakes.
# Create an empty mask
labeled_mask = np.zeros_like(mask)
Comment on lines +24 to +25
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

labeled_mask = np.zeros_like(mask) inherits the input mask dtype (commonly uint8), so labels can overflow/wrap once sample_num > 255. PlantCV labeled masks elsewhere use np.int32 (e.g., plantcv/plantcv/masks2labels.py:32). Consider initializing labeled_mask as a 2D np.int32 array matching mask.shape[:2].

Suggested change
# Create an empty mask
labeled_mask = np.zeros_like(mask)
# Create an empty labeled mask with sufficient integer range for labels
labeled_mask = np.zeros(mask.shape[:2], dtype=np.int32)

Copilot uses AI. Check for mistakes.
tries = 0
sample_num = 0
while len(np.unique(labeled_mask)) - 1 < num_masks:
tries += 1
spot = _make_random_circle(img=img, mask=mask, radius=radius)
spot_mask = roi2mask(img=img, roi=spot)
within = _is_mask_within(full_mask=mask, submask=spot_mask)
if within and _check_overlapping_masks(labeled_mask, spot_mask):
Comment on lines +26 to +33
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop condition recalculates np.unique(labeled_mask) on every iteration, which scans the full array repeatedly and can be expensive on large masks. Since you already track sample_num, you can drive the loop with while sample_num < num_masks: (or maintain a counter) to avoid repeated full-array uniques.

Copilot uses AI. Check for mistakes.
sample_num += 1
# Label spots with unique integers
labeled_mask[np.where(spot_mask > 0)] = sample_num
if tries > num_masks * 100: # Prevent infinite loop
# warn if stuck, break loop
warn("Too many iterations. Placed " + str(sample_num) +
" circular masks instead of " + str(num_masks))
break
return labeled_mask


def _check_overlapping_masks(labeled_mask, new_mask):
"""
Check for any overlapping masks

Parameters
----------
labeled_mask = numpy.ndarray, the labeled_mask that the new_mask may be added to
new_mask = numpy.ndarray, the new proposed region to mask

Returns
-------
Boolean, True if new_mask does not overlap the labeled mask
"""
# set all labeled mask values to 1 for bin_mask
bin_mask = np.copy(labeled_mask)
bin_mask[np.where(bin_mask > 0)] = 1
new_bin = np.copy(new_mask)
new_bin[np.where(new_bin > 0)] = 1
# check max of sum of binary mask and new_mask
return np.max(np.add(bin_mask, new_bin)) == 1


def _make_random_circle(img, mask, radius=5):
"""
Make a circular ROI at a random point in a mask

Parameters
----------
img = numpy.ndarray, input image
mask = numpy.ndarray, binary mask of an object
radius = int, radius of circular mask to make. Defaults to 5.

Returns
-------
spot = PlantCV.Objects class, Circular ROI in random part of mask.
"""
# Find coordinates in mask where mask is non-zero
coords = np.column_stack(np.where(mask > 0))
# Randomly select a center point from these coordinates
center = coords[random.randint(0, len(coords) - 1)]
x, y = center[1], center[0]
# Create an ROI from the random center point
spot = circle(img=img, x=x, y=y, r=radius)
return spot
Comment on lines +81 to +88
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_make_random_circle can raise at runtime in two common cases: (1) if mask has no non-zero pixels then len(coords) == 0 and random.randint(0, -1) will error; (2) if a non-zero pixel is near the image border, roi_methods.circle will raise (fatal_error) because the ROI extends outside the image. Consider filtering candidate centers to those that are at least radius pixels from the image edges and returning early (or warning) when no valid centers exist.

Copilot uses AI. Check for mistakes.


def _is_mask_within(full_mask, submask):
"""
Check if a mask is wholly within another mask

Parameters
----------
full_mask = numpy.ndarray, complete binary mask of an object
sub_mask = numpy.ndarray, binary mask of a smaller part of the full mask

Returns
-------
within = Boolean, comparison of full_mask against full_mask and sub_mask.
"""
combined = cv2.bitwise_and(submask, full_mask)
within = np.array_equal(combined, submask)
return within
26 changes: 26 additions & 0 deletions tests/plantcv/test_sub_mask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import cv2
import numpy as np
from plantcv.plantcv.submask import sub_mask

def test_sub_mask_success(test_data):
"""Test for PlantCV."""
img = cv2.imread(test_data.small_rgb_img, -1)
mask = cv2.imread(test_data.small_bin_img, -1)
spots = sub_mask(img, mask, 2, 2)
assert len(np.unique(spots)) == 3
Comment on lines +5 to +10
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests assert exact label counts, but sub_mask is inherently random and currently has no way to provide a deterministic seed/RNG. This can make CI flaky if a different set of random centers results in a different number of placed spots. Consider adding an optional seed/rng parameter to sub_mask (similar to io.random_subset) and setting it in the tests.

Copilot uses AI. Check for mistakes.


def test_sub_mask_too_large(test_data):
"""Test for PlantCV."""
img = cv2.imread(test_data.small_rgb_img, -1)
mask = cv2.imread(test_data.small_bin_img, -1)
spots = sub_mask(img, mask, 2, 20)
assert len(np.unique(spots)) == 1


def test_sub_mask_too_many(test_data):
"""Test for PlantCV."""
img = cv2.imread(test_data.small_rgb_img, -1)
mask = cv2.imread(test_data.small_bin_img, -1)
spots = sub_mask(img, mask, 5, 3)
assert len(np.unique(spots)) == 2
Loading