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
57 changes: 57 additions & 0 deletions docs/analyze_texture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
## Analyze the Texture Characteristics of Objects

Texture analysis outputs numeric properties for individual plants, seeds, leaves, etc.

**plantcv.analyze.texture**(*img, labeled_mask, methods=None, distances=None, angles=None,
levels=None, symmetric=False, normalize=False, n_labels=1, label=None*)

**returns** analysis_image

- **Parameters:**
- img - Grayscale image data for plotting. If img has multiple channels it will be coerced to grayscale.
- labeled_mask - Labeled mask of objects (32-bit, output from [`pcv.create_labels`](create_labels.md) or [`pcv.roi.filter`](roi_filter.md)).
- methods - A list of texture phenotypes to return. If None (the default) then the entire list of possible methods is used (`["contrast", "dissimilarity", "homogeneity", "ASM", "energy", "correlation", "mean", "variance", "std", "entropy"]`)
- distances - A list of distances between pixels to use, defaults to None which will use `[1]` to only compare adjacent pixels.
- angles - A list of angles between pixels to compare, defaults to None, which will use `[0]`.
- symmetric - Logical, Should the order of values pairs be ignored? The default, False, will not always have [i, j] in the gray co-occurence matrix equal to [j, i] and will calculate both values.
- normalize - Logical, should the matrix be rescaled to sum to 1? Defaults to False.
- n_labels - Total number expected individual objects (default = 1).
- label - Optional label parameter, modifies the variable name of observations recorded. Can be a prefix or list (default = pcv.params.sample_label).

- **Context:**
- Used to output texture characteristics of individual objects (labeled regions).
- About the analysis image: The analysis image is a scatterplot of the extracted phenotypes.

