Skip to content

Add radial percentile function#1768

Open
k034b363 wants to merge 25 commits intov5.0from
1646-radial-percentile
Open

Add radial percentile function#1768
k034b363 wants to merge 25 commits intov5.0from
1646-radial-percentile

Conversation

@k034b363
Copy link
Contributor

@k034b363 k034b363 commented Aug 26, 2025

Describe your changes
This PR adds a function pcv.analyze.radial_percentile that calculates the average pixel values within a threshold distance from the center of a focal object, expressed as a percent of maximum distance. It also adds this calculated average to outputs. It can take as input either a grayscale or RGB image, in which case the number of outputs will match the number of channels. It can also take an ROI or list of ROIs to calculate the average for multiple objects.

Type of update

  • New feature or feature enhancement

Associated issues
Closes #1646 (in a simpler way than described in that issue)

Additional context
I am intending to use this function as is for a paper on seed Xray analysis, so the decision to simplify was in service of movement on that project. There are options to make this function more complex in the future if use cases are discovered.

(josh): I think this is ready to review but I am not sure that the way labels work for these traits works for v5. It looks like it is fine to me but if someone who understands the labels change from 4 to 5 could double check that would be great.

For the reviewer
See this page for instructions on how to review the pull request.

  • PR functionality reviewed in a Jupyter Notebook
  • All tests pass
  • Test coverage remains 100%
  • Documentation tested
  • New documentation pages added to plantcv/mkdocs.yml
  • Changes to function input/output signatures added to updating.md
  • Code reviewed
  • PR approved

@k034b363 k034b363 linked an issue Aug 26, 2025 that may be closed by this pull request
@deepsource-io
Copy link

deepsource-io bot commented Aug 26, 2025

DeepSource Code Review

We reviewed changes in 0c92ddd...6c2182f on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
Python Feb 27, 2026 9:08p.m. Review ↗
Test coverage Feb 27, 2026 9:08p.m. Review ↗

Code Coverage Summary

Language Line Coverage (New Code) Line Coverage (Overall)
Aggregate
100%
[✓ above threshold]
100%
Python
100%
[✓ above threshold]
100%

➟ Additional coverage metrics may have been reported. See full coverage report ↗

@k034b363 k034b363 self-assigned this Aug 26, 2025
@k034b363 k034b363 added new feature New feature ideas and solutions work in progress Mark work in progress labels Aug 26, 2025
@k034b363 k034b363 modified the milestones: PlantCV v5.0, PlantCV v4.x Aug 26, 2025
@joshqsumner joshqsumner changed the base branch from main to v5.0 February 6, 2026 17:29
@joshqsumner joshqsumner added ready to review and removed work in progress Mark work in progress labels Feb 6, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new analysis feature pcv.analyze.radial_percentile to compute mean pixel intensity within a radial percentile from an object center, records results to Outputs, and introduces tests + documentation.

Changes:

  • Implement plantcv.plantcv.analyze.radial_percentile (with optional ROI support) and add observations to Outputs
  • Add pytest coverage and new test data paths in the test fixture
  • Add user documentation + navigation entry and update the versioned API change log

Reviewed changes

Copilot reviewed 7 out of 14 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/plantcv/analyze/test_radial_percentile.py Adds tests for RGB/gray paths and empty-mask behavior
tests/conftest.py Adds test fixture paths for radial percentile images/masks/ROI
plantcv/plantcv/analyze/radial.py Introduces radial_percentile implementation and helper distance/mean computation
plantcv/plantcv/analyze/init.py Exports radial_percentile from plantcv.analyze
mkdocs.yml Adds the new radial percentile doc page into the site nav
docs/updating.md Documents the new function signature in the updating guide
docs/analyze_radial_percentile.md Adds the new user-facing documentation page
Comments suppressed due to low confidence (12)

plantcv/plantcv/analyze/radial.py:91

  • Using truthiness for roi can behave unexpectedly if Objects implements __len__/emptiness semantics. Prefer an explicit check (if roi is not None:) so that an intentionally-empty ROI set doesn't silently fall back to the non-ROI code path.
    if roi:

