From 53ecded9470247d3b2d32e4ef148d74d9a705546 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 9 Oct 2025 16:01:57 -0700 Subject: [PATCH 1/2] Expose all configurable parameters across analyses Added 32 configurable parameters across all 5 analyses: Segmentation (8 params): - Analysis: conf, iou, row_tolerance - Visualization: mask_alpha, color_brightness, bbox_thickness, font_scale, text_thickness StarchArea (3 params): - Analysis: starch_threshold, blur_kernel - Visualization: mask_alpha BlushColor (8 params): - Analysis: fruit_threshold - Visualization: blush_color_r/g/b, text_x/y, font_scale, text_thickness SuperficialScald (6 params): - Analysis: morph_kernel, min_threshold, purple_threshold, blur_kernel, hist_factor, hist_top_n PeelColor (7 params): - Analysis: purple_threshold, lightness_min/max, green_min/max, yellow_min/max, normalize_lightness All parameters use descriptive names and include comprehensive documentation in parameter_adjustments.md with defaults, ranges, types, and usage examples. --- CLAUDE.md | Bin 0 -> 8283 bytes Granny/Analyses/BlushColor.py | 112 +++++++++++-- Granny/Analyses/PeelColor.py | 119 +++++++++++++- Granny/Analyses/Segmentation.py | 127 ++++++++++++++- Granny/Analyses/StarchArea.py | 49 +++++- Granny/Analyses/SuperficialScald.py | 101 +++++++++++- docs/users_guide/parameter_adjustments.md | 188 ++++++++++++++++++++++ 7 files changed, 661 insertions(+), 35 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/users_guide/parameter_adjustments.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..a67fef7b9c725d0cc2492fd957f2d5286d50d42d GIT binary patch literal 8283 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 literal 0 HcmV?d00001 diff --git a/Granny/Analyses/BlushColor.py b/Granny/Analyses/BlushColor.py index aeb50d3..d6231fd 100644 --- a/Granny/Analyses/BlushColor.py +++ b/Granny/Analyses/BlushColor.py @@ -103,8 +103,102 @@ def __init__(self): self.threshold.setValue(148) self.threshold.setIsRequired(False) - # adds threshold to the parameter input list - self.addInParam(self.threshold) + # Fruit detection threshold parameter (B channel in LAB space) + self.fruit_threshold = IntValue( + "fruit_threshold", + "fruit_threshold", + "Threshold for fruit pixel detection using the B channel in LAB color space. " + + "Pixels with B channel values > this threshold are considered fruit. " + + "Range is 0 to 255, default is 140.", + ) + self.fruit_threshold.setMin(0) + self.fruit_threshold.setMax(255) + self.fruit_threshold.setValue(140) + self.fruit_threshold.setIsRequired(False) + + # Visualization parameters + self.blush_color_r = IntValue( + "blush_color_r", + "blush_color_r", + "Red component of blush mask color (BGR format). Range is 0 to 255, default is 150.", + ) + self.blush_color_r.setMin(0) + self.blush_color_r.setMax(255) + self.blush_color_r.setValue(150) + self.blush_color_r.setIsRequired(False) + + self.blush_color_g = IntValue( + "blush_color_g", + "blush_color_g", + "Green component of blush mask color (BGR format). Range is 0 to 255, default is 55.", + ) + self.blush_color_g.setMin(0) + self.blush_color_g.setMax(255) + self.blush_color_g.setValue(55) + self.blush_color_g.setIsRequired(False) + + self.blush_color_b = IntValue( + "blush_color_b", + "blush_color_b", + "Blue component of blush mask color (BGR format). Range is 0 to 255, default is 50.", + ) + self.blush_color_b.setMin(0) + self.blush_color_b.setMax(255) + self.blush_color_b.setValue(50) + self.blush_color_b.setIsRequired(False) + + self.text_x = IntValue( + "text_x", + "text_x", + "X coordinate for text position on output image. Default is 20.", + ) + self.text_x.setMin(0) + self.text_x.setMax(5000) + self.text_x.setValue(20) + self.text_x.setIsRequired(False) + + self.text_y = IntValue( + "text_y", + "text_y", + "Y coordinate for text position on output image. Default is 50.", + ) + self.text_y.setMin(0) + self.text_y.setMax(5000) + self.text_y.setValue(50) + self.text_y.setIsRequired(False) + + self.font_scale = FloatValue( + "font_scale", + "font_scale", + "Font scale for text labels on output images. Default is 1.0.", + ) + self.font_scale.setMin(0.1) + self.font_scale.setMax(10.0) + self.font_scale.setValue(1.0) + self.font_scale.setIsRequired(False) + + self.text_thickness = IntValue( + "text_thickness", + "text_thickness", + "Thickness of text labels in pixels. Default is 3.", + ) + self.text_thickness.setMin(1) + self.text_thickness.setMax(50) + self.text_thickness.setValue(3) + self.text_thickness.setIsRequired(False) + + # adds thresholds to the parameter input list + self.addInParam( + self.threshold, + self.fruit_threshold, + self.blush_color_r, + self.blush_color_g, + self.blush_color_b, + self.text_x, + self.text_y, + self.font_scale, + self.text_thickness, + ) def _calculateBlush( self, img: NDArray[np.uint8] @@ -126,21 +220,21 @@ def _calculateBlush( # create thresholded matrices blush_threshold = self.threshold.getValue() - fruit_px = lab_img[:, :, 2] > 140 + fruit_px = lab_img[:, :, 2] > self.fruit_threshold.getValue() blush_px = lab_img[:, :, 1] > blush_threshold - new_img[:, :, 0][blush_px] = 150 - new_img[:, :, 1][blush_px] = 55 - new_img[:, :, 2][blush_px] = 50 + new_img[:, :, 0][blush_px] = self.blush_color_r.getValue() + new_img[:, :, 1][blush_px] = self.blush_color_g.getValue() + new_img[:, :, 2][blush_px] = self.blush_color_b.getValue() blush_pct = 100 * blush_px.sum() / fruit_px.sum() cv2.putText( new_img, "Blush: " + str(blush_pct.round(1)) + "%", - (20, 50), + (self.text_x.getValue(), self.text_y.getValue()), fontFace=cv2.FONT_HERSHEY_SIMPLEX, - fontScale=1, + fontScale=self.font_scale.getValue(), color=(0, 0, 255), - thickness=3, + thickness=self.text_thickness.getValue(), ) return blush_px.sum() / fruit_px.sum(), new_img diff --git a/Granny/Analyses/PeelColor.py b/Granny/Analyses/PeelColor.py index 3e14a86..2d5d737 100644 --- a/Granny/Analyses/PeelColor.py +++ b/Granny/Analyses/PeelColor.py @@ -55,6 +55,103 @@ def __init__(self): "input", "input", "The directory where input images are located." ) self.input_images.setIsRequired(True) + + # Purple removal threshold parameter + self.purple_threshold = IntValue( + "purple_threshold", + "purple_threshold", + "Threshold for removing purple background/tray pixels using YCrCb color space. " + + "Pixels with Cb channel <= this value are kept. Range is 0 to 255, default is 126.", + ) + self.purple_threshold.setMin(0) + self.purple_threshold.setMax(255) + self.purple_threshold.setValue(126) + self.purple_threshold.setIsRequired(False) + + # Lightness minimum parameter + self.lightness_min = IntValue( + "lightness_min", + "lightness_min", + "Minimum lightness value for peel color detection in LAB color space. " + + "Range is 0 to 255, default is 0.", + ) + self.lightness_min.setMin(0) + self.lightness_min.setMax(255) + self.lightness_min.setValue(0) + self.lightness_min.setIsRequired(False) + + # Lightness maximum parameter + self.lightness_max = IntValue( + "lightness_max", + "lightness_max", + "Maximum lightness value for peel color detection in LAB color space. " + + "Range is 0 to 255, default is 255.", + ) + self.lightness_max.setMin(0) + self.lightness_max.setMax(255) + self.lightness_max.setValue(255) + self.lightness_max.setIsRequired(False) + + # Green channel minimum parameter + self.green_min = IntValue( + "green_min", + "green_min", + "Minimum green channel value for peel color detection in LAB color space. " + + "Range is 0 to 255, default is 0.", + ) + self.green_min.setMin(0) + self.green_min.setMax(255) + self.green_min.setValue(0) + self.green_min.setIsRequired(False) + + # Green channel maximum parameter + self.green_max = IntValue( + "green_max", + "green_max", + "Maximum green channel value for peel color detection in LAB color space. " + + "Range is 0 to 255, default is 128.", + ) + self.green_max.setMin(0) + self.green_max.setMax(255) + self.green_max.setValue(128) + self.green_max.setIsRequired(False) + + # Yellow channel minimum parameter + self.yellow_min = IntValue( + "yellow_min", + "yellow_min", + "Minimum yellow channel value for peel color detection in LAB color space. " + + "Range is 0 to 255, default is 128.", + ) + self.yellow_min.setMin(0) + self.yellow_min.setMax(255) + self.yellow_min.setValue(128) + self.yellow_min.setIsRequired(False) + + # Yellow channel maximum parameter + self.yellow_max = IntValue( + "yellow_max", + "yellow_max", + "Maximum yellow channel value for peel color detection in LAB color space. " + + "Range is 0 to 255, default is 255.", + ) + self.yellow_max.setMin(0) + self.yellow_max.setMax(255) + self.yellow_max.setValue(255) + self.yellow_max.setIsRequired(False) + + # Normalization lightness parameter + self.normalize_lightness = IntValue( + "normalize_lightness", + "normalize_lightness", + "Target lightness value for color normalization in LAB space. " + + "Range is 0 to 100, default is 50.", + ) + self.normalize_lightness.setMin(0) + self.normalize_lightness.setMax(100) + self.normalize_lightness.setValue(50) + self.normalize_lightness.setIsRequired(False) + self.output_images = ImageListValue( "output", "output", @@ -67,7 +164,17 @@ def __init__(self): datetime.now().strftime("%Y-%m-%d-%H-%M"), ) self.output_images.setValue(result_dir) - self.addInParam(self.input_images) + self.addInParam( + self.input_images, + self.purple_threshold, + self.lightness_min, + self.lightness_max, + self.green_min, + self.green_max, + self.yellow_min, + self.yellow_max, + self.normalize_lightness, + ) # sets up output result directory self.output_results = MetaDataValue( @@ -138,7 +245,7 @@ def remove_purple(self, img: NDArray[np.uint8]) -> NDArray[np.uint8]: # 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] <= 126)) + threshold_3 = np.logical_and((ycc_img[:, :, 2] >= 0), (ycc_img[:, :, 2] <= self.purple_threshold.getValue())) # combine to one matrix th123 = np.logical_and( @@ -166,9 +273,9 @@ def get_green_yellow_values( lab_img = cast(NDArray[np.uint8], cv2.cvtColor(img, cv2.COLOR_BGR2LAB)) # create binary matrices - threshold_1 = np.logical_and((lab_img[:, :, 0] > 0), (lab_img[:, :, 0] < 255)) - threshold_2 = np.logical_and((lab_img[:, :, 1] > 0), (lab_img[:, :, 1] < 128)) - threshold_3 = np.logical_and((lab_img[:, :, 2] > 128), (lab_img[:, :, 2] < 255)) + 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())) # combine to one matrix th123 = np.logical_and( @@ -188,7 +295,7 @@ def get_green_yellow_values( # normalize by shifting point in the spherical coordinates radius = np.sqrt(mean_l**2 + mean_a**2 + mean_b**2) - scaled_l = 50 + scaled_l = self.normalize_lightness.getValue() scaled_a = np.sign(mean_a) * np.sqrt( np.abs(radius**2 - scaled_l**2) / (1 + (mean_b / mean_a) ** 2) ) diff --git a/Granny/Analyses/Segmentation.py b/Granny/Analyses/Segmentation.py index 3a96811..f44184c 100644 --- a/Granny/Analyses/Segmentation.py +++ b/Granny/Analyses/Segmentation.py @@ -32,7 +32,9 @@ from Granny.Models.IO.ImageIO import ImageIO from Granny.Models.IO.RGBImageFile import RGBImageFile from Granny.Models.Values.FileNameValue import FileNameValue +from Granny.Models.Values.FloatValue import FloatValue from Granny.Models.Values.ImageListValue import ImageListValue +from Granny.Models.Values.IntValue import IntValue from numpy.typing import NDArray @@ -129,6 +131,99 @@ def __init__(self): "input", "input", "The directory where input images are located." ) self.input_images.setIsRequired(True) + + # YOLO confidence threshold parameter + self.conf_threshold = FloatValue( + "conf", + "confidence", + "Confidence threshold for YOLO detections. Only detections with confidence " + + "scores above this threshold will be kept. Range is 0.0 to 1.0, " + + "default is 0.25.", + ) + self.conf_threshold.setMin(0.0) + self.conf_threshold.setMax(1.0) + self.conf_threshold.setValue(0.25) + self.conf_threshold.setIsRequired(False) + + # YOLO IOU threshold parameter + self.iou_threshold = FloatValue( + "iou", + "iou_threshold", + "Intersection over Union (IOU) threshold for non-maximum suppression. " + + "Used to filter overlapping detections. Range is 0.0 to 1.0, " + + "default is 0.45.", + ) + self.iou_threshold.setMin(0.0) + self.iou_threshold.setMax(1.0) + self.iou_threshold.setValue(0.45) + self.iou_threshold.setIsRequired(False) + + # Visualization parameters + self.mask_alpha = FloatValue( + "mask_alpha", + "mask_alpha", + "Alpha transparency value for mask overlay on output images. " + + "Range is 0.0 (transparent) to 1.0 (opaque), default is 0.5.", + ) + self.mask_alpha.setMin(0.0) + self.mask_alpha.setMax(1.0) + self.mask_alpha.setValue(0.5) + self.mask_alpha.setIsRequired(False) + + self.color_brightness = FloatValue( + "color_brightness", + "color_brightness", + "Brightness value for mask colors in HSV color space. " + + "Range is 0.0 (dark) to 1.0 (bright), default is 1.0.", + ) + self.color_brightness.setMin(0.0) + self.color_brightness.setMax(1.0) + self.color_brightness.setValue(1.0) + self.color_brightness.setIsRequired(False) + + self.bbox_thickness = IntValue( + "bbox_thickness", + "bbox_thickness", + "Thickness of bounding box lines in pixels. Default is 5.", + ) + self.bbox_thickness.setMin(1) + self.bbox_thickness.setMax(50) + self.bbox_thickness.setValue(5) + self.bbox_thickness.setIsRequired(False) + + self.font_scale = FloatValue( + "font_scale", + "font_scale", + "Font scale for text labels on output images. Default is 2.0.", + ) + self.font_scale.setMin(0.1) + self.font_scale.setMax(10.0) + self.font_scale.setValue(2.0) + self.font_scale.setIsRequired(False) + + self.text_thickness = IntValue( + "text_thickness", + "text_thickness", + "Thickness of text labels in pixels. Default is 3.", + ) + self.text_thickness.setMin(1) + self.text_thickness.setMax(50) + self.text_thickness.setValue(3) + self.text_thickness.setIsRequired(False) + + # Sorting/grouping parameter + self.row_tolerance = IntValue( + "row_tolerance", + "row_tolerance", + "Row grouping tolerance factor. Fruits are grouped into rows when their y-centers " + + "differ by more than height/row_tolerance pixels. Smaller values = looser grouping, " + + "larger values = tighter grouping. Default is 20.", + ) + self.row_tolerance.setMin(1) + self.row_tolerance.setMax(100) + self.row_tolerance.setValue(20) + self.row_tolerance.setIsRequired(False) + self.seg_images = ImageListValue( "seg_img", "segmented_images", @@ -172,7 +267,18 @@ def __init__(self): ) ) - self.addInParam(self.model, self.input_images) + self.addInParam( + self.model, + self.input_images, + self.conf_threshold, + self.iou_threshold, + self.mask_alpha, + self.color_brightness, + self.bbox_thickness, + self.font_scale, + self.text_thickness, + self.row_tolerance, + ) def _getModelUrl(self, model_name: str): """ @@ -219,7 +325,12 @@ def _segmentInstances(self, image: NDArray[np.uint8]) -> List[Any]: including: masks, boxes, xyxy's, classes, confident scores """ # detects instances on the image - results = self.segmentation_model.predict(image, retina_masks=True) # type: ignore + results = self.segmentation_model.predict( + image, + retina_masks=True, + conf=self.conf_threshold.getValue(), + iou=self.iou_threshold.getValue() + ) # type: ignore return results @@ -251,9 +362,9 @@ def _extractMaskedImage(self, tray_image: Image) -> Image: img = tray_image.getImage() result = img.copy() - alpha = 0.5 + alpha = self.mask_alpha.getValue() num_instances = masks.shape[0] - brightness = 1.0 + brightness = self.color_brightness.getValue() hsv = [(i / num_instances, 1, brightness) for i in range(num_instances)] colors = list(map(lambda c: colorsys.hsv_to_rgb(*c), hsv)) random.shuffle(colors) @@ -269,15 +380,15 @@ def _extractMaskedImage(self, tray_image: Image) -> Image: x1, y1, x2, y2, _, _ = sorted_boxes[i] x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) - cv2.rectangle(result, (x1, y1), (x2, y2), (r * 255, g * 255, b * 255), 5) + cv2.rectangle(result, (x1, y1), (x2, y2), (r * 255, g * 255, b * 255), self.bbox_thickness.getValue()) cv2.putText( result, "{:2.0f}-{:.3f}".format(i, confs[i]), (x1, y1), fontFace=cv2.FONT_HERSHEY_SIMPLEX, - fontScale=2, + fontScale=self.font_scale.getValue(), color=(255, 255, 255), - thickness=3, + thickness=self.text_thickness.getValue(), ) image_instance: Image = RGBImage( pathlib.Path(tray_image.getImageName()).stem + f"_masked_image" + ".png" @@ -313,7 +424,7 @@ def _sortInstances(self, boxes: NDArray[np.float32], img_shape: Tuple[int, int]) df["apple_id"] = 0 df["nums"] = df.index df = df.sort_values("ycenter", ascending=True).reset_index(drop=True) - df["rows"] = (df["ycenter"].diff().abs().gt(h // 20).cumsum() + 1).fillna(1).astype(int) + df["rows"] = (df["ycenter"].diff().abs().gt(h // self.row_tolerance.getValue()).cumsum() + 1).fillna(1).astype(int) df_list: List[pd.DataFrame] = [] apple_id = 1 diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 5a531ef..87e7cea 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.py @@ -26,6 +26,7 @@ from Granny.Models.IO.RGBImageFile import RGBImageFile from Granny.Models.Values.FloatValue import FloatValue from Granny.Models.Values.ImageListValue import ImageListValue +from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue from numpy.typing import NDArray @@ -188,6 +189,45 @@ def __init__(self): "input", "input", "The directory where input images are located." ) self.input_images.setIsRequired(True) + + # Starch threshold parameter + self.starch_threshold = IntValue( + "starch_threshold", + "starch_threshold", + "Threshold value for starch detection. Pixels with gray values <= this threshold " + + "are considered starch. Lower values detect only darker starch regions, higher " + + "values include lighter regions. Range is 0 to 255, default is 172.", + ) + self.starch_threshold.setMin(0) + self.starch_threshold.setMax(255) + self.starch_threshold.setValue(172) + self.starch_threshold.setIsRequired(False) + + # Gaussian blur kernel size parameter + self.blur_kernel = IntValue( + "blur_kernel", + "blur_kernel", + "Size of the Gaussian blur kernel for noise reduction preprocessing. " + + "Must be an odd positive integer. Larger values produce more smoothing. " + + "Default is 7 (creates a 7x7 kernel).", + ) + self.blur_kernel.setMin(1) + self.blur_kernel.setMax(99) + self.blur_kernel.setValue(7) + self.blur_kernel.setIsRequired(False) + + # Visualization parameter + self.mask_alpha = FloatValue( + "mask_alpha", + "mask_alpha", + "Alpha transparency value for starch mask overlay on output images. " + + "Range is 0.0 (transparent) to 1.0 (opaque), default is 0.6.", + ) + self.mask_alpha.setMin(0.0) + self.mask_alpha.setMax(1.0) + self.mask_alpha.setValue(0.6) + self.mask_alpha.setIsRequired(False) + self.output_images = ImageListValue( "output", "output", @@ -200,7 +240,7 @@ def __init__(self): datetime.now().strftime("%Y-%m-%d-%H-%M"), ) self.output_images.setValue(result_dir) - self.addInParam(self.input_images) + self.addInParam(self.input_images, self.starch_threshold, self.blur_kernel, self.mask_alpha) # sets up output result directory self.output_results = MetaDataValue( @@ -222,7 +262,7 @@ def _drawMask( """ result = img.copy() color = (0, 0, 0) - alpha = 0.6 + alpha = self.mask_alpha.getValue() for c in range(3): result[:, :, c] = np.where( mask == 0, @@ -282,7 +322,8 @@ def adjustImage( new_img = img.copy() # blurs the image to remove sharp noises, then converts it to gray scale - img = cast(NDArray[np.uint8], cv2.GaussianBlur(img, (7, 7), 0)) + kernel_size = self.blur_kernel.getValue() + img = cast(NDArray[np.uint8], cv2.GaussianBlur(img, (kernel_size, kernel_size), 0)) gray = cast(NDArray[np.uint8], cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) # re-adjusts the image to [0 255] @@ -290,7 +331,7 @@ def adjustImage( gray = adjustImage(gray, low, high) # create thresholded matrices - image_threshold = 172 + image_threshold = self.starch_threshold.getValue() mask = np.logical_and((gray > 0), (gray <= image_threshold)).astype(np.uint8) # creates new image using threshold matrices diff --git a/Granny/Analyses/SuperficialScald.py b/Granny/Analyses/SuperficialScald.py index 49d8a9c..1b08c5e 100644 --- a/Granny/Analyses/SuperficialScald.py +++ b/Granny/Analyses/SuperficialScald.py @@ -53,6 +53,79 @@ def __init__(self): "input", "input", "The directory where input images are located." ) self.input_images.setIsRequired(True) + + # Morphological kernel size parameter + self.morph_kernel = IntValue( + "morph_kernel", + "morph_kernel", + "Size of the morphological (ellipse) kernel for mask smoothing operations. " + + "Larger values produce more smoothing. Default is 10 (creates a 10x10 kernel).", + ) + self.morph_kernel.setMin(1) + self.morph_kernel.setMax(99) + self.morph_kernel.setValue(10) + self.morph_kernel.setIsRequired(False) + + # Minimum threshold parameter + self.min_threshold = IntValue( + "min_threshold", + "min_threshold", + "Minimum threshold value for scald detection. Pixels below this threshold " + + "are considered potential scald regions. Range is 0 to 255, default is 100.", + ) + self.min_threshold.setMin(0) + self.min_threshold.setMax(255) + self.min_threshold.setValue(100) + self.min_threshold.setIsRequired(False) + + # Purple threshold parameter + self.purple_threshold = IntValue( + "purple_threshold", + "purple_threshold", + "Threshold for removing purple background/tray pixels using YCrCb color space. " + + "Pixels with Cb channel <= this value are kept. Range is 0 to 255, default is 126.", + ) + self.purple_threshold.setMin(0) + self.purple_threshold.setMax(255) + self.purple_threshold.setValue(126) + self.purple_threshold.setIsRequired(False) + + # Gaussian blur kernel parameter + self.blur_kernel = IntValue( + "blur_kernel", + "blur_kernel", + "Size of the Gaussian blur kernel for image smoothing. Must be an odd positive " + + "integer. Default is 3 (creates a 3x3 kernel).", + ) + self.blur_kernel.setMin(1) + self.blur_kernel.setMax(99) + self.blur_kernel.setValue(3) + self.blur_kernel.setIsRequired(False) + + # Histogram range factor parameter + self.hist_factor = FloatValue( + "hist_factor", + "hist_factor", + "Fraction of histogram range to subtract from threshold calculation. " + + "Range is 0.0 to 1.0, default is 0.333 (1/3).", + ) + self.hist_factor.setMin(0.0) + self.hist_factor.setMax(1.0) + self.hist_factor.setValue(0.333) + self.hist_factor.setIsRequired(False) + + # Histogram analysis parameter + self.hist_top_n = IntValue( + "hist_top_n", + "hist_top_n", + "Number of top histogram values to consider for threshold calculation. " + + "Default is 10.", + ) + self.hist_top_n.setMin(1) + self.hist_top_n.setMax(100) + self.hist_top_n.setValue(10) + self.hist_top_n.setIsRequired(False) + self.output_images = ImageListValue( "output", "output", @@ -65,7 +138,15 @@ def __init__(self): datetime.now().strftime("%Y-%m-%d-%H-%M"), ) self.output_images.setValue(result_dir) - self.addInParam(self.input_images) + self.addInParam( + self.input_images, + self.morph_kernel, + self.min_threshold, + self.purple_threshold, + self.blur_kernel, + self.hist_factor, + self.hist_top_n, + ) # sets up output result directory self.output_results = MetaDataValue( @@ -87,8 +168,9 @@ def _smoothMask(self, bin_mask: NDArray[np.uint8]) -> NDArray[np.uint8]: """ bin_mask = bin_mask - # create a circular structuring element of size 10 - ksize = (10, 10) + # create a circular structuring element + kernel_size = self.morph_kernel.getValue() + ksize = (kernel_size, kernel_size) strel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, ksize=ksize) # using to structuring element to perform one close and one open operation on the binary mask @@ -125,9 +207,11 @@ def _removeScald( def _calculate_threshold_from_hist(hist: NDArray[np.int8]) -> int: hist_range = 255 - (hist[::-1] != 0).argmax() - (hist != 0).argmax() - threshold = np.max(np.argsort(hist)[-10:]) - threshold = int(threshold - 1 / 3 * hist_range) - threshold = 100 if threshold < 100 else int(threshold) + top_n = self.hist_top_n.getValue() + threshold = np.max(np.argsort(hist)[-top_n:]) + threshold = int(threshold - self.hist_factor.getValue() * hist_range) + min_thresh = self.min_threshold.getValue() + threshold = min_thresh if threshold < min_thresh else int(threshold) return threshold # create binary matrices @@ -169,7 +253,7 @@ def _removeTrayResidue(self, img: NDArray[np.uint8]) -> NDArray[np.uint8]: # 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] <= 126)) + threshold_3 = np.logical_and((ycc_img[:, :, 2] >= 0), (ycc_img[:, :, 2] <= self.purple_threshold.getValue())) # combine to one matrix th123 = np.logical_and( @@ -201,7 +285,8 @@ def _score_image( nopurple_img = img.copy() # Image smoothing - img = cast(NDArray[np.uint8], cv2.GaussianBlur(img, (3, 3), sigmaX=0, sigmaY=0)) + blur_size = self.blur_kernel.getValue() + img = cast(NDArray[np.uint8], cv2.GaussianBlur(img, (blur_size, blur_size), sigmaX=0, sigmaY=0)) # Removal of scald regions bw, img = self._removeScald(img) diff --git a/docs/users_guide/parameter_adjustments.md b/docs/users_guide/parameter_adjustments.md new file mode 100644 index 0000000..498ab27 --- /dev/null +++ b/docs/users_guide/parameter_adjustments.md @@ -0,0 +1,188 @@ +# Parameter Adjustments + +This guide covers the configurable parameters available in Granny for fine-tuning analysis results. + +## Segmentation Parameters + +### Analysis Parameters + +#### Confidence Threshold (`--conf`) +- **Default:** 0.25 | **Range:** 0.0 - 1.0 | **Type:** Float +- Controls YOLO detection confidence. Higher values = fewer, more accurate detections. + +#### IOU Threshold (`--iou`) +- **Default:** 0.45 | **Range:** 0.0 - 1.0 | **Type:** Float +- Intersection over Union threshold for non-maximum suppression. Controls how overlapping detections are filtered. + +#### Row Grouping Tolerance (`--row_tolerance`) +- **Default:** 20 | **Range:** 1 - 100 | **Type:** Integer +- Controls fruit row detection sensitivity. Fruits grouped into rows when y-centers differ by more than height/row_tolerance pixels. + +### Visualization Parameters + +#### Mask Alpha (`--mask_alpha`) +- **Default:** 0.5 | **Range:** 0.0 - 1.0 | **Type:** Float +- Transparency of mask overlay on output images (0.0 = transparent, 1.0 = opaque). + +#### Color Brightness (`--color_brightness`) +- **Default:** 1.0 | **Range:** 0.0 - 1.0 | **Type:** Float +- Brightness value for mask colors in HSV color space. + +#### Bounding Box Thickness (`--bbox_thickness`) +- **Default:** 5 | **Range:** 1 - 50 | **Type:** Integer +- Thickness of bounding box lines in pixels. + +#### Font Scale (`--font_scale`) +- **Default:** 2.0 | **Range:** 0.1 - 10.0 | **Type:** Float +- Font scale for text labels on output images. + +#### Text Thickness (`--text_thickness`) +- **Default:** 3 | **Range:** 1 - 50 | **Type:** Integer +- Thickness of text labels in pixels. + +--- + +## Starch Analysis Parameters + +### Analysis Parameters + +#### Starch Threshold (`--starch_threshold`) +- **Default:** 172 | **Range:** 0 - 255 | **Type:** Integer +- Pixels with gray values ≤ this threshold are considered starch. Lower = only darkest regions, higher = includes lighter regions. + +#### Blur Kernel (`--blur_kernel`) +- **Default:** 7 | **Range:** 1 - 99 (odd values recommended) | **Type:** Integer +- Gaussian blur kernel size for noise reduction. Larger values = more smoothing. + +### Visualization Parameters + +#### Mask Alpha (`--mask_alpha`) +- **Default:** 0.6 | **Range:** 0.0 - 1.0 | **Type:** Float +- Transparency of starch mask overlay on output images. + +--- + +## Blush Color Analysis Parameters + +### Analysis Parameters + +#### Blush Threshold (`--th`) +- **Default:** 148 | **Range:** 0 - 255 | **Type:** Integer +- A channel threshold in LAB color space for blush detection. + +#### Fruit Threshold (`--fruit_threshold`) +- **Default:** 140 | **Range:** 0 - 255 | **Type:** Integer +- B channel threshold in LAB color space for fruit pixel detection. + +### Visualization Parameters + +#### Blush Color RGB (`--blush_color_r`, `--blush_color_g`, `--blush_color_b`) +- **Defaults:** R=150, G=55, B=50 | **Range:** 0 - 255 each | **Type:** Integer +- RGB color values for blush mask overlay (BGR format). + +#### Text Position (`--text_x`, `--text_y`) +- **Defaults:** X=20, Y=50 | **Range:** 0 - 5000 | **Type:** Integer +- X and Y coordinates for text position on output images. + +#### Font Scale (`--font_scale`) +- **Default:** 1.0 | **Range:** 0.1 - 10.0 | **Type:** Float +- Font scale for text labels. + +#### Text Thickness (`--text_thickness`) +- **Default:** 3 | **Range:** 1 - 50 | **Type:** Integer +- Thickness of text labels in pixels. + +--- + +## Superficial Scald Analysis Parameters + +### Analysis Parameters + +#### Morphological Kernel (`--morph_kernel`) +- **Default:** 10 | **Range:** 1 - 99 | **Type:** Integer +- Size of ellipse kernel for morphological operations. Larger = more smoothing. + +#### Minimum Threshold (`--min_threshold`) +- **Default:** 100 | **Range:** 0 - 255 | **Type:** Integer +- Minimum threshold for scald detection. Pixels below this are potential scald regions. + +#### Purple Threshold (`--purple_threshold`) +- **Default:** 126 | **Range:** 0 - 255 | **Type:** Integer +- Threshold for removing purple background/tray pixels using YCrCb color space. + +#### Blur Kernel (`--blur_kernel`) +- **Default:** 3 | **Range:** 1 - 99 (odd values recommended) | **Type:** Integer +- Gaussian blur kernel size for image smoothing. + +#### Histogram Factor (`--hist_factor`) +- **Default:** 0.333 | **Range:** 0.0 - 1.0 | **Type:** Float +- Fraction of histogram range to subtract from threshold calculation. + +#### Histogram Top N (`--hist_top_n`) +- **Default:** 10 | **Range:** 1 - 100 | **Type:** Integer +- Number of top histogram values to consider for threshold calculation. + +--- + +## Peel Color Analysis Parameters + +### Analysis Parameters + +#### Purple Threshold (`--purple_threshold`) +- **Default:** 126 | **Range:** 0 - 255 | **Type:** Integer +- Threshold for removing purple background/tray pixels using YCrCb color space. + +#### Lightness Range (`--lightness_min`, `--lightness_max`) +- **Defaults:** Min=0, Max=255 | **Range:** 0 - 255 | **Type:** Integer +- Lightness channel range in LAB color space for peel color detection. + +#### Green Range (`--green_min`, `--green_max`) +- **Defaults:** Min=0, Max=128 | **Range:** 0 - 255 | **Type:** Integer +- Green channel range in LAB color space for green detection. + +#### Yellow Range (`--yellow_min`, `--yellow_max`) +- **Defaults:** Min=128, Max=255 | **Range:** 0 - 255 | **Type:** Integer +- Yellow channel range in LAB color space for yellow detection. + +#### Normalize Lightness (`--normalize_lightness`) +- **Default:** 50 | **Range:** 0 - 100 | **Type:** Integer +- Target lightness value for color normalization in LAB space. + +--- + +## Usage Examples + +### Segmentation with Custom Parameters +```bash +granny -i cli --analysis segmentation --input ./images/ --conf 0.35 --iou 0.6 --row_tolerance 15 +``` + +### Starch Analysis with Custom Threshold +```bash +granny -i cli --analysis starch --input ./segmented/ --starch_threshold 180 --blur_kernel 9 +``` + +### Blush with Custom Visualization +```bash +granny -i cli --analysis blush --input ./pears/ --th 150 --blush_color_r 200 --font_scale 1.5 +``` + +### Scald with Fine-Tuned Detection +```bash +granny -i cli --analysis scald --input ./apples/ --min_threshold 105 --hist_factor 0.4 --hist_top_n 15 +``` + +### Peel Color with Custom Ranges +```bash +granny -i cli --analysis color --input ./pears/ --green_max 125 --yellow_min 130 --normalize_lightness 55 +``` + +--- + +## Tips + +- **Start with defaults** and adjust incrementally based on your specific images +- **Visualization parameters** only affect output images, not analysis results +- **Analysis parameters** directly impact ratings and measurements +- Test different values on a small subset before processing large batches +- Document which parameters work best for your specific fruit varieties and imaging conditions From df33c3607223c490e52af10dd184ce761c979193 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 13 Oct 2025 15:16:14 -0700 Subject: [PATCH 2/2] Remove CLAUDE.md from tracking and add to .gitignore --- .gitignore | 1 + CLAUDE.md | Bin 8283 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 02c7566..8b5b402 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ test_perf.py *.jpeg *.jpg *.tiff +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a67fef7b9c725d0cc2492fd957f2d5286d50d42d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8283 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