From ac60d30ef69bdb0099219cbf63dc3a1610f03b28 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Tue, 17 Dec 2024 14:34:25 -0600 Subject: [PATCH 01/36] Create segment_ends.py --- plantcv/plantcv/morphology/segment_ends.py | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 plantcv/plantcv/morphology/segment_ends.py diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py new file mode 100644 index 000000000..86f4a8b30 --- /dev/null +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -0,0 +1,65 @@ +# Find both segment end coordinates +import os +import cv2 +import numpy as np +from plantcv.plantcv import params +from plantcv.plantcv._debug import _debug +from plantcv.plantcv.morphology import _iterative_prune +from plantcv.plantcv._helpers import _cv2_findcontours + + +def segment_ends(objects, mask=None, label=None): + """Find tips in skeletonized image. + The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 + + Inputs: + skel_img = Skeletonized image + mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. + label = Optional label parameter, modifies the variable name of + observations recorded (default = pcv.params.sample_label). + Returns: + tip_img = Image with just tips, rest 0 + + :param skel_img: numpy.ndarray + :param mask: numpy.ndarray + :param label: str + :return tip_img: numpy.ndarray + """ + + mask_copy = mask.copy() + leaf_objects = objects + tip_plot = cv2.cvtColor(mask_copy, cv2.COLOR_GRAY2RGB) + segment_end_objs1 = [] + segment_end_objs2 = [] + + # Find segment end coordinates + for i, cnt in enumerate(leaf_objects): + # Draw leaf objects + find_segment_tangents = np.zeros(mask.shape[:2], np.uint8) + cv2.drawContours(find_segment_tangents, leaf_objects, i, 255, 1, lineType=8) + # Prune back ends of leaves + pruned_segment = _iterative_prune(find_segment_tangents, 1) + # Segment ends are the portions pruned off + segment_ends = find_segment_tangents - pruned_segment + segment_end_obj, _ = _cv2_findcontours(bin_img=segment_ends) + segment_end_objs1.append(segment_end_obj[0]) + segment_end_objs2.append(segment_end_obj[1]) + + # Initialize list of tip data points + tip_list = [] + labels = [] + inner_list = [] + for i, coor in enumerate(segment_end_objs1): + x, y = coor.ravel()[:2] + coord = (int(x), int(y)) + inner_list.append(coord) + print(coord) + labels.append(i) + cv2.drawContours(tip_plot, leaf_objects, i, (150, 150, 150), params.line_thickness, lineType=8) # segments + cv2.circle(tip_plot, (x, y), params.line_thickness, (50, 0, 255), -1) # Red auricles + tip = segment_end_objs2[i] + x, y = tip.ravel()[:2] + coord = (int(x), int(y)) + tip_list.append(coord) + cv2.circle(tip_plot, (x, y), params.line_thickness, (0, 255, 0), -1) # green tips + _debug(visual=tip_plot, filename=os.path.join(params.debug_outdir, f"{params.device}_segment_ends.png")) From a5ef615e60f70901bb1f9b2eebc5d57dfbced46c Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Tue, 17 Dec 2024 14:39:29 -0600 Subject: [PATCH 02/36] range rather than enumerate --- plantcv/plantcv/morphology/segment_ends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 86f4a8b30..9d8ea1da6 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -33,7 +33,7 @@ def segment_ends(objects, mask=None, label=None): segment_end_objs2 = [] # Find segment end coordinates - for i, cnt in enumerate(leaf_objects): + for i in range(len(leaf_objects)): # Draw leaf objects find_segment_tangents = np.zeros(mask.shape[:2], np.uint8) cv2.drawContours(find_segment_tangents, leaf_objects, i, 255, 1, lineType=8) From 269137a43018ec8d05e281d7ffc2cc78886ee997 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Wed, 18 Dec 2024 15:36:32 -0600 Subject: [PATCH 03/36] helper for _find_tips avoid saving to outputs in the helper function but produce the same relevant outputs, update instances of find_tips getting used throughout morphology --- .../plantcv/morphology/_iterative_prune.py | 4 +- plantcv/plantcv/morphology/find_tips.py | 38 +++++++++++++++---- .../plantcv/morphology/segment_curvature.py | 4 +- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/plantcv/plantcv/morphology/_iterative_prune.py b/plantcv/plantcv/morphology/_iterative_prune.py index 4816bfdb1..e1f51e128 100644 --- a/plantcv/plantcv/morphology/_iterative_prune.py +++ b/plantcv/plantcv/morphology/_iterative_prune.py @@ -2,7 +2,7 @@ import numpy as np from plantcv.plantcv import params from plantcv.plantcv import image_subtract -from plantcv.plantcv.morphology import find_tips +from plantcv.plantcv.morphology import _find_tips from plantcv.plantcv._helpers import _cv2_findcontours @@ -26,7 +26,7 @@ def _iterative_prune(skel_img, size): # Iteratively remove endpoints (tips) from a skeleton for _ in range(0, size): - endpoints = find_tips(pruned_img) + endpoints, _, _ = _find_tips(pruned_img) pruned_img = image_subtract(pruned_img, endpoints) # Make debugging image diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index 4d40c5c19..07791201f 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -9,8 +9,8 @@ from plantcv.plantcv._helpers import _cv2_findcontours -def find_tips(skel_img, mask=None, label=None): - """Find tips in skeletonized image. +def _find_tips(skel_img, mask=None): + """Helper function to find tips in skeletonized image. The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 Inputs: @@ -79,12 +79,34 @@ def find_tips(skel_img, mask=None, label=None): tip_labels.append(i) cv2.circle(tip_plot, (x, y), params.line_thickness, (0, 255, 0), -1) - outputs.add_observation(sample=label, variable='tips', trait='list of tip coordinates identified from a skeleton', - method='plantcv.plantcv.morphology.find_tips', scale='pixels', datatype=list, - value=tip_list, label=tip_labels) - # Reset debug mode params.debug = debug - _debug(visual=tip_plot, filename=os.path.join(params.debug_outdir, f"{params.device}_skeleton_tips.png")) - return tip_img + return tip_img, tip_list, tip_labels + +def find_tips(skel_img, mask=None, label=None): + """Find tips in skeletonized image. + The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 + + Inputs: + skel_img = Skeletonized image + mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. + label = Optional label parameter, modifies the variable name of + observations recorded (default = pcv.params.sample_label). + Returns: + tip_img = Image with just tips, rest 0 + + :param skel_img: numpy.ndarray + :param mask: numpy.ndarray + :param label: str + :return tip_img: numpy.ndarray + """ + tip_img, tip_list, tip_labels = _find_tips(skel_img=skel_img, mask=mask) + + _debug(visual=tip_img, filename=os.path.join(params.debug_outdir, f"{params.device}_skeleton_tips.png")) + # Save coordinates to Outputs + outputs.add_observation(sample=label, variable='tips', trait='list of tip coordinates identified from a skeleton', + method='plantcv.plantcv.morphology.find_tips', scale='pixels', datatype=list, + value=tip_list, label=tip_labels) + + return tip_img \ No newline at end of file diff --git a/plantcv/plantcv/morphology/segment_curvature.py b/plantcv/plantcv/morphology/segment_curvature.py index 8e7b3a413..c44cacba6 100644 --- a/plantcv/plantcv/morphology/segment_curvature.py +++ b/plantcv/plantcv/morphology/segment_curvature.py @@ -5,7 +5,7 @@ from plantcv.plantcv import params from plantcv.plantcv import outputs from plantcv.plantcv import color_palette -from plantcv.plantcv.morphology import find_tips +from plantcv.plantcv.morphology import _find_tips from plantcv.plantcv.morphology import segment_path_length from plantcv.plantcv.morphology import segment_euclidean_length from plantcv.plantcv._debug import _debug @@ -58,7 +58,7 @@ def segment_curvature(segmented_img, objects, label=None): # Draw segments one by one to group segment tips together finding_tips_img = np.zeros(segmented_img.shape[:2], np.uint8) cv2.drawContours(finding_tips_img, objects, i, (255, 255, 255), 1, lineType=8) - segment_tips = find_tips(finding_tips_img) + segment_tips, _, _ = _find_tips(finding_tips_img) tip_objects, _ = _cv2_findcontours(bin_img=segment_tips) points = [] From cf17f11a200235d788d192245cd8c522e93ff9fb Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Wed, 18 Dec 2024 15:41:05 -0600 Subject: [PATCH 04/36] update more instances of find_tips to use helper --- plantcv/plantcv/morphology/segment_euclidean_length.py | 4 ++-- plantcv/plantcv/morphology/segment_insertion_angle.py | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_euclidean_length.py b/plantcv/plantcv/morphology/segment_euclidean_length.py index dea6d99e5..269106d39 100644 --- a/plantcv/plantcv/morphology/segment_euclidean_length.py +++ b/plantcv/plantcv/morphology/segment_euclidean_length.py @@ -8,7 +8,7 @@ from plantcv.plantcv import color_palette from plantcv.plantcv._debug import _debug from plantcv.plantcv._helpers import _cv2_findcontours -from plantcv.plantcv.morphology import find_tips +from plantcv.plantcv.morphology import _find_tips from scipy.spatial.distance import euclidean @@ -52,7 +52,7 @@ def segment_euclidean_length(segmented_img, objects, label=None): # Draw segments one by one to group segment tips together finding_tips_img = np.zeros(segmented_img.shape[:2], np.uint8) cv2.drawContours(finding_tips_img, objects, i, (255, 255, 255), 1, lineType=8) - segment_tips = find_tips(finding_tips_img) + segment_tips, _, _ = _find_tips(finding_tips_img) tip_objects, _ = _cv2_findcontours(bin_img=segment_tips) points = [] if not len(tip_objects) == 2: diff --git a/plantcv/plantcv/morphology/segment_insertion_angle.py b/plantcv/plantcv/morphology/segment_insertion_angle.py index c0cc6e44a..e375f35fd 100644 --- a/plantcv/plantcv/morphology/segment_insertion_angle.py +++ b/plantcv/plantcv/morphology/segment_insertion_angle.py @@ -10,7 +10,7 @@ from plantcv.plantcv import fatal_error from plantcv.plantcv import color_palette from plantcv.plantcv.morphology import _iterative_prune -from plantcv.plantcv.morphology import find_tips +from plantcv.plantcv.morphology import _find_tips from plantcv.plantcv.morphology.segment_tangent_angle import _slope_to_intesect_angle from plantcv.plantcv._debug import _debug from plantcv.plantcv._helpers import _cv2_findcontours @@ -62,12 +62,7 @@ def segment_insertion_angle(skel_img, segmented_img, leaf_objects, stem_objects, pruned_away = [] # Create a list of tip tuples to use for sorting - tips = find_tips(skel_img) - tips = dilate(tips, 3, 1) - tip_objects, _ = _cv2_findcontours(bin_img=tips) - tip_tuples = [] - for i, cnt in enumerate(tip_objects): - tip_tuples.append((cnt[0][0][0], cnt[0][0][1])) + tips, tip_tuples, _ = _find_tips(skel_img) for i, cnt in enumerate(leaf_objects): # Draw leaf objects From 034ebac57f8c8bab53bc3dc9586167ed290c2b7a Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Wed, 18 Dec 2024 15:46:21 -0600 Subject: [PATCH 05/36] remove label from helper function --- plantcv/plantcv/morphology/find_tips.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index 07791201f..4299e363f 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -26,10 +26,6 @@ def _find_tips(skel_img, mask=None): :param label: str :return tip_img: numpy.ndarray """ - # Set lable to params.sample_label if None - if label is None: - label = params.sample_label - # In a kernel: 1 values line up with 255s, -1s line up with 0s, and 0s correspond to dont care endpoint1 = np.array([[-1, -1, -1], [-1, 1, -1], From d0f49b204132cb284dddb01343bd63033ddd3d50 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Wed, 18 Dec 2024 15:46:25 -0600 Subject: [PATCH 06/36] Update __init__.py --- plantcv/plantcv/morphology/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plantcv/plantcv/morphology/__init__.py b/plantcv/plantcv/morphology/__init__.py index 858112df5..df866f756 100644 --- a/plantcv/plantcv/morphology/__init__.py +++ b/plantcv/plantcv/morphology/__init__.py @@ -1,5 +1,5 @@ from plantcv.plantcv.morphology.find_branch_pts import find_branch_pts -from plantcv.plantcv.morphology.find_tips import find_tips +from plantcv.plantcv.morphology.find_tips import _find_tips, find_tips from plantcv.plantcv.morphology._iterative_prune import _iterative_prune from plantcv.plantcv.morphology.segment_skeleton import segment_skeleton from plantcv.plantcv.morphology.segment_sort import segment_sort @@ -17,7 +17,7 @@ from plantcv.plantcv.morphology.analyze_stem import analyze_stem from plantcv.plantcv.morphology.fill_segments import fill_segments -__all__ = ["find_branch_pts", "find_tips", "prune", "skeletonize", "check_cycles", "segment_skeleton", "segment_angle", +__all__ = ["find_branch_pts", "_find_tips", "find_tips", "prune", "skeletonize", "check_cycles", "segment_skeleton", "segment_angle", "segment_path_length", "segment_euclidean_length", "segment_curvature", "segment_sort", "segment_id", "segment_tangent_angle", "segment_insertion_angle", "segment_combine", "_iterative_prune", "analyze_stem", "fill_segments"] From 0ea31289b13c80e609775e4f0c8aa1bf4d142deb Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 19 Dec 2024 09:39:02 -0600 Subject: [PATCH 07/36] add label logic into the front facing function --- plantcv/plantcv/morphology/find_tips.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index 4299e363f..66c3a24ba 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -100,6 +100,10 @@ def find_tips(skel_img, mask=None, label=None): tip_img, tip_list, tip_labels = _find_tips(skel_img=skel_img, mask=mask) _debug(visual=tip_img, filename=os.path.join(params.debug_outdir, f"{params.device}_skeleton_tips.png")) + + # Set lable to params.sample_label if None + if label is None: + label = params.sample_label # Save coordinates to Outputs outputs.add_observation(sample=label, variable='tips', trait='list of tip coordinates identified from a skeleton', method='plantcv.plantcv.morphology.find_tips', scale='pixels', datatype=list, From 66f25fbad04589c3e2d6b4d1300a1a4c383ab4ea Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 19 Dec 2024 09:39:19 -0600 Subject: [PATCH 08/36] update segment sort stop editing tip info --- plantcv/plantcv/morphology/segment_sort.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_sort.py b/plantcv/plantcv/morphology/segment_sort.py index c86c4bcde..836eb0225 100644 --- a/plantcv/plantcv/morphology/segment_sort.py +++ b/plantcv/plantcv/morphology/segment_sort.py @@ -7,7 +7,7 @@ from plantcv.plantcv import params from plantcv.plantcv import outputs from plantcv.plantcv import logical_and -from plantcv.plantcv.morphology import find_tips +from plantcv.plantcv.morphology import _find_tips from plantcv.plantcv._debug import _debug @@ -47,7 +47,7 @@ def segment_sort(skel_img, objects, mask=None, first_stem=True): else: labeled_img = mask.copy() - tips_img = find_tips(skel_img) + tips_img, _, _ = _find_tips(skel_img) tips_img = dilate(tips_img, 3, 1) # Loop through segment contours @@ -59,10 +59,6 @@ def segment_sort(skel_img, objects, mask=None, first_stem=True): # The first contour is the base, and while it contains a tip, it isn't a leaf if i == 0 and first_stem: primary_objects.append(cnt) - # Remove the first "tip" since it corresponds to stem not leaf. This helps - # leaf number to match the number of "tips" - outputs.observations[label]["tips"]["value"] = outputs.observations[label]["tips"]["value"][1:] - outputs.observations[label]["tips"]["label"] = outputs.observations[label]["tips"]["label"][:-1] # Sort segments else: From 7558e8c9d288f9fd03d30f34fb43e2077eb5a9ed Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 19 Dec 2024 11:41:37 -0600 Subject: [PATCH 09/36] add segment_ends to morphology init --- plantcv/plantcv/morphology/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plantcv/plantcv/morphology/__init__.py b/plantcv/plantcv/morphology/__init__.py index df866f756..1e59eecfd 100644 --- a/plantcv/plantcv/morphology/__init__.py +++ b/plantcv/plantcv/morphology/__init__.py @@ -16,8 +16,9 @@ from plantcv.plantcv.morphology.segment_combine import segment_combine from plantcv.plantcv.morphology.analyze_stem import analyze_stem from plantcv.plantcv.morphology.fill_segments import fill_segments +from plantcv.plantcv.morphology.segment_ends import segment_ends __all__ = ["find_branch_pts", "_find_tips", "find_tips", "prune", "skeletonize", "check_cycles", "segment_skeleton", "segment_angle", "segment_path_length", "segment_euclidean_length", "segment_curvature", "segment_sort", "segment_id", "segment_tangent_angle", "segment_insertion_angle", "segment_combine", "_iterative_prune", "analyze_stem", - "fill_segments"] + "fill_segments", "segment_ends"] From acffd97276e64180409fa8596a408b86edf84565 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 19 Dec 2024 12:11:03 -0600 Subject: [PATCH 10/36] add segment_img as required input, add outputs img input not just for plotting debug --- plantcv/plantcv/morphology/segment_ends.py | 37 +++++++++++++--------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 9d8ea1da6..9fe83521f 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -2,40 +2,36 @@ import os import cv2 import numpy as np -from plantcv.plantcv import params +from plantcv.plantcv import params, outputs from plantcv.plantcv._debug import _debug from plantcv.plantcv.morphology import _iterative_prune from plantcv.plantcv._helpers import _cv2_findcontours -def segment_ends(objects, mask=None, label=None): +def segment_ends(segmented_img, objects, label=None): """Find tips in skeletonized image. The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 Inputs: - skel_img = Skeletonized image + objects = List of contours to analyze mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. label = Optional label parameter, modifies the variable name of observations recorded (default = pcv.params.sample_label). - Returns: - tip_img = Image with just tips, rest 0 - :param skel_img: numpy.ndarray - :param mask: numpy.ndarray + :param segmented_img: numpy.ndarray + :param objects: list :param label: str - :return tip_img: numpy.ndarray """ - mask_copy = mask.copy() + labeled_img = segmented_img.copy() leaf_objects = objects - tip_plot = cv2.cvtColor(mask_copy, cv2.COLOR_GRAY2RGB) segment_end_objs1 = [] segment_end_objs2 = [] # Find segment end coordinates for i in range(len(leaf_objects)): # Draw leaf objects - find_segment_tangents = np.zeros(mask.shape[:2], np.uint8) + find_segment_tangents = np.zeros(labeled_img.shape[:2], np.uint8) cv2.drawContours(find_segment_tangents, leaf_objects, i, 255, 1, lineType=8) # Prune back ends of leaves pruned_segment = _iterative_prune(find_segment_tangents, 1) @@ -55,11 +51,22 @@ def segment_ends(objects, mask=None, label=None): inner_list.append(coord) print(coord) labels.append(i) - cv2.drawContours(tip_plot, leaf_objects, i, (150, 150, 150), params.line_thickness, lineType=8) # segments - cv2.circle(tip_plot, (x, y), params.line_thickness, (50, 0, 255), -1) # Red auricles + cv2.drawContours(labeled_img, leaf_objects, i, (150, 150, 150), params.line_thickness, lineType=8) # segments + cv2.circle(labeled_img, (x, y), params.line_thickness, (50, 0, 255), -1) # Red auricles tip = segment_end_objs2[i] x, y = tip.ravel()[:2] coord = (int(x), int(y)) tip_list.append(coord) - cv2.circle(tip_plot, (x, y), params.line_thickness, (0, 255, 0), -1) # green tips - _debug(visual=tip_plot, filename=os.path.join(params.debug_outdir, f"{params.device}_segment_ends.png")) + cv2.circle(labeled_img, (x, y), params.line_thickness, (0, 255, 0), -1) # green tips + + # Set lable to params.sample_label if None + if label is None: + label = params.sample_label + # Save coordinates to Outputs + outputs.add_observation(sample=label, variable='segment_tips', trait='list of tip coordinates identified from segments', + method='plantcv.plantcv.morphology.segment_ends', scale='None', datatype=list, + value=tip_list, label=labels) + outputs.add_observation(sample=label, variable='segment_branch_points', trait='list of branch point coordinates identified from segments', + method='plantcv.plantcv.morphology.segment_ends', scale='None', datatype=list, + value=inner_list, label=labels) + _debug(visual=labeled_img, filename=os.path.join(params.debug_outdir, f"{params.device}_segment_ends.png")) From 43cfbdb2e90e1160f0cbdef2c2449b476d31bafe Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 19 Dec 2024 12:11:19 -0600 Subject: [PATCH 11/36] Create test_segment_ends.py --- tests/plantcv/morphology/test_segment_ends.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/plantcv/morphology/test_segment_ends.py diff --git a/tests/plantcv/morphology/test_segment_ends.py b/tests/plantcv/morphology/test_segment_ends.py new file mode 100644 index 000000000..970fca890 --- /dev/null +++ b/tests/plantcv/morphology/test_segment_ends.py @@ -0,0 +1,11 @@ +import cv2 +from plantcv.plantcv import outputs +from plantcv.plantcv.morphology import segment_ends + + +def test_segment_ends(morphology_test_data): + """Test for PlantCV.""" + leaf_obj = morphology_test_data.load_segments(morphology_test_data.segments_file, "leaves") + skeleton = cv2.imread(morphology_test_data.skel_img, -1) + segment_ends(segmented_img=skeleton, objects=leaf_obj) + assert len(outputs.observations['default']['segment_branch_points']['value']) == 4 From 81d05747aac19bb2243beb6d71be4889411c7b83 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 19 Dec 2024 12:18:05 -0600 Subject: [PATCH 12/36] update segment_id allow optimal_assignment of IDs --- plantcv/plantcv/morphology/segment_id.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_id.py b/plantcv/plantcv/morphology/segment_id.py index bf24db742..a7277772d 100644 --- a/plantcv/plantcv/morphology/segment_id.py +++ b/plantcv/plantcv/morphology/segment_id.py @@ -7,13 +7,14 @@ from plantcv.plantcv._debug import _debug -def segment_id(skel_img, objects, mask=None): +def segment_id(skel_img, objects, mask=None, optimal_assignment=None): """Plot segment IDs. Inputs: skel_img = Skeletonized image objects = List of contours mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. + optimal_assignment = functions similar to the "label" parameter where it replaces the unique labels Returns: segmented_img = Segmented image @@ -38,9 +39,14 @@ def segment_id(skel_img, objects, mask=None): # Create a color scale, use a previously stored scale if available rand_color = color_palette(num=len(objects), saved=True) + # Plot all segment contours for i, cnt in enumerate(objects): - cv2.drawContours(segmented_img, cnt, -1, rand_color[i], params.line_thickness, lineType=8) + if optimal_assignment is not None: + color_index = optimal_assignment[i] + else: + color_index = i + cv2.drawContours(segmented_img, cnt, -1, rand_color[color_index], params.line_thickness, lineType=8) # Store coordinates for labels label_coord_x.append(objects[i][0][0][0]) label_coord_y.append(objects[i][0][0][1]) @@ -48,12 +54,20 @@ def segment_id(skel_img, objects, mask=None): labeled_img = segmented_img.copy() for i, cnt in enumerate(objects): - # Label slope lines + if optimal_assignment is not None: + # relabel IDs + text = f"{optimal_assignment[i]}" + color_index = optimal_assignment[i] + + else: + text = f"{i}" + color_index = i + # Label segments w = label_coord_x[i] h = label_coord_y[i] - text = f"ID:{i}" + cv2.putText(img=labeled_img, text=text, org=(w, h), fontFace=cv2.FONT_HERSHEY_SIMPLEX, - fontScale=params.text_size, color=rand_color[i], thickness=params.text_thickness) + fontScale=params.text_size, color=rand_color[color_index], thickness=params.text_thickness) _debug(visual=labeled_img, filename=os.path.join(params.debug_outdir, f"{params.device}_segmented_ids.png")) From 4c48eacef6c442c3a332a72e4e4ced556309df36 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 19 Dec 2024 13:39:20 -0600 Subject: [PATCH 13/36] Update test_segment_id.py add optimal assignment input --- tests/plantcv/morphology/test_segment_id.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plantcv/morphology/test_segment_id.py b/tests/plantcv/morphology/test_segment_id.py index bed778194..256f47dc2 100644 --- a/tests/plantcv/morphology/test_segment_id.py +++ b/tests/plantcv/morphology/test_segment_id.py @@ -6,7 +6,7 @@ def test_segment_id(morphology_test_data): """Test for PlantCV.""" skel = cv2.imread(morphology_test_data.skel_img, -1) leaf_obj = morphology_test_data.load_segments(morphology_test_data.segments_file, "leaves") - _, labeled_img = segment_id(skel_img=skel, objects=leaf_obj, mask=skel) + _, labeled_img = segment_id(skel_img=skel, objects=leaf_obj, mask=skel, optimal_assignment=[0,1,2,3]) assert skel.shape == labeled_img.shape[:2] From eddfab50e46f80784f35230448df46065e0a97f6 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Fri, 20 Dec 2024 10:59:47 -0600 Subject: [PATCH 14/36] convert to color for debug image --- plantcv/plantcv/morphology/segment_ends.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 9fe83521f..5de05eb74 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -24,6 +24,8 @@ def segment_ends(segmented_img, objects, label=None): """ labeled_img = segmented_img.copy() + if len(np.shape(labeled_img)) == 2: + labeled_img = cv2.cvtColor(labeled_img, cv2.COLOR_GRAY2RGB) leaf_objects = objects segment_end_objs1 = [] segment_end_objs2 = [] From 49bdc7835eff0a4a43383cca09303777770c31a6 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Fri, 20 Dec 2024 14:00:10 -0600 Subject: [PATCH 15/36] segment_ends check tip or branch_pt --- plantcv/plantcv/morphology/segment_ends.py | 76 ++++++++++++---------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 5de05eb74..1567d9868 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -2,65 +2,71 @@ import os import cv2 import numpy as np +from plantcv.plantcv import dilate, logical_and from plantcv.plantcv import params, outputs from plantcv.plantcv._debug import _debug -from plantcv.plantcv.morphology import _iterative_prune +from plantcv.plantcv.morphology import _iterative_prune, _find_tips from plantcv.plantcv._helpers import _cv2_findcontours -def segment_ends(segmented_img, objects, label=None): - """Find tips in skeletonized image. - The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 +def segment_ends(skel_img, leaf_objects, mask=None, label=None): + """Find tips and segment branch points . Inputs: - objects = List of contours to analyze - mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. - label = Optional label parameter, modifies the variable name of - observations recorded (default = pcv.params.sample_label). + skel_img = Skeletonized image + leaf_objects = List of leaf segments + mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. + label = Optional label parameter, modifies the variable name of + observations recorded (default = pcv.params.sample_label). :param segmented_img: numpy.ndarray :param objects: list :param label: str """ + # Store debug + debug = params.debug + params.debug = None - labeled_img = segmented_img.copy() - if len(np.shape(labeled_img)) == 2: - labeled_img = cv2.cvtColor(labeled_img, cv2.COLOR_GRAY2RGB) - leaf_objects = objects - segment_end_objs1 = [] - segment_end_objs2 = [] + + if mask is None: + labeled_img = skel_img.copy() + else: + labeled_img = mask.copy() + labeled_img = cv2.cvtColor(labeled_img, cv2.COLOR_GRAY2RGB) + tips, _, _ = _find_tips(skel_img) + # Initialize list of tip data points + tip_list = [] + labels = [] + inner_list = [] # Find segment end coordinates for i in range(len(leaf_objects)): + labels.append(i) # Draw leaf objects find_segment_tangents = np.zeros(labeled_img.shape[:2], np.uint8) cv2.drawContours(find_segment_tangents, leaf_objects, i, 255, 1, lineType=8) + cv2.drawContours(labeled_img, leaf_objects, i, (150, 150, 150), params.line_thickness, lineType=8) # segments debug # Prune back ends of leaves pruned_segment = _iterative_prune(find_segment_tangents, 1) # Segment ends are the portions pruned off segment_ends = find_segment_tangents - pruned_segment segment_end_obj, _ = _cv2_findcontours(bin_img=segment_ends) - segment_end_objs1.append(segment_end_obj[0]) - segment_end_objs2.append(segment_end_obj[1]) - - # Initialize list of tip data points - tip_list = [] - labels = [] - inner_list = [] - for i, coor in enumerate(segment_end_objs1): - x, y = coor.ravel()[:2] - coord = (int(x), int(y)) - inner_list.append(coord) - print(coord) - labels.append(i) - cv2.drawContours(labeled_img, leaf_objects, i, (150, 150, 150), params.line_thickness, lineType=8) # segments - cv2.circle(labeled_img, (x, y), params.line_thickness, (50, 0, 255), -1) # Red auricles - tip = segment_end_objs2[i] - x, y = tip.ravel()[:2] - coord = (int(x), int(y)) - tip_list.append(coord) - cv2.circle(labeled_img, (x, y), params.line_thickness, (0, 255, 0), -1) # green tips + # Determine if a segment is segment tip or branch point + for j, obj in enumerate(segment_end_obj): + segment_plot = np.zeros(skel_img.shape[:2], np.uint8) + cv2.drawContours(segment_plot, obj, -1, 255, 1, lineType=8) + segment_plot = dilate(segment_plot, 3, 1) + overlap_img = logical_and(segment_plot, tips) + x, y = segment_end_obj[j].ravel()[:2] + coord = (int(x), int(y)) + # If none of the tips are within a segment_end then it's an insertion segment + if np.sum(overlap_img) == 0: + inner_list.append(coord) + cv2.circle(labeled_img, coord, params.line_thickness, (50, 0, 255), -1) # Red auricles + else: + tip_list.append(coord) + cv2.circle(labeled_img, coord, params.line_thickness, (0, 255, 0), -1) # green tips # Set lable to params.sample_label if None if label is None: label = params.sample_label @@ -71,4 +77,6 @@ def segment_ends(segmented_img, objects, label=None): outputs.add_observation(sample=label, variable='segment_branch_points', trait='list of branch point coordinates identified from segments', method='plantcv.plantcv.morphology.segment_ends', scale='None', datatype=list, value=inner_list, label=labels) + # Reset debug mode + params.debug = debug _debug(visual=labeled_img, filename=os.path.join(params.debug_outdir, f"{params.device}_segment_ends.png")) From 5753678d964cf9a47a320127fe8aa0a5625f63ae Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 09:10:39 -0600 Subject: [PATCH 16/36] test_segment_ends_no_mask --- tests/plantcv/morphology/test_segment_ends.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/plantcv/morphology/test_segment_ends.py b/tests/plantcv/morphology/test_segment_ends.py index 970fca890..e3a05e84c 100644 --- a/tests/plantcv/morphology/test_segment_ends.py +++ b/tests/plantcv/morphology/test_segment_ends.py @@ -5,7 +5,19 @@ def test_segment_ends(morphology_test_data): """Test for PlantCV.""" + # Clear previous outputs + outputs.clear() leaf_obj = morphology_test_data.load_segments(morphology_test_data.segments_file, "leaves") skeleton = cv2.imread(morphology_test_data.skel_img, -1) - segment_ends(segmented_img=skeleton, objects=leaf_obj) + segment_ends(skel_img=skeleton, leaf_objects=leaf_obj, mask=skeleton) assert len(outputs.observations['default']['segment_branch_points']['value']) == 4 + + +def test_segment_ends_no_mask(morphology_test_data): + """Test for PlantCV.""" + # Clear previous outputs + outputs.clear() + leaf_obj = morphology_test_data.load_segments(morphology_test_data.segments_file, "leaves") + skeleton = cv2.imread(morphology_test_data.skel_img, -1) + segment_ends(skel_img=skeleton, leaf_objects=leaf_obj, mask=None) + assert len(outputs.observations['default']['segment_branch_points']['value']) == 4 \ No newline at end of file From fc6074d1d543268ef69e89e320150d5fafeed0ae Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 09:13:54 -0600 Subject: [PATCH 17/36] segment_sort no need to store label since no outputs in sorting, skip the label storage. wasn't used downstream but also don't need to store anymore since _find_tips also doesn't store to outputs --- plantcv/plantcv/morphology/segment_sort.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_sort.py b/plantcv/plantcv/morphology/segment_sort.py index 836eb0225..152f99884 100644 --- a/plantcv/plantcv/morphology/segment_sort.py +++ b/plantcv/plantcv/morphology/segment_sort.py @@ -5,7 +5,6 @@ import numpy as np from plantcv.plantcv import dilate from plantcv.plantcv import params -from plantcv.plantcv import outputs from plantcv.plantcv import logical_and from plantcv.plantcv.morphology import _find_tips from plantcv.plantcv._debug import _debug @@ -36,9 +35,6 @@ def segment_sort(skel_img, objects, mask=None, first_stem=True): debug = params.debug params.debug = None - # Store label - label = params.sample_label - secondary_objects = [] primary_objects = [] From be3cdb7e412439d71776f4bc3943b71966a9491d Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 09:36:03 -0600 Subject: [PATCH 18/36] Re-defined variable from outer scope --- plantcv/plantcv/morphology/segment_ends.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 1567d9868..3a04b53d0 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -49,8 +49,8 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Prune back ends of leaves pruned_segment = _iterative_prune(find_segment_tangents, 1) # Segment ends are the portions pruned off - segment_ends = find_segment_tangents - pruned_segment - segment_end_obj, _ = _cv2_findcontours(bin_img=segment_ends) + ends = find_segment_tangents - pruned_segment + segment_end_obj, _ = _cv2_findcontours(bin_img=ends) # Determine if a segment is segment tip or branch point for j, obj in enumerate(segment_end_obj): segment_plot = np.zeros(skel_img.shape[:2], np.uint8) From 86db239bc959bbc05084c378c34fb1aeed383720 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 09:36:13 -0600 Subject: [PATCH 19/36] remove unused var --- plantcv/plantcv/morphology/segment_insertion_angle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plantcv/plantcv/morphology/segment_insertion_angle.py b/plantcv/plantcv/morphology/segment_insertion_angle.py index e375f35fd..9c84ce570 100644 --- a/plantcv/plantcv/morphology/segment_insertion_angle.py +++ b/plantcv/plantcv/morphology/segment_insertion_angle.py @@ -62,7 +62,7 @@ def segment_insertion_angle(skel_img, segmented_img, leaf_objects, stem_objects, pruned_away = [] # Create a list of tip tuples to use for sorting - tips, tip_tuples, _ = _find_tips(skel_img) + tips, _, _ = _find_tips(skel_img) for i, cnt in enumerate(leaf_objects): # Draw leaf objects From 9e84adcb3f0df5d7df32470b0c5effbdad9d4286 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 09:37:24 -0600 Subject: [PATCH 20/36] white space fixes --- plantcv/plantcv/morphology/find_tips.py | 1 + plantcv/plantcv/morphology/segment_id.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index 66c3a24ba..e565bb6b9 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -80,6 +80,7 @@ def _find_tips(skel_img, mask=None): return tip_img, tip_list, tip_labels + def find_tips(skel_img, mask=None, label=None): """Find tips in skeletonized image. The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 diff --git a/plantcv/plantcv/morphology/segment_id.py b/plantcv/plantcv/morphology/segment_id.py index a7277772d..f2f5a5869 100644 --- a/plantcv/plantcv/morphology/segment_id.py +++ b/plantcv/plantcv/morphology/segment_id.py @@ -39,7 +39,6 @@ def segment_id(skel_img, objects, mask=None, optimal_assignment=None): # Create a color scale, use a previously stored scale if available rand_color = color_palette(num=len(objects), saved=True) - # Plot all segment contours for i, cnt in enumerate(objects): if optimal_assignment is not None: @@ -58,14 +57,12 @@ def segment_id(skel_img, objects, mask=None, optimal_assignment=None): # relabel IDs text = f"{optimal_assignment[i]}" color_index = optimal_assignment[i] - else: text = f"{i}" color_index = i # Label segments w = label_coord_x[i] h = label_coord_y[i] - cv2.putText(img=labeled_img, text=text, org=(w, h), fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=params.text_size, color=rand_color[color_index], thickness=params.text_thickness) From dcea2a9e877b2ba17c2d1cebb2335fb4fd1a1bbc Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 09:38:28 -0600 Subject: [PATCH 21/36] line length fixes --- plantcv/plantcv/morphology/__init__.py | 3 ++- plantcv/plantcv/morphology/segment_ends.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plantcv/plantcv/morphology/__init__.py b/plantcv/plantcv/morphology/__init__.py index 1e59eecfd..04fe24db3 100644 --- a/plantcv/plantcv/morphology/__init__.py +++ b/plantcv/plantcv/morphology/__init__.py @@ -18,7 +18,8 @@ from plantcv.plantcv.morphology.fill_segments import fill_segments from plantcv.plantcv.morphology.segment_ends import segment_ends -__all__ = ["find_branch_pts", "_find_tips", "find_tips", "prune", "skeletonize", "check_cycles", "segment_skeleton", "segment_angle", +__all__ = ["find_branch_pts", "_find_tips", "find_tips", "prune", "skeletonize", "check_cycles", + "segment_skeleton", "segment_angle", "segment_path_length", "segment_euclidean_length", "segment_curvature", "segment_sort", "segment_id", "segment_tangent_angle", "segment_insertion_angle", "segment_combine", "_iterative_prune", "analyze_stem", "fill_segments", "segment_ends"] diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 3a04b53d0..6792258dc 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -71,10 +71,12 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): if label is None: label = params.sample_label # Save coordinates to Outputs - outputs.add_observation(sample=label, variable='segment_tips', trait='list of tip coordinates identified from segments', + outputs.add_observation(sample=label, variable='segment_tips', + trait='list of tip coordinates identified from segments', method='plantcv.plantcv.morphology.segment_ends', scale='None', datatype=list, value=tip_list, label=labels) - outputs.add_observation(sample=label, variable='segment_branch_points', trait='list of branch point coordinates identified from segments', + outputs.add_observation(sample=label, variable='segment_branch_points', + trait='list of branch point coordinates identified from segments', method='plantcv.plantcv.morphology.segment_ends', scale='None', datatype=list, value=inner_list, label=labels) # Reset debug mode From 333e3539c21e6b096b271d792cd35af97a337d9f Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 09:40:49 -0600 Subject: [PATCH 22/36] remove trailing whitespace --- plantcv/plantcv/morphology/find_tips.py | 2 +- plantcv/plantcv/morphology/segment_ends.py | 6 +++--- plantcv/plantcv/morphology/segment_id.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index e565bb6b9..c8a045f37 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -105,7 +105,7 @@ def find_tips(skel_img, mask=None, label=None): # Set lable to params.sample_label if None if label is None: label = params.sample_label - # Save coordinates to Outputs + # Save coordinates to Outputs outputs.add_observation(sample=label, variable='tips', trait='list of tip coordinates identified from a skeleton', method='plantcv.plantcv.morphology.find_tips', scale='pixels', datatype=list, value=tip_list, label=tip_labels) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 6792258dc..b072d1de2 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -1,4 +1,4 @@ -# Find both segment end coordinates +# Find both segment end coordinates import os import cv2 import numpy as np @@ -37,7 +37,7 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Initialize list of tip data points tip_list = [] labels = [] - inner_list = [] + inner_list = [] # Find segment end coordinates for i in range(len(leaf_objects)): @@ -70,7 +70,7 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Set lable to params.sample_label if None if label is None: label = params.sample_label - # Save coordinates to Outputs + # Save coordinates to Outputs outputs.add_observation(sample=label, variable='segment_tips', trait='list of tip coordinates identified from segments', method='plantcv.plantcv.morphology.segment_ends', scale='None', datatype=list, diff --git a/plantcv/plantcv/morphology/segment_id.py b/plantcv/plantcv/morphology/segment_id.py index f2f5a5869..a1a89e109 100644 --- a/plantcv/plantcv/morphology/segment_id.py +++ b/plantcv/plantcv/morphology/segment_id.py @@ -14,7 +14,7 @@ def segment_id(skel_img, objects, mask=None, optimal_assignment=None): skel_img = Skeletonized image objects = List of contours mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. - optimal_assignment = functions similar to the "label" parameter where it replaces the unique labels + optimal_assignment = functions similar to the "label" parameter where it replaces the unique labels Returns: segmented_img = Segmented image @@ -43,7 +43,7 @@ def segment_id(skel_img, objects, mask=None, optimal_assignment=None): for i, cnt in enumerate(objects): if optimal_assignment is not None: color_index = optimal_assignment[i] - else: + else: color_index = i cv2.drawContours(segmented_img, cnt, -1, rand_color[color_index], params.line_thickness, lineType=8) # Store coordinates for labels From ee3e4be9adb9f6b080d3bb0acf036dfabe05985b Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 10:58:55 -0600 Subject: [PATCH 23/36] more whitespaces fixes --- plantcv/plantcv/morphology/find_tips.py | 4 ++-- plantcv/plantcv/morphology/segment_ends.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index c8a045f37..efd396ddd 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -109,5 +109,5 @@ def find_tips(skel_img, mask=None, label=None): outputs.add_observation(sample=label, variable='tips', trait='list of tip coordinates identified from a skeleton', method='plantcv.plantcv.morphology.find_tips', scale='pixels', datatype=list, value=tip_list, label=tip_labels) - - return tip_img \ No newline at end of file + + return tip_img diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index b072d1de2..0d9bcf27a 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -26,8 +26,7 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Store debug debug = params.debug params.debug = None - - + if mask is None: labeled_img = skel_img.copy() else: @@ -59,7 +58,6 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): overlap_img = logical_and(segment_plot, tips) x, y = segment_end_obj[j].ravel()[:2] coord = (int(x), int(y)) - # If none of the tips are within a segment_end then it's an insertion segment if np.sum(overlap_img) == 0: inner_list.append(coord) From 0b63217b949c7364d5f6d43221d55e9bea82b8fc Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 16:31:01 -0600 Subject: [PATCH 24/36] move _iterative_prune and _find_tips to _helpers rearrage ome imports and change where things are getting called from --- plantcv/plantcv/_helpers.py | 114 ++++++++++++++++++ plantcv/plantcv/morphology/__init__.py | 7 +- .../plantcv/morphology/_iterative_prune.py | 46 ------- plantcv/plantcv/morphology/find_tips.py | 77 +----------- plantcv/plantcv/morphology/prune.py | 6 +- .../plantcv/morphology/segment_curvature.py | 3 +- plantcv/plantcv/morphology/segment_ends.py | 4 +- .../morphology/segment_euclidean_length.py | 3 +- .../morphology/segment_insertion_angle.py | 4 +- plantcv/plantcv/morphology/segment_sort.py | 2 +- .../morphology/segment_tangent_angle.py | 3 +- tests/plantcv/morphology/test_prune.py | 9 +- 12 files changed, 127 insertions(+), 151 deletions(-) delete mode 100644 plantcv/plantcv/morphology/_iterative_prune.py diff --git a/plantcv/plantcv/_helpers.py b/plantcv/plantcv/_helpers.py index 9b10d7ba0..47a60bb01 100644 --- a/plantcv/plantcv/_helpers.py +++ b/plantcv/plantcv/_helpers.py @@ -1,11 +1,125 @@ import cv2 import numpy as np +from plantcv.plantcv.dilate import dilate from plantcv.plantcv.logical_and import logical_and +from plantcv.plantcv.image_subtract import image_subtract from plantcv.plantcv import fatal_error, warn from plantcv.plantcv import params import pandas as pd +def _iterative_prune(skel_img, size): + """Iteratively remove endpoints (tips) from a skeletonized image. + The pruning algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 + "Prunes" barbs off a skeleton. + Inputs: + skel_img = Skeletonized image + size = Size to get pruned off each branch + Returns: + pruned_img = Pruned image + :param skel_img: numpy.ndarray + :param size: int + :return pruned_img: numpy.ndarray + """ + pruned_img = skel_img.copy() + # Store debug + debug = params.debug + params.debug = None + + # Iteratively remove endpoints (tips) from a skeleton + for _ in range(0, size): + endpoints, _, _ = _find_tips(pruned_img) + pruned_img = image_subtract(pruned_img, endpoints) + + # Make debugging image + pruned_plot = np.zeros(skel_img.shape[:2], np.uint8) + pruned_plot = cv2.cvtColor(pruned_plot, cv2.COLOR_GRAY2RGB) + skel_obj, skel_hierarchy = _cv2_findcontours(bin_img=pruned_img) + pruned_obj, pruned_hierarchy = _cv2_findcontours(bin_img=pruned_img) + + # Reset debug mode + params.debug = debug + + cv2.drawContours(pruned_plot, skel_obj, -1, (0, 0, 255), params.line_thickness, + lineType=8, hierarchy=skel_hierarchy) + cv2.drawContours(pruned_plot, pruned_obj, -1, (255, 255, 255), params.line_thickness, + lineType=8, hierarchy=pruned_hierarchy) + + return pruned_img + + +def _find_tips(skel_img, mask=None): + """Helper function to find tips in skeletonized image. + The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 + + Inputs: + skel_img = Skeletonized image + mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. + label = Optional label parameter, modifies the variable name of + observations recorded (default = pcv.params.sample_label). + Returns: + tip_img = Image with just tips, rest 0 + + :param skel_img: numpy.ndarray + :param mask: numpy.ndarray + :param label: str + :return tip_img: numpy.ndarray + """ + # In a kernel: 1 values line up with 255s, -1s line up with 0s, and 0s correspond to dont care + endpoint1 = np.array([[-1, -1, -1], + [-1, 1, -1], + [0, 1, 0]]) + endpoint2 = np.array([[-1, -1, -1], + [-1, 1, 0], + [-1, 0, 1]]) + + endpoint3 = np.rot90(endpoint1) + endpoint4 = np.rot90(endpoint2) + endpoint5 = np.rot90(endpoint3) + endpoint6 = np.rot90(endpoint4) + endpoint7 = np.rot90(endpoint5) + endpoint8 = np.rot90(endpoint6) + + endpoints = [endpoint1, endpoint2, endpoint3, endpoint4, endpoint5, endpoint6, endpoint7, endpoint8] + tip_img = np.zeros(skel_img.shape[:2], dtype=int) + for endpoint in endpoints: + tip_img = np.logical_or(cv2.morphologyEx(skel_img, op=cv2.MORPH_HITMISS, kernel=endpoint, + borderType=cv2.BORDER_CONSTANT, borderValue=0), tip_img) + tip_img = tip_img.astype(np.uint8) * 255 + # Store debug + debug = params.debug + params.debug = None + tip_objects, _ = _cv2_findcontours(bin_img=tip_img) + + if mask is None: + # Make debugging image + dilated_skel = dilate(skel_img, params.line_thickness, 1) + tip_plot = cv2.cvtColor(dilated_skel, cv2.COLOR_GRAY2RGB) + + else: + # Make debugging image on mask + mask_copy = mask.copy() + tip_plot = cv2.cvtColor(mask_copy, cv2.COLOR_GRAY2RGB) + skel_obj, skel_hier = _cv2_findcontours(bin_img=skel_img) + cv2.drawContours(tip_plot, skel_obj, -1, (150, 150, 150), params.line_thickness, + lineType=8, hierarchy=skel_hier) + + # Initialize list of tip data points + tip_list = [] + tip_labels = [] + for i, tip in enumerate(tip_objects): + x, y = tip.ravel()[:2] + coord = (int(x), int(y)) + tip_list.append(coord) + tip_labels.append(i) + cv2.circle(tip_plot, (x, y), params.line_thickness, (0, 255, 0), -1) + + # Reset debug mode + params.debug = debug + + return tip_img, tip_list, tip_labels + + def _hough_circle(gray_img, mindist, candec, accthresh, minradius, maxradius, maxfound=None): """ Hough Circle Detection diff --git a/plantcv/plantcv/morphology/__init__.py b/plantcv/plantcv/morphology/__init__.py index 04fe24db3..dc44af6c8 100644 --- a/plantcv/plantcv/morphology/__init__.py +++ b/plantcv/plantcv/morphology/__init__.py @@ -1,6 +1,5 @@ from plantcv.plantcv.morphology.find_branch_pts import find_branch_pts -from plantcv.plantcv.morphology.find_tips import _find_tips, find_tips -from plantcv.plantcv.morphology._iterative_prune import _iterative_prune +from plantcv.plantcv.morphology.find_tips import find_tips from plantcv.plantcv.morphology.segment_skeleton import segment_skeleton from plantcv.plantcv.morphology.segment_sort import segment_sort from plantcv.plantcv.morphology.prune import prune @@ -18,8 +17,8 @@ from plantcv.plantcv.morphology.fill_segments import fill_segments from plantcv.plantcv.morphology.segment_ends import segment_ends -__all__ = ["find_branch_pts", "_find_tips", "find_tips", "prune", "skeletonize", "check_cycles", +__all__ = ["find_branch_pts", "find_tips", "prune", "skeletonize", "check_cycles", "segment_skeleton", "segment_angle", "segment_path_length", "segment_euclidean_length", "segment_curvature", "segment_sort", "segment_id", - "segment_tangent_angle", "segment_insertion_angle", "segment_combine", "_iterative_prune", "analyze_stem", + "segment_tangent_angle", "segment_insertion_angle", "segment_combine", "analyze_stem", "fill_segments", "segment_ends"] diff --git a/plantcv/plantcv/morphology/_iterative_prune.py b/plantcv/plantcv/morphology/_iterative_prune.py deleted file mode 100644 index e1f51e128..000000000 --- a/plantcv/plantcv/morphology/_iterative_prune.py +++ /dev/null @@ -1,46 +0,0 @@ -import cv2 -import numpy as np -from plantcv.plantcv import params -from plantcv.plantcv import image_subtract -from plantcv.plantcv.morphology import _find_tips -from plantcv.plantcv._helpers import _cv2_findcontours - - -def _iterative_prune(skel_img, size): - """Iteratively remove endpoints (tips) from a skeletonized image. - The pruning algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 - "Prunes" barbs off a skeleton. - Inputs: - skel_img = Skeletonized image - size = Size to get pruned off each branch - Returns: - pruned_img = Pruned image - :param skel_img: numpy.ndarray - :param size: int - :return pruned_img: numpy.ndarray - """ - pruned_img = skel_img.copy() - # Store debug - debug = params.debug - params.debug = None - - # Iteratively remove endpoints (tips) from a skeleton - for _ in range(0, size): - endpoints, _, _ = _find_tips(pruned_img) - pruned_img = image_subtract(pruned_img, endpoints) - - # Make debugging image - pruned_plot = np.zeros(skel_img.shape[:2], np.uint8) - pruned_plot = cv2.cvtColor(pruned_plot, cv2.COLOR_GRAY2RGB) - skel_obj, skel_hierarchy = _cv2_findcontours(bin_img=pruned_img) - pruned_obj, pruned_hierarchy = _cv2_findcontours(bin_img=pruned_img) - - # Reset debug mode - params.debug = debug - - cv2.drawContours(pruned_plot, skel_obj, -1, (0, 0, 255), params.line_thickness, - lineType=8, hierarchy=skel_hierarchy) - cv2.drawContours(pruned_plot, pruned_obj, -1, (255, 255, 255), params.line_thickness, - lineType=8, hierarchy=pruned_hierarchy) - - return pruned_img diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index efd396ddd..ad602dda2 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -1,84 +1,9 @@ """Find tips from skeleton image.""" import os -import cv2 -import numpy as np from plantcv.plantcv import params -from plantcv.plantcv import dilate from plantcv.plantcv import outputs from plantcv.plantcv._debug import _debug -from plantcv.plantcv._helpers import _cv2_findcontours - - -def _find_tips(skel_img, mask=None): - """Helper function to find tips in skeletonized image. - The endpoints algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 - - Inputs: - skel_img = Skeletonized image - mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. - label = Optional label parameter, modifies the variable name of - observations recorded (default = pcv.params.sample_label). - Returns: - tip_img = Image with just tips, rest 0 - - :param skel_img: numpy.ndarray - :param mask: numpy.ndarray - :param label: str - :return tip_img: numpy.ndarray - """ - # In a kernel: 1 values line up with 255s, -1s line up with 0s, and 0s correspond to dont care - endpoint1 = np.array([[-1, -1, -1], - [-1, 1, -1], - [0, 1, 0]]) - endpoint2 = np.array([[-1, -1, -1], - [-1, 1, 0], - [-1, 0, 1]]) - - endpoint3 = np.rot90(endpoint1) - endpoint4 = np.rot90(endpoint2) - endpoint5 = np.rot90(endpoint3) - endpoint6 = np.rot90(endpoint4) - endpoint7 = np.rot90(endpoint5) - endpoint8 = np.rot90(endpoint6) - - endpoints = [endpoint1, endpoint2, endpoint3, endpoint4, endpoint5, endpoint6, endpoint7, endpoint8] - tip_img = np.zeros(skel_img.shape[:2], dtype=int) - for endpoint in endpoints: - tip_img = np.logical_or(cv2.morphologyEx(skel_img, op=cv2.MORPH_HITMISS, kernel=endpoint, - borderType=cv2.BORDER_CONSTANT, borderValue=0), tip_img) - tip_img = tip_img.astype(np.uint8) * 255 - # Store debug - debug = params.debug - params.debug = None - tip_objects, _ = _cv2_findcontours(bin_img=tip_img) - - if mask is None: - # Make debugging image - dilated_skel = dilate(skel_img, params.line_thickness, 1) - tip_plot = cv2.cvtColor(dilated_skel, cv2.COLOR_GRAY2RGB) - - else: - # Make debugging image on mask - mask_copy = mask.copy() - tip_plot = cv2.cvtColor(mask_copy, cv2.COLOR_GRAY2RGB) - skel_obj, skel_hier = _cv2_findcontours(bin_img=skel_img) - cv2.drawContours(tip_plot, skel_obj, -1, (150, 150, 150), params.line_thickness, - lineType=8, hierarchy=skel_hier) - - # Initialize list of tip data points - tip_list = [] - tip_labels = [] - for i, tip in enumerate(tip_objects): - x, y = tip.ravel()[:2] - coord = (int(x), int(y)) - tip_list.append(coord) - tip_labels.append(i) - cv2.circle(tip_plot, (x, y), params.line_thickness, (0, 255, 0), -1) - - # Reset debug mode - params.debug = debug - - return tip_img, tip_list, tip_labels +from plantcv.plantcv._helpers import _find_tips def find_tips(skel_img, mask=None, label=None): diff --git a/plantcv/plantcv/morphology/prune.py b/plantcv/plantcv/morphology/prune.py index e71b16dfa..4edbf9ac0 100644 --- a/plantcv/plantcv/morphology/prune.py +++ b/plantcv/plantcv/morphology/prune.py @@ -5,11 +5,9 @@ import numpy as np from plantcv.plantcv import params from plantcv.plantcv import image_subtract -from plantcv.plantcv.morphology import segment_sort -from plantcv.plantcv.morphology import segment_skeleton -from plantcv.plantcv.morphology import _iterative_prune +from plantcv.plantcv.morphology import segment_sort, segment_skeleton from plantcv.plantcv._debug import _debug -from plantcv.plantcv._helpers import _cv2_findcontours +from plantcv.plantcv._helpers import _cv2_findcontours, _iterative_prune def prune(skel_img, size=0, mask=None): diff --git a/plantcv/plantcv/morphology/segment_curvature.py b/plantcv/plantcv/morphology/segment_curvature.py index c44cacba6..94ee8c85b 100644 --- a/plantcv/plantcv/morphology/segment_curvature.py +++ b/plantcv/plantcv/morphology/segment_curvature.py @@ -5,11 +5,10 @@ from plantcv.plantcv import params from plantcv.plantcv import outputs from plantcv.plantcv import color_palette -from plantcv.plantcv.morphology import _find_tips from plantcv.plantcv.morphology import segment_path_length from plantcv.plantcv.morphology import segment_euclidean_length from plantcv.plantcv._debug import _debug -from plantcv.plantcv._helpers import _cv2_findcontours +from plantcv.plantcv._helpers import _cv2_findcontours, _find_tips def segment_curvature(segmented_img, objects, label=None): diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 0d9bcf27a..5a1ccc54b 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -5,9 +5,7 @@ from plantcv.plantcv import dilate, logical_and from plantcv.plantcv import params, outputs from plantcv.plantcv._debug import _debug -from plantcv.plantcv.morphology import _iterative_prune, _find_tips -from plantcv.plantcv._helpers import _cv2_findcontours - +from plantcv.plantcv._helpers import _cv2_findcontours, _find_tips, _iterative_prune def segment_ends(skel_img, leaf_objects, mask=None, label=None): """Find tips and segment branch points . diff --git a/plantcv/plantcv/morphology/segment_euclidean_length.py b/plantcv/plantcv/morphology/segment_euclidean_length.py index 269106d39..e2422ff44 100644 --- a/plantcv/plantcv/morphology/segment_euclidean_length.py +++ b/plantcv/plantcv/morphology/segment_euclidean_length.py @@ -7,8 +7,7 @@ from plantcv.plantcv import fatal_error from plantcv.plantcv import color_palette from plantcv.plantcv._debug import _debug -from plantcv.plantcv._helpers import _cv2_findcontours -from plantcv.plantcv.morphology import _find_tips +from plantcv.plantcv._helpers import _cv2_findcontours, _find_tips from scipy.spatial.distance import euclidean diff --git a/plantcv/plantcv/morphology/segment_insertion_angle.py b/plantcv/plantcv/morphology/segment_insertion_angle.py index 9c84ce570..9e18f589a 100644 --- a/plantcv/plantcv/morphology/segment_insertion_angle.py +++ b/plantcv/plantcv/morphology/segment_insertion_angle.py @@ -9,11 +9,9 @@ from plantcv.plantcv import logical_and from plantcv.plantcv import fatal_error from plantcv.plantcv import color_palette -from plantcv.plantcv.morphology import _iterative_prune -from plantcv.plantcv.morphology import _find_tips from plantcv.plantcv.morphology.segment_tangent_angle import _slope_to_intesect_angle from plantcv.plantcv._debug import _debug -from plantcv.plantcv._helpers import _cv2_findcontours +from plantcv.plantcv._helpers import _cv2_findcontours, _find_tips, _iterative_prune def segment_insertion_angle(skel_img, segmented_img, leaf_objects, stem_objects, size, label=None): diff --git a/plantcv/plantcv/morphology/segment_sort.py b/plantcv/plantcv/morphology/segment_sort.py index 152f99884..e797871b9 100644 --- a/plantcv/plantcv/morphology/segment_sort.py +++ b/plantcv/plantcv/morphology/segment_sort.py @@ -6,7 +6,7 @@ from plantcv.plantcv import dilate from plantcv.plantcv import params from plantcv.plantcv import logical_and -from plantcv.plantcv.morphology import _find_tips +from plantcv.plantcv._helpers import _find_tips from plantcv.plantcv._debug import _debug diff --git a/plantcv/plantcv/morphology/segment_tangent_angle.py b/plantcv/plantcv/morphology/segment_tangent_angle.py index 6a5d86938..a281a929d 100644 --- a/plantcv/plantcv/morphology/segment_tangent_angle.py +++ b/plantcv/plantcv/morphology/segment_tangent_angle.py @@ -6,9 +6,8 @@ from plantcv.plantcv import params from plantcv.plantcv import outputs from plantcv.plantcv import color_palette -from plantcv.plantcv.morphology import _iterative_prune from plantcv.plantcv._debug import _debug -from plantcv.plantcv._helpers import _cv2_findcontours +from plantcv.plantcv._helpers import _cv2_findcontours, _iterative_prune def _slope_to_intesect_angle(m1, m2): diff --git a/tests/plantcv/morphology/test_prune.py b/tests/plantcv/morphology/test_prune.py index ab09fdc65..cc2317e76 100644 --- a/tests/plantcv/morphology/test_prune.py +++ b/tests/plantcv/morphology/test_prune.py @@ -1,6 +1,6 @@ import cv2 import numpy as np -from plantcv.plantcv.morphology import prune, _iterative_prune +from plantcv.plantcv.morphology import prune def test_prune(morphology_test_data): @@ -22,10 +22,3 @@ def test_prune_size0(morphology_test_data): skeleton = cv2.imread(morphology_test_data.skel_img, -1) pruned_img, _, _ = prune(skel_img=skeleton, size=0) assert np.sum(pruned_img) == np.sum(skeleton) - - -def test_iterative_prune(morphology_test_data): - """Test for PlantCV.""" - skeleton = cv2.imread(morphology_test_data.skel_img, -1) - pruned_img = _iterative_prune(skel_img=skeleton, size=3) - assert np.sum(pruned_img) < np.sum(skeleton) From 554b49925ce8c67d24d9bcdf886f5013c6f0b57f Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Mon, 23 Dec 2024 16:46:33 -0600 Subject: [PATCH 25/36] whitespace fixes --- plantcv/plantcv/morphology/find_tips.py | 2 +- plantcv/plantcv/morphology/segment_ends.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plantcv/plantcv/morphology/find_tips.py b/plantcv/plantcv/morphology/find_tips.py index ad602dda2..4831c8772 100644 --- a/plantcv/plantcv/morphology/find_tips.py +++ b/plantcv/plantcv/morphology/find_tips.py @@ -26,7 +26,7 @@ def find_tips(skel_img, mask=None, label=None): tip_img, tip_list, tip_labels = _find_tips(skel_img=skel_img, mask=mask) _debug(visual=tip_img, filename=os.path.join(params.debug_outdir, f"{params.device}_skeleton_tips.png")) - + # Set lable to params.sample_label if None if label is None: label = params.sample_label diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 5a1ccc54b..3e829dfbe 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -7,6 +7,7 @@ from plantcv.plantcv._debug import _debug from plantcv.plantcv._helpers import _cv2_findcontours, _find_tips, _iterative_prune + def segment_ends(skel_img, leaf_objects, mask=None, label=None): """Find tips and segment branch points . From f802504b914af0b4925bf6180c98fd3c71a89ed2 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 2 Jan 2025 09:26:22 -0600 Subject: [PATCH 26/36] move segment_end sorting logic into _helpers --- plantcv/plantcv/_helpers.py | 50 ++++++++++++++++++++++ plantcv/plantcv/morphology/segment_ends.py | 41 ++---------------- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/plantcv/plantcv/_helpers.py b/plantcv/plantcv/_helpers.py index 47a60bb01..a4192af17 100644 --- a/plantcv/plantcv/_helpers.py +++ b/plantcv/plantcv/_helpers.py @@ -8,6 +8,56 @@ import pandas as pd +def _find_segment_ends(skel_img, leaf_objects, plotting_img, size): + """Find both segment ends and sort into tips or inner branchpoints. + + Inputs: + skel_img = Skeletonized image + leaf_objects = List of leaf segments + plotting_img = Mask for debugging, might be a copy of the Skeletonized image + size = Size of inner segment ends (in pixels) + + :param skel_img: numpy.ndarray + :param leaf_objects: list + :param plotting_img: numpy.ndarray + """ + labeled_img = cv2.cvtColor(plotting_img, cv2.COLOR_GRAY2RGB) + tips, _, _ = _find_tips(skel_img) + # Initialize list of tip data points + labels = [] + tip_list = [] + inner_list = [] + + # Find segment end coordinates + for i in range(len(leaf_objects)): + labels.append(i) + # Draw leaf objects + find_segment_tangents = np.zeros(labeled_img.shape[:2], np.uint8) + cv2.drawContours(find_segment_tangents, leaf_objects, i, 255, 1, lineType=8) + cv2.drawContours(labeled_img, leaf_objects, i, (150, 150, 150), params.line_thickness, lineType=8) # segments debug + # Prune back ends of leaves + pruned_segment = _iterative_prune(find_segment_tangents, size) + # Segment ends are the portions pruned off + ends = find_segment_tangents - pruned_segment + segment_end_obj, _ = _cv2_findcontours(bin_img=ends) + # Determine if a segment is segment tip or branch point + for j, obj in enumerate(segment_end_obj): + segment_plot = np.zeros(skel_img.shape[:2], np.uint8) + cv2.drawContours(segment_plot, obj, -1, 255, 1, lineType=8) + segment_plot = dilate(segment_plot, 3, 1) + overlap_img = logical_and(segment_plot, tips) + x, y = segment_end_obj[j].ravel()[:2] + coord = (int(x), int(y)) + # If none of the tips are within a segment_end then it's an insertion segment + if np.sum(overlap_img) == 0: + inner_list.append(coord) + cv2.circle(labeled_img, coord, params.line_thickness, (50, 0, 255), -1) # Red auricles + else: + tip_list.append(coord) + cv2.circle(labeled_img, coord, params.line_thickness, (0, 255, 0), -1) # green tips + + return labeled_img, tip_list, inner_list, labels + def _iterative_prune(skel_img, size): """Iteratively remove endpoints (tips) from a skeletonized image. The pruning algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 3e829dfbe..99e08a216 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -1,11 +1,8 @@ # Find both segment end coordinates import os -import cv2 -import numpy as np -from plantcv.plantcv import dilate, logical_and from plantcv.plantcv import params, outputs from plantcv.plantcv._debug import _debug -from plantcv.plantcv._helpers import _cv2_findcontours, _find_tips, _iterative_prune +from plantcv.plantcv._helpers import _find_segment_ends def segment_ends(skel_img, leaf_objects, mask=None, label=None): @@ -30,40 +27,8 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): labeled_img = skel_img.copy() else: labeled_img = mask.copy() - labeled_img = cv2.cvtColor(labeled_img, cv2.COLOR_GRAY2RGB) - tips, _, _ = _find_tips(skel_img) - # Initialize list of tip data points - tip_list = [] - labels = [] - inner_list = [] - - # Find segment end coordinates - for i in range(len(leaf_objects)): - labels.append(i) - # Draw leaf objects - find_segment_tangents = np.zeros(labeled_img.shape[:2], np.uint8) - cv2.drawContours(find_segment_tangents, leaf_objects, i, 255, 1, lineType=8) - cv2.drawContours(labeled_img, leaf_objects, i, (150, 150, 150), params.line_thickness, lineType=8) # segments debug - # Prune back ends of leaves - pruned_segment = _iterative_prune(find_segment_tangents, 1) - # Segment ends are the portions pruned off - ends = find_segment_tangents - pruned_segment - segment_end_obj, _ = _cv2_findcontours(bin_img=ends) - # Determine if a segment is segment tip or branch point - for j, obj in enumerate(segment_end_obj): - segment_plot = np.zeros(skel_img.shape[:2], np.uint8) - cv2.drawContours(segment_plot, obj, -1, 255, 1, lineType=8) - segment_plot = dilate(segment_plot, 3, 1) - overlap_img = logical_and(segment_plot, tips) - x, y = segment_end_obj[j].ravel()[:2] - coord = (int(x), int(y)) - # If none of the tips are within a segment_end then it's an insertion segment - if np.sum(overlap_img) == 0: - inner_list.append(coord) - cv2.circle(labeled_img, coord, params.line_thickness, (50, 0, 255), -1) # Red auricles - else: - tip_list.append(coord) - cv2.circle(labeled_img, coord, params.line_thickness, (0, 255, 0), -1) # green tips + labeled_img, tip_list, inner_list, labels = _find_segment_ends( + skel_img=skel_img, leaf_objects=leaf_objects, plotting_img=labeled_img, size=1) # Set lable to params.sample_label if None if label is None: label = params.sample_label From d8fd5c017d8cc3812cb6fe5055b8a00ef1515a7b Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 2 Jan 2025 09:36:37 -0600 Subject: [PATCH 27/36] add logic for returning the optimal segment assignment --- plantcv/plantcv/morphology/segment_ends.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 99e08a216..bfe31a0c0 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -1,5 +1,6 @@ # Find both segment end coordinates import os +import numpy as np from plantcv.plantcv import params, outputs from plantcv.plantcv._debug import _debug from plantcv.plantcv._helpers import _find_segment_ends @@ -14,10 +15,15 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. label = Optional label parameter, modifies the variable name of observations recorded (default = pcv.params.sample_label). + + Returns: + sorted_ids = Optimal assignment of leaf objects based on inner-segment y-coordinates :param segmented_img: numpy.ndarray - :param objects: list + :param leaf_objects: list + :param mask: numpy.ndarray :param label: str + :return sorted_ids: list """ # Store debug debug = params.debug @@ -27,6 +33,7 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): labeled_img = skel_img.copy() else: labeled_img = mask.copy() + # Find and sort segment ends, and create debug image labeled_img, tip_list, inner_list, labels = _find_segment_ends( skel_img=skel_img, leaf_objects=leaf_objects, plotting_img=labeled_img, size=1) # Set lable to params.sample_label if None @@ -44,3 +51,14 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Reset debug mode params.debug = debug _debug(visual=labeled_img, filename=os.path.join(params.debug_outdir, f"{params.device}_segment_ends.png")) + + # Determine optimal segment order by y-coordinate order + d = {} + for i, coord in enumerate(inner_list): + d[coord[1]] = i + + keys = list(d.keys()) + values = list(d.values()) + sorted_key_index = np.argsort(keys) + sorted_ids = [values[i] for i in sorted_key_index] + return sorted_ids[::-1] From 0b296b15bd0a53dee2f1f1ba6ac09f05fcf124e9 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 2 Jan 2025 09:44:47 -0600 Subject: [PATCH 28/36] updating.md add segment_ends --- docs/updating.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/updating.md b/docs/updating.md index 25566975f..25e0ee404 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -685,6 +685,11 @@ pages for more details on the input and output variable types. * post v3.11: labeled_img = **plantcv.morphology.segment_curvature**(*segmented_img, objects, label="default"*) * post v4.0: labeled_img = **plantcv.morphology.segment_curvature**(*segmented_img, objects, label=None*) +#### plantcv.morphology.segment_ends + +* pre v4.6: NA +* post v4.6: **plantcv.morphology.segment_ends**(*skel_img, leaf_objects, mask=None, label=None*) + #### plantcv.morphology.segment_euclidean_length * pre v3.3: NA From 6fde9cd0e0657eac3ffa746a61ea4ee9b2e3cf78 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 2 Jan 2025 10:35:18 -0600 Subject: [PATCH 29/36] Create segment_ends.md --- docs/segment_ends.md | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/segment_ends.md diff --git a/docs/segment_ends.md b/docs/segment_ends.md new file mode 100644 index 000000000..9e9e454d6 --- /dev/null +++ b/docs/segment_ends.md @@ -0,0 +1,60 @@ +## Identify Segment Ends + +Find segment tip and inner branch-point coordinates, and sort them by the y-coordinates of the branch points + +**plantcv.morphology.segment_ends**(*skel_img, leaf_objects, mask=None, label=None*) + +**returns** Optimal assignment of segment IDs + +- **Parameters:** + - skel_img - Skeleton image (output from [plantcv.morphology.skeletonize](skeletonize.md)) + - leaf_objects - Secondary segment objects (output from [plantcv.morphology.segment_sort](segment_sort.md)). + - mask - Binary mask for plotting. If provided, segmented and labeled image will be overlaid on the mask (optional). + - label - Optional label parameter, modifies the variable name of observations recorded. (default = `pcv.params.sample_label`) +- **Context:** + - Aims to sort leaf objects by biological age. This tends to work somewhat consistently for grass species that have leav + +**Reference Images** + +![Screenshot](img/documentation_images/segment_id/skeleton_image.jpg) + +![Screenshot](img/documentation_images/segment_id/mask_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" + +# Adjust line thickness with the global line thickness parameter (default = 5) +pcv.params.line_thickness = 3 + +sorted_ids = pcv.morphology.segment_ends(skel_img=skeleton, + leaf_objects=leaf_obj, + mask=plant_mask, + label="leaves") + +segmented_img, leaves_labeled = pcv.morphology.segment_id(skel_img=skeleton, + objects=leaf_obj, + mask=plant_mask, + optimal_assignment=sorted_ids) +# Without optimal assignment leaf tips are used by default +segmented_img, leaves_labeled = pcv.morphology.segment_id(skel_img=skeleton, + objects=leaf_obj, + mask=plant_mask) + +``` + +*Input Segmented Image, Leaves Only with Mask* + +![Screenshot](img/documentation_images//.jpg) + + + +*Labeled Image, Leaves Only with Mask without Optimal Reassignment of segments* + +![Screenshot](img/documentation_images/segment_id/labeled_leaves_mask.jpg) + +**Source Code:** [Here](https://github.com/danforthcenter/plantcv/blob/main/plantcv/plantcv/morphology/segment_ends.py) From a8c74a5642a778bd1a645be1b166db038707b77f Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 2 Jan 2025 15:07:58 -0600 Subject: [PATCH 30/36] whitespace deepsource fixes --- plantcv/plantcv/_helpers.py | 3 ++- plantcv/plantcv/morphology/segment_ends.py | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plantcv/plantcv/_helpers.py b/plantcv/plantcv/_helpers.py index a4192af17..0899edc23 100644 --- a/plantcv/plantcv/_helpers.py +++ b/plantcv/plantcv/_helpers.py @@ -55,9 +55,10 @@ def _find_segment_ends(skel_img, leaf_objects, plotting_img, size): else: tip_list.append(coord) cv2.circle(labeled_img, coord, params.line_thickness, (0, 255, 0), -1) # green tips - + return labeled_img, tip_list, inner_list, labels + def _iterative_prune(skel_img, size): """Iteratively remove endpoints (tips) from a skeletonized image. The pruning algorithm was inspired by Jean-Patrick Pommier: https://gist.github.com/jeanpat/5712699 diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index bfe31a0c0..6aff72ec4 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -15,7 +15,7 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): mask = (Optional) binary mask for debugging. If provided, debug image will be overlaid on the mask. label = Optional label parameter, modifies the variable name of observations recorded (default = pcv.params.sample_label). - + Returns: sorted_ids = Optimal assignment of leaf objects based on inner-segment y-coordinates @@ -51,12 +51,11 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Reset debug mode params.debug = debug _debug(visual=labeled_img, filename=os.path.join(params.debug_outdir, f"{params.device}_segment_ends.png")) - + # Determine optimal segment order by y-coordinate order - d = {} + d = {} for i, coord in enumerate(inner_list): - d[coord[1]] = i - + d[coord[1]] = i keys = list(d.keys()) values = list(d.values()) sorted_key_index = np.argsort(keys) From 85c2c9fe20a6a1bcc6d4929b7728aa5bd26aad88 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Fri, 3 Jan 2025 09:03:24 -0600 Subject: [PATCH 31/36] add debug images for segment ends --- .../segment_ends/segment_end_pts.png | Bin 0 -> 27889 bytes .../segment_ends/setaria_mask.png | Bin 0 -> 23563 bytes docs/segment_ends.md | 14 +++----------- 3 files changed, 3 insertions(+), 11 deletions(-) create mode 100644 docs/img/documentation_images/segment_ends/segment_end_pts.png create mode 100644 docs/img/documentation_images/segment_ends/setaria_mask.png diff --git a/docs/img/documentation_images/segment_ends/segment_end_pts.png b/docs/img/documentation_images/segment_ends/segment_end_pts.png new file mode 100644 index 0000000000000000000000000000000000000000..89b4493c8ed89230498e4907a30bd15399781b81 GIT binary patch literal 27889 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 Date: Fri, 3 Jan 2025 14:33:04 -0600 Subject: [PATCH 32/36] return sorted objs instead of ordered labels --- plantcv/plantcv/morphology/segment_ends.py | 11 ++++++----- plantcv/plantcv/morphology/segment_id.py | 10 +++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index 6aff72ec4..fed10e0fa 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -7,7 +7,7 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): - """Find tips and segment branch points . + """Find tips and segment branch points. Inputs: skel_img = Skeletonized image @@ -17,7 +17,7 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): observations recorded (default = pcv.params.sample_label). Returns: - sorted_ids = Optimal assignment of leaf objects based on inner-segment y-coordinates + sorted_obs = Reordered segments based on segment branch point y-coordinates :param segmented_img: numpy.ndarray :param leaf_objects: list @@ -55,9 +55,10 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Determine optimal segment order by y-coordinate order d = {} for i, coord in enumerate(inner_list): - d[coord[1]] = i + d[coord[1]] = leaf_objects[i] # y-coord is the key and index the value keys = list(d.keys()) values = list(d.values()) sorted_key_index = np.argsort(keys) - sorted_ids = [values[i] for i in sorted_key_index] - return sorted_ids[::-1] + sorted_objs = [values[i] for i in sorted_key_index[::-1]] + + return sorted_objs diff --git a/plantcv/plantcv/morphology/segment_id.py b/plantcv/plantcv/morphology/segment_id.py index a1a89e109..d7c159959 100644 --- a/plantcv/plantcv/morphology/segment_id.py +++ b/plantcv/plantcv/morphology/segment_id.py @@ -45,10 +45,10 @@ def segment_id(skel_img, objects, mask=None, optimal_assignment=None): color_index = optimal_assignment[i] else: color_index = i - cv2.drawContours(segmented_img, cnt, -1, rand_color[color_index], params.line_thickness, lineType=8) + cv2.drawContours(segmented_img, cnt, -1, rand_color[i], params.line_thickness, lineType=8) # Store coordinates for labels - label_coord_x.append(objects[i][0][0][0]) - label_coord_y.append(objects[i][0][0][1]) + label_coord_x.append(objects[color_index][0][0][0]) + label_coord_y.append(objects[color_index][0][0][1]) labeled_img = segmented_img.copy() @@ -61,8 +61,8 @@ def segment_id(skel_img, objects, mask=None, optimal_assignment=None): text = f"{i}" color_index = i # Label segments - w = label_coord_x[i] - h = label_coord_y[i] + w = label_coord_x[color_index] + h = label_coord_y[color_index] cv2.putText(img=labeled_img, text=text, org=(w, h), fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=params.text_size, color=rand_color[color_index], thickness=params.text_thickness) From 2e4149dd33b2adf0220bfc7357567accea8f8bc3 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Fri, 3 Jan 2025 14:57:29 -0600 Subject: [PATCH 33/36] Update mkdocs.yml --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index a0e83e2d7..d625c7455 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -118,6 +118,7 @@ nav: - 'Segment Angles': segment_angle.md - 'Combine Segments': segment_combine.md - 'Segment Curvature': segment_curvature.md + - 'Segment Ends': segment_ends.md - 'Segment Euclidean Length': segment_euclidean_length.md - 'Segment ID': segment_id.md - 'Segment Insertion Angle': segment_insertion_angle.md From fa7f689548616f834b87d829b53b9d70634d7c8e Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Fri, 3 Jan 2025 14:57:43 -0600 Subject: [PATCH 34/36] update docs to reflect new sorting approach --- docs/segment_ends.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/segment_ends.md b/docs/segment_ends.md index d087398e4..b51b3db12 100644 --- a/docs/segment_ends.md +++ b/docs/segment_ends.md @@ -4,7 +4,7 @@ Find segment tip and inner branch-point coordinates, and sort them by the y-coor **plantcv.morphology.segment_ends**(*skel_img, leaf_objects, mask=None, label=None*) -**returns** Optimal assignment of segment IDs +**returns** Re-ordered leaf segments - **Parameters:** - skel_img - Skeleton image (output from [plantcv.morphology.skeletonize](skeletonize.md)) @@ -29,18 +29,17 @@ pcv.params.debug = "plot" # Adjust line thickness with the global line thickness parameter (default = 5) pcv.params.line_thickness = 3 -sorted_ids = pcv.morphology.segment_ends(skel_img=skeleton, +sorted_obs = pcv.morphology.segment_ends(skel_img=skeleton, leaf_objects=leaf_obj, mask=plant_mask, label="leaves") segmented_img, leaves_labeled = pcv.morphology.segment_id(skel_img=skeleton, objects=leaf_obj, - mask=plant_mask, - optimal_assignment=sorted_ids) + mask=plant_mask # Without optimal assignment leaf tips are used by default segmented_img, leaves_labeled = pcv.morphology.segment_id(skel_img=skeleton, - objects=leaf_obj, + objects=sorted_obs, mask=plant_mask) ``` From 2b8480ad23408935205c9b8ae893791856593c1c Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Wed, 22 Jan 2025 15:04:20 -0600 Subject: [PATCH 35/36] update segment_ends sorting logic --- plantcv/plantcv/morphology/segment_ends.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plantcv/plantcv/morphology/segment_ends.py b/plantcv/plantcv/morphology/segment_ends.py index fed10e0fa..e60790cba 100644 --- a/plantcv/plantcv/morphology/segment_ends.py +++ b/plantcv/plantcv/morphology/segment_ends.py @@ -55,10 +55,9 @@ def segment_ends(skel_img, leaf_objects, mask=None, label=None): # Determine optimal segment order by y-coordinate order d = {} for i, coord in enumerate(inner_list): - d[coord[1]] = leaf_objects[i] # y-coord is the key and index the value - keys = list(d.keys()) + d[i] = coord[1] # y-coord is the key and index the value values = list(d.values()) - sorted_key_index = np.argsort(keys) - sorted_objs = [values[i] for i in sorted_key_index[::-1]] + sorted_key_index = np.argsort(values) + sorted_objs = [leaf_objects[i] for i in sorted_key_index[::-1]] return sorted_objs From a3d0cfffb4788e6df677127ea41f6bb9f96556a3 Mon Sep 17 00:00:00 2001 From: HaleySchuhl Date: Thu, 30 Jan 2025 09:34:22 -0600 Subject: [PATCH 36/36] minor tweaks to docs for clarity and accuracy --- docs/segment_ends.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/segment_ends.md b/docs/segment_ends.md index b51b3db12..c71a1b75e 100644 --- a/docs/segment_ends.md +++ b/docs/segment_ends.md @@ -9,7 +9,7 @@ Find segment tip and inner branch-point coordinates, and sort them by the y-coor - **Parameters:** - skel_img - Skeleton image (output from [plantcv.morphology.skeletonize](skeletonize.md)) - leaf_objects - Secondary segment objects (output from [plantcv.morphology.segment_sort](segment_sort.md)). - - mask - Binary mask for plotting. If provided, segmented and labeled image will be overlaid on the mask (optional). + - mask - Binary mask for plotting. If provided, the debugging image will be overlaid on the mask (optional). - label - Optional label parameter, modifies the variable name of observations recorded. (default = `pcv.params.sample_label`) - **Context:** - Aims to sort leaf objects by biological age. This tends to work somewhat consistently for grass species that have leav @@ -26,18 +26,18 @@ from plantcv import plantcv as pcv # or "plot" (Jupyter Notebooks or X11) pcv.params.debug = "plot" -# Adjust line thickness with the global line thickness parameter (default = 5) +# Adjust point thickness with the global line_thickness parameter (default = 5) pcv.params.line_thickness = 3 sorted_obs = pcv.morphology.segment_ends(skel_img=skeleton, - leaf_objects=leaf_obj, + leaf_objects=leaf_objs, mask=plant_mask, label="leaves") segmented_img, leaves_labeled = pcv.morphology.segment_id(skel_img=skeleton, - objects=leaf_obj, + objects=leaf_objs, mask=plant_mask -# Without optimal assignment leaf tips are used by default +# Without ID re-assignment segmented_img, leaves_labeled = pcv.morphology.segment_id(skel_img=skeleton, objects=sorted_obs, mask=plant_mask)