- **Example use:**
- [Use In Seed Analysis Tutorial](https://plantcv.org/tutorials/seed-analysis-workflow)

- **Output data stored:** Data including any of ("contrast", "dissimilarity", "homogeneity", "ASM", "energy", "correlation", "mean", "variance", "std", "entropy") automatically are stored to the [`Outputs` class](outputs.md) when this function is
run. These data can be accessed during a workflow (example below). For more detail about data output see
[Summary of Output Observations](output_measurements.md#summary-of-output-observations)

**Original image**

![Screenshot](img/documentation_images/analyze_size/original_image.jpg)
Comment thread
joshqsumner marked this conversation as resolved.

```python

from plantcv import plantcv as pcv

# Set global debug behavior to None (default), "print" (to file),
# or "plot" (Jupyter Notebooks or X11)

pcv.params.debug = "plot"

# Characterize object texture from 3rd Channel Grayscale image
_ = pcv.analyze.texture(img=img[:,:,2], labeled_mask=mask)

# Access data stored out from analyze.texture
plant_contrast = pcv.outputs.observations['default_1']['contrast']['value']

```

**Texture Features Plot**

![Screenshot](img/documentation_images/analyze_texture/texture_debug.png)

**Source Code:** [Here](https://github.com/danforthcenter/plantcv/blob/main/plantcv/plantcv/analyze/texture.py)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,9 @@ pages for more details on the input and output variable types.
* pre v4.0: (see plantcv.hyperspectral.analyze_spectral)
* post v4.0: analysis_image = **plantcv.analyze.spectral_reflectance**(*hsi, labeled_mask, n_labels=1, label=None*)

#### plantcv.analyze.texture

* post v5.0 texture_chart = **plantcv.analyze.texture**(*img, labeled_mask, methods=None, distances=None, angles=None, levels=None, symmetric=False, normalize=False, n_labels=1, label=None*)

#### plantcv.analyze.thermal

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ nav:
- 'Analyze Size and Shape': analyze_size.md
- 'Horizontal Boundary Tool': analyze_bound_horizontal2.md
- 'Vertical Boundary Tool': analyze_bound_vertical2.md
- 'Analyze Texture': analyze_texture.md
- 'Analyze Thermal': analyze_thermal.md
- 'Analyze Spectral Reflectance': analyze_spectral_reflectance.md
- 'Analyze Spectral Index': analyze_spectral_index.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.texture import texture

__all__ = ["color", "bound_horizontal", "bound_vertical", "grayscale", "size", "thermal", "spectral_reflectance",
"spectral_index", "yii", "npq", "distribution"]
"spectral_index", "yii", "npq", "distribution", "texture"]
145 changes: 145 additions & 0 deletions plantcv/plantcv/analyze/texture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Analyzes the texture of objects and outputs data."""
from skimage.feature import graycomatrix, graycoprops
from plantcv.plantcv._globals import params, outputs
from plantcv.plantcv._helpers import _iterate_analysis, _rgb2gray
from plantcv.plantcv._debug import _debug
import altair as alt
import pandas as pd
import numpy as np
import cv2
import os


def texture(img, labeled_mask, methods=None,
distances=None, angles=None,
symmetric=False, normalize=False,
n_labels=1, label=None):
"""A function that analyzes the texture of objects and outputs data.

Parameters
----------
img = numpy.ndarray, grayscale image data. If data is not grayscale then
it will be coerced to grayscale using pcv._helpers._rgb2gray.
labeled_mask = numpy.ndarray, Labeled mask of objects (32-bit).
methods = list, List of str specifying phenotypes to return. Options
come from skimage.feature.graycoprops and include "contrast",
"dissimilarity", "homogeneity", "ASM", "energy", "correlation",
"mean", "variance", "std", "entropy". The default, None, will
use all methods.
distances = list of Int. Pixel pair distances, defaults to 1 which compares
adjacent pixels.
angles = list of float, angles or direction to check travel for each
distance.
symmetric = bool, optional
If True, the output is symmetric because the order of value pairs
is ignored so that (i, j) and (j, i) the same. The default is False.
normalize = bool, optional
If True then the matrix is rescaled to sum to 1. Default is False.
n_labels = Int, Total number expected individual objects (default = 1).
label = str, Optional label parameter, modifies the variable name of
observations recorded (default = pcv.params.sample_label).

Returns
-------
plot = altair.vegalite.v5.api.FacetChart, Diagnostic image showing measurements.
"""
if distances is None:
distances = [1]
if angles is None:
angles = [0]
if len(np.shape(img)) > 2:
img = _rgb2gray(img)
if label is None:
label = params.sample_label
if methods is None:
methods = ["contrast", "dissimilarity", "homogeneity",
"ASM", "energy", "correlation",
"mean", "variance", "std", "entropy"]
# for the debug image I would like to have a mix of matrices, but for non-uint8
Comment thread
joshqsumner marked this conversation as resolved.
# that could get really large if there is a multi-object mask.
_ = _iterate_analysis(img=img, labeled_mask=labeled_mask,
n_labels=n_labels, label=label,
function=_analyze_texture,
**{'distances': distances, 'angles': angles,
'symmetric': symmetric, 'methods': methods,
'normalize': normalize}
)
plot = _make_texture_debug_plot()
_debug(visual=plot, cmap="turbo",
filename=os.path.join(params.debug_outdir, str(params.device) + "_textures.png"))
return plot


def _analyze_texture(img, mask, label, methods, distances, angles, symmetric, normalize):
"""Analyze the texture of individual objects.

Parameters
----------
img = numpy.ndarray, grayscale image data
mask = Binary image data
label = str, Optional label parameter, modifies the variable name of
observations recorded (default = pcv.params.sample_label).
methods = list, List of str specifying phenotypes to return. Options
come from skimage.feature.graycoprops and include "contrast",
"dissimilarity", "homogeneity", "ASM", "energy", "correlation",
"mean", "variance", "std", "entropy"
distances = list of Int. Pixel pair distances, defaults to 1 which compares
adjacent pixels.
angles = list of float, angles or direction to check travel for each
distance.
symmetric = bool, optional
If True, the output is symmetric because the order of value pairs
is ignored so that (i, j) and (j, i) the same. The default is False.
normalize = bool, optional
If True then the matrix is rescaled to sum to 1. Default is False.

Returns
-------
glcm = numpy.ndarray, currently not used.
"""
params.device += 1
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

params.device is incremented inside _analyze_texture, but device numbering is already auto-incremented by _debug. Mutating the global device counter inside per-object analysis can create confusing gaps/out-of-order debug filenames across a workflow. Remove this increment (or only manage device IDs in the debug/visualization layer).

Suggested change
params.device += 1

Copilot uses AI. Check for mistakes.
# keep only section of image in mask
subimg = cv2.bitwise_and(img, img, mask=mask).astype(np.uint8)
# get gray level cooccurence matrix
glcm = graycomatrix(subimg, distances=distances, angles=angles,
levels=256, symmetric=symmetric, normed=normalize)
# loop over methods, distances, and angles
for method in methods:
props = graycoprops(glcm, method)
for i, dis in enumerate(distances):
for j, ang in enumerate(angles):
outputs.add_observation(
sample=label,
variable=method + "_" + str(dis) + "_" + str(ang),
trait=method,
method='plantcv.plantcv.analyze.texture',
scale="none", datatype=float,
value=props[i, j], label="none"
)
Comment thread
joshqsumner marked this conversation as resolved.
return glcm

Comment thread
joshqsumner marked this conversation as resolved.

def _make_texture_debug_plot():
"""Makes a plot using the outputs from analyze.texture to use as a debug image"""
rows = []
for key, value in outputs.observations.items():
for k, v in value.items():
if v["method"] == 'plantcv.plantcv.analyze.texture':
row = [key, k, v["value"]]
rows.append(row)
df = pd.DataFrame(rows)
df.columns = ["label", "variable", "value"]
df["facet"] = np.where(
df['variable'].isin(
["ASM", "correlation", "dissimilarity",
"energy", "entropy", "homogeneity",
"mean"]),
'Bounded', 'Unbounded')
plot = alt.Chart(df).mark_point().encode(
x='variable:N',
y='value:Q'
).facet("facet:N").resolve_scale(
y='independent',
x='independent'
)
return plot
22 changes: 22 additions & 0 deletions tests/plantcv/analyze/test_texture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import cv2
import numpy as np
from plantcv.plantcv._globals import outputs
Comment thread
joshqsumner marked this conversation as resolved.
from plantcv.plantcv.analyze.texture import texture


def test_analyze_texture(test_data):
"""Test for PlantCV."""
outputs.clear()
gray = cv2.imread(test_data.small_gray_img, -1)
mask = cv2.imread(test_data.small_bin_img, -1)
_ = texture(gray, mask)
assert isinstance(outputs.observations["default_1"]["contrast_1_0"]["value"], np.float64)


def test_analyze_texture_rgb(test_data):
"""Test for PlantCV."""
outputs.clear()
rgb_img = cv2.imread(test_data.small_rgb_img)
mask = cv2.imread(test_data.small_bin_img, -1)
_ = texture(rgb_img, mask)
assert isinstance(outputs.observations["default_1"]["contrast_1_0"]["value"], np.float64)