Skip to content
Merged

Delta E #1905

Show file tree
Hide file tree
Changes from 11 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
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.
66 changes: 66 additions & 0 deletions docs/transform_deltaE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Calculate Delta E between observed and expected color cards

Calculates Delta E between a Macbeth ColorChecker or Astrobotany.com Calibration sticker style color card and the expected color values.

**plantcv.transform.deltaE**(*rgb_img, color_chip_size=None, roi=None, obs="calibrated", method="deltaE_ciede2000", \*\*kwargs*)

**returns** Delta E matrix

- **Parameters**
- rgb_img - Input RGB image data containing a color card.
- color_chip_size - Type of color card to be detected, ("classic", "passport", "nano", "mini", "cameratrax", or "astro", by default `None`) or a tuple of the `(width, height)` dimensions of the color card chips in millimeters. If set then size scalings parameters `pcv.params.unit`, `pcv.params.px_width`, and `pcv.params.px_height`
are automatically set, and utilized throughout linear and area type measurements stored to `Outputs`.
- roi - Optional rectangular ROI as returned by [`pcv.roi.rectangle`](roi_rectangle.md) within which to look for the color card. (default = None)
- obs - label for metadata and debug images, typically "calibrated" or "uncalibrated" depending on whether the `rgb_img` has been color corrected.
- method - Function name from `skimage.color` as a string to use to calculate delta E. Currently `deltaE_cie76`, `deltaE_ciede2000`, `deltaE_ciede94`, and `deltaE_cmc` are supported
- **kwargs - Other keyword arguments passed to `cv2.adaptiveThreshold` and `cv2.circle`.
- adaptive_method - Adaptive threhold method. 0 (mean) or 1 (Gaussian) (default = 1).
- block_size - Size of a pixel neighborhood that is used to calculate a threshold value (default = 51). We suggest using 127 if using `adaptive_method=0`.
- radius - Radius of circle to make the color card labeled mask (default = 20).
- min_size - Minimum chip size for filtering objects after edge detection (default = 1000)
- aspect_ratio - Optional aspect ratio (width / height) below which objects will get removed. Orientation agnostic since automatically set to the reciprocal if <1 (default = 1.27)
- solidity - Optional solidity (object area / convex hull area) filter (default = 0.8)

- **Returns**
- deltaE - Delta E values per each color chip as a matrix.

- **Context**
- Delta E is a perception-based metric for the difference between colors. 0 indicates no perceptual difference and higher values indicate more difference. Generally a delta E value less than 1 is imperceptibly different and values greater than 3.5 are clearly distinct. Whether a particular color chip's delta E value matters for your experimental goals depends on your hypothesis and analysis plan.


!!! note
Delta E is calculated when a color card is detected with `plantcv.transform.detect_color_card` by default
and a debug image is generated showing the differences in the color card against the expected colors.
This function will use `plantcv.transform.detect_color_card` to find the color card, potentially in an image that has already
been color-corrected so that the delta E values can be compared pre vs post calibration.

```python
from plantcv import plantcv as pcv
rgb_img, path, filename = pcv.readimage("target_img.png")

pcv.params.debug = "plot"
# Delta E debug visualization shown below
cc_matrix = pcv.transform.detect_color_card(rgb_img=rgb_img)

# Next we may color correct the image
tgt_matrix = pcv.transform.std_color_matrix(pos=3)
corrected_img = pcv.transform.affine_color_correction(rgb_img=rgb_img,
source_matrix=cc_matrix,
target_matrix=tgt_matrix)

# Delta E on the corrected image
e_matrix = deltaE(corrected_img)
Comment thread
joshqsumner marked this conversation as resolved.
Outdated
# outputs metadata will have min, mean, max, std dev of deltaE
print(pcv.outputs.metadata["max_deltaE_uncalibrated"]) # the uncalibrated metadata is added by detect_color_card
# [np.float64(49.5516548320252)]
print(pcv.outputs.metadata["max_deltaE_calibrated"]) # the calibrated metadata is added by deltaE
# [np.float64(15.730570870191682)]
```