plantcv/plantcv/analyze/radial.py:66

  • The public function docstring is currently a placeholder (\"_summary_\") and doesn’t document important behavior (return type shape for grayscale vs RGB, how empty masks/ROIs are represented, that observations are always written to Outputs, and the meaning of 'maximum distance'). Please expand the docstring to match the behavior and the docs page.
def radial_percentile(img, mask, roi=None, percentile=50, label=None):
    """_summary_

docs/updating.md:372

  • The version tag post v9.9 looks like a typo/inaccurate release reference for this new API entry, and the signature lists label=\"default\" while the implementation uses label=None (defaulting to pcv.params.sample_label). Please correct the version and align the signature/defaults with the actual function.
#### plantcv.analyze.radial_percentile
* pre v4.9: NA
* post v9.9: avgs = **plantcv.analyze.radial_percentile**(*img, mask, roi=None, percentile=50, label="default"*)

docs/analyze_radial_percentile.md:35

  • Correct the typo 'Caclulates' to 'Calculates'.
# Caclulates the average values of pixels that fall within the distance percentile from the center of an object.

docs/analyze_radial_percentile.md:24

  • In this context, 'ran' should be 'run' ("when this function is run").
- **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

tests/plantcv/analyze/test_radial_percentile.py:1

  • There’s a duplicated assignment (mask = mask = ...) that should be reduced to a single assignment, and the module docstring references pcv.analyze.radial rather than pcv.analyze.radial_percentile, which makes the test intent harder to follow.
"""Tests for pcv.analyze.radial"""

tests/plantcv/analyze/test_radial_percentile.py:35

  • There’s a duplicated assignment (mask = mask = ...) that should be reduced to a single assignment, and the module docstring references pcv.analyze.radial rather than pcv.analyze.radial_percentile, which makes the test intent harder to follow.
    mask = mask = np.zeros((100,100),dtype="uint8")

tests/plantcv/analyze/test_radial_percentile.py:44

  • There’s a duplicated assignment (mask = mask = ...) that should be reduced to a single assignment, and the module docstring references pcv.analyze.radial rather than pcv.analyze.radial_percentile, which makes the test intent harder to follow.
    mask = mask = np.zeros((100,100),dtype="uint8")

tests/plantcv/analyze/test_radial_percentile.py:9

  • The tests cover RGB (no ROI) and grayscale (with ROI) plus empty cases, but don’t cover RGB with ROI, grayscale without ROI, or verification that outputs.add_observation writes the expected keys/values (which is a primary side-effect of this function). Adding at least one test that asserts pcv.outputs.observations[...] entries for a known input would better protect the new feature.
def test_radial_RGB(test_data):

tests/plantcv/analyze/test_radial_percentile.py:18

  • The tests cover RGB (no ROI) and grayscale (with ROI) plus empty cases, but don’t cover RGB with ROI, grayscale without ROI, or verification that outputs.add_observation writes the expected keys/values (which is a primary side-effect of this function). Adding at least one test that asserts pcv.outputs.observations[...] entries for a known input would better protect the new feature.
def test_radial_gray(test_data):

tests/plantcv/analyze/test_radial_percentile.py:31

  • The tests cover RGB (no ROI) and grayscale (with ROI) plus empty cases, but don’t cover RGB with ROI, grayscale without ROI, or verification that outputs.add_observation writes the expected keys/values (which is a primary side-effect of this function). Adding at least one test that asserts pcv.outputs.observations[...] entries for a known input would better protect the new feature.
def test_radial_empty(test_data):

tests/plantcv/analyze/test_radial_percentile.py:40

  • The tests cover RGB (no ROI) and grayscale (with ROI) plus empty cases, but don’t cover RGB with ROI, grayscale without ROI, or verification that outputs.add_observation writes the expected keys/values (which is a primary side-effect of this function). Adding at least one test that asserts pcv.outputs.observations[...] entries for a known input would better protect the new feature.
def test_radial_roi_empty(test_data):

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"""
# 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.
Comment on lines +124 to +135
# 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')
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.
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)
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.
Comment on lines +46 to +51
# 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)
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.
_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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new feature New feature ideas and solutions ready to review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a radial option to analyze.distribution

4 participants