Conversation
|
|
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 ↗
There was a problem hiding this comment.
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 toOutputs - 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
roican behave unexpectedly ifObjectsimplements__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 toOutputs, 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.9looks like a typo/inaccurate release reference for this new API entry, and the signature listslabel=\"default\"while the implementation useslabel=None(defaulting topcv.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 referencespcv.analyze.radialrather thanpcv.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 referencespcv.analyze.radialrather thanpcv.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 referencespcv.analyze.radialrather thanpcv.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_observationwrites the expected keys/values (which is a primary side-effect of this function). Adding at least one test that assertspcv.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_observationwrites the expected keys/values (which is a primary side-effect of this function). Adding at least one test that assertspcv.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_observationwrites the expected keys/values (which is a primary side-effect of this function). Adding at least one test that assertspcv.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_observationwrites the expected keys/values (which is a primary side-effect of this function). Adding at least one test that assertspcv.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"] |
There was a problem hiding this comment.
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.
| 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] |
| # 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') |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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) |
| # 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) |
There was a problem hiding this comment.
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) |
| _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)) |
There was a problem hiding this comment.
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).
Describe your changes
This PR adds a function
pcv.analyze.radial_percentilethat 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
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.
plantcv/mkdocs.ymlupdating.md