-
Notifications
You must be signed in to change notification settings - Fork 279
Add radial percentile function #1768
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v5.0
Are you sure you want to change the base?
Changes from all commits
3276561
e6a9d40
7fe52e1
ff2492e
c7f8965
4b1c3c6
4ad9abf
ba3bcc6
bc09289
9893fc5
4e54af6
71f9aa2
bcaa177
203746a
3721b74
e1c31a7
03f4637
9e882d8
ebd70f7
e5633e4
3a6fbe8
f9bc661
fa4749b
86ef5d9
6c2182f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| ## Analyze radial percentile | ||
|
|
||
| This function calculates the average value of pixels within a cutoff threshold from the center of an object and writes | ||
| the values out to the [Outputs class](outputs.md). | ||
|
|
||
|
|
||
| **plantcv.analyze.radial_percentile**(*img, mask, roi=None, percentile=50, label=None*) | ||
|
|
||
| **returns** List of average values for either grayscale or RGB | ||
|
|
||
| - **Parameters:** | ||
| - img - RGB or grayscale image. | ||
| - mask - Binary mask. | ||
| - roi - Optional ROIs for calculating on multiple objects in an image. | ||
| - percentile - cutoff for considering pixels in the average. Expressed as a percent of maximum distance from the object's center (default = 50). | ||
| - label - Optional label parameter, modifies the variable name of observations recorded. Can be a prefix or list (default = pcv.params.sample_label). | ||
| - **Outputs:** | ||
| - A list of average values for RGB or gray channels for each object (if ROIs are provided), or for the single object in the image. | ||
| - **Example use:** | ||
| - Useful for calculating the intensity of the middle of seeds from an X-ray image. | ||
| - Also could be useful in determining if there are color differences in the middle of a plant rosette. | ||
|
|
||
| - **Output data stored:** Data ('gray_X%_avg', or 'red_X%_avg', 'green_X%_avg', 'blue_X%_avg') automatically gets stored to | ||
| the [`Outputs` class](outputs.md) when this function is ran. These data can always get accessed during a workflow (example | ||
| below). For more detail about data output see [Summary of Output Observations](output_measurements.md#summary-of-output-observations) | ||
|
|
||
| **Multi-ROI object on original image** | ||
|
|
||
|  | ||
|
|
||
| ```python | ||
|
|
||
| from plantcv import plantcv as pcv | ||
|
|
||
| # Caclulates the average values of pixels that fall within the distance percentile from the center of an object. | ||
| list_of_averages = pcv.analyze.radial_percentile(img=img, mask=mask, roi=rois, percentile=40) | ||
|
|
||
| # Access data stored out from analyze.radial_percentile | ||
| gray_avg_seed1 = pcv.outputs.observations['default_1']['gray_40%_avg']['value'] | ||
|
|
||
| ``` | ||
|
|
||
|
|
||
| **Debug depicting one example of seed cropped to 40% of maximum distance from center** | ||
|
|
||
|  | ||
|
|
||
| **Source Code:** [Here](https://github.com/danforthcenter/plantcv/blob/main/plantcv/plantcv/analyze/radial.py) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,146 @@ | ||||||||||||||||||||||||||||||||||||
| """Outputs the average pixel values from a percentile radially outward from an object's center.""" | ||||||||||||||||||||||||||||||||||||
| from plantcv.plantcv import params, Objects, apply_mask, auto_crop, outputs | ||||||||||||||||||||||||||||||||||||
| from plantcv.plantcv import roi as roi_ | ||||||||||||||||||||||||||||||||||||
| from plantcv.plantcv._debug import _debug | ||||||||||||||||||||||||||||||||||||
| import cv2 | ||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||
| import numpy as np | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def _calc_dists(img, mask, percentile, store_debug, ind=None): | ||||||||||||||||||||||||||||||||||||
| """Calculates the distances of each pixel, | ||||||||||||||||||||||||||||||||||||
| the cutoff based on given percentile, | ||||||||||||||||||||||||||||||||||||
| and the average pixel values within that cutoff. | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Parameters | ||||||||||||||||||||||||||||||||||||
| ---------- | ||||||||||||||||||||||||||||||||||||
| img : numpy.ndarray | ||||||||||||||||||||||||||||||||||||
| RGB or grayscale image cropped to a focal object | ||||||||||||||||||||||||||||||||||||
| mask : numpy.ndarray | ||||||||||||||||||||||||||||||||||||
| Segmented mask of the object from img | ||||||||||||||||||||||||||||||||||||
| percentile : number | ||||||||||||||||||||||||||||||||||||
| Cutoff for inclusion of pixels (percent from center) | ||||||||||||||||||||||||||||||||||||
| store_debug : str or None | ||||||||||||||||||||||||||||||||||||
| Value for params.debug when function is called | ||||||||||||||||||||||||||||||||||||
| ind : int, optional | ||||||||||||||||||||||||||||||||||||
| Tracker for which in multi ROI gets a debug, by default None | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Returns | ||||||||||||||||||||||||||||||||||||
| ------- | ||||||||||||||||||||||||||||||||||||
| avgs : float or list | ||||||||||||||||||||||||||||||||||||
| average pixel values (gray or RGB) within the distance percentile | ||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||
| # if mask is empty then return NaNs, nothing is in the mask. | ||||||||||||||||||||||||||||||||||||
| if np.sum(mask) == 0: | ||||||||||||||||||||||||||||||||||||
| return ["nan", "nan", "nan"] | ||||||||||||||||||||||||||||||||||||
| # Analyze shape properties | ||||||||||||||||||||||||||||||||||||
| m = cv2.moments(mask, binaryImage=True) | ||||||||||||||||||||||||||||||||||||
| cmx = m['m10'] / m['m00'] | ||||||||||||||||||||||||||||||||||||
| cmy = m['m01'] / m['m00'] | ||||||||||||||||||||||||||||||||||||
| center = (cmx, cmy) | ||||||||||||||||||||||||||||||||||||
| # Calculate point distances from center | ||||||||||||||||||||||||||||||||||||
| y, x = np.ogrid[:img.shape[0], :img.shape[1]] | ||||||||||||||||||||||||||||||||||||
| distances = np.sqrt((x - center[0])**2 + (y - center[1])**2) | ||||||||||||||||||||||||||||||||||||
| if len(img.shape) == 3: | ||||||||||||||||||||||||||||||||||||
| distances = np.stack([distances for _ in range(3)], axis=2) | ||||||||||||||||||||||||||||||||||||
| # Distance cutoff based on percentile | ||||||||||||||||||||||||||||||||||||
| cutoff = (np.sqrt((img.shape[0]/2)**2 + (img.shape[1]/2)**2))*(percentile/100) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+44
to
+47
|
||||||||||||||||||||||||||||||||||||
| if len(img.shape) == 3: | |
| distances = np.stack([distances for _ in range(3)], axis=2) | |
| # Distance cutoff based on percentile | |
| cutoff = (np.sqrt((img.shape[0]/2)**2 + (img.shape[1]/2)**2))*(percentile/100) | |
| # Compute maximum distance based on object pixels (mask) and scale by percentile | |
| max_distance = np.max(distances[mask > 0]) | |
| cutoff = max_distance * (percentile / 100.0) | |
| if len(img.shape) == 3: | |
| distances = np.stack([distances for _ in range(3)], axis=2) |
Copilot
AI
Feb 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mean is computed over all pixels within the cutoff radius, including background pixels introduced by apply_mask(..., mask_color='black') (i.e., zeros) as long as they lie inside the radius. This biases the average downward and makes results dependent on crop size/shape. Restrict the averaging to pixels that are both within the cutoff radius and inside the object mask (e.g., set background to NaN using the mask before nanmean).
| # Distance cutoff based on percentile | |
| cutoff = (np.sqrt((img.shape[0]/2)**2 + (img.shape[1]/2)**2))*(percentile/100) | |
| img_cutoff = np.where(distances < cutoff, img, np.nan) | |
| # One example debug | |
| if ind == 0: | |
| example = np.where(distances < cutoff, img, 0) | |
| mask_broadcast = mask[:, :, np.newaxis] | |
| else: | |
| mask_broadcast = mask | |
| # Distance cutoff based on percentile | |
| cutoff = (np.sqrt((img.shape[0]/2)**2 + (img.shape[1]/2)**2))*(percentile/100) | |
| # Only include pixels that are both within the cutoff radius and inside the object mask | |
| valid_region = (distances < cutoff) & (mask_broadcast > 0) | |
| img_cutoff = np.where(valid_region, img, np.nan) | |
| # One example debug | |
| if ind == 0: | |
| example = np.where(valid_region, img, 0) |
Copilot
AI
Feb 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mean is computed over all pixels within the cutoff radius, including background pixels introduced by apply_mask(..., mask_color='black') (i.e., zeros) as long as they lie inside the radius. This biases the average downward and makes results dependent on crop size/shape. Restrict the averaging to pixels that are both within the cutoff radius and inside the object mask (e.g., set background to NaN using the mask before nanmean).
Copilot
AI
Feb 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty-mask handling returns string values ("nan") and always returns a 3-element list, even for grayscale images. This creates two concrete issues: (1) grayscale + empty mask will be treated as RGB in the outputs loop (since it becomes a list), and (2) outputs.add_observation(... datatype=float, value=\"nan\") passes a string where a float is declared. Use np.nan (float) and return a shape consistent with the input image (scalar for grayscale, 3-list for RGB), and ensure Outputs receives float NaNs rather than strings.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| """Tests for pcv.analyze.radial""" | ||
| import cv2 | ||
| import numpy as np | ||
| from plantcv.plantcv.analyze.radial import radial_percentile | ||
| from plantcv.plantcv import Objects | ||
| from plantcv.plantcv._helpers import _cv2_findcontours | ||
|
|
||
|
|
||
| def test_radial_RGB(test_data): | ||
| """Test for PlantCV.""" | ||
| # Read in test data | ||
| img = cv2.imread(test_data.rgb_seed) | ||
| mask = cv2.imread(test_data.rgb_seed_mask, -1) | ||
| avgs = radial_percentile(img=img, mask=mask) | ||
| assert int(avgs[0][0]) == 138 | ||
|
|
||
|
|
||
| def test_radial_gray(test_data): | ||
| """Test for PlantCV.""" | ||
| # Read in roi | ||
| circle = cv2.imread(test_data.small_circle, -1) | ||
| roi_contour, roi_hierarchy = _cv2_findcontours(bin_img=circle) | ||
| roi = Objects(contours=[roi_contour], hierarchy=[roi_hierarchy]) | ||
| # Read in test data as gray | ||
| img = cv2.imread(test_data.rgb_seed, 0) | ||
| mask = cv2.imread(test_data.rgb_seed_mask, -1) | ||
| avgs = radial_percentile(img=img, mask=mask, roi=roi) | ||
| assert int(avgs[0]) == 96 | ||
|
|
||
|
|
||
| def test_radial_empty(test_data): | ||
| """Test for PlantCV.""" | ||
| # Read in test data | ||
| img = cv2.imread(test_data.rgb_seed) | ||
| mask = mask = np.zeros((100,100),dtype="uint8") | ||
| avgs = radial_percentile(img=img, mask=mask) | ||
| assert avgs[0][0] == "nan" | ||
|
|
||
|
|
||
| def test_radial_roi_empty(test_data): | ||
| """Test for PlantCV.""" | ||
| # Read in test data | ||
| img = cv2.imread(test_data.rgb_seed) | ||
| mask = mask = np.zeros((100,100),dtype="uint8") | ||
| circle = cv2.imread(test_data.small_circle, -1) | ||
| roi_contour, roi_hierarchy = _cv2_findcontours(bin_img=circle) | ||
| roi = Objects(contours=[roi_contour], hierarchy=[roi_hierarchy]) | ||
| avgs = radial_percentile(img=img, mask=mask, roi=roi) | ||
| assert avgs[0][0] == "nan" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty-mask handling returns string values ("nan") and always returns a 3-element list, even for grayscale images. This creates two concrete issues: (1) grayscale + empty mask will be treated as RGB in the outputs loop (since it becomes a list), and (2)
outputs.add_observation(... datatype=float, value=\"nan\")passes a string where a float is declared. Usenp.nan(float) and return a shape consistent with the input image (scalar for grayscale, 3-list for RGB), and ensureOutputsreceives float NaNs rather than strings.