Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3276561
Add function radial_percentile
k034b363 Aug 22, 2025
e6a9d40
Add tests for radial_percentile
k034b363 Aug 25, 2025
7fe52e1
Add docs page for radial_percentile
k034b363 Aug 25, 2025
ff2492e
Add docs images to radial_percentile
k034b363 Aug 26, 2025
c7f8965
Update mkdocs.yml and fix deepsource
k034b363 Aug 26, 2025
4b1c3c6
replace empty test mask
k034b363 Aug 26, 2025
4ad9abf
Try a different test for runtime error
k034b363 Aug 26, 2025
ba3bcc6
A different test image
k034b363 Aug 26, 2025
bc09289
Add roi to tests
k034b363 Aug 26, 2025
9893fc5
Try different roi
k034b363 Aug 26, 2025
4e54af6
Remove error because outputs nans
k034b363 Aug 26, 2025
71f9aa2
Merge branch 'main' into 1646-radial-percentile
k034b363 Aug 26, 2025
bcaa177
return test for empty mask
k034b363 Aug 26, 2025
203746a
Merge branch 'main' into 1646-radial-percentile
joshqsumner Feb 6, 2026
3721b74
Merge branch 'v5.0' into 1646-radial-percentile
joshqsumner Feb 6, 2026
e1c31a7
logic to handle empty masks in _calc_dists
joshqsumner Feb 6, 2026
03f4637
test with empty mask
joshqsumner Feb 6, 2026
9e882d8
test empty ROI
joshqsumner Feb 6, 2026
ebd70f7
append without making generator
joshqsumner Feb 6, 2026
e5633e4
Delete small_circle.jpg
joshqsumner Feb 6, 2026
3a6fbe8
Merge branch 'v5.0' into 1646-radial-percentile
joshqsumner Feb 6, 2026
f9bc661
remove whitespace
joshqsumner Feb 6, 2026
fa4749b
Merge branch '1646-radial-percentile' of https://github.com/danforthc…
joshqsumner Feb 6, 2026
86ef5d9
Merge branch 'v5.0' into 1646-radial-percentile
nfahlgren Feb 24, 2026
6c2182f
Merge branch 'v5.0' into 1646-radial-percentile
k034b363 Feb 27, 2026
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
48 changes: 48 additions & 0 deletions docs/analyze_radial_percentile.md
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**

![Screenshot](img/documentation_images/analyze_radial/radial_doc1.png)

