diff --git a/docs/analyze_texture.md b/docs/analyze_texture.md new file mode 100644 index 000000000..272d9f514 --- /dev/null +++ b/docs/analyze_texture.md @@ -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) + +```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) diff --git a/docs/img/documentation_images/analyze_texture/texture_debug.png b/docs/img/documentation_images/analyze_texture/texture_debug.png new file mode 100644 index 000000000..e9dcaea94 Binary files /dev/null and b/docs/img/documentation_images/analyze_texture/texture_debug.png differ diff --git a/docs/updating.md b/docs/updating.md index fae2c1758..4cc340382 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index d41c85b24..7f8a1c83b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/plantcv/plantcv/analyze/__init__.py b/plantcv/plantcv/analyze/__init__.py index 414d1e3d9..f64e17b9e 100644 --- a/plantcv/plantcv/analyze/__init__.py +++ b/plantcv/plantcv/analyze/__init__.py @@ -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"] diff --git a/plantcv/plantcv/analyze/texture.py b/plantcv/plantcv/analyze/texture.py new file mode 100644 index 000000000..acd540baa --- /dev/null +++ b/plantcv/plantcv/analyze/texture.py @@ -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 + # 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 + # 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" + ) + return glcm + + +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 diff --git a/tests/plantcv/analyze/test_texture.py b/tests/plantcv/analyze/test_texture.py new file mode 100644 index 000000000..5e22cfd5b --- /dev/null +++ b/tests/plantcv/analyze/test_texture.py @@ -0,0 +1,22 @@ +import cv2 +import numpy as np +from plantcv.plantcv._globals import outputs +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)