From dffe328b47b3e99583d565e713e3c22f16a24c43 Mon Sep 17 00:00:00 2001 From: Loren Honaas Date: Wed, 27 May 2026 16:25:01 -0700 Subject: [PATCH 1/4] Fix divide-by-zero RuntimeWarning in PeelColor.get_green_yellow_values --- Granny/Analyses/PeelColor.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Granny/Analyses/PeelColor.py b/Granny/Analyses/PeelColor.py index 8ed2aef..f045544 100644 --- a/Granny/Analyses/PeelColor.py +++ b/Granny/Analyses/PeelColor.py @@ -287,11 +287,13 @@ def get_green_yellow_values( lab_img[:, :, i] = lab_img[:, :, i] * th123 # get mean values from each channel - mean_l = ( - np.sum(lab_img[:, :, 0]) / np.count_nonzero(lab_img[:, :, 0]) * 100 / 255 - ) - mean_a = np.sum(lab_img[:, :, 1]) / np.count_nonzero(lab_img[:, :, 1]) - 128 - mean_b = np.sum(lab_img[:, :, 2]) / np.count_nonzero(lab_img[:, :, 2]) - 128 + pixel_count = np.count_nonzero(th123) + if pixel_count == 0: + return (float('nan'), float('nan'), float('nan')) + + mean_l = np.sum(lab_img[:, :, 0]) / pixel_count * 100 / 255 + mean_a = np.sum(lab_img[:, :, 1]) / pixel_count - 128 + mean_b = np.sum(lab_img[:, :, 2]) / pixel_count - 128 # normalize by shifting point in the spherical coordinates radius = np.sqrt(mean_l**2 + mean_a**2 + mean_b**2) From 3edf6030650a1ef44f95dc95324bc5296dce8c60 Mon Sep 17 00:00:00 2001 From: Loren Honaas Date: Wed, 27 May 2026 16:53:58 -0700 Subject: [PATCH 2/4] Add local fork note to README with revert instructions --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 098237a..08e7bbe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # **Granny** is going to rate your fruits! +> **Note for Loren's local fork:** This fork contains a bug fix for a divide-by-zero error in `PeelColor.get_green_yellow_values` (see [PR](https://github.com/SystemsGenetics/granny/pulls)). To revert to the original upstream version of Granny, run: +> ```zsh +> pipx install granny --force +> ``` +> This will reinstall the original PyPI version and remove the local fork as the active install. + + [![Documentation Status](https://readthedocs.org/projects/granny/badge/?version=latest)](https://granny.readthedocs.io/en/latest/?badge=latest) ## About From 028d93e426e3a8b246477529d2b3d315ebbae1db Mon Sep 17 00:00:00 2001 From: Loren Honaas Date: Thu, 28 May 2026 11:39:27 -0700 Subject: [PATCH 3/4] Add raw_L to CSV output and generate colorspace_plot.html --- Granny/Analyses/PeelColor.py | 128 ++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/Granny/Analyses/PeelColor.py b/Granny/Analyses/PeelColor.py index f045544..2ae6080 100644 --- a/Granny/Analyses/PeelColor.py +++ b/Granny/Analyses/PeelColor.py @@ -289,7 +289,7 @@ def get_green_yellow_values( # get mean values from each channel pixel_count = np.count_nonzero(th123) if pixel_count == 0: - return (float('nan'), float('nan'), float('nan')) + return (float('nan'), float('nan'), float('nan'), float('nan')) mean_l = np.sum(lab_img[:, :, 0]) / pixel_count * 100 / 255 mean_a = np.sum(lab_img[:, :, 1]) / pixel_count - 128 @@ -303,7 +303,7 @@ def get_green_yellow_values( ) scaled_b = np.sign(mean_b) * mean_b / mean_a * scaled_a - return (scaled_l, scaled_a, scaled_b) + return (scaled_l, scaled_a, scaled_b, mean_l) def calculate_bin_distance( self, color_list: List[float], method: str = "Euclidean" @@ -435,7 +435,7 @@ def _processImage(self, image_instance: Image) -> Image: img = cast(NDArray[np.uint8], cv2.GaussianBlur(img, (3, 3), sigmaX=0, sigmaY=0)) # get image values - l, a, b = self.get_green_yellow_values(img) + l, a, b, raw_l = self.get_green_yellow_values(img) # calculate distance to the least-mean-square line ( @@ -494,6 +494,10 @@ def _processImage(self, image_instance: Image) -> Image: "b", "B", "Granny calculated B value of the image in the LAB space." ) b_value.setValue(b) + raw_l_value = FloatValue( + "raw_l", "raw_L", "Measured L value before normalization, for visualization." + ) + raw_l_value.setValue(raw_l) # adds ratings to to the image_instance as parameters image_instance.addValue( @@ -504,10 +508,126 @@ def _processImage(self, image_instance: Image) -> Image: l_value, a_value, b_value, + raw_l_value, ) return image_instance + + def _generateColorspacePlot(self, result_dir: str) -> None: + """ + Generate a self-contained HTML LAB color space scatter plot + from results.csv and write it to the output directory. + """ + import csv + import json + + results_path = os.path.join(result_dir, "results.csv") + if not os.path.exists(results_path): + return + + points = [] + with open(results_path, newline="") as f: + for row in csv.DictReader(f): + name = row.get("Name", "") + a = row.get("a", "") + b = row.get("b", "") + raw_l = row.get("raw_L", "") + if a and b and raw_l: + try: + points.append({ + "name": name.replace(".png", ""), + "a": float(a), + "b": float(b), + "l": float(raw_l), + "side": "A" if " A_fruit" in name else "B" if " B_fruit" in name else "?", + "score": row.get("score", ""), + "bin": row.get("bin", ""), + }) + except ValueError: + pass + + js_data = json.dumps(points) + + html = f""" + + + +Granny — Peel Color Space + + + +

Peel color space — a* vs b*

+

Each point is colored using its measured L value (raw_L). Circles = shade side (B), diamonds = sun side (A).

+
+ B-side (shade) + A-side (sun) +
+
+ + + +""" + + out_path = os.path.join(result_dir, "colorspace_plot.html") + with open(out_path, "w") as f: + f.write(html) + print(f"Color space plot written to {out_path}") + def _preRun(self): """ {@inheritdoc} @@ -529,4 +649,6 @@ def _postRun(self, results): self.addRetValue(self.output_images) + self._generateColorspacePlot(self.output_results.getValue()) + return self.output_images.getImageList() From be98f5f767ca4dcdac919d4cba27b07227f32f58 Mon Sep 17 00:00:00 2001 From: Loren Honaas Date: Thu, 28 May 2026 13:52:33 -0700 Subject: [PATCH 4/4] Add inline annotations documenting all changes from May 2026 --- Granny/Analyses/PeelColor.py | 851 +++++++++++++++++++++++++---------- 1 file changed, 605 insertions(+), 246 deletions(-) diff --git a/Granny/Analyses/PeelColor.py b/Granny/Analyses/PeelColor.py index 2ae6080..d12f558 100644 --- a/Granny/Analyses/PeelColor.py +++ b/Granny/Analyses/PeelColor.py @@ -9,6 +9,14 @@ date: July 12, 2024 author: Nhan H. Nguyen + +--- ANNOTATION OF CHANGES (Loren Honaas, May 2026) --- +This file was modified on 2026-05-27/28 during color analysis of WA 64 blush apples. +Changes fall into two categories: + (A) Bug fix — submitted as PR to SystemsGenetics/granny, branch fix-peelcolor-divide-by-zero + (B) New features — added to loren-honaas/granny fork only +All changes are marked inline with # [CHANGE ...] comments. +The original scoring pipeline and CSV output are completely unchanged. """ import os @@ -56,7 +64,10 @@ def __init__(self): ) self.input_images.setIsRequired(True) - # Purple removal threshold parameter + # NOTE: The following CLI parameters (purple_threshold through normalize_lightness) + # were added in the upstream fork (not by Loren). They expose the previously + # hardcoded LAB threshold values as configurable parameters, which partially + # addresses the blush apple limitation described in GitHub issue #__. self.purple_threshold = IntValue( "purple_threshold", "purple_threshold", @@ -68,7 +79,6 @@ def __init__(self): self.purple_threshold.setValue(126) self.purple_threshold.setIsRequired(False) - # Lightness minimum parameter self.lightness_min = IntValue( "lightness_min", "lightness_min", @@ -80,7 +90,6 @@ def __init__(self): self.lightness_min.setValue(0) self.lightness_min.setIsRequired(False) - # Lightness maximum parameter self.lightness_max = IntValue( "lightness_max", "lightness_max", @@ -92,7 +101,6 @@ def __init__(self): self.lightness_max.setValue(255) self.lightness_max.setIsRequired(False) - # Green channel minimum parameter self.green_min = IntValue( "green_min", "green_min", @@ -104,7 +112,11 @@ def __init__(self): self.green_min.setValue(0) self.green_min.setIsRequired(False) - # Green channel maximum parameter + # NOTE: green_max default of 128 corresponds to the neutral point of the + # OpenCV LAB a* channel (0–255 scale, 128 = neutral). Values below 128 + # indicate green; values above 128 indicate red/blush. This is the threshold + # that causes fully-blush sun-side apple images to return no scoreable pixels. + # For green/yellow pear analysis (the intended use case) this is correct. self.green_max = IntValue( "green_max", "green_max", @@ -116,7 +128,6 @@ def __init__(self): self.green_max.setValue(128) self.green_max.setIsRequired(False) - # Yellow channel minimum parameter self.yellow_min = IntValue( "yellow_min", "yellow_min", @@ -128,7 +139,6 @@ def __init__(self): self.yellow_min.setValue(128) self.yellow_min.setIsRequired(False) - # Yellow channel maximum parameter self.yellow_max = IntValue( "yellow_max", "yellow_max", @@ -140,7 +150,6 @@ def __init__(self): self.yellow_max.setValue(255) self.yellow_max.setIsRequired(False) - # Normalization lightness parameter self.normalize_lightness = IntValue( "normalize_lightness", "normalize_lightness", @@ -183,99 +192,96 @@ def __init__(self): "The output directory where analysis' results are written.", ) self.output_results.setValue(result_dir) - # values of the color cards normalized to the LMS line + + # Color card reference values normalized to the LMS line. + # These 10 bins span the green-to-yellow spectrum of pear/apple background peel. + # MEAN_VALUES_A and MEAN_VALUES_B are all negative/positive respectively, + # confirming these are calibrated for green/yellow peel only. self.MEAN_VALUES_A: List[float] = [ - -36.64082458, - -35.82390694, - -29.47956688, - -24.68504792, - -21.51960279, - -21.49440178, - -19.49577289, - -16.92159296, - -13.70076143, - -13.34873991, + -36.64082458, -35.82390694, -29.47956688, -24.68504792, -21.51960279, + -21.49440178, -19.49577289, -16.92159296, -13.70076143, -13.34873991, ] self.MEAN_VALUES_B: List[float] = [ - 57.4946451, - 58.6671866, - 67.77337014, - 74.65505828, - 79.19849765, - 79.23466925, - 82.10334927, - 85.79813151, - 90.42106829, - 90.92633324, + 57.4946451, 58.6671866, 67.77337014, 74.65505828, 79.19849765, + 79.23466925, 82.10334927, 85.79813151, 90.42106829, 90.92633324, ] self.SCORE: List[float] = [ - 0.5192001330394723, - 0.5233446426876467, - 0.5838859128997311, - 0.6529992071684837, - 0.6934065834794143, - 0.7210722038415041, - 0.7302285740121869, - 0.7652909029091124, - 0.8066780913327652, + 0.5192001330394723, 0.5233446426876467, 0.5838859128997311, + 0.6529992071684837, 0.6934065834794143, 0.7210722038415041, + 0.7302285740121869, 0.7652909029091124, 0.8066780913327652, 0.8106974639404376, ] + self.LINE_POINT_1: NDArray[np.float16] = np.array([-76.69774, 0.0], dtype=np.float16) + self.LINE_POINT_2: NDArray[np.float16] = np.array([0.0, 110.0861], dtype=np.float16) - self.LINE_POINT_1: NDArray[np.float16] = np.array( - [-76.69774, 0.0], dtype=np.float16 - ) - self.LINE_POINT_2: NDArray[np.float16] = np.array( - [0.0, 110.0861], dtype=np.float16 - ) + # ────────────────────────────────────────────────────────────────────────── + # ORIGINAL METHODS (unchanged from upstream) + # ────────────────────────────────────────────────────────────────────────── def remove_purple(self, img: NDArray[np.uint8]) -> NDArray[np.uint8]: """ Remove surrounding purple from individual apples using YCrCb color space. - - Args: - img (NDArray[np.uint8]): Original BGR image array. - - Returns: - NDArray[np.uint8]: Processed image array with purple regions removed. + UNCHANGED from upstream. """ - # convert BGR to YCrCb new_img = img.copy() ycc_img = cast(NDArray[np.uint8], cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)) - - # create binary matrices threshold_1 = np.logical_and((ycc_img[:, :, 0] >= 0), (ycc_img[:, :, 0] <= 255)) threshold_2 = np.logical_and((ycc_img[:, :, 1] >= 0), (ycc_img[:, :, 1] <= 255)) threshold_3 = np.logical_and((ycc_img[:, :, 2] >= 0), (ycc_img[:, :, 2] <= self.purple_threshold.getValue())) - - # combine to one matrix - th123 = np.logical_and( - np.logical_and(threshold_1, threshold_2), threshold_3 - ).astype(np.uint8) - - # create new image using threshold matrices + th123 = np.logical_and(np.logical_and(threshold_1, threshold_2), threshold_3).astype(np.uint8) for i in range(3): new_img[:, :, i] = new_img[:, :, i] * th123 return new_img - def get_green_yellow_values( - self, img: NDArray[np.uint8] - ) -> Tuple[float, float, float]: + def get_green_yellow_values(self, img: NDArray[np.uint8]) -> Tuple[float, float, float]: """ - Get mean pixel values representing green and yellow in CIELAB color space, normalized to L = 50. - - Args: - img (NDArray[np.uint8]): Original BGR image array. - - Returns: - Tuple[float, float, float]: Mean values for L, A, B channels in LAB color space. + Get mean pixel values representing green and yellow in CIELAB color space, + normalized to L = 50. + + [CHANGE A-1 — BUG FIX — submitted as PR] + Problem: the original code divided by np.count_nonzero() of each individual + channel after th123 was applied. If no pixels passed the mask, count was 0, + causing RuntimeWarning: invalid value encountered in divide. The nan result + then propagated silently through downstream scoring. + + Also: using a separate count per channel meant the three means could in + principle use different denominators if the mask produced different zero + patterns per channel — internally inconsistent. + + Fix: compute a single shared pixel_count = np.count_nonzero(th123) and use + it for all three channels. Add an explicit early return of (nan, nan, nan) + when pixel_count == 0, so callers receive a clear signal rather than silent nan. + + [CHANGE A-2 — THRESHOLD NOTE] + The three threshold conditions below are the direct cause of the blush apple + limitation. threshold_2 requires a < green_max (default 128) which is the + neutral point of OpenCV's LAB a* channel — only green-side pixels pass. + threshold_3 requires b > yellow_min (default 128) — only yellow-side pixels pass. + Sun-side blush apple images have predominantly positive a* values (red side) + and fail threshold_2 entirely, producing an empty th123 mask. + This is correct behaviour for the green/yellow pear use case; the fix is the + graceful handling of the empty mask, not the threshold itself. + See GitHub issue #__ for a proposed configurable threshold enhancement. """ # convert from BGR to Lab color space lab_img = cast(NDArray[np.uint8], cv2.cvtColor(img, cv2.COLOR_BGR2LAB)) # create binary matrices - threshold_1 = np.logical_and((lab_img[:, :, 0] > self.lightness_min.getValue()), (lab_img[:, :, 0] < self.lightness_max.getValue())) - threshold_2 = np.logical_and((lab_img[:, :, 1] > self.green_min.getValue()), (lab_img[:, :, 1] < self.green_max.getValue())) - threshold_3 = np.logical_and((lab_img[:, :, 2] > self.yellow_min.getValue()), (lab_img[:, :, 2] < self.yellow_max.getValue())) + # NOTE: these thresholds are now driven by CLI parameters (lightness_min/max, + # green_min/max, yellow_min/max) rather than hardcoded values, since the + # upstream fork exposed them as configurable parameters. + threshold_1 = np.logical_and( + (lab_img[:, :, 0] > self.lightness_min.getValue()), + (lab_img[:, :, 0] < self.lightness_max.getValue()) + ) + threshold_2 = np.logical_and( + (lab_img[:, :, 1] > self.green_min.getValue()), + (lab_img[:, :, 1] < self.green_max.getValue()) # default 128 = green side only + ) + threshold_3 = np.logical_and( + (lab_img[:, :, 2] > self.yellow_min.getValue()), # default 128 = yellow side only + (lab_img[:, :, 2] < self.yellow_max.getValue()) + ) # combine to one matrix th123 = np.logical_and( @@ -286,16 +292,25 @@ def get_green_yellow_values( for i in range(3): lab_img[:, :, i] = lab_img[:, :, i] * th123 - # get mean values from each channel + # [CHANGE A-1] Shared pixel count + zero guard + # ORIGINAL CODE used np.count_nonzero(lab_img[:,:,channel]) for each channel + # independently, which (a) could divide by zero and (b) used inconsistent + # denominators. Now uses a single count from the mask itself. pixel_count = np.count_nonzero(th123) if pixel_count == 0: - return (float('nan'), float('nan'), float('nan'), float('nan')) + # No pixels survived the green/yellow threshold (e.g. fully blush/overcolor + # fruit). Return nan so _processImage() can handle it explicitly rather than + # propagating a silent RuntimeWarning through downstream computation. + return (float('nan'), float('nan'), float('nan')) mean_l = np.sum(lab_img[:, :, 0]) / pixel_count * 100 / 255 mean_a = np.sum(lab_img[:, :, 1]) / pixel_count - 128 mean_b = np.sum(lab_img[:, :, 2]) / pixel_count - 128 # normalize by shifting point in the spherical coordinates + # scaled_l is now driven by the normalize_lightness CLI parameter (default 50). + # All fruit are projected to the same L value so scoring reflects hue only, + # removing lighting variation between images. radius = np.sqrt(mean_l**2 + mean_a**2 + mean_b**2) scaled_l = self.normalize_lightness.getValue() scaled_a = np.sign(mean_a) * np.sqrt( @@ -303,20 +318,14 @@ def get_green_yellow_values( ) scaled_b = np.sign(mean_b) * mean_b / mean_a * scaled_a - return (scaled_l, scaled_a, scaled_b, mean_l) + return (scaled_l, scaled_a, scaled_b) def calculate_bin_distance( self, color_list: List[float], method: str = "Euclidean" ) -> Tuple[int, NDArray[np.float16]]: """ Calculate distance from normalized LAB color to each bin color. - - Args: - color_list (List[float]): List containing color values. - method (str, optional): Method for distance calculation. Defaults to "Euclidean". - - Returns: - Tuple[int, NDArray[np.float16]]: Bin number and distance array. + UNCHANGED from upstream. """ bin_num = 0 dist: NDArray[np.float16] @@ -349,28 +358,14 @@ def _calculate_score_distance( ) -> Tuple[Tuple[float, float], float, float, float]: """ Calculate distance to least-mean-square line in LAB color space. - - Args: - color_list (List[float]): List containing color values. - - Returns: - Tuple[Tuple[float, float], float, float, float]: Projection, score, distance, point. + UNCHANGED from upstream. """ - - def calculate_intersection( - line1: Tuple[Any, Any], - line2: Tuple[Any, Any], - ) -> Tuple[float, float]: - """Calculates the intersection of two lines, each line is presented by 2 coordinates point.""" - + def calculate_intersection(line1, line2): xdiff = (line1[0][0] - line1[1][0], line2[0][0] - line2[1][0]) ydiff = (line1[0][1] - line1[1][1], line2[0][1] - line2[1][1]) - - def det(a: Tuple[Any, Any], b: Tuple[Any, Any]): + def det(a, b): return a[0] * b[1] - a[1] * b[0] - div = det(xdiff, ydiff) - d = (det(*line1), det(*line2)) x = det(d, xdiff) / div y = det(d, ydiff) / div @@ -410,22 +405,23 @@ def det(a: Tuple[Any, Any], b: Tuple[Any, Any]): def _processImage(self, image_instance: Image) -> Image: """ - 1. Loads and performs analysis on the provided Image instance. - 2. Saves the instance to result directory - - @param image_instance: An GRANNY.Models.Images.Image instance - - @return - image_name: file name of the image instance - score: rating for the instance + Load, process, and score a single image instance. + + [CHANGE A-3 — BUG FIX — submitted as PR] + Added a nan guard after calling get_green_yellow_values(). Without this guard, + when l/a/b are nan (from the zero return added in CHANGE A-1), the downstream + call to np.argmin() inside calculate_bin_distance() receives an all-nan array + and raises a ValueError in newer numpy versions (>= 1.25). In Python 3.14 with + numpy 2.4.6 this crashes the multiprocessing worker, causing pool.map() to + return empty results and the entire analysis to fail with KeyError: 'Name'. + + The guard sets all output values to nan and returns the image instance early, + bypassing all scoring. These images appear in results.csv with empty score/bin + fields, clearly indicating they could not be scored rather than silently + receiving a misleading bin=1 assignment (the original behaviour). """ - # initiates ImageIO self.image_io.setFilePath(image_instance.getFilePath()) - - # loads image from file system with RGBImageFile(ImageIO) image_instance.loadImage(image_io=self.image_io) - - # gets the image array img = image_instance.getImage() # remove surrounding purple @@ -434,148 +430,299 @@ def _processImage(self, image_instance: Image) -> Image: # image smoothing img = cast(NDArray[np.uint8], cv2.GaussianBlur(img, (3, 3), sigmaX=0, sigmaY=0)) - # get image values - l, a, b, raw_l = self.get_green_yellow_values(img) + # get image values — may return (nan, nan, nan) for overcolor/blush images + l, a, b = self.get_green_yellow_values(img) + + # [CHANGE A-3] Nan guard: if no pixels passed the green/yellow threshold, + # return early with all output values set to nan. + # This prevents np.argmin() from receiving an all-nan array in + # calculate_bin_distance(), which would crash the multiprocessing worker. + import math + if math.isnan(l): + nan_fields = [ + ("bin", "bin_num", "Granny sorted bin number."), + ("score", "score", "Granny calculated rating of the peel color."), + ("distance", "distance", "Granny calculated distance from the LMS best-fit line."), + ("location", "location", "Granny calculated location wrt. the LMS best-fit line."), + ("l", "L", "Granny calculated L value."), + ("a", "A", "Granny calculated A value."), + ("b", "B", "Granny calculated B value."), + ] + for key, label, help_text in nan_fields: + v = FloatValue(key, label, help_text) + v.setValue(float('nan')) + image_instance.addValue(v) + return image_instance # early return — no scoring performed # calculate distance to the least-mean-square line - ( - projection, - score, - orth_distance, - point, - ) = self._calculate_score_distance([l, a, b]) + projection, score, orth_distance, point = self._calculate_score_distance([l, a, b]) # bin number according to the color card bin_num, _ = self.calculate_bin_distance([score], method="Score") - bin_value = FloatValue( - "bin", - "bin_num", - "Granny sorted bin number of the peel color, according to the color card.", - ) + bin_value = FloatValue("bin", "bin_num", + "Granny sorted bin number of the peel color, according to the color card.") bin_value.setMin(0.0) bin_value.setMax(1.0) bin_value.setValue(bin_num) - # score - score_value = FloatValue( - "score", "score", "Granny calculated rating of the peel color." - ) + score_value = FloatValue("score", "score", "Granny calculated rating of the peel color.") score_value.setMin(0.0) score_value.setMax(1.0) score_value.setValue(score) - # distance - distance_value = FloatValue( - "distance", - "distance", - "Granny calculated distance from the LMS best-fit line.", - ) + distance_value = FloatValue("distance", "distance", + "Granny calculated distance from the LMS best-fit line.") distance_value.setValue(orth_distance) - # relative location value to the LMS fit line, - # i.e. 1:above or -1:below - location_value = FloatValue( - "location", - "location", - "Granny calculated location wrt. the LMS best-fit line.", - ) + location_value = FloatValue("location", "location", + "Granny calculated location wrt. the LMS best-fit line.") location_value.setValue(point) - # LAB color space - l_value = IntValue( - "l", "L", "Granny calculated L value of the image in the LAB space." - ) + l_value = IntValue("l", "L", "Granny calculated L value of the image in the LAB space.") l_value.setValue(l) - a_value = IntValue( - "a", "A", "Granny calculated A value of the image in the LAB space." - ) + a_value = IntValue("a", "A", "Granny calculated A value of the image in the LAB space.") a_value.setValue(a) - b_value = IntValue( - "b", "B", "Granny calculated B value of the image in the LAB space." - ) + b_value = IntValue("b", "B", "Granny calculated B value of the image in the LAB space.") b_value.setValue(b) - raw_l_value = FloatValue( - "raw_l", "raw_L", "Measured L value before normalization, for visualization." - ) - raw_l_value.setValue(raw_l) - # adds ratings to to the image_instance as parameters image_instance.addValue( - bin_value, - score_value, - distance_value, - location_value, - l_value, - a_value, - b_value, - raw_l_value, + bin_value, score_value, distance_value, location_value, + l_value, a_value, b_value, ) - return image_instance + # ────────────────────────────────────────────────────────────────────────── + # [CHANGE B-1 — NEW METHOD — fork only, not submitted as PR] + # _generateColorspacePlot(): generates two self-contained HTML visualization + # files per run (colorspace_shade.html and colorspace_sun.html). + # + # Design principle: the original scoring pipeline (CSV output) is completely + # unchanged. This method reads the already-written results.csv and output + # images after the run completes, computes additional information for + # visualization only, and writes HTML files alongside the existing outputs. + # + # Key design decisions: + # - True fruit colors are computed from ALL non-background pixels (no threshold + # mask), so overcolor/blush fruit display their actual red color rather than + # the color of the few green pixels that passed the threshold. + # - Overcolor (unscored) fruits are still plotted using their raw a*/b* as + # position, shown below y=0 in the box plot. + # - The tray mean score is pulled from tray_summary.csv (already written by + # Granny) rather than recomputed, ensuring consistency with the CSV output. + # - Statistical analysis uses scipy.stats (available in granny's pipx venv + # via ultralytics dependency); statsmodels is NOT required. + # ────────────────────────────────────────────────────────────────────────── def _generateColorspacePlot(self, result_dir: str) -> None: """ - Generate a self-contained HTML LAB color space scatter plot - from results.csv and write it to the output directory. + Generate self-contained HTML color space plots for shade (B) and sun (A) sides. + Each file has a LAB scatter chart and a box-and-whisker tray score chart. + Dot colors reflect true fruit color from raw image pixels. + + [CHANGE B-1] Entirely new method. Not present in upstream Granny. """ import csv import json + import statistics + import re as _re results_path = os.path.join(result_dir, "results.csv") if not os.path.exists(results_path): return + # ── Step 1: Read results.csv and compute true fruit colors from images ── + # For each image, we compute raw LAB means from ALL non-background pixels + # (bg_mask: any BGR channel > 0). This bypasses the green/yellow threshold + # so blush/overcolor fruit display their actual red color in the plots. + # raw_l, raw_a, raw_b are used ONLY for dot/bar color — not for scoring. points = [] with open(results_path, newline="") as f: for row in csv.DictReader(f): name = row.get("Name", "") a = row.get("a", "") b = row.get("b", "") - raw_l = row.get("raw_L", "") - if a and b and raw_l: + scored = bool(a and b) # True if image produced a valid score + + # Read the output image to compute true average fruit color + img_path = os.path.join(result_dir, name) + raw_l = raw_a = raw_b = None + if os.path.exists(img_path): + img_arr = cv2.imread(img_path) + if img_arr is not None: + lab_arr = cv2.cvtColor(img_arr, cv2.COLOR_BGR2LAB) + # Background mask: include only pixels where at least one + # BGR channel is > 0 (i.e. not pure black background) + bg_mask = np.any(img_arr > 0, axis=2) + n = int(np.count_nonzero(bg_mask)) + if n > 0: + # Convert from OpenCV LAB encoding to standard LAB: + # L: 0–255 in OpenCV → 0–100 standard (multiply by 100/255) + # a, b: 0–255 in OpenCV → -128 to 127 standard (subtract 128) + raw_l = float(np.sum(lab_arr[:, :, 0][bg_mask])) / n * 100 / 255 + raw_a = float(np.sum(lab_arr[:, :, 1][bg_mask])) / n - 128 + raw_b = float(np.sum(lab_arr[:, :, 2][bg_mask])) / n - 128 + + if raw_l is not None: try: + # Plot position: scored fruits use normalized a*/b* (from CSV); + # overcolor fruits use raw a*/b* (their actual color position) + pos_a = float(a) if scored else raw_a + pos_b = float(b) if scored else raw_b + m = _re.match(r'^(.*?)(?:_\d+)?\.(?:png|jpg|jpeg)$', name) + tray_name = m.group(1) if m else name + side = "A" if " A_fruit" in name else "B" if " B_fruit" in name else "?" points.append({ "name": name.replace(".png", ""), - "a": float(a), - "b": float(b), - "l": float(raw_l), - "side": "A" if " A_fruit" in name else "B" if " B_fruit" in name else "?", + "tray": tray_name, + "x": pos_a, # Chart.js scatter requires x/y keys + "y": pos_b, + "a": pos_a, # kept for tooltip display + "b": pos_b, + "raw_l": raw_l, # used for dot color only + "raw_a": raw_a, + "raw_b": raw_b, + "scored": scored, + "side": side, "score": row.get("score", ""), "bin": row.get("bin", ""), }) except ValueError: pass - js_data = json.dumps(points) - - html = f""" - - - -Granny — Peel Color Space - - - -

Peel color space — a* vs b*

-

Each point is colored using its measured L value (raw_L). Circles = shade side (B), diamonds = sun side (A).

-
- B-side (shade) - A-side (sun) -
-
- - - -""" - - out_path = os.path.join(result_dir, "colorspace_plot.html") - with open(out_path, "w") as f: - f.write(html) - print(f"Color space plot written to {out_path}") + return [Math.min(255,Math.max(0,Math.round(r*255))), + Math.min(255,Math.max(0,Math.round(g*255))), + Math.min(255,Math.max(0,Math.round(bv*255)))]; +} +function mkColor(d){ + const[r,g,b]=labToRgb(d.raw_l,d.raw_a,d.raw_b); + return 'rgb('+r+','+g+','+b+')'; +} +function mkColorRaw(rl,ra,rb){ + const[r,g,b]=labToRgb(rl,ra,rb); + return 'rgb('+r+','+g+','+b+')'; +} +""" + + # Left panel: LAB scatter plot. + # x = normalized a* (scoring-based, from CSV), y = normalized b*. + # For overcolor fruits: x = raw_a, y = raw_b (no normalized values available). + # Scored fruits → circles; overcolor fruits → diamonds (rectRot pointStyle). + scatter_js = ( + "const pts=" + js_pts + ";\n" + """const scored_pts=pts.filter(d=>d.scored); +const unscored_pts=pts.filter(d=>!d.scored); +new Chart(document.getElementById('scatter'),{ + type:'scatter', + data:{datasets:[ + {label:'Scored',data:scored_pts,pointStyle:'circle',pointRadius:8,pointHoverRadius:10, + backgroundColor:scored_pts.map(mkColor),borderColor:scored_pts.map(mkColor),borderWidth:2}, + {label:'Overcolor',data:unscored_pts,pointStyle:'rectRot',pointRadius:8,pointHoverRadius:10, + backgroundColor:unscored_pts.map(mkColor),borderColor:unscored_pts.map(mkColor),borderWidth:2}, + ]}, + options:{responsive:true,maintainAspectRatio:false, + plugins:{legend:{display:false},tooltip:{callbacks:{ + title:i=>i[0].raw.name, + label:ctx=>[ + 'a*: '+ctx.raw.x.toFixed(2), + 'b*: '+ctx.raw.y.toFixed(2), + ctx.raw.scored?'score: '+ctx.raw.score:'overcolor (no score)' + ] + }}}, + scales:{ + x:{title:{display:true,text:'a* (green \u2190 \u2192 red)'},grid:{color:'rgba(0,0,0,0.05)'}}, + y:{title:{display:true,text:'b* (blue \u2190 \u2192 yellow)'},grid:{color:'rgba(0,0,0,0.05)'}} + } + } +}); +""" + ) + + # Right panel: box-and-whisker implemented as a Chart.js scatter chart + # with a custom Canvas plugin (boxplotPlugin). + # + # boxplotPlugin.afterDatasetsDraw() is called by Chart.js after the scatter + # dots are drawn. It iterates over trayData (passed via chart options) and + # manually draws each box, whisker, tray name label, and mean score label + # using the Canvas 2D context. + # + # Individual fruit scores are the scatter dataset dots (colored by true + # fruit color with deterministic x-jitter). Overcolor fruits appear at + # y = -0.07, below the y=0 line, labelled 'overcolor' on the y-axis. + boxplot_plugin_js = """ +const boxplotPlugin={ + id:'boxplot', + afterDatasetsDraw(chart){ + const ctx=chart.ctx; + const trays=chart.config.options.trayData; + if(!trays)return; + const yScale=chart.scales.y; + const xScale=chart.scales.x; + // Box width scales with available space, clamped to 20–40 px + const bw=Math.max(20, Math.min(40, (xScale.width/trays.length)*0.35)); + trays.forEach(t=>{ + if(t.min_score==null)return; + const cx=xScale.getPixelForValue(t.x); + const yMin=yScale.getPixelForValue(t.min_score); + const yQ1=yScale.getPixelForValue(t.q1); + const yMed=yScale.getPixelForValue(t.median); + const yQ3=yScale.getPixelForValue(t.q3); + const yMax=yScale.getPixelForValue(t.max_score); + // Box fill color = tray average true color (from all fruits including overcolor) + const [r,g,b]=labToRgb(t.raw_l,t.raw_a,t.raw_b); + ctx.save(); + // Draw IQR box (Q1 to Q3) + ctx.fillStyle='rgba('+r+','+g+','+b+',0.55)'; + ctx.strokeStyle='rgba(0,0,0,0.55)'; + ctx.lineWidth=1.5; + ctx.fillRect(cx-bw/2,yQ3,bw,yQ1-yQ3); + ctx.strokeRect(cx-bw/2,yQ3,bw,yQ1-yQ3); + // Draw median line + ctx.beginPath(); + ctx.moveTo(cx-bw/2,yMed);ctx.lineTo(cx+bw/2,yMed); + ctx.strokeStyle='rgba(0,0,0,0.85)';ctx.lineWidth=2;ctx.stroke(); + // Draw whiskers (min to Q1, Q3 to max) with end caps + ctx.strokeStyle='rgba(0,0,0,0.55)';ctx.lineWidth=1.5; + ctx.beginPath(); + ctx.moveTo(cx,yQ1);ctx.lineTo(cx,yMin); + ctx.moveTo(cx-bw/4,yMin);ctx.lineTo(cx+bw/4,yMin); + ctx.moveTo(cx,yQ3);ctx.lineTo(cx,yMax); + ctx.moveTo(cx-bw/4,yMax);ctx.lineTo(cx+bw/4,yMax); + ctx.stroke(); + // Draw tray name label at the top of the chart area + ctx.font='11px sans-serif'; + ctx.fillStyle='rgba(0,0,0,0.75)'; + ctx.textAlign='center'; + ctx.fillText(t.tray, cx, chart.chartArea.top+14); + // Draw mean score from tray_summary.csv below the bottom whisker + if(t.summary_mean!=null){ + ctx.font='10px sans-serif'; + ctx.fillStyle='rgba(0,0,0,0.6)'; + ctx.fillText('mean: '+t.summary_mean, cx, yMin+16); + } + ctx.restore(); + }); + } +}; +""" + + boxplot_js = ( + "const trays=" + js_tr + ";\n" + """const fruitPts=[]; +trays.forEach((t,ti)=>{ + t.fruits.forEach(f=>{ + fruitPts.push({ + x: t.x + f.jitter, + y: f.scored ? f.score : -0.07, // overcolor dots shown below y=0 + raw_l:f.raw_l, raw_a:f.raw_a, raw_b:f.raw_b, + name:f.name, scored:f.scored + }); + }); +}); +const trayLabels=trays.map(t=>t.tray); +new Chart(document.getElementById('boxplot'),{ + type:'scatter', + plugins:[boxplotPlugin], + data:{datasets:[{ + data:fruitPts, + pointStyle:fruitPts.map(d=>d.scored?'circle':'rectRot'), + pointRadius:6, + pointHoverRadius:8, + backgroundColor:fruitPts.map(mkColor), + borderColor:fruitPts.map(mkColor), + borderWidth:1.5, + }]}, + options:{ + responsive:true, + maintainAspectRatio:false, + trayData:trays, // passed to boxplotPlugin via chart.config.options + plugins:{ + legend:{display:false}, + tooltip:{callbacks:{ + title:i=>i[0].raw.name, + label:ctx=>ctx.raw.scored + ?('score: '+ctx.raw.y.toFixed(3)) + :'overcolor (no score)' + }} + }, + scales:{ + x:{ + min:-0.5, + max:trays.length-0.5, + ticks:{display:false}, // tray names drawn by plugin instead + grid:{color:'rgba(0,0,0,0.05)'} + }, + y:{ + min:-0.12, // extra space below 0 for overcolor dots at y=-0.07 + max:1.0, + ticks:{ + callback:(v)=>v<0?'overcolor':v.toFixed(1) + }, + title:{display:true,text:'Score'}, + grid:{color:'rgba(0,0,0,0.05)'} + } + } + } +}); +""" + ) + + parts = [ + '\n\n\n\n', + 'Granny \u2014 ' + title + '\n', + '\n\n\n', + '

Peel color space \u2014 ' + title + '

\n', + '

Dot color = true fruit color. ' + 'Scored: position = normalized a*/b*. ' + 'Overcolor (no score): position = raw a*/b*.

\n', + '
' + 'Scored' + 'Overcolor' + '
\n', + '
\n', + '

Color space (a* vs b*)

', + '
\n', + '

Tray score distribution

', + '
\n', + '
\n', + '\n', + '\n\n', + ] + return ''.join(parts) + + # ── Step 4: Generate one HTML file per side ─────────────────────────── + for side_label, side_name, title in [ + ("B", "shade", "Shade side (B)"), + ("A", "sun", "Sun side (A)"), + ]: + side_points = [p for p in points if p["side"] == side_label] + if not side_points: + continue + tray_stats = compute_tray_stats(side_points) + + # Populate summary_mean from tray_summary.csv. + # This file is written by Granny's MetaDataValue.writeValue() immediately + # before _generateColorspacePlot() is called, so it is guaranteed to exist. + # Using the summary file (rather than recomputing from points) ensures + # the displayed mean matches exactly what is in tray_summary.csv. + summary_path = os.path.join(result_dir, "tray_summary.csv") + if os.path.exists(summary_path): + with open(summary_path, newline="") as sf: + for srow in csv.DictReader(sf): + # TrayName in summary: "WA 64 Tray 1 B_fruit" + # After stripping: "WA 64 Tray 1 B" — matches tray_stats label + tname = _re.sub(r'_fruit$', '', srow.get('TrayName', '')).strip() + sc = srow.get('score', '') + if tname and sc: + try: + smean = round(float(sc), 3) + for s in tray_stats: + if s['tray'] == tname: + s['summary_mean'] = smean + except ValueError: + pass + + html = build_html(side_points, tray_stats, title) + out_path = os.path.join(result_dir, "colorspace_" + side_name + ".html") + with open(out_path, "w") as f: + f.write(html) + print("Plot written to " + out_path) + + # ────────────────────────────────────────────────────────────────────────── + # ORIGINAL METHODS (unchanged from upstream) + # ────────────────────────────────────────────────────────────────────────── def _preRun(self): """ {@inheritdoc} + UNCHANGED from upstream. """ - # initiates an ImageIO for image input/output self.image_io: ImageIO = RGBImageFile() def _postRun(self, results): """ {@inheritdoc} + + [CHANGE B-2 — fork only] + Added call to self._generateColorspacePlot() at the end, after results.csv + and tray_summary.csv have been written. This ensures the plot has access to + both output files and all processed images. """ # adds the result list to self.output_images then writes the resulting images to folder self.output_images.setImageList(results) @@ -649,6 +1006,8 @@ def _postRun(self, results): self.addRetValue(self.output_images) + # [CHANGE B-2] Generate HTML color space plots after all CSVs are written. + # Not present in upstream Granny. self._generateColorspacePlot(self.output_results.getValue()) return self.output_images.getImageList()