```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**

![Screenshot](img/documentation_images/analyze_radial/radial_doc2.png)

**Source Code:** [Here](https://github.com/danforthcenter/plantcv/blob/main/plantcv/plantcv/analyze/radial.py)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions docs/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ pages for more details on the input and output variable types.
* pre v4.0: NA
* post v4.0: npq, npq_hist = **plantcv.analyze.npq**(*ps_da_light, ps_da_dark, labeled_mask, n_labels=1, auto_fm=False, min_bin=0, max_bin="auto", measurement_labels=None, label=None*)

#### plantcv.analyze.radial_percentile
* pre v4.9: NA
* post v9.9: avgs = **plantcv.analyze.radial_percentile**(*img, mask, roi=None, percentile=50, label="default"*)

#### plantcv.analyze.size

* pre v4.0: (see plantcv.analyze_object)
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ nav:
- 'Analyze Spectral Index': analyze_spectral_index.md
- 'Analyze YII': analyze_yii.md
- 'Analyze NPQ': analyze_npq.md
- 'Analyze Radial Average': analyze_radial_percentile.md
- 'Annotation Tools':
- 'Points': Points.md
- 'Apply Mask': apply_mask.md
Expand Down
3 changes: 2 additions & 1 deletion plantcv/plantcv/analyze/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from plantcv.plantcv.analyze.yii import yii
from plantcv.plantcv.analyze.npq import npq
from plantcv.plantcv.analyze.distribution import distribution
from plantcv.plantcv.analyze.radial import radial_percentile

__all__ = ["color", "bound_horizontal", "bound_vertical", "grayscale", "size", "thermal", "spectral_reflectance",
"spectral_index", "yii", "npq", "distribution"]
"spectral_index", "yii", "npq", "distribution", "radial_percentile"]
146 changes: 146 additions & 0 deletions 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"]
Copy link

Copilot AI Feb 24, 2026

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.

Suggested change
return ["nan", "nan", "nan"]
# Maintain consistency with image dimensionality:
# - grayscale (2D): return a scalar float NaN
# - RGB (3D): return a list of three float NaNs (one per channel)
if len(img.shape) == 2:
return np.nan
else:
return [np.nan, np.nan, np.nan]

Copilot uses AI. Check for mistakes.
# 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
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The cutoff is computed from the half-diagonal of the cropped image, not from the maximum distance from the object's center (as described in the PR). If the object center is not exactly at the crop center, this will produce an incorrect cutoff radius. Compute max_distance from distances relative to the object (e.g., using mask pixels) and scale that by percentile/100.

Suggested change
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 uses AI. Check for mistakes.
img_cutoff = np.where(distances < cutoff, img, np.nan)
# One example debug
if ind == 0:
example = np.where(distances < cutoff, img, 0)
Comment on lines +46 to +51
Copy link

Copilot AI Feb 24, 2026

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).

Suggested change
# 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 uses AI. Check for mistakes.
params.debug = store_debug
_debug(visual=example, filename=os.path.join(params.debug_outdir, str(params.device) + "_radial_average.png"))
params.debug = None

avgs = float(np.nanmean(img_cutoff))
Copy link

Copilot AI Feb 24, 2026

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 uses AI. Check for mistakes.

if len(img.shape) == 3:
avgs = [float(np.nanmean(img_cutoff[:, :, [2]])),
float(np.nanmean(img_cutoff[:, :, [1]])),
float(np.nanmean(img_cutoff[:, :, [0]]))]
return avgs


def radial_percentile(img, mask, roi=None, percentile=50, label=None):
"""_summary_

Parameters
----------
img : numpy.ndarray
RGB or grayscale image
mask : numpy.ndarray
Binary mask with objects of interest segmented
roi : plantcv.plantcv.Objects, optional
Region of Interest, single or multi, to identify objects, by default None
percentile : int, optional
Percentile of max distance from center in which to average pixel values, by default 50
label : str, optional
Optional label for outputs (default = pcv.params.sample_label)

Returns
-------
avgs : list
average pixel values (gray or RGB) within the distance percentile
"""
if label is None:
label = params.sample_label

store_debug = params.debug
params.debug = None
if roi:
avgs = []
for i, _ in enumerate(roi.contours):
# Loop through rois (even if there is only 1)
roi_ind = Objects(contours=[roi.contours[i]], hierarchy=[roi.hierarchy[i]])
# Filter mask by roi and apply it to the image
filt = roi_.filter(mask=mask, roi=roi_ind)
# Check for empty
if len(np.unique(filt)) == 1:
noavg = ["nan"]
if len(img.shape) == 3:
noavg = ["nan", "nan", "nan"]
avgs.append(noavg)
else:
masked = apply_mask(img=img, mask=filt, mask_color='black')
# Crop the image and the mask to the roi
crop_img = auto_crop(img=masked, mask=filt, padding_x=1, padding_y=1, color='black')
crop_mask = auto_crop(img=filt, mask=filt, padding_x=1, padding_y=1, color='black')

# Calculate average of each channel
avgs.append(_calc_dists(img=crop_img, mask=crop_mask, percentile=percentile, store_debug=store_debug, ind=i))

else:
masked = apply_mask(img=img, mask=mask, mask_color='black')
# Crop the image to the mask if the mask is not empty
crop_img = masked
crop_mask = mask
if np.sum(mask) > 0:
crop_img = auto_crop(img=masked, mask=mask, padding_x=1, padding_y=1, color='black')
crop_mask = auto_crop(img=mask, mask=mask, padding_x=1, padding_y=1, color='black')
# Calculate averages of each channel
avgs = [_calc_dists(img=crop_img, mask=crop_mask, percentile=percentile, store_debug=store_debug, ind=0)]

# Outputs
for idx, i in enumerate(avgs):
if isinstance(i, float):
outputs.add_observation(sample=label+"_"+str(idx+1), variable='gray_'+str(percentile)+'%_avg',
trait='gray_'+str(percentile)+'%_radial_average',
method='plantcv.plantcv.analyze.radial',
scale='none', datatype=float, value=i, label='none')
elif isinstance(i, list):
outputs.add_observation(sample=label+"_"+str(idx+1), variable='red_'+str(percentile)+'%_avg',
trait='red_'+str(percentile)+'%_radial_average',
method='plantcv.plantcv.analyze.radial',
scale='none', datatype=float, value=i[0], label='none')
Comment on lines +124 to +135
Copy link

Copilot AI Feb 24, 2026

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.

Copilot uses AI. Check for mistakes.
outputs.add_observation(sample=label+"_"+str(idx+1), variable='green_'+str(percentile)+'%_avg',
trait='green_'+str(percentile)+'%_radial_average',
method='plantcv.plantcv.analyze.radial',
scale='none', datatype=float, value=i[1], label='none')
outputs.add_observation(sample=label+"_"+str(idx+1), variable='blue_'+str(percentile)+'%_avg',
trait='blue_'+str(percentile)+'%_radial_average',
method='plantcv.plantcv.analyze.radial',
scale='none', datatype=float, value=i[2], label='none')

params.debug = store_debug
return avgs
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ def __init__(self):
self.kmeans_classifier_gray_dir = os.path.join(self.datadir, "kmeans_classifier_gray_dir")
# nd2 file
self.nd2_img = os.path.join(self.datadir, "test_nd2_img.nd2")
# Image, mask, and roi for radial percentile
self.rgb_seed = os.path.join(self.datadir, "rgb_seed.jpg")
self.rgb_seed_mask = os.path.join(self.datadir, "rgb_seed_mask.jpg")
self.small_circle = os.path.join(self.datadir, "small_circle.png")
self.empty_mask = os.path.join(self.datadir, "seed_empty_mask.png")
# flir thermal img
self.flir_img = os.path.join(self.datadir, "FLIR_test.jpg")
# parallel results to process
Expand Down
49 changes: 49 additions & 0 deletions tests/plantcv/analyze/test_radial_percentile.py
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"
Binary file added tests/testdata/gray_seed.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/testdata/gray_seed_mask.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/testdata/rgb_seed.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/testdata/rgb_seed_mask.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/testdata/small_circle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.