Calling `plantcv.transform.detect_color_card` on the uncorrected image:

![Uncalibrated Color Card](img/documentation_images/deltaE/uncalibrated.png)

Using `plantcv.transform.deltaE` on the corrected image to check the new Delta E values:

![Calibrated Color Card](img/documentation_images/deltaE/calibrated.png)
10 changes: 5 additions & 5 deletions docs/transform_detect_color_card.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Automatically detects a Macbeth ColorChecker or Astrobotany.com Calibration Sticker style color card and creates a labeled mask.

**plantcv.transform.detect_color_card**(*rgb_img, color_chip_size=None, roi=None, \*\*kwargs*)
**plantcv.transform.detect_color_card**(*rgb_img, color_chip_size=None, roi=None, deltaE=True, \*\*kwargs*)
Comment thread
joshqsumner marked this conversation as resolved.
Outdated

**returns** color_matrix

Expand All @@ -12,6 +12,7 @@ Automatically detects a Macbeth ColorChecker or Astrobotany.com Calibration Stic
- color_chip_size - Type of color card to be detected, ("classic", "passport", "nano", "mini", "cameratrax", or "astro", by default `None`) or a tuple of the `(width, height)` dimensions of the color card chips in millimeters. If set then size scalings parameters `pcv.params.unit`, `pcv.params.px_width`, and `pcv.params.px_height`
are automatically set, and utilized throughout linear and area type measurements stored to `Outputs`.
- roi - Optional rectangular ROI as returned by [`pcv.roi.rectangle`](roi_rectangle.md) within which to look for the color card. (default = None)
- delta_E - Boolean, should Delta E be calculated between the observed and expected color card values? This will add mean, std deviation, max, and min delta E values to `outputs.metadata`. See [`pcv.transform.deltaE`](transform_deltaE.md)
- **kwargs - Other keyword arguments passed to `cv2.adaptiveThreshold` and `cv2.circle`.
- adaptive_method - Adaptive threhold method. 0 (mean) or 1 (Gaussian) (default = 1).
- block_size - Size of a pixel neighborhood that is used to calculate a threshold value (default = 51). We suggest using 127 if using `adaptive_method=0`.
Expand Down Expand Up @@ -45,19 +46,18 @@ Automatically detects a Macbeth ColorChecker or Astrobotany.com Calibration Stic
from plantcv import plantcv as pcv
rgb_img, path, filename = pcv.readimage("target_img.png")
# Using a supported color card size will automatically set size scaling parameters
cc_mask = pcv.transform.detect_color_card(rgb_img=rgb_img, color_chip_size="passport")
cc_matrix = pcv.transform.detect_color_card(rgb_img=rgb_img, color_chip_size="passport")
# Or if using another Macbeth ColorChecker you can explicitly set the color chip size (in millimeters)
cc_mask = pcv.transform.detect_color_card(rgb_img=rgb_img, color_chip_size=(12, 12))
cc_matrix = pcv.transform.detect_color_card(rgb_img=rgb_img, color_chip_size=(12, 12))

avg_chip_size = pcv.outputs.metadata['median_color_chip_size']['value'][0]
avg_chip_w = pcv.outputs.metadata['median_color_chip_width']['value'][0]
avg_chip_h = pcv.outputs.metadata['median_color_chip_height']['value'][0]

# When using detect_color_card, you will always set pos=3
tgt_matrix = pcv.transform.std_color_matrix(pos=3)
headers, card_matrix = pcv.transform.get_color_matrix(rgb_img=rgb_img, mask=cc_mask)
corrected_img = pcv.transform.affine_color_correction(rgb_img=rgb_img,
source_matrix=card_matrix,
source_matrix=cc_matrix,
target_matrix=tgt_matrix)

```
Expand Down
7 changes: 6 additions & 1 deletion docs/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -1436,12 +1436,17 @@ pages for more details on the input and output variable types.
* post v3.0: mask = **pcv.transform.create_color_card_mask**(*rgb_img, radius, start_coord, spacing, nrows, ncols, exclude=[]*)
* post v4.9: mask = **pcv.transform.create_color_card_mask**(*rgb_img, radius, start_coord, spacing, nrows, ncols, exclude=None*)

#### plantcv.transform.deltaE

* pre v5.0: NA
* post v5.0: deltaE_matrix = **plantcv.transform.deltaE**(*rgb_img, color_chip_size=None, roi=None, obs="calibrated", method="deltaE_ciede2000", \*\*kwargs*)

#### plantcv.transform.detect_color_card

* pre v4.0.1: NA
* post v4.0.1: labeled_mask = **plantcv.transform.detect_color_card**(*rgb_img, label=None, \*\*kwargs*)
* post v4.9: labeled_mask = **plantcv.transform.detect_color_card**(*rgb_img, label=None, color_chip_size=None, roi=None, \*\*kwargs*)
* post v5.0: color_matrix = **plantcv.transform.detect_color_card**(*rgb_img, color_chip_size=None, roi=None, \*\*kwargs*)
* post v5.0: color_matrix = **plantcv.transform.detect_color_card**(*rgb_img, color_chip_size=None, roi=None, delta_E=True, \*\*kwargs*)

#### plantcv.transform.find_color_card

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ nav:
- 'Convert Color Card to Matrix': get_color_matrix.md
- 'Color Correction Workflow': transform_correct_color.md
- 'Quick Color Check': quick_color_check.md
- 'Delta E Metric': transform_deltaE.md
- 'Affine Color Correction': transform_affine_color_correction.md
- 'Standard Color Matrix': std_color_matrix.md
- 'Gamma Correction': transform_gamma_correct.md
Expand Down
7 changes: 4 additions & 3 deletions plantcv/plantcv/transform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from plantcv.plantcv.transform.color_correction import std_color_matrix
from plantcv.plantcv.transform.color_correction import astro_color_matrix
from plantcv.plantcv.transform.color_correction import affine_color_correction
from plantcv.plantcv.transform.detect_color_card import deltaE
from plantcv.plantcv.transform.detect_color_card import detect_color_card
from plantcv.plantcv.transform.detect_color_card import mask_color_card
from plantcv.plantcv.transform.rescale import rescale
Expand All @@ -26,6 +27,6 @@
__all__ = ["get_color_matrix", "get_matrix_m", "calc_transformation_matrix", "apply_transformation_matrix",
"save_matrix", "load_matrix", "correct_color", "create_color_card_mask", "quick_color_check",
"std_color_matrix", "affine_color_correction", "rescale", "nonuniform_illumination", "resize",
"resize_factor", "warp", "rotate", "warp", "warp_align", "gamma_correct", "detect_color_card", "checkerboard_calib",
"calibrate_camera", "merge_images", "auto_correct_color", "mask_color_card", "auto_correct_color_nonlinear",
"astro_color_matrix"]
"resize_factor", "warp", "rotate", "warp", "warp_align", "gamma_correct", "deltaE",
"detect_color_card", "checkerboard_calib", "calibrate_camera", "merge_images", "auto_correct_color",
"mask_color_card", "auto_correct_color_nonlinear", "astro_color_matrix"]
68 changes: 68 additions & 0 deletions plantcv/plantcv/transform/delta_e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Calculate Delta E between color cards"""
import os
import cv2
import numpy as np
from skimage import color
from matplotlib import pyplot as plt
from plantcv.plantcv._globals import params, outputs
from plantcv.plantcv.transform.color_correction import std_color_matrix, astro_color_matrix


def _delta_e(obs_rgb, card_type=None, obs="uncalibrated", method="deltaE_ciede2000"):
"""Calculate summary of Delta E between two color cards

Parameters
----------
obs_rgb : numpy.ndarray
Observed RGB color chip values as returned from plantcv.transform.detect_color_card
card_type : str
either "macbeth" or "astro" for color card type, defaults to None for compatibility with detection.
obs : str
string describing what the obs_rgb data is, typically "uncalibrated" for an image input into color correction
or "calibrated" for an image that has been through color correction.
method : str
function name from skimage.color to calculate delta E. Currently deltaE_(cie76|ciede2000|ciede94|cmc) are
supported

Returns
-------
delta_e_mat
numpy.ndarray, Delta E values between color chips.
"""
if card_type is None or isinstance(card_type, tuple):
card_type = "macbeth"
if card_type.upper() == "ASTRO":
std = astro_color_matrix()
obs_mat = (255 * np.delete(obs_rgb, 0, axis=1).reshape(3, 5, 3)).astype("uint8")
exp_mat = (255 * np.delete(std, 0, axis=1).reshape(3, 5, 3)).astype("uint8")
else:
std = std_color_matrix()
# format both rgb colors into 6x4 uint8 image
obs_mat = (255 * np.delete(obs_rgb, 0, axis=1).reshape(6, 4, 3)).astype("uint8")
exp_mat = (255 * np.rot90(np.delete(std, 0, axis=1).reshape(4, 6, 3), 3)).astype("uint8")
# convert to LAB for skimage color functions
obs_lab = cv2.cvtColor(obs_mat, cv2.COLOR_RGB2LAB)
exp_lab = cv2.cvtColor(exp_mat, cv2.COLOR_RGB2LAB)
# get function from skimage color
delta_e_fun = getattr(color, method)
# there are other parameters we could allow changes to but I don't think we need to yet.
delta_e_mat = delta_e_fun(obs_lab, exp_lab)
Comment thread
joshqsumner marked this conversation as resolved.
Outdated
# store metadata describing delta E
outputs.add_metadata(term="mean_deltaE_" + obs, datatype=float, value=np.mean(delta_e_mat))
outputs.add_metadata(term="std_deltaE_" + obs, datatype=float, value=np.std(delta_e_mat))
outputs.add_metadata(term="max_deltaE_" + obs, datatype=float, value=np.max(delta_e_mat))
outputs.add_metadata(term="min_deltaE_" + obs, datatype=float, value=np.min(delta_e_mat))
# make a debug plot
if params.debug:
params.device += 1
_, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.imshow(obs_mat)
ax1.set_title(obs.title() + ' Color Card')
ax2.imshow(exp_mat)
ax2.set_title('Reference Colors')
if params.debug == "print":
plt.savefig(fname=os.path.join(params.debug_outdir, f"{params.device}_{obs}_{method}.png"))
if params.debug == "plot":
plt.show()
Comment thread
joshqsumner marked this conversation as resolved.
Outdated

return delta_e_mat
47 changes: 46 additions & 1 deletion plantcv/plantcv/transform/detect_color_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from plantcv.plantcv._debug import _debug
from plantcv.plantcv._helpers import _rgb2hsv, _rgb2gray, _cv2_findcontours, _object_composition, _rect_filter
from plantcv.plantcv.transform.color_correction import get_color_matrix
from plantcv.plantcv.transform.delta_e import _delta_e


def _is_square(contour, min_size, aspect_ratio=1.27, solidity=0.8):
Expand Down Expand Up @@ -708,7 +709,46 @@ def mask_color_card(rgb_img, card_type="macbeth", **kwargs):
return bounding_mask


def detect_color_card(rgb_img, color_chip_size=None, roi=None, **kwargs):
def deltaE(rgb_img, color_chip_size=None, roi=None, obs="calibrated", method="deltaE_ciede2000", **kwargs):
"""Calculate Delta E from an rgb image with a color card

Parameters
----------
rgb_img : numpy.ndarray
Input rgb image, possibly color corrected.
color_chip_size : str, tuple, optional
"passport", "classic", "nano", "mini, ""cameratrax", or "astro"; or tuple formatted (width, height)
in millimeters (default = None)
roi : plantcv.plantcv.Objects, optional
A rectangular ROI as returned from pcv.roi.rectangle to detect a color card only in that region.
obs : str
string describing what the obs_rgb data is, typically "uncalibrated" for an image input into color correction
or "calibrated" for an image that has been through color correction.
method : str
function name from skimage.color to calculate delta E. Currently deltaE_(cie76|ciede2000|ciede94|cmc) are
supported
**kwargs
Other keyword arguments passed to cv2.adaptiveThreshold and cv2.circle via plantcv.transform.detect_color_card.

Valid keyword arguments:
adaptive_method: 0 (mean) or 1 (Gaussian) (default = 1)
block_size: int (default = 51)
radius: int (default = 20)
min_size: int (default = 1000)
aspect_ratio: float (default = 1.27)
solidity: float (default = 0.8)

Returns
-------
delta_E
numpy.ndarray, Delta E values between color chips.
"""
obs_rgb = detect_color_card(rgb_img, color_chip_size, roi, deltaE=False, **kwargs)
Comment thread
joshqsumner marked this conversation as resolved.
Outdated
delta_E = _delta_e(obs_rgb, color_chip_size, obs, method)
return delta_E


def detect_color_card(rgb_img, color_chip_size=None, roi=None, delta_E=True, **kwargs):
"""Automatically detects a Macbeth ColorChecker or Astrobotany.com Calibration Sticker style color card.

Parameters
Expand All @@ -720,6 +760,8 @@ def detect_color_card(rgb_img, color_chip_size=None, roi=None, **kwargs):
in millimeters (default = None)
roi : plantcv.plantcv.Objects, optional
A rectangular ROI as returned from pcv.roi.rectangle to detect a color card only in that region.
delta_E : Boolean, optional
Should DeltaE be calculated between the observed vs expected color card? Defaults to True.
**kwargs
Other keyword arguments passed to cv2.adaptiveThreshold and cv2.circle.

Expand Down Expand Up @@ -782,5 +824,8 @@ def detect_color_card(rgb_img, color_chip_size=None, roi=None, **kwargs):

# Debugging
_debug(visual=debug_img, filename=os.path.join(params.debug_outdir, f"{params.device}_color_card.png"))
# Calculate Delta E
if delta_E:
_ = _delta_e(color_matrix, card_type=color_chip_size)

return color_matrix
38 changes: 38 additions & 0 deletions tests/plantcv/transform/test_deltaE.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Tests for deltaE."""
import os
import cv2
import pytest
import numpy as np
from plantcv.plantcv._globals import outputs, params
from plantcv.plantcv.transform.detect_color_card import deltaE


def test_deltaE_macbeth(transform_test_data):
"""Test for PlantCV."""
outputs.clear()
rgb_img = cv2.imread(transform_test_data.colorcard_img)
de_matrix = deltaE(rgb_img=rgb_img, color_chip_size="classic")
assert np.shape(de_matrix) == (6, 4)
assert outputs.metadata["max_deltaE_calibrated"]["value"] == np.float64(40.10999900620317)
Comment thread
joshqsumner marked this conversation as resolved.
Outdated


def test_deltaE_astro(transform_test_data):
"""Test for PlantCV."""
outputs.clear()
rgb_img = cv2.imread(transform_test_data.astrocard_img)
de_matrix = deltaE(rgb_img=rgb_img, color_chip_size="astro")
assert np.shape(de_matrix) == (3, 5)
assert outputs.metadata["max_deltaE_calibrated"]["value"] == np.float64(78.00042935949308)
Comment thread
joshqsumner marked this conversation as resolved.
Outdated


@pytest.mark.parametrize("debug", ["print", "plot", None])
def test_deltaE_plotting(debug, transform_test_data, tmpdir):
"""Test for PlantCV."""
cache = tmpdir.mkdir("cache")
debug_outdir = params.debug_outdir
params.debug_outdir = os.path.join(cache)
params.debug = debug
rgb_img = cv2.imread(transform_test_data.colorcard_img)
de_matrix = deltaE(rgb_img=rgb_img, color_chip_size="classic")
params.debug_outdir = debug_outdir
Comment thread
joshqsumner marked this conversation as resolved.
assert np.shape(de_matrix) == (6, 4)