diff --git a/.github/workflows/pytest-coverage.yml b/.github/workflows/pytest-coverage.yml new file mode 100644 index 0000000..df9faba --- /dev/null +++ b/.github/workflows/pytest-coverage.yml @@ -0,0 +1,48 @@ +name: Pytest with Coverage + +on: + pull_request: + branches: + - dev + - main + push: + branches: + - dev + - main + +permissions: + contents: read + id-token: write + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + pip install -e . + + - name: Run pytest with coverage + run: | + pytest --cov=Granny --cov-report=lcov:coverage.lcov --cov-report=term-missing + + - name: Upload coverage to QLTY Cloud + if: matrix.python-version == '3.12' + uses: qltysh/qlty-action/coverage@v1 + with: + oidc: true + files: coverage.lcov diff --git a/.gitignore b/.gitignore index 8b5b402..f5ca0a4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ dist/ 06-Package *logs/ _build/ +venv/ +results/ # Files *.pyc @@ -26,7 +28,6 @@ output.out *.sh *.zip test_perf.py -*.csv *.onnx *.pt *.lock @@ -38,3 +39,5 @@ test_perf.py *.jpg *.tiff CLAUDE.md +.coverage +coverage.lcov diff --git a/Granny/Analyses/Analysis.py b/Granny/Analyses/Analysis.py index 49d5021..002677e 100644 --- a/Granny/Analyses/Analysis.py +++ b/Granny/Analyses/Analysis.py @@ -136,6 +136,82 @@ def resetRetValues(self): """ self.ret_values = {} + def _parse_qr_from_filename(self, filename: str) -> dict: + """ + Extract QR code information from segmented image filename. + + Expected format: PROJECT_LOT_DATE_VARIETY_fruit_##.png + Example: APPLE2025_LOT001_2025-12-02_BB-Late_fruit_01.png + + Args: + filename: Image filename (with or without path) + + Returns: + Dictionary with QR information: + { + 'project': project code or empty string, + 'lot': lot code or empty string, + 'date': date string or empty string, + 'variety': variety string or empty string + } + + Notes: + - Returns empty strings for all fields if parsing fails + - Handles legacy filenames gracefully (no QR data) + """ + import re + from pathlib import Path + + # Extract just the filename without path + filename_only = Path(filename).name + + # Pattern: PROJECT_LOT_DATE_VARIETY_fruit_##.png + # Use regex to match everything before "_fruit_##" + pattern = r'^(.+?)_(.+?)_(.+?)_(.+?)_fruit_\d+\.(?:png|jpg|jpeg)$' + match = re.match(pattern, filename_only) + + if match: + return { + 'project': match.group(1), + 'lot': match.group(2), + 'date': match.group(3), + 'variety': match.group(4) + } + else: + # Parsing failed - return empty strings (no QR data) + return { + 'project': '', + 'lot': '', + 'date': '', + 'variety': '' + } + + def _add_qr_metadata(self, result_img, filename: str): + """ + Parse QR/barcode metadata from filename and add to result image. + + Args: + result_img: Image instance to add metadata values to + filename: Image filename to parse + """ + qr_info = self._parse_qr_from_filename(filename) + if qr_info['project']: + project_val = StringValue("project", "project", "Project code from QR code") + project_val.setValue(qr_info['project']) + result_img.addValue(project_val) + + lot_val = StringValue("lot", "lot", "Lot code from QR code") + lot_val.setValue(qr_info['lot']) + result_img.addValue(lot_val) + + date_val = StringValue("date", "date", "Date from QR code") + date_val.setValue(qr_info['date']) + result_img.addValue(date_val) + + variety_val = StringValue("variety", "variety", "Variety from QR code") + variety_val.setValue(qr_info['variety']) + result_img.addValue(variety_val) + def performAnalysis(self) -> List[Image]: """ Once all required parameters have been set, this function is used diff --git a/Granny/Analyses/BlushColor.py b/Granny/Analyses/BlushColor.py index 1db637b..a4d8e04 100644 --- a/Granny/Analyses/BlushColor.py +++ b/Granny/Analyses/BlushColor.py @@ -25,6 +25,7 @@ from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.StringValue import StringValue from numpy.typing import NDArray @@ -266,6 +267,9 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(result_img, image_instance.getImageName()) + # saves the calculated score to the image_instance as a parameter rating = FloatValue( "rating", "rating", "Granny calculated rating of total blush area." diff --git a/Granny/Analyses/PeelColor.py b/Granny/Analyses/PeelColor.py index 8ed2aef..971531f 100644 --- a/Granny/Analyses/PeelColor.py +++ b/Granny/Analyses/PeelColor.py @@ -27,6 +27,7 @@ from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.StringValue import StringValue from numpy.typing import NDArray @@ -493,6 +494,9 @@ def _processImage(self, image_instance: Image) -> Image: ) b_value.setValue(b) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(image_instance, image_instance.getImageName()) + # adds ratings to to the image_instance as parameters image_instance.addValue( bin_value, diff --git a/Granny/Analyses/Segmentation.py b/Granny/Analyses/Segmentation.py index 16cd2e8..9e0c618 100644 --- a/Granny/Analyses/Segmentation.py +++ b/Granny/Analyses/Segmentation.py @@ -35,6 +35,7 @@ from Granny.Models.Values.FloatValue import FloatValue from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue +from Granny.Utils.QRCodeDetector import QRCodeDetector from numpy.typing import NDArray @@ -142,7 +143,7 @@ def __init__(self): ) self.conf_threshold.setMin(0.0) self.conf_threshold.setMax(1.0) - self.conf_threshold.setValue(0.25) + self.conf_threshold.setValue(0.7) self.conf_threshold.setIsRequired(False) # YOLO IOU threshold parameter @@ -211,6 +212,36 @@ def __init__(self): self.text_thickness.setValue(3) self.text_thickness.setIsRequired(False) + self.text_color_r = IntValue( + "text_color_r", + "text_color_r", + "Red channel value for text color (0-255). Default is 0 (black).", + ) + self.text_color_r.setMin(0) + self.text_color_r.setMax(255) + self.text_color_r.setValue(0) + self.text_color_r.setIsRequired(False) + + self.text_color_g = IntValue( + "text_color_g", + "text_color_g", + "Green channel value for text color (0-255). Default is 0 (black).", + ) + self.text_color_g.setMin(0) + self.text_color_g.setMax(255) + self.text_color_g.setValue(0) + self.text_color_g.setIsRequired(False) + + self.text_color_b = IntValue( + "text_color_b", + "text_color_b", + "Blue channel value for text color (0-255). Default is 0 (black).", + ) + self.text_color_b.setMin(0) + self.text_color_b.setMax(255) + self.text_color_b.setValue(0) + self.text_color_b.setIsRequired(False) + # Sorting/grouping parameter self.row_tolerance = IntValue( "row_tolerance", @@ -267,6 +298,10 @@ def __init__(self): ) ) + # Initialize QR code detector for variety information extraction + self.qr_detector = QRCodeDetector() + self.variety_info = None # Will store detected variety information if QR code found + self.addInParam( self.model, self.input_images, @@ -277,6 +312,9 @@ def __init__(self): self.bbox_thickness, self.font_scale, self.text_thickness, + self.text_color_r, + self.text_color_g, + self.text_color_b, self.row_tolerance, ) @@ -388,7 +426,7 @@ def _extractMaskedImage(self, tray_image: Image) -> Image: (x1, y1), fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=self.font_scale.getValue(), - color=(255, 255, 255), + color=(self.text_color_b.getValue(), self.text_color_g.getValue(), self.text_color_r.getValue()), thickness=self.text_thickness.getValue(), ) image_instance: Image = RGBImage( @@ -542,7 +580,19 @@ def _extractImage(self, tray_image: Image) -> List[Image]: mask = sorted_masks[i] for channel in range(3): individual_image[:, :, channel] = tray_image_array[y1:y2, x1:x2, channel] * mask[y1:y2, x1:x2] # type: ignore - image_name = pathlib.Path(tray_image.getImageName()).stem + f"_fruit_{i+1:02d}" + ".png" + + # Build filename: use QR data if detected, otherwise use default tray name + if self.variety_info is not None: + # QR code detected - use PROJECT_LOT_DATE_VARIETY_fruit_##.png + project = self.variety_info['project'] + lot = self.variety_info['lot'] + date = self.variety_info['date'] + variety = self.variety_info['full'] + image_name = f"{project}_{lot}_{date}_{variety}_fruit_{i+1:02d}.png" + else: + # No QR code - use default naming: tray_name_fruit_##.png + image_name = pathlib.Path(tray_image.getImageName()).stem + f"_fruit_{i+1:02d}" + ".png" + image_instance: Image = RGBImage(image_name) image_instance.setImage(individual_image) individual_images.append(image_instance) @@ -610,6 +660,22 @@ def performAnalysis(self) -> List[Image]: if h > w: image_instance.rotateImage() + # Detect QR code to extract variety information (optional) + try: + qr_data, qr_points = self.qr_detector.detect(image_instance.getImage()) + if qr_data: + self.variety_info = self.qr_detector.extract_variety_info(qr_data) + print(f"QR Code detected: {qr_data}") + print(f" Project: {self.variety_info['project']}, Lot: {self.variety_info['lot']}") + print(f" Date: {self.variety_info['date']}, Variety: {self.variety_info['full']}") + else: + print("No QR code detected - using default naming") + self.variety_info = None + except Exception as e: + # QR detection failed, continue with default naming + print(f"QR detection error: {str(e)} - using default naming") + self.variety_info = None + # predicts fruit instances in the image result = self._segmentInstances(image=image_instance.getImage()) @@ -636,6 +702,7 @@ def performAnalysis(self) -> List[Image]: self.masked_images.setImageList([masked_image]) self.masked_images.writeValue() + except: AttributeError("Error with the results.") diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 8f75ae0..15be58c 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.py @@ -13,6 +13,7 @@ """ import os +import yaml from datetime import datetime from multiprocessing import Pool from typing import Dict, List, Tuple, cast @@ -28,18 +29,44 @@ from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.StringValue import StringValue from numpy.typing import NDArray +def load_starch_scales() -> Dict[str, Dict[str, List[float]]]: + """ + Load starch scale data from YAML asset file. + + Reads the starch_scales.yml file from Granny/assets/ directory and returns + the variety-specific starch index and rating mappings. + + Returns: + Dict[str, Dict[str, List[float]]]: Dictionary mapping variety names to their + starch scales. Format: {'HONEY_CRISP': {'index': [...], 'rating': [...]}, ...} + """ + # Get path to this file (Granny/Analyses/StarchArea.py) + current_dir = os.path.dirname(__file__) + + # Navigate to Granny/assets/starch_scales.yml + yaml_path = os.path.join(current_dir, '..', 'assets', 'starch_scales.yml') + + # Load and return the YAML data + with open(yaml_path, 'r') as file: + starch_data = yaml.safe_load(file) + + return starch_data + + class StarchScales: """ A class to store starch scale indices and corresponding ratings for different apple varieties. - This class provides predefined starch index and rating values for various apple varieties. + This class provides predefined starch index and rating values for various apple varieties + loaded from the YAML asset file (Granny/assets/starch_scales.yml). These values are used to evaluate the starch content in apples, which is an indicator of their ripeness and suitability for consumption or storage. - Attributes: (Refers to docs/_static/users_guide/ for the list of starch indices in this module) + Attributes: (Loaded from starch_scales.yml) HONEY_CRISP (Dict[str, List[float]]): Starch index and rating for Honey Crisp apples. WA38_1 (Dict[str, List[float]]): Starch index and rating for WA38_1 apples. WA38_2 (Dict[str, List[float]]): Starch index and rating for WA38_2 apples. @@ -49,115 +76,13 @@ class StarchScales: JONAGOLD (Dict[str, List[float]]): Starch index and rating for Jonagold apples. CORNELL (Dict[str, List[float]]): Starch index and rating for Cornell apples. """ + pass - HONEY_CRISP: Dict[str, List[float]] = { - "index": [1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0], - "rating": [ - 0.998998748, - 0.947464712, - 0.868898986, - 0.783941273, - 0.676589664, - 0.329929925, - 0.024131710, - ], - } - WA38_1: Dict[str, List[float]] = { - "index": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], - "rating": [ - 0.893993948, - 0.855859903, - 0.757963861, - 0.597765822, - 0.164192649, - 0.080528335, - ], - } - WA38_2: Dict[str, List[float]] = { - "index": [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0], - "rating": [ - 0.950925926, - 0.912917454, - 0.839858059, - 0.749211356, - 0.770660718, - 0.634160550, - 0.571832210, - 0.522944438, - 0.178909419, - 0.017493382, - 0.075675075, - ], - } - ALLAN_BROS: Dict[str, List[float]] = { - "index": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], - "rating": [ - 0.997783524, - 0.988769830, - 0.951909478, - 0.877526853, - 0.721066082, - 0.673838851, - 0.417864608, - 0.091652858, - ], - } - GOLDEN_DELICIOUS: Dict[str, List[float]] = { - "index": [1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 6.0], - "rating": [ - 0.998544220, - 0.981819854, - 0.974722333, - 0.902015343, - 0.893566670, - 0.784215902, - 0.780621478, - 0.607040963, - 0.717128225, - 0.485321449, - 0.279959478, - 0.068212979, - ], - } - GRANNY_SMITH: Dict[str, List[float]] = { - "index": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0], - "rating": [ - 0.920742836, - 0.890332499, - 0.808227909, - 0.721813109, - 0.595806394, - 0.278299256, - 0.104111379, - ], - } - JONAGOLD: Dict[str, List[float]] = { - "index": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0], - "rating": [ - 0.898336414, - 0.859494456, - 0.806417832, - 0.742177914, - 0.653981582, - 0.483778570, - 0.387202327, - 0.284663986, - 0.175593498, - ], - } - CORNELL: Dict[str, List[float]] = { - "index": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], - "rating": [ - 0.990554095, - 0.915430492, - 0.822470328, - 0.726896529, - 0.610745795, - 0.338955981, - 0.150869695, - 0.041547982, - ], - } + +# Load starch scales from YAML and dynamically set them as class attributes +_starch_data = load_starch_scales() +for variety_name, scale_data in _starch_data.items(): + setattr(StarchScales, variety_name, scale_data) class StarchArea(Analysis): @@ -194,13 +119,15 @@ def __init__(self): 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.", + "Threshold value for starch detection (0-255 range). This value is converted to a " + + "percentage (value/255) and applied to each image's actual pixel range. " + + "Pixels with gray values <= threshold percentage are considered starch. " + + "Lower values detect only darker starch regions, higher values include lighter regions. " + + "Default is 140 (55% of range).", ) self.starch_threshold.setMin(0) self.starch_threshold.setMax(255) - self.starch_threshold.setValue(172) + self.starch_threshold.setValue(140) self.starch_threshold.setIsRequired(False) # Gaussian blur kernel size parameter @@ -274,10 +201,12 @@ def _calculateStarch(self, img: NDArray[np.uint8]) -> Tuple[float, NDArray[np.ui Calculates the starch content in the given image and return the modified image. This function processes the input image to calculate the starch content. The process - involves blurring the image to remove noise, converting it to grayscale, adjusting - its intensity values, and creating a binary thresholded image to identify the starch - regions. The ratio of starch pixels to the total pixels in the ground truth is - returned along with the modified image. + involves blurring the image to remove noise, converting it to grayscale, extracting + the actual pixel range (min/max), and applying a percentage-based threshold to identify + starch regions. The threshold value (0-255) is converted to a percentage and applied + to each image's actual pixel range, ensuring consistent starch detection across images + with different lighting conditions. The ratio of starch pixels to the total pixels in + the ground truth is returned along with the modified image. Args: img (NDArray[np.uint8]): The input image as a NumPy array of type np.uint8. @@ -287,48 +216,29 @@ def _calculateStarch(self, img: NDArray[np.uint8]) -> Tuple[float, NDArray[np.ui - float: The ratio of starch pixels to total pixels in the ground truth. - NDArray[np.uint8]: The modified image with identified starch regions. """ - - def extractImage(img: NDArray[np.uint8]) -> Tuple[int, int]: - """ - Extracts minimum and maximum pixel value of an image - """ - hist, _ = np.histogram(gray, bins=256, range=(0, 255)) - low = (hist != 0).argmax() - high = 255 - (hist[::-1] != 0).argmax() - return low, high - - def adjustImage(img: NDArray[np.uint8], lIn: int, hIn: int, lOut: int = 0, hOut: int = 255): - """ - Adjusts the intensity values of an image I to new values. This function is equivalent - to normalize the image pixel values to [0, 255]. - """ - # Ensure img is in the range [lIn, hIn] - img = np.clip(img, lIn, hIn) - - # Normalize the image to the range [0, 1] - out = (img - lIn) / (hIn - lIn) - - # Scale and shift the normalized image to the range [lOut, hOut] - out = out * (hOut - lOut) + lOut - - return out.astype(np.uint8) - new_img = img.copy() - # blurs the image to remove sharp noises, then converts it to gray scale + # Blur the image to remove sharp noises, then convert to grayscale 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)) + grayscale = cast(NDArray[np.uint8], cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) - # re-adjusts the image to [0 255] - low, high = extractImage(gray) - gray = adjustImage(gray, low, high) + # Get actual min/max pixel values from histogram + hist, _ = np.histogram(grayscale, bins=256, range=(0, 255)) + low = (hist != 0).argmax() + high = 255 - (hist[::-1] != 0).argmax() - # create thresholded matrices + # Calculate percentage-based threshold + # User inputs threshold in 0-255 range (e.g., 140) + # Convert to percentage and apply to actual image range image_threshold = self.starch_threshold.getValue() - mask = np.logical_and((gray > 0), (gray <= image_threshold)).astype(np.uint8) + threshold_percentage = image_threshold / 255.0 + threshold_value = low + (high - low) * threshold_percentage - # creates new image using threshold matrices + # Create thresholded mask using percentage-based threshold on original range + mask = np.logical_and((grayscale > 0), (grayscale <= threshold_value)).astype(np.uint8) + + # Apply mask overlay to image new_img = self._drawMask(new_img, mask) ground_truth = np.count_nonzero( @@ -420,6 +330,9 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(result_img, image_instance.getImageName()) + # saves the calculated score to the image_instance as a parameter rating = FloatValue( "rating", "rating", "Granny calculated rating of total starch area." diff --git a/Granny/Analyses/SuperficialScald.py b/Granny/Analyses/SuperficialScald.py index c838079..1a5ad45 100644 --- a/Granny/Analyses/SuperficialScald.py +++ b/Granny/Analyses/SuperficialScald.py @@ -28,6 +28,7 @@ from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.StringValue import StringValue from numpy.typing import NDArray @@ -367,6 +368,9 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(binarized_image) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(result_img, image_instance.getImageName()) + # saves the calculated score to the image_instance as a parameter rating = FloatValue( "rating", "rating", "Granny calculated rating of total starch area." diff --git a/Granny/Models/Values/BoolValue.py b/Granny/Models/Values/BoolValue.py index 34ec38a..372a0fa 100644 --- a/Granny/Models/Values/BoolValue.py +++ b/Granny/Models/Values/BoolValue.py @@ -13,7 +13,7 @@ def __init__(self, name: str, label: str, help: str): super().__init__(name, label, help) self.type = bool - def validate(self) -> bool: + def validate(self, value=None) -> bool: """ {@inheritdoc} """ diff --git a/Granny/Models/Values/MetaDataValue.py b/Granny/Models/Values/MetaDataValue.py index 5ddc1f5..70bbf00 100644 --- a/Granny/Models/Values/MetaDataValue.py +++ b/Granny/Models/Values/MetaDataValue.py @@ -47,7 +47,13 @@ def writeValue(self): ) image_rating.to_csv(os.path.join(self.value, "results.csv"), header=True, index=False) tray_avg = image_rating.drop(columns=["Name"]) - tray_avg = tray_avg.groupby("TrayName").mean().reset_index() + string_cols = tray_avg.select_dtypes(include=["object"]).columns.difference(["TrayName"]).tolist() + tray_numeric = tray_avg.groupby("TrayName").mean(numeric_only=True).reset_index() + if string_cols: + tray_strings = tray_avg.groupby("TrayName")[string_cols].first().reset_index() + tray_avg = tray_strings.merge(tray_numeric, on="TrayName") + else: + tray_avg = tray_numeric tray_avg.to_csv(os.path.join(self.value, "tray_summary.csv"), header=True, index=False) def getImageList(self): diff --git a/Granny/Utils/QRCodeDetector.py b/Granny/Utils/QRCodeDetector.py new file mode 100644 index 0000000..88609e2 --- /dev/null +++ b/Granny/Utils/QRCodeDetector.py @@ -0,0 +1,177 @@ +""" +QR Code and Barcode Detection Utility + +This module provides functionality to detect and decode QR codes and barcodes +in images, primarily used to extract variety information from tray images. + +date: November 18, 2025 +author: Aden Athar +""" + +import cv2 +import numpy as np +from typing import Optional, Tuple + +try: + from pyzbar import pyzbar + PYZBAR_AVAILABLE = True +except ImportError as e: + PYZBAR_AVAILABLE = False + PYZBAR_ERROR = str(e) + + +class QRCodeDetector: + """ + Detects and decodes QR codes and barcodes from images. + + This class uses OpenCV's QRCodeDetector for QR codes and pyzbar for + 1D barcodes (Code128, Code39, EAN, UPC, etc.) to find codes in tray + images and extract variety information (e.g., "BB-Late", "CC-Early"). + """ + + def __init__(self): + """Initialize the QR code and barcode detector.""" + self.detector = cv2.QRCodeDetector() + self.barcode_enabled = PYZBAR_AVAILABLE + + if not PYZBAR_AVAILABLE: + print("WARNING: Barcode detection unavailable. Install libzbar0:") + print(" Ubuntu/Debian: sudo apt-get install libzbar0") + print(" macOS: brew install zbar") + print(" Windows: Download from http://zbar.sourceforge.net/") + + def detect(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np.ndarray]]: + """ + Detect and decode a QR code or barcode in an image. + + Tries QR code detection first, then falls back to barcode detection + if no QR code is found and pyzbar is available. + + Args: + image: Input image as numpy array (BGR format from OpenCV) + + Returns: + Tuple of (decoded_data, points) where: + - decoded_data: String containing code data, or None if not found + - points: numpy array of code corner points, or None if not found + """ + # Try QR code detection first + data, points, _ = self.detector.detectAndDecode(image) + + if data: + return data, points + + # Fall back to barcode detection if pyzbar is available + if self.barcode_enabled: + barcode_data, barcode_points = self._detect_barcode(image) + if barcode_data: + return barcode_data, barcode_points + + return None, None + + def _detect_barcode(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np.ndarray]]: + """ + Detect and decode a barcode using pyzbar, trying multiple rotations. + + Barcodes may appear at any angle in the image. This method tries the + original orientation first, then rotates by 90, 180, and 270 degrees + to ensure detection regardless of how the image was captured. + + Args: + image: Input image as numpy array (BGR format from OpenCV) + + Returns: + Tuple of (decoded_data, points) where: + - decoded_data: String containing barcode data, or None if not found + - points: numpy array of barcode corner points, or None if not found + """ + if not PYZBAR_AVAILABLE: + return None, None + + # Convert to grayscale for better detection + if len(image.shape) == 3: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + else: + gray = image + + # Try original and 3 rotations (0, 90, 180, 270 degrees) + rotations = [ + None, + cv2.ROTATE_90_CLOCKWISE, + cv2.ROTATE_180, + cv2.ROTATE_90_COUNTERCLOCKWISE, + ] + + for rotation in rotations: + rotated = gray if rotation is None else cv2.rotate(gray, rotation) + barcodes = pyzbar.decode(rotated) + + if barcodes: + barcode = barcodes[0] + data = barcode.data.decode('utf-8') + points = np.array(barcode.polygon, dtype=np.float32) + return data, points + + return None, None + + def extract_variety_info(self, qr_data: str) -> dict: + """ + Parse variety information from QR code data. + + Supports two formats: + 1. New format: "PROJECT|LOT|DATE|VARIETY" (pipe-delimited) + 2. Legacy format: "BB-Late" (dash-separated variety only) + + Args: + qr_data: Raw QR code string + + Returns: + Dictionary with parsed variety information: + { + 'raw': original QR string, + 'project': project code or 'UNKNOWN', + 'lot': lot code or 'UNKNOWN', + 'date': date string or 'UNKNOWN', + 'variety': variety code (e.g., 'BB'), + 'timing': timing info (e.g., 'Late'), + 'full': full variety string (e.g., 'BB-Late') + } + """ + variety_info = {'raw': qr_data} + + # Check if new pipe-delimited format + if '|' in qr_data: + parts = qr_data.split('|') + if len(parts) >= 4: + variety_info['project'] = parts[0] + variety_info['lot'] = parts[1] + variety_info['date'] = parts[2] + variety_info['full'] = parts[3] + + # Parse variety and timing from full variety string + variety_parts = parts[3].split('-') + variety_info['variety'] = variety_parts[0] if len(variety_parts) > 0 else '' + variety_info['timing'] = variety_parts[1] if len(variety_parts) > 1 else '' + else: + # Malformed pipe-delimited format + variety_info.update({ + 'project': 'UNKNOWN', + 'lot': 'UNKNOWN', + 'date': 'UNKNOWN', + 'full': qr_data, + 'variety': '', + 'timing': '' + }) + else: + # Legacy format (just variety, e.g., "BB-Late") + parts = qr_data.split('-') + variety_info.update({ + 'project': 'UNKNOWN', + 'lot': 'UNKNOWN', + 'date': 'UNKNOWN', + 'full': qr_data, + 'variety': parts[0] if len(parts) > 0 else '', + 'timing': parts[1] if len(parts) > 1 else '' + }) + + return variety_info diff --git a/Granny/Utils/__init__.py b/Granny/Utils/__init__.py new file mode 100644 index 0000000..feddb93 --- /dev/null +++ b/Granny/Utils/__init__.py @@ -0,0 +1 @@ +# Utils module diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml new file mode 100644 index 0000000..be38aa8 --- /dev/null +++ b/Granny/assets/starch_scales.yml @@ -0,0 +1,122 @@ +# Starch Scale Data for Apple Varieties +# +# This file contains starch index and rating mappings for different apple varieties. +# These values are used to evaluate starch content in apples, which indicates +# ripeness and suitability for consumption or storage. +# +# Format: +# variety_name: +# index: [list of starch index values] +# rating: [corresponding rating values] +# +# Each variety has its own starch scale based on research and industry standards. +# +# MSU varieties calibrated with 55% threshold (2026-02-12) + +WA38_1: + # Floral pattern - Calibrated with 55% threshold (2026-02-27) + # Index 1.5 and 3.0 interpolated for monotonicity + index: [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0] + rating: [0.718113375, 0.755543174, 0.792972973, 0.681798986, 0.599623535, 0.517448084, 0.503024803, 0.370892475, 0.208640787, 0.114769044, 0.110350810] + +WA38_2: + # Radial pattern - Calibrated with 55% threshold (2026-02-27) + # Index 3.0 interpolated for monotonicity + index: [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0] + rating: [0.880123743, 0.860423233, 0.818344566, 0.705450409, 0.690472869, 0.675495329, 0.504455394, 0.257632831, 0.212304108, 0.113385348, 0.112093828] + +ENZA: + # Calibrated with 55% threshold (2026-02-27) + index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + rating: [0.955854609, 0.737849901, 0.691676110, 0.638212444, 0.545670580, 0.497288589, 0.319361428, 0.275901680] + +GOLDEN_DELICIOUS: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.983347032, 0.978499607, 0.956362227, 0.879515383, 0.782063377, 0.569627467, 0.293948219, 0.122036979] + +GRANNY_SMITH: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + rating: [0.928685026, 0.899479649, 0.822260611, 0.727163318, 0.620976841, 0.444128086, 0.294305672] + +JONAGOLD: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.979482831, 0.968446545, 0.931265757, 0.802088459, 0.642265245, 0.318271857, 0.180840708, 0.063420488] + +CORNELL: + # Calibrated with 55% threshold (2026-02-25) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.950814279, 0.859641067, 0.814814815, 0.646408007, 0.537191447, 0.323038716, 0.226249041, 0.141040656] + +PURDUE: + # Calibrated with 55% threshold (2026-02-25) + index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + rating: [0.881028019, 0.658026584, 0.598684959, 0.313197178, 0.235314219, 0.115934565, 0.044768709] + +DANJOU: + # Calibrated with 55% threshold (2026-02-25) + # Index 1.5 and 3.5 interpolated for monotonicity + index: [1.2, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0] + rating: [0.846337197, 0.817211435, 0.788085673, 0.556374127, 0.446657794, 0.328440397, 0.210223000, 0.145805937, 0.052457699] + +AMBROSIA: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.971872720, 0.962896555, 0.908653906, 0.802704025, 0.690480695, 0.482492036, 0.341191274, 0.075346564] + +PINKLADY: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + rating: [0.982401785, 0.963264866, 0.932712253, 0.769275561, 0.656903113, 0.478645492, 0.326187668] + +REDDELICIOUS: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.970333145, 0.927816076, 0.859277333, 0.713880479, 0.506900655, 0.459462246, 0.268753816, 0.163449196] + +EMPIRE: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + rating: [0.970695189, 0.948662668, 0.791846554, 0.779420670, 0.745290611, 0.663642314, 0.460208582] + +FUJI: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.974763241, 0.901142588, 0.845687779, 0.805437350, 0.717441404, 0.539812629, 0.333291895, 0.168391585] + +IDARED: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + rating: [0.982588090, 0.955356105, 0.874148553, 0.813374837, 0.657680211, 0.612489305, 0.332182826] + +JONATHAN: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.979482831, 0.968446545, 0.931266065, 0.802088459, 0.642265245, 0.318271857, 0.180828569, 0.063420488] + +HONEYCRISP: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.981640465, 0.978403947, 0.937568685, 0.799603933, 0.620809574, 0.469053198, 0.211703838, 0.075295890] + +BRAEBURN: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.975845161, 0.865763207, 0.770965694, 0.691933910, 0.675968067, 0.513611895, 0.322461173, 0.156570377] + +EVERCRISP: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.985204666, 0.942957942, 0.921500522, 0.808915231, 0.680752183, 0.667710727, 0.329598534, 0.089511665] + +GALA: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.985676118, 0.900733517, 0.890346985, 0.643454695, 0.534702139, 0.530157722, 0.296498379, 0.125968575] + +ROME: + # MSU starch scale - 55% threshold (2026-02-12) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.952191921, 0.903481677, 0.776124925, 0.769690669, 0.606057588, 0.411963190, 0.238755173, 0.127008793] diff --git a/Pipfile b/Pipfile index e417889..409c9c4 100644 --- a/Pipfile +++ b/Pipfile @@ -12,4 +12,4 @@ pytest = "*" ipykernel = "*" [requires] -python_version = "3.9" +python_version = "3.12" diff --git a/docs/dev_guide/adding_analysis.rst b/docs/dev_guide/adding_analysis.rst index b79687e..4f8abf7 100644 --- a/docs/dev_guide/adding_analysis.rst +++ b/docs/dev_guide/adding_analysis.rst @@ -10,11 +10,28 @@ An analysis module in Granny: - Inherits from the ``Analysis`` abstract base class - Defines input parameters using the Value system -- Implements the ``performAnalysis()`` method +- Implements three abstract methods: ``_preRun()``, ``_processImage()``, and ``_postRun()`` - Returns a list of processed ``Image`` objects - Automatically integrates with all Granny interfaces (CLI, GUI) +- Leverages built-in multiprocessing for parallel image processing - Can be chained with other analyses using the Scheduler +The Analysis Architecture +------------------------- + +The base ``Analysis`` class provides a ``performAnalysis()`` method that: + +1. Loads images from the input directory via ``ImageListValue`` +2. Calls your ``_preRun()`` method for setup +3. Processes images in parallel using ``multiprocessing.Pool`` +4. Calls your ``_postRun()`` method for post-processing and saving results + +You implement the three abstract methods to customize behavior: + +- ``_preRun()``: Setup before processing (initialize variables, load models, etc.) +- ``_processImage(image)``: Process a single image (runs in parallel across CPU cores) +- ``_postRun(results)``: Post-processing after all images are done (save CSV, cleanup) + The Value System ---------------- @@ -34,7 +51,7 @@ Granny uses a type-safe Value system for parameters. Each parameter is represent - ``BoolValue`` - Boolean flags - ``FileNameValue`` - File paths - ``FileDirValue`` - Directory paths -- ``ImageListValue`` - Lists of images (typically input/output directories) +- ``ImageListValue`` - Directory containing images (handles loading/saving) - ``MetaDataValue`` - Metadata storage for results Step-by-Step Guide @@ -65,7 +82,7 @@ Start your file with necessary imports: import os from datetime import datetime - from typing import List + from typing import Dict, List, Tuple import cv2 import numpy as np @@ -74,10 +91,10 @@ Start your file with necessary imports: from Granny.Analyses.Analysis import Analysis from Granny.Models.Images.Image import Image from Granny.Models.Images.RGBImage import RGBImage - from Granny.Models.IO.ImageIO import ImageIO from Granny.Models.IO.RGBImageFile import RGBImageFile from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.FloatValue import FloatValue + from Granny.Models.Values.StringValue import StringValue from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.MetaDataValue import MetaDataValue @@ -98,7 +115,6 @@ Create your class inheriting from ``Analysis``: images (List[Image]): List of loaded images for processing input_images (ImageListValue): Input directory parameter output_images (ImageListValue): Output directory parameter - output_results (MetaDataValue): Results directory parameter my_threshold (IntValue): Example threshold parameter """ @@ -118,6 +134,7 @@ Initialize your analysis with parameters: super().__init__() self.images: List[Image] = [] + self.results_data: List[dict] = [] # For collecting CSV data # Required: Input images parameter self.input_images = ImageListValue( @@ -143,14 +160,6 @@ Initialize your analysis with parameters: self.output_images.setValue(result_dir) # Set default value self.addInParam(self.output_images) - # Output directory for CSV results - self.output_results = MetaDataValue( - "results", - "results", - "The output directory where analysis results are written." - ) - self.output_results.setValue(result_dir) - # Analysis parameter: threshold self.my_threshold = IntValue( "threshold", # Machine-readable name @@ -184,95 +193,130 @@ Initialize your analysis with parameters: - Use ``addRetValue()`` for values that other analyses can use - Set ``setIsRequired(True)`` for mandatory parameters -Step 5: Implement performAnalysis() -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Step 5: Implement the Three Abstract Methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This is the core method that executes your analysis: +Instead of overriding ``performAnalysis()``, implement these three methods: .. code-block:: python - def performAnalysis(self) -> List[Image]: + def _preRun(self): + """ + Setup before image processing begins. + + This method is called once before any images are processed. + Use it to: + - Initialize result containers + - Load models or resources + - Print analysis parameters """ - Perform the analysis on all input images. + self.results_data = [] # Reset results for this run + + # Get output directory + self.output_dir = self.output_images.getValue() + + # Print analysis info + print(f"\n{'='*60}") + print(f"MY NEW ANALYSIS") + print(f"{'='*60}") + print(f"Input directory: {self.input_images.getValue()}") + print(f"Output directory: {self.output_dir}") + print(f"Threshold: {self.my_threshold.getValue()}") + print(f"Mask alpha: {self.mask_alpha.getValue()}") + print(f"Processing {len(self.images)} images...") + print(f"{'='*60}\n") + + def _processImage(self, image: Image) -> Image: + """ + Process a single image. + + This method runs in parallel across multiple CPU cores. + Each call receives one Image instance and should return a processed Image. + + Args: + image: The input Image instance to process Returns: - List[Image]: List of processed Image objects with analysis results + Image: The processed Image with results """ - # Step 1: Load input images - input_dir = self.input_images.getValue() - output_dir = self.output_images.getValue() - results_dir = self.output_results.getValue() + # Get parameter values + threshold = self.my_threshold.getValue() + alpha = self.mask_alpha.getValue() - print(f"Loading images from: {input_dir}") - imageIO = ImageIO() - self.images = imageIO.load(input_dir, RGBImageFile) + # Load the image data + image_io = RGBImageFile() + image_io.setFilePath(image.getFilePath()) + image.loadImage(image_io) - if not self.images: - print("No images found to analyze.") - return [] + # Get the numpy array (BGR format from OpenCV) + img_array = image.getImage() - print(f"Processing {len(self.images)} images...") + # Perform your analysis + result_array, metric_value = self._analyze_image(img_array, threshold, alpha) - # Step 2: Process each image - result_images = [] - results_data = [] # For CSV output - - for idx, image in enumerate(self.images, 1): - print(f" Processing image {idx}/{len(self.images)}: {image.getFileName()}") - - # Get the numpy array - img_array = image.getImageFile().getImage() - - # Perform your analysis - result_array, metric_value = self._analyze_image(img_array) - - # Create result image - result_image = RGBImage() - result_file = RGBImageFile() - result_file.setImage(result_array) - result_file.setFileName(image.getFileName()) - result_file.setFilePath(output_dir) - result_image.setImageFile(result_file) - - # Add metadata to the image - result_image.addMetadata(self.metadata) - result_image.addMetadata([ - {"name": "metric_value", "value": metric_value} - ]) - - result_images.append(result_image) - results_data.append({ - "filename": image.getFileName(), - "metric_value": metric_value - }) + # Update the image with the result + image.setImage(result_array) + + # Add metadata to the image + metric_val = StringValue("metric", "metric", "Analysis metric value") + metric_val.setValue(str(metric_value)) + image.addValue(metric_val) - # Step 3: Save results - print(f"Saving results to: {output_dir}") - imageIO.save(result_images) + return image + + def _postRun(self, results: List[Image]) -> List[Image]: + """ + Post-processing after all images are processed. + + This method is called once after all images have been processed. + Use it to: + - Save images to disk + - Generate CSV reports + - Print summary statistics + + Args: + results: List of processed Image objects from _processImage() + + Returns: + List[Image]: The final list of result images + """ + print(f"\nSaving {len(results)} images to: {self.output_dir}") + + # Save each image + image_io = RGBImageFile() + for image in results: + image.saveImage(image_io, self.output_dir) + + # Collect data for CSV + csv_data = [] + for image in results: + metadata = image.getMetaData() + csv_data.append({ + "filename": image.getImageName(), + "metric": metadata.get("metric", StringValue("", "", "")).getValue() + }) # Save CSV - self._save_csv(results_data, results_dir) + self._save_csv(csv_data, self.output_dir) - print("Analysis complete!") - return result_images + print(f"\n{'='*60}") + print(f"Analysis complete! Processed {len(results)} images.") + print(f"{'='*60}\n") - def _analyze_image(self, img: NDArray) -> tuple[NDArray, float]: + return results + + def _analyze_image(self, img: NDArray, threshold: int, alpha: float) -> Tuple[NDArray, float]: """ - Perform analysis on a single image. + Perform analysis on a single image array. Args: img: Input image as numpy array (BGR format) + threshold: Detection threshold + alpha: Mask transparency Returns: Tuple of (processed_image, metric_value) """ - # Get parameter values - threshold = self.my_threshold.getValue() - alpha = self.mask_alpha.getValue() - - # Your image processing logic here - # This is a simple example - replace with your actual algorithm - # Convert to grayscale gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) @@ -319,18 +363,7 @@ This is the core method that executes your analysis: print(f"Results saved to: {csv_path}") -Step 6: Add Your Analysis to the Import Path -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Edit ``Granny/Analyses/__init__.py`` to include your new analysis: - -.. code-block:: python - - from .MyNewAnalysis import MyNewAnalysis - -This makes your analysis discoverable by the CLI interface. - -Step 7: Update the CLI Interface +Step 6: Update the CLI Interface ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Edit ``Granny/Interfaces/UI/GrannyCLI.py`` to add your analysis to the choices list: @@ -352,7 +385,7 @@ Also import your analysis class at the top of the file: from Granny.Analyses.MyNewAnalysis import MyNewAnalysis -Step 8: Create Tests +Step 7: Create Tests ~~~~~~~~~~~~~~~~~~~~~ Create a test file ``tests/test_Analyses/test_MyNewAnalysis.py``: @@ -367,22 +400,19 @@ Create a test file ``tests/test_Analyses/test_MyNewAnalysis.py``: assert analysis.__analysis_name__ == "myanalysis" assert analysis.my_threshold.getValue() == 128 - def test_performAnalysis(): - """Test the analysis with sample data.""" + def test_parameters(): + """Test parameter constraints.""" analysis = MyNewAnalysis() - # Set test parameters - analysis.input_images.setValue("test-assets/sample_images") - analysis.my_threshold.setValue(100) + # Test threshold bounds + assert analysis.my_threshold.getMin() == 0 + assert analysis.my_threshold.getMax() == 255 - # Run analysis - results = analysis.performAnalysis() + # Test alpha bounds + assert analysis.mask_alpha.getMin() == 0.0 + assert analysis.mask_alpha.getMax() == 1.0 - # Verify results - assert isinstance(results, list) - # Add more specific assertions based on your analysis - -Step 9: Test Your Analysis +Step 8: Test Your Analysis ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Run your analysis from the command line: @@ -400,6 +430,16 @@ Run the test suite: Best Practices -------------- +Multiprocessing Considerations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Since ``_processImage()`` runs in parallel: + +- Don't modify shared state (use return values instead) +- Each image should be processed independently +- Heavy initialization belongs in ``_preRun()`` +- Aggregation and saving belongs in ``_postRun()`` + Parameter Naming ~~~~~~~~~~~~~~~~ @@ -415,18 +455,10 @@ Documentation - Include examples in the module docstring - Explain the scientific/algorithmic basis of your analysis -Performance -~~~~~~~~~~~ - -- Process images efficiently using NumPy operations -- Consider multiprocessing for independent image processing (see ``BlushColor`` for example) -- Minimize memory usage for large image sets -- Provide progress feedback via print statements - Error Handling ~~~~~~~~~~~~~~ -- Validate input parameters in ``performAnalysis()`` +- Validate input parameters in ``_preRun()`` - Handle cases where no images are found - Provide clear error messages to users - Gracefully handle edge cases (empty images, invalid formats) @@ -476,17 +508,37 @@ If your analysis produces values that other analyses might use: ) self.addRetValue(self.fruit_coordinates) - # In performAnalysis() + # In _postRun() self.fruit_coordinates.setValue([(x1, y1, x2, y2), ...]) -Custom Image Types -~~~~~~~~~~~~~~~~~~ +CPU Core Configuration +~~~~~~~~~~~~~~~~~~~~~~ + +The base ``Analysis`` class automatically handles CPU core allocation: + +- Default (0): Uses 80% of available cores +- User can override via ``--cpu N`` CLI argument +- Set ``self.cpu.setValue(4)`` in ``__init__`` to change default + +Working with Image Metadata +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add metadata to images using ``Value`` objects: + +.. code-block:: python + + from Granny.Models.Values.StringValue import StringValue + from Granny.Models.Values.FloatValue import FloatValue -If you need a specialized image type beyond ``RGBImage``: + # In _processImage() + score_val = FloatValue("score", "score", "Analysis score") + score_val.setValue(95.5) + image.addValue(score_val) -1. Create a new class inheriting from ``Image`` in ``Granny/Models/Images/`` -2. Create a corresponding file class inheriting from ``ImageFile`` in ``Granny/Models/IO/`` -3. Implement required methods for loading and saving + # In _postRun(), retrieve metadata + for image in results: + metadata = image.getMetaData() + score = metadata.get("score").getValue() Troubleshooting --------------- @@ -495,7 +547,6 @@ Troubleshooting - Verify ``__analysis_name__`` is set correctly - Check that you added the analysis to GrannyCLI's choices list -- Ensure the import statement is in ``Granny/Analyses/__init__.py`` **Parameters not showing in help:** @@ -506,16 +557,14 @@ Troubleshooting **Images not loading:** - Verify the input directory path is correct -- Check that images are in a supported format (JPG, PNG) -- Ensure ``ImageIO`` is initialized correctly -- Use ``RGBImageFile`` for standard RGB images +- Check that images are in a supported format (JPG, PNG, JPEG, TIFF) +- Ensure ``RGBImageFile`` is used correctly with ``setFilePath()`` -**Results not saving:** +**Multiprocessing errors:** -- Verify the output directory is writable -- Check that ``imageIO.save()`` is called with the result images -- Ensure each image has both an ImageFile and metadata set -- Verify directory paths are created with ``os.makedirs(path, exist_ok=True)`` +- Ensure ``_processImage()`` doesn't access shared mutable state +- Check that all objects passed between processes are picklable +- Move file I/O to ``_preRun()`` or ``_postRun()`` if needed Next Steps ---------- diff --git a/docs/dev_guide/adding_interface.rst b/docs/dev_guide/adding_interface.rst index dbed692..332bc58 100644 --- a/docs/dev_guide/adding_interface.rst +++ b/docs/dev_guide/adding_interface.rst @@ -124,10 +124,13 @@ Create your class inheriting from ``GrannyUI``: self.analysis_name: str = "" self.port: int = 5000 -Step 4: Implement addProgramArgs() -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Step 4: Implement addProgramArgs() (Optional but Recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This method adds interface-specific arguments to the argument parser. -This method adds interface-specific arguments to the argument parser: +**Note:** ``addProgramArgs()`` is NOT an abstract method in the base class - only ``run()`` +is required. However, most interfaces implement this method to add their own arguments: .. code-block:: python @@ -236,7 +239,7 @@ This is the main entry point for your interface: return jsonify({ "success": True, "message": f"Processed {len(results)} images", - "results": [img.getFileName() for img in results] + "results": [img.getImageName() for img in results] }) except Exception as e: diff --git a/docs/dev_guide/api_reference.rst b/docs/dev_guide/api_reference.rst index b3450ab..72defce 100644 --- a/docs/dev_guide/api_reference.rst +++ b/docs/dev_guide/api_reference.rst @@ -57,6 +57,16 @@ Analysis Base Class - ``id`` - Unique analysis identifier (UUID) - ``path`` - Current directory path + .. py:attribute:: cpu + :type: IntValue + + Number of CPU cores for parallel processing. Default 0 = auto (80% of cores). + + .. py:attribute:: images + :type: List[Image] + + List of loaded images (populated by ``performAnalysis()``). + **Methods:** .. py:method:: __init__() @@ -111,23 +121,54 @@ Analysis Base Class Clear all return values. .. py:method:: performAnalysis() -> List[Image] - :abstractmethod: - **ABSTRACT:** Perform the analysis on input images. + Perform the analysis on input images. **Do not override this method.** - This method must be implemented by all subclasses. + The default implementation: + 1. Loads images from ``input_images`` parameter via ``ImageListValue.readValue()`` + 2. Calls ``_preRun()`` for setup + 3. Processes images in parallel using ``multiprocessing.Pool`` + 4. Calls ``_postRun()`` with results - :return: List of processed Image objects + :return: List of processed Image objects (from ``_postRun()``) :rtype: List[Image] - **Implementation guidelines:** + **Abstract Methods (must implement):** + + .. py:method:: _preRun() -> None + :abstractmethod: + + Setup before image processing begins. Called once before any images are processed. + + Use this for: + - Initializing result containers + - Loading models or resources + - Printing analysis parameters + + .. py:method:: _processImage(image: Image) -> Image + :abstractmethod: + + Process a single image. Runs in parallel across CPU cores. + + :param image: Input Image instance + :return: Processed Image instance + :rtype: Image + + **Important:** This method runs in separate processes. Avoid modifying shared state. + + .. py:method:: _postRun(results: List[Image]) -> List[Image] + :abstractmethod: + + Post-processing after all images are done. Called once with all results. - 1. Load images from ``input_images`` parameter - 2. Process each image - 3. Create result Image objects with results - 4. Add metadata to result images - 5. Save results (images and CSV) - 6. Return list of result images + Use this for: + - Saving images to disk + - Generating CSV reports + - Computing aggregate statistics + + :param results: List of processed Image objects from ``_processImage()`` + :return: Final list of result images + :rtype: List[Image] GrannyUI Base Class ~~~~~~~~~~~~~~~~~~~ @@ -171,19 +212,8 @@ GrannyUI Base Class 5. Call ``analysis.performAnalysis()`` 6. Handle/display results - .. py:method:: addProgramArgs() -> None - :abstractmethod: - - **ABSTRACT:** Add interface-specific arguments to the argument parser. - - This method is called during initialization to set up command-line arguments - that the interface needs. - - Example:: - - def addProgramArgs(self): - grp = self.parser.add_argument_group("My Interface Args") - grp.add_argument("--my-option", type=str, help="...") + **Note:** ``addProgramArgs()`` is commonly implemented but not required by the base class. + See ``GrannyCLI`` for an example implementation. Value Classes ------------- @@ -241,11 +271,6 @@ Value Base Class Whether the user has set this value. - .. py:attribute:: required - :type: bool - - Whether this parameter is required. - **Methods:** .. py:method:: validate(value: Any) -> bool @@ -433,12 +458,32 @@ FileDirValue ImageListValue ~~~~~~~~~~~~~~ -.. py:class:: ImageListValue(Value) +.. py:class:: ImageListValue(FileDirValue) Image list/directory parameter type. Represents a directory containing images. + Handles loading and saving of image lists. **Location:** ``Granny/Models/Values/ImageListValue.py`` + **Additional Methods:** + + .. py:method:: readValue() -> None + + Load all images from the directory into the internal image list. + Called automatically by ``Analysis.performAnalysis()``. + + .. py:method:: writeValue() -> None + + Save all images in the internal list to the directory. + + .. py:method:: getImageList() -> List[Image] + + Get the list of loaded Image objects. + + .. py:method:: setImageList(images: List[Image]) -> None + + Set the list of Image objects. + **Example:** .. code-block:: python @@ -451,6 +496,9 @@ ImageListValue input_images.setIsRequired(True) input_images.setValue("./demo/images") + # After readValue() is called: + images = input_images.getImageList() + MetaDataValue ~~~~~~~~~~~~~ @@ -474,159 +522,246 @@ MetaDataValue Image Classes ------------- -Image -~~~~~ +Image Base Class +~~~~~~~~~~~~~~~~ .. py:class:: Image - Base class for images in Granny. + Abstract base class for images in Granny. **Location:** ``Granny/Models/Images/Image.py`` + **Constructor:** + + .. py:method:: __init__(filepath: str) + + Initialize the Image with a file path. + + :param filepath: Path to the image file + + **Attributes:** + + .. py:attribute:: filepath + :type: str + + Absolute file path of the image. + + .. py:attribute:: image + :type: NDArray[np.uint8] + + The image data as a NumPy array. + + .. py:attribute:: metadata + :type: Dict[str, Value] + + Dictionary of metadata values attached to this image. + + .. py:attribute:: results + :type: Any + + Segmentation results (for use with YOLO models). + **Methods:** - .. py:method:: setImageFile(image_file: ImageFile) -> None + .. py:method:: addValue(*values: Value) -> None - Set the ImageFile object containing the actual image data. + Add metadata values to the image. - .. py:method:: getImageFile() -> ImageFile + :param values: One or more Value objects to attach - Get the ImageFile object. + Example:: + + score = FloatValue("score", "score", "Analysis score") + score.setValue(95.5) + image.addValue(score) + + .. py:method:: getValue(key: str) -> Value + + Get a metadata value by name. + + :param key: The name of the metadata value + :return: The Value object - .. py:method:: getFileName() -> str + .. py:method:: getFilePath() -> str + + Get the absolute file path. - Get the image filename. + .. py:method:: getImageName() -> str - .. py:method:: addMetadata(metadata: List[dict]) -> None + Get the filename (without path). - Add metadata to the image. + .. py:method:: getShape() -> tuple - :param metadata: List of metadata dictionaries with "name" and "value" keys + Get the image dimensions (height, width, channels). - .. py:method:: getMetadata() -> List[dict] + **Abstract Methods:** + + .. py:method:: loadImage(image_io: ImageIO) -> None + :abstractmethod: + + Load image data using the provided ImageIO instance. + + .. py:method:: saveImage(image_io: ImageIO, folder: str) -> None + :abstractmethod: + + Save image data to the specified folder. + + .. py:method:: getImage() -> NDArray[np.uint8] + :abstractmethod: + + Get the image data as a NumPy array. + + .. py:method:: setImage(image: NDArray[np.uint8]) -> None + :abstractmethod: + + Set the image data from a NumPy array. + + .. py:method:: getMetaData() -> Dict[str, Value] + :abstractmethod: Get all metadata attached to this image. + .. py:method:: setMetaData(metadata: Dict[str, Value]) -> None + :abstractmethod: + + Set the metadata for the image. + RGBImage ~~~~~~~~ .. py:class:: RGBImage(Image) - RGB color image type. + RGB color image type. The most common image type used in Granny analyses. **Location:** ``Granny/Models/Images/RGBImage.py`` - This is the most common image type used in Granny analyses. + **Constructor:** - **Example:** + .. py:method:: __init__(filepath: str) - .. code-block:: python + Initialize with a file path. - result_image = RGBImage() - result_file = RGBImageFile() - result_file.setImage(processed_array) - result_file.setFileName("result.jpg") - result_file.setFilePath("./output") - result_image.setImageFile(result_file) - result_image.addMetadata([{"name": "score", "value": 95.5}]) + :param filepath: Path to the image file -ImageFile Classes ------------------ + **Additional Methods:** -ImageFile -~~~~~~~~~ + .. py:method:: toRGB() -> None -.. py:class:: ImageFile + Convert image from BGR to RGB format. - Base class for image file handlers. + .. py:method:: toBGR() -> None - **Location:** ``Granny/Models/IO/ImageIO.py`` + Convert image from RGB to BGR format. - **Methods:** + .. py:method:: rotateImage() -> None - .. py:method:: setImage(image: NDArray) -> None + Rotate the image 90 degrees clockwise. - Set the image data as a NumPy array. + **Example:** - .. py:method:: getImage() -> NDArray + .. code-block:: python - Get the image data as a NumPy array. + # Load an image + image = RGBImage("/path/to/image.jpg") + image_io = RGBImageFile() + image_io.setFilePath(image.getFilePath()) + image.loadImage(image_io) - .. py:method:: setFileName(name: str) -> None + # Process the image + img_array = image.getImage() + processed = cv2.cvtColor(img_array, cv2.COLOR_BGR2GRAY) + image.setImage(processed) - Set the filename. + # Add metadata + score = FloatValue("score", "score", "Analysis score") + score.setValue(95.5) + image.addValue(score) - .. py:method:: getFileName() -> str + # Save + image.saveImage(image_io, "./output") - Get the filename. +ImageIO Classes +--------------- - .. py:method:: setFilePath(path: str) -> None +ImageIO Base Class +~~~~~~~~~~~~~~~~~~ - Set the directory path. +.. py:class:: ImageIO - .. py:method:: getFilePath() -> str + Abstract base class for image file handlers. Handles loading and saving individual images. - Get the directory path. + **Location:** ``Granny/Models/IO/ImageIO.py`` -RGBImageFile -~~~~~~~~~~~~ + **Attributes:** -.. py:class:: RGBImageFile(ImageFile) + .. py:attribute:: filepath + :type: str - RGB image file handler. Uses OpenCV for loading/saving. + Full path to the image file. - **Location:** ``Granny/Models/IO/RGBImageFile.py`` + .. py:attribute:: image_dir + :type: str - Automatically handles image I/O in BGR format (OpenCV convention). + Directory containing the image. -I/O Classes ------------ + .. py:attribute:: image_name + :type: str -ImageIO -~~~~~~~ + Filename of the image. -.. py:class:: ImageIO + **Methods:** - Handles loading and saving of images. + .. py:method:: setFilePath(filepath: str) -> None - **Location:** ``Granny/Models/IO/ImageIO.py`` + Set the file path and extract directory/filename. - **Methods:** + :param filepath: Full path to the image file - .. py:method:: load(path: str, file_class: Type[ImageFile]) -> List[Image] + **Abstract Methods:** - Load all images from a directory. + .. py:method:: loadImage() -> NDArray[np.uint8] + :abstractmethod: - :param path: Directory path - :param file_class: ImageFile class to use (e.g., RGBImageFile) - :return: List of loaded Image objects + Load and return the image data. - **Example:** + .. py:method:: saveImage(image: NDArray[np.uint8], output_path: str) -> None + :abstractmethod: - .. code-block:: python + Save the image to the specified directory. - imageIO = ImageIO() - images = imageIO.load("./input", RGBImageFile) + .. py:method:: getType() -> str + :abstractmethod: - .. py:method:: save(images: List[Image]) -> None + Get the image type (e.g., "rgb", "gray"). - Save all images to their designated paths. +RGBImageFile +~~~~~~~~~~~~ - :param images: List of Image objects to save +.. py:class:: RGBImageFile(ImageIO) - **Example:** + RGB image file handler. Uses OpenCV for loading/saving. - .. code-block:: python + **Location:** ``Granny/Models/IO/RGBImageFile.py`` - imageIO = ImageIO() - imageIO.save(result_images) + Images are loaded/saved in BGR format (OpenCV convention). + + **Example:** + + .. code-block:: python + + from Granny.Models.IO.RGBImageFile import RGBImageFile + + # Load an image + image_io = RGBImageFile() + image_io.setFilePath("/path/to/image.jpg") + img_array = image_io.loadImage() # Returns NDArray in BGR format + + # Save an image + image_io.saveImage(img_array, "/output/directory") Scheduler Class --------------- -Scheduler -~~~~~~~~~ - .. py:class:: Scheduler Manages dependencies between analyses and executes them in correct order. @@ -660,11 +795,18 @@ Scheduler scheduler.add_analysis(segmentation, []) scheduler.add_analysis(starch, [segmentation]) - .. py:method:: schedule() -> List[Any] + .. py:method:: schedule() -> List[int] + + Determine execution order based on dependencies. + + :return: List of analysis IDs in the order they should be run + :raises ValueError: If there is a cycle in the dependencies + + .. py:method:: run() -> None Execute all analyses in dependency order. - :return: List of results from all analyses + Calls ``schedule()`` internally, then runs each analysis via ``performAnalysis()``. Utility Functions ----------------- @@ -715,47 +857,63 @@ Loading and Processing Images .. code-block:: python - from Granny.Models.IO.ImageIO import ImageIO + from Granny.Models.Images.RGBImage import RGBImage from Granny.Models.IO.RGBImageFile import RGBImageFile - # Load images - imageIO = ImageIO() - images = imageIO.load("./input", RGBImageFile) + # In _processImage(): + def _processImage(self, image: Image) -> Image: + # Load image data + image_io = RGBImageFile() + image_io.setFilePath(image.getFilePath()) + image.loadImage(image_io) + + # Get NumPy array (BGR format) + img_array = image.getImage() + + # Process with OpenCV/NumPy + result = cv2.cvtColor(img_array, cv2.COLOR_BGR2GRAY) - # Process each image - for image in images: - img_array = image.getImageFile().getImage() # Get NumPy array - # Process img_array with OpenCV/NumPy - result_array = cv2.cvtColor(img_array, cv2.COLOR_BGR2GRAY) + # Update image + image.setImage(result) + return image -Creating Result Images -~~~~~~~~~~~~~~~~~~~~~~ +Saving Results in _postRun() +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - from Granny.Models.Images.RGBImage import RGBImage from Granny.Models.IO.RGBImageFile import RGBImageFile - # Create result image - result_image = RGBImage() - result_file = RGBImageFile() - result_file.setImage(processed_array) - result_file.setFileName("output.jpg") - result_file.setFilePath("./results") - result_image.setImageFile(result_file) - - # Add metadata - result_image.addMetadata([ - {"name": "score", "value": 85.5}, - {"name": "threshold", "value": 128} - ]) - result_image.addMetadata(self.metadata) # Add standard metadata - - # Save - imageIO.save([result_image]) - -Setting Analysis Parameters -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + def _postRun(self, results: List[Image]) -> List[Image]: + output_dir = self.output_images.getValue() + + image_io = RGBImageFile() + for image in results: + image.saveImage(image_io, output_dir) + + return results + +Adding Metadata to Images +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from Granny.Models.Values.StringValue import StringValue + from Granny.Models.Values.FloatValue import FloatValue + + # Add metadata in _processImage() + score = FloatValue("score", "score", "Analysis score") + score.setValue(95.5) + image.addValue(score) + + # Retrieve metadata in _postRun() + for image in results: + metadata = image.getMetaData() + if "score" in metadata: + score_value = metadata["score"].getValue() + +Setting Analysis Parameters (in interfaces) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -787,13 +945,16 @@ For better type checking, use these type hints: from Granny.Models.Images.Image import Image from Granny.Models.Values.Value import Value - def performAnalysis(self) -> List[Image]: + def _processImage(self, image: Image) -> Image: + ... + + def _postRun(self, results: List[Image]) -> List[Image]: ... def getInParams(self) -> Dict[str, Value]: ... - def process_image(self, img: NDArray) -> NDArray: + def process_array(self, img: NDArray) -> NDArray: ... See Also diff --git a/docs/dev_guide/example_analysis.rst b/docs/dev_guide/example_analysis.rst index 62def42..447a979 100644 --- a/docs/dev_guide/example_analysis.rst +++ b/docs/dev_guide/example_analysis.rst @@ -12,7 +12,7 @@ This example implements a **BruiseDetection** analysis that: - Calculates bruise percentage - Provides adjustable threshold and visualization parameters - Outputs annotated images and CSV results -- Follows all Granny best practices +- Uses the multiprocessing architecture via ``_preRun()``, ``_processImage()``, ``_postRun()`` The algorithm uses LAB color space to detect darker regions that indicate bruising. @@ -39,10 +39,10 @@ File: ``Granny/Analyses/BruiseDetection.py`` Date: 2024-01-15 """ - import os import csv + import os from datetime import datetime - from typing import List, Tuple + from typing import Dict, List, Tuple import cv2 import numpy as np @@ -50,11 +50,10 @@ File: ``Granny/Analyses/BruiseDetection.py`` from Granny.Analyses.Analysis import Analysis from Granny.Models.Images.Image import Image - from Granny.Models.Images.RGBImage import RGBImage - from Granny.Models.IO.ImageIO import ImageIO from Granny.Models.IO.RGBImageFile import RGBImageFile from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.FloatValue import FloatValue + from Granny.Models.Values.StringValue import StringValue from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.MetaDataValue import MetaDataValue @@ -67,16 +66,24 @@ File: ``Granny/Analyses/BruiseDetection.py`` identifying darker regions in LAB color space. Results include both visual annotations and quantitative measurements. + The base Analysis class handles: + - Loading images from input directory + - Parallel processing via multiprocessing.Pool + - CPU core management + + You implement: + - _preRun(): Setup before processing + - _processImage(): Process single image (runs in parallel) + - _postRun(): Save results after all images processed + Attributes: - images (List[Image]): Loaded input images + images (List[Image]): Loaded input images (set by base class) input_images (ImageListValue): Input directory parameter output_images (ImageListValue): Output directory for annotated images - output_results (MetaDataValue): Output directory for CSV results lightness_threshold (IntValue): L-channel threshold for bruise detection min_bruise_area (IntValue): Minimum area (pixels) for valid bruise morphological_kernel (IntValue): Kernel size for noise removal mask_alpha (FloatValue): Transparency for bruise mask overlay - bruise_color (tuple): Color for bruise mask visualization (BGR) """ # This name will be used in CLI: granny -i cli --analysis bruise @@ -89,12 +96,9 @@ File: ``Granny/Analyses/BruiseDetection.py`` Sets up input/output directories, detection thresholds, and visualization parameters with sensible defaults. """ - # STEP 1: Initialize base class + # STEP 1: Initialize base class (REQUIRED) super().__init__() - # Initialize image list - self.images: List[Image] = [] - # STEP 2: Set up input parameter self.input_images = ImageListValue( "input", @@ -198,45 +202,35 @@ File: ``Granny/Analyses/BruiseDetection.py`` self.font_scale.setIsRequired(False) self.addInParam(self.font_scale) - self.text_thickness = IntValue( - "text_thick", - "text_thickness", - "Thickness of text annotations in pixels. " - "Range: 1-50, default: 2." - ) - self.text_thickness.setMin(1) - self.text_thickness.setMax(50) - self.text_thickness.setValue(2) - self.text_thickness.setIsRequired(False) - self.addInParam(self.text_thickness) - # Bruise mask color (BGR format for OpenCV) self.bruise_color = (0, 0, 255) # Red - def performAnalysis(self) -> List[Image]: + # ========================================================================= + # REQUIRED ABSTRACT METHODS + # ========================================================================= + + def _preRun(self): """ - Perform bruise detection on all input images. + Setup before image processing begins. - Workflow: - 1. Load images from input directory - 2. Process each image to detect bruises - 3. Create annotated output images - 4. Save results to disk (images and CSV) - 5. Return processed images + Called once by performAnalysis() before parallel processing starts. + self.images is already populated with loaded Image objects. - Returns: - List[Image]: List of processed Image objects with bruise annotations + Use this for: + - Getting parameter values + - Initializing output directory + - Printing analysis info """ - # STEP 1: Get parameter values - input_dir = self.input_images.getValue() - output_dir = self.output_images.getValue() - results_dir = self.output_results.getValue() + # Get output directory path + self.output_dir = self.output_images.getValue() + self.results_dir = self.output_results.getValue() + # Print analysis info print(f"\n{'='*60}") print(f"BRUISE DETECTION ANALYSIS") print(f"{'='*60}") - print(f"Input directory: {input_dir}") - print(f"Output directory: {output_dir}") + print(f"Input directory: {self.input_images.getValue()}") + print(f"Output directory: {self.output_dir}") print(f"\nAnalysis Parameters:") print(f" Lightness threshold: {self.lightness_threshold.getValue()}") print(f" Min bruise area: {self.min_bruise_area.getValue()} pixels") @@ -244,74 +238,123 @@ File: ``Granny/Analyses/BruiseDetection.py`` print(f"\nVisualization Parameters:") print(f" Mask alpha: {self.mask_alpha.getValue()}") print(f" Font scale: {self.font_scale.getValue()}") + print(f"\nProcessing {len(self.images)} images...") print(f"{'='*60}\n") - # STEP 2: Load images - print(f"Loading images from: {input_dir}") - imageIO = ImageIO() - self.images = imageIO.load(input_dir, RGBImageFile) - - if not self.images: - print("ERROR: No images found in input directory.") - return [] - - print(f"Found {len(self.images)} images to process.\n") - - # STEP 3: Process each image - result_images = [] - results_data = [] - - for idx, image in enumerate(self.images, 1): - filename = image.getFileName() - print(f"Processing {idx}/{len(self.images)}: {filename}") - - # Get the image as NumPy array (BGR format from OpenCV) - img_array = image.getImageFile().getImage() - - # Perform bruise detection - annotated_img, bruise_pct, bruise_count = self._detect_bruises(img_array) - - print(f" -> Bruise coverage: {bruise_pct:.2f}%") - print(f" -> Bruise regions: {bruise_count}") - - # Create result image - result_image = RGBImage() - result_file = RGBImageFile() - result_file.setImage(annotated_img) - result_file.setFileName(filename) - result_file.setFilePath(output_dir) - result_image.setImageFile(result_file) - - # Add metadata - result_image.addMetadata(self.metadata) # Standard metadata - result_image.addMetadata([ - {"name": "bruise_percentage", "value": bruise_pct}, - {"name": "bruise_count", "value": bruise_count}, - {"name": "lightness_threshold", "value": self.lightness_threshold.getValue()} - ]) - - result_images.append(result_image) - results_data.append({ - "filename": filename, - "bruise_percentage": f"{bruise_pct:.2f}", - "bruise_count": bruise_count, - "lightness_threshold": self.lightness_threshold.getValue() + def _processImage(self, image: Image) -> Image: + """ + Process a single image for bruise detection. + + This method runs in PARALLEL across multiple CPU cores. + Each call receives one Image and must return the processed Image. + + IMPORTANT: Don't modify shared state here - it won't work + with multiprocessing. Return all results via the Image object. + + Args: + image: Input Image instance (filepath set, image not loaded) + + Returns: + Image: The same Image with processed data and metadata + """ + # Get parameter values (safe - these are read-only) + l_thresh = self.lightness_threshold.getValue() + min_area = self.min_bruise_area.getValue() + kernel_size = self.morphological_kernel.getValue() + alpha = self.mask_alpha.getValue() + font_scale = self.font_scale.getValue() + + # Load the image data + image_io = RGBImageFile() + image_io.setFilePath(image.getFilePath()) + image.loadImage(image_io) + + # Get the numpy array (BGR format from OpenCV) + img_array = image.getImage() + + # Perform bruise detection + result_array, bruise_pct, bruise_count = self._detect_bruises( + img_array, l_thresh, min_area, kernel_size, alpha, font_scale + ) + + # Update the image with processed result + image.setImage(result_array) + + # Add metadata to the image (will be collected in _postRun) + bruise_pct_val = FloatValue("bruise_percentage", "bruise_pct", "Bruise percentage") + bruise_pct_val.setValue(bruise_pct) + image.addValue(bruise_pct_val) + + bruise_count_val = IntValue("bruise_count", "bruise_count", "Number of bruise regions") + bruise_count_val.setValue(bruise_count) + image.addValue(bruise_count_val) + + threshold_val = IntValue("threshold_used", "threshold", "L threshold used") + threshold_val.setValue(l_thresh) + image.addValue(threshold_val) + + return image + + def _postRun(self, results: List[Image]) -> List[Image]: + """ + Post-processing after all images are processed. + + Called once with all processed Image objects. + Use this to save images, generate CSV reports, print summaries. + + Args: + results: List of processed Image objects from _processImage() + + Returns: + List[Image]: The final list of result images + """ + print(f"\nSaving {len(results)} images to: {self.output_dir}") + + # Ensure output directory exists + os.makedirs(self.output_dir, exist_ok=True) + + # Save each image and collect CSV data + image_io = RGBImageFile() + csv_data = [] + + for image in results: + # Save the image + image.saveImage(image_io, self.output_dir) + + # Collect data for CSV + metadata = image.getMetaData() + csv_data.append({ + "filename": image.getImageName(), + "bruise_percentage": f"{metadata['bruise_percentage'].getValue():.2f}", + "bruise_count": metadata['bruise_count'].getValue(), + "lightness_threshold": metadata['threshold_used'].getValue() }) - # STEP 4: Save results - print(f"\nSaving annotated images to: {output_dir}") - imageIO.save(result_images) + print(f" Saved: {image.getImageName()} - " + f"Bruise: {metadata['bruise_percentage'].getValue():.2f}%") - print(f"Saving CSV results to: {results_dir}") - self._save_csv(results_data, results_dir) + # Save CSV results + self._save_csv(csv_data, self.results_dir) print(f"\n{'='*60}") - print(f"Analysis complete! Processed {len(result_images)} images.") + print(f"Analysis complete! Processed {len(results)} images.") print(f"{'='*60}\n") - return result_images + return results - def _detect_bruises(self, img: NDArray) -> Tuple[NDArray, float, int]: + # ========================================================================= + # HELPER METHODS + # ========================================================================= + + def _detect_bruises( + self, + img: NDArray, + l_thresh: int, + min_area: int, + kernel_size: int, + alpha: float, + font_scale: float + ) -> Tuple[NDArray, float, int]: """ Detect bruises on a single fruit image. @@ -325,16 +368,15 @@ File: ``Granny/Analyses/BruiseDetection.py`` Args: img: Input image as NumPy array (BGR format) + l_thresh: Lightness threshold + min_area: Minimum bruise area in pixels + kernel_size: Morphological kernel size + alpha: Mask transparency + font_scale: Text font scale Returns: Tuple of (annotated_image, bruise_percentage, bruise_count) """ - # Get parameter values - l_thresh = self.lightness_threshold.getValue() - min_area = self.min_bruise_area.getValue() - kernel_size = self.morphological_kernel.getValue() - alpha = self.mask_alpha.getValue() - # Convert to LAB color space lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) l_channel, a_channel, b_channel = cv2.split(lab) @@ -370,7 +412,7 @@ File: ``Granny/Analyses/BruiseDetection.py`` # Create visualization annotated = self._create_visualization( - img, bruise_mask, bruise_pct, bruise_count, alpha + img, bruise_mask, bruise_pct, bruise_count, alpha, font_scale ) return annotated, bruise_pct, bruise_count @@ -381,7 +423,8 @@ File: ``Granny/Analyses/BruiseDetection.py`` bruise_mask: NDArray, bruise_pct: float, bruise_count: int, - alpha: float + alpha: float, + font_scale: float ) -> NDArray: """ Create annotated visualization of bruise detection results. @@ -392,6 +435,7 @@ File: ``Granny/Analyses/BruiseDetection.py`` bruise_pct: Bruise percentage bruise_count: Number of bruise regions alpha: Transparency for mask overlay + font_scale: Font scale for text Returns: Annotated image @@ -404,8 +448,7 @@ File: ``Granny/Analyses/BruiseDetection.py`` result = cv2.addWeighted(img, 1.0, mask_color, alpha, 0) # Add text annotations - font_scale = self.font_scale.getValue() - thickness = self.text_thickness.getValue() + thickness = 2 font = cv2.FONT_HERSHEY_SIMPLEX # Bruise percentage @@ -444,22 +487,15 @@ File: ``Granny/Analyses/BruiseDetection.py`` writer.writeheader() writer.writerows(data) - print(f" -> CSV saved: {csv_path}") + print(f"Results saved to: {csv_path}") + Integration Steps ----------------- After creating the file, follow these steps to integrate it into Granny: -1. **Update Analysis Imports** - - Edit ``Granny/Analyses/__init__.py``: - - .. code-block:: python - - from .BruiseDetection import BruiseDetection - -2. **Update CLI Interface** +1. **Update CLI Interface** Edit ``Granny/Interfaces/UI/GrannyCLI.py``: @@ -475,7 +511,7 @@ After creating the file, follow these steps to integrate it into Granny: choices=["segmentation", "blush", "color", "scald", "starch", "bruise"] -3. **Create Test File** +2. **Create Test File** Create ``tests/test_Analyses/test_BruiseDetection.py``: @@ -547,11 +583,13 @@ Usage Examples --mask_alpha 0.7 \ --font_scale 1.5 -**Check available parameters:** +**Specify CPU cores:** .. code-block:: bash - granny -i cli --analysis bruise --help + granny -i cli --analysis bruise \ + --input ./images/ \ + --cpu 4 Running Tests ------------- @@ -570,18 +608,40 @@ Key Takeaways This example demonstrates: 1. **Proper class structure** with inheritance from ``Analysis`` -2. **Complete parameter setup** including both analysis and visualization parameters -3. **Type-safe parameters** using IntValue, FloatValue, ImageListValue -4. **Parameter constraints** with min/max ranges -5. **Comprehensive docstrings** for class and methods -6. **Image processing workflow** using OpenCV and NumPy -7. **Result storage** for both images and CSV data -8. **Metadata handling** for result images -9. **User feedback** via print statements -10. **Clean code organization** with helper methods - -Best Practices Demonstrated ----------------------------- +2. **Three abstract methods**: ``_preRun()``, ``_processImage()``, ``_postRun()`` +3. **Parallel processing** via the base class (no manual Pool management) +4. **Type-safe parameters** using IntValue, FloatValue, ImageListValue +5. **Parameter constraints** with min/max ranges +6. **Comprehensive docstrings** for class and methods +7. **Image processing workflow** using OpenCV and NumPy +8. **Result storage** for both images and CSV data +9. **Metadata handling** via ``addValue()`` on Image objects +10. **User feedback** via print statements + +Architecture Notes +------------------ + +**Why three methods instead of one performAnalysis()?** + +The base ``Analysis.performAnalysis()`` handles: + +- Loading images from the input directory +- Managing the multiprocessing Pool +- CPU core allocation (80% default, or user-specified) +- Calling your methods in the right order + +This means you get parallel processing for free just by implementing the three methods correctly. + +**Multiprocessing constraints in _processImage():** + +Since ``_processImage()`` runs in separate processes: + +- Don't modify instance variables (changes won't propagate) +- Return results via the Image object's metadata +- Aggregation happens in ``_postRun()`` +- Parameter values can be read (they're pickled with the object) + +**Best Practices Demonstrated:** - **Separation of concerns:** Detection logic separate from visualization - **Configurable parameters:** All magic numbers are parameters @@ -599,6 +659,5 @@ Next Steps - Modify this example for your specific use case - Add additional parameters as needed - Implement more sophisticated algorithms -- Add multiprocessing for better performance - Create unit tests for your specific analysis - Update documentation with your analysis details diff --git a/gitignore b/gitignore new file mode 100644 index 0000000..8b5b402 --- /dev/null +++ b/gitignore @@ -0,0 +1,40 @@ +# Directories +*data +__pycache__ +.vscode/ +.DS_Store +01-* +baseline/ +00-* +build/ +dist/ +06-Package +*logs/ +_build/ + +# Files +*.pyc +*.out +*.pptx +output.out +*.csv +*.txt +*.err +*.h5 +*.ipynb +*.egg* +*.sh +*.zip +test_perf.py +*.csv +*.onnx +*.pt +*.lock +*.ini +*.vscode +*.yaml +*.png +*.jpeg +*.jpg +*.tiff +CLAUDE.md diff --git a/setup.py b/setup.py index 959b5bf..ff5ac3d 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,19 @@ import setuptools -requirements = """ -ultralytics -numpy -opencv-python -pytest -""".split() +install_requires = [ + "ultralytics>=8.0,<9.0", + "numpy>=1.24", + "opencv-python>=4.8", + "pandas>=2.0", + "pyzbar>=0.1.9", +] +extras_require = { + "dev": [ + "pytest>=7.0", + "pytest-cov>=4.0", + ] +} setuptools.setup( name="granny", @@ -17,9 +24,8 @@ author="Nhan H. Nguyen, Heidi Hargarten, Loren Honaas, Stephen P. Ficklin", license="GNU General Public License v3.0", python_requires=">=3.9", - install_requires=[ - requirements, - ], + install_requires=install_requires, + extras_require=extras_require, entry_points={ "console_scripts": [ "granny = Granny.GrannyBase:run", diff --git a/tests/test_Analyses/test_Analysis.py b/tests/test_Analyses/test_Analysis.py new file mode 100644 index 0000000..ce0cd59 --- /dev/null +++ b/tests/test_Analyses/test_Analysis.py @@ -0,0 +1,76 @@ +from Granny.Analyses.StarchArea import StarchArea +from Granny.Models.Images.RGBImage import RGBImage + + +def _get_analysis(): + """Use StarchArea as a concrete implementation of Analysis.""" + return StarchArea() + + +def test_parse_qr_from_filename_valid(): + analysis = _get_analysis() + result = analysis._parse_qr_from_filename( + "APPLE2025_LOT001_2025-12-02_BB-Late_fruit_01.png" + ) + assert result["project"] == "APPLE2025" + assert result["lot"] == "LOT001" + assert result["date"] == "2025-12-02" + assert result["variety"] == "BB-Late" + + +def test_parse_qr_from_filename_with_path(): + analysis = _get_analysis() + result = analysis._parse_qr_from_filename( + "/some/path/to/APPLE2025_LOT001_2025-12-02_BB-Late_fruit_05.png" + ) + assert result["project"] == "APPLE2025" + assert result["variety"] == "BB-Late" + + +def test_parse_qr_from_filename_jpg(): + analysis = _get_analysis() + result = analysis._parse_qr_from_filename( + "PROJ_LOT_DATE_VAR_fruit_01.jpg" + ) + assert result["project"] == "PROJ" + assert result["variety"] == "VAR" + + +def test_parse_qr_from_filename_legacy(): + """Legacy filenames without QR data should return empty strings.""" + analysis = _get_analysis() + result = analysis._parse_qr_from_filename("apple_fruit_01.png") + assert result["project"] == "" + assert result["lot"] == "" + assert result["date"] == "" + assert result["variety"] == "" + + +def test_parse_qr_from_filename_no_match(): + analysis = _get_analysis() + result = analysis._parse_qr_from_filename("random_image.png") + assert result["project"] == "" + + +def test_add_qr_metadata_valid(): + analysis = _get_analysis() + img = RGBImage("APPLE2025_LOT001_2025-12-02_BB-Late_fruit_01.png") + analysis._add_qr_metadata(img, "APPLE2025_LOT001_2025-12-02_BB-Late_fruit_01.png") + + metadata = img.getMetaData() + assert "project" in metadata + assert metadata["project"].getValue() == "APPLE2025" + assert metadata["lot"].getValue() == "LOT001" + assert metadata["date"].getValue() == "2025-12-02" + assert metadata["variety"].getValue() == "BB-Late" + + +def test_add_qr_metadata_legacy(): + """Legacy filenames should not add QR metadata.""" + analysis = _get_analysis() + img = RGBImage("apple_fruit_01.png") + analysis._add_qr_metadata(img, "apple_fruit_01.png") + + metadata = img.getMetaData() + assert "project" not in metadata + assert "lot" not in metadata diff --git a/tests/test_Analyses/test_BlushColor.py b/tests/test_Analyses/test_BlushColor.py index 08805b9..5385447 100644 --- a/tests/test_Analyses/test_BlushColor.py +++ b/tests/test_Analyses/test_BlushColor.py @@ -1,15 +1,107 @@ +import numpy as np +import pytest from Granny.Analyses.BlushColor import BlushColor def test_BlushColorInstantiation(): - """Test that BlushColor can be instantiated""" analysis = BlushColor() assert analysis is not None assert analysis.__analysis_name__ == "blush" def test_BlushColorInputImages(): - """Test that BlushColor input_images can be set""" analysis = BlushColor() analysis.input_images.setValue("demo/pear_images/full_masked_images") assert analysis.input_images.getValue() == "demo/pear_images/full_masked_images" + + +# --------------------------------------------------------------------------- +# Default parameter values +# --------------------------------------------------------------------------- + +def test_default_threshold_is_148(): + assert BlushColor().threshold.getValue() == 148 + + +def test_default_fruit_threshold_is_140(): + assert BlushColor().fruit_threshold.getValue() == 140 + + +def test_default_blush_color(): + a = BlushColor() + assert a.blush_color_r.getValue() == 150 + assert a.blush_color_g.getValue() == 55 + assert a.blush_color_b.getValue() == 50 + + +def test_default_text_position(): + a = BlushColor() + assert a.text_x.getValue() == 20 + assert a.text_y.getValue() == 50 + + +def test_default_font_scale_is_1(): + assert BlushColor().font_scale.getValue() == pytest.approx(1.0) + + +def test_default_text_thickness_is_3(): + assert BlushColor().text_thickness.getValue() == 3 + + +def test_threshold_boundary_values(): + a = BlushColor() + a.threshold.setValue(0) + assert a.threshold.getValue() == 0 + a.threshold.setValue(255) + assert a.threshold.getValue() == 255 + + +# --------------------------------------------------------------------------- +# _calculateBlush with synthetic images +# --------------------------------------------------------------------------- + +def _make_bgr(r, g, b, size=50): + img = np.zeros((size, size, 3), dtype=np.uint8) + img[:, :, 0] = b + img[:, :, 1] = g + img[:, :, 2] = r + return img + + +def test_calculate_blush_returns_ratio_between_0_and_1(): + a = BlushColor() + img = _make_bgr(200, 100, 100) + ratio, _ = a._calculateBlush(img) + assert 0.0 <= ratio <= 1.0 + + +def test_calculate_blush_output_shape_matches_input(): + a = BlushColor() + img = _make_bgr(200, 100, 100) + _, result = a._calculateBlush(img) + assert result.shape == img.shape + + +def test_calculate_blush_threshold_affects_ratio(): + img = _make_bgr(200, 150, 100) + low_a = BlushColor() + low_a.threshold.setValue(50) + high_a = BlushColor() + high_a.threshold.setValue(200) + low_ratio, _ = low_a._calculateBlush(img) + high_ratio, _ = high_a._calculateBlush(img) + assert low_ratio != high_ratio + + +def test_calculate_blush_all_black_image(): + a = BlushColor() + img = np.zeros((50, 50, 3), dtype=np.uint8) + ratio, result = a._calculateBlush(img) + assert result.shape == img.shape + + +def test_calculate_blush_returns_float(): + a = BlushColor() + img = _make_bgr(180, 100, 80) + ratio, _ = a._calculateBlush(img) + assert isinstance(ratio, float) diff --git a/tests/test_Analyses/test_PeelColor.py b/tests/test_Analyses/test_PeelColor.py index 78e4824..6664d9d 100644 --- a/tests/test_Analyses/test_PeelColor.py +++ b/tests/test_Analyses/test_PeelColor.py @@ -1,15 +1,103 @@ +import numpy as np +import pytest from Granny.Analyses.PeelColor import PeelColor def test_PeelColorInstantiation(): - """Test that PeelColor can be instantiated""" analysis = PeelColor() assert analysis is not None assert analysis.__analysis_name__ == "color" def test_PeelColorInputImages(): - """Test that PeelColor input_images can be set""" analysis = PeelColor() analysis.input_images.setValue("demo/pear_images/full_masked_images") assert analysis.input_images.getValue() == "demo/pear_images/full_masked_images" + + +# --------------------------------------------------------------------------- +# Default parameter values +# --------------------------------------------------------------------------- + +def test_default_purple_threshold_is_126(): + assert PeelColor().purple_threshold.getValue() == 126 + + +def test_default_lightness_range(): + a = PeelColor() + assert a.lightness_min.getValue() == 0 + assert a.lightness_max.getValue() == 255 + + +def test_default_green_range(): + a = PeelColor() + assert a.green_min.getValue() == 0 + assert a.green_max.getValue() == 128 + + +def test_default_yellow_range(): + a = PeelColor() + assert a.yellow_min.getValue() == 128 + assert a.yellow_max.getValue() == 255 + + +def test_default_normalize_lightness_is_50(): + assert PeelColor().normalize_lightness.getValue() == 50 + + +def test_mean_values_length_match(): + a = PeelColor() + assert len(a.MEAN_VALUES_A) == len(a.MEAN_VALUES_B) == len(a.SCORE) + + +def test_line_points_are_2d(): + a = PeelColor() + assert a.LINE_POINT_1.shape == (2,) + assert a.LINE_POINT_2.shape == (2,) + + +# --------------------------------------------------------------------------- +# remove_purple +# --------------------------------------------------------------------------- + +def test_remove_purple_output_shape(): + a = PeelColor() + img = np.full((50, 50, 3), 128, dtype=np.uint8) + result = a.remove_purple(img) + assert result.shape == img.shape + + +def test_remove_purple_does_not_modify_original(): + a = PeelColor() + img = np.full((50, 50, 3), 100, dtype=np.uint8) + original = img.copy() + a.remove_purple(img) + np.testing.assert_array_equal(img, original) + + +# --------------------------------------------------------------------------- +# calculate_bin_distance +# --------------------------------------------------------------------------- + +def test_calculate_bin_distance_euclidean_returns_valid_bin(): + a = PeelColor() + bin_num, dist = a.calculate_bin_distance([-20.0, 70.0], method="Euclidean") + assert 1 <= bin_num <= len(a.SCORE) + + +def test_calculate_bin_distance_score_method(): + a = PeelColor() + bin_num, dist = a.calculate_bin_distance([0.6], method="Score") + assert 1 <= bin_num <= len(a.SCORE) + + +def test_calculate_bin_distance_x_component(): + a = PeelColor() + bin_num, _ = a.calculate_bin_distance([-20.0, 70.0], method="X-component") + assert 1 <= bin_num <= len(a.SCORE) + + +def test_calculate_bin_distance_y_component(): + a = PeelColor() + bin_num, _ = a.calculate_bin_distance([-20.0, 70.0], method="Y-component") + assert 1 <= bin_num <= len(a.SCORE) diff --git a/tests/test_Analyses/test_Segmentation.py b/tests/test_Analyses/test_Segmentation.py index b1a8bae..37c2e0c 100644 --- a/tests/test_Analyses/test_Segmentation.py +++ b/tests/test_Analyses/test_Segmentation.py @@ -1,5 +1,66 @@ +import pytest from Granny.Analyses.Segmentation import Segmentation + def test_performAnalysis(): analysis = Segmentation() - \ No newline at end of file + + +# --------------------------------------------------------------------------- +# Default parameter values +# --------------------------------------------------------------------------- + +def test_default_conf_threshold_is_0_7(): + assert Segmentation().conf_threshold.getValue() == pytest.approx(0.7) + + +def test_default_iou_threshold(): + assert Segmentation().iou_threshold.getValue() == pytest.approx(0.45) + + +def test_default_font_scale(): + s = Segmentation() + assert s.font_scale.getValue() == pytest.approx(2.0) + + +def test_default_text_thickness_is_3(): + assert Segmentation().text_thickness.getValue() == 3 + + +def test_default_text_color_is_black(): + s = Segmentation() + assert s.text_color_r.getValue() == 0 + assert s.text_color_g.getValue() == 0 + assert s.text_color_b.getValue() == 0 + + +def test_text_color_boundary_values(): + s = Segmentation() + s.text_color_r.setValue(255) + s.text_color_g.setValue(255) + s.text_color_b.setValue(255) + assert s.text_color_r.getValue() == 255 + assert s.text_color_g.getValue() == 255 + assert s.text_color_b.getValue() == 255 + + +def test_conf_threshold_range(): + s = Segmentation() + s.conf_threshold.setValue(0.0) + assert s.conf_threshold.getValue() == pytest.approx(0.0) + s.conf_threshold.setValue(1.0) + assert s.conf_threshold.getValue() == pytest.approx(1.0) + + +def test_qr_detector_initialized(): + from Granny.Utils.QRCodeDetector import QRCodeDetector + s = Segmentation() + assert isinstance(s.qr_detector, QRCodeDetector) + + +def test_variety_info_initially_none(): + assert Segmentation().variety_info is None + + +def test_analysis_name_is_segmentation(): + assert Segmentation().__analysis_name__ == "segmentation" diff --git a/tests/test_Analyses/test_StarchArea.py b/tests/test_Analyses/test_StarchArea.py index ad5fecf..3eb069a 100644 --- a/tests/test_Analyses/test_StarchArea.py +++ b/tests/test_Analyses/test_StarchArea.py @@ -1,15 +1,221 @@ -from Granny.Analyses.StarchArea import StarchArea -from Granny.Models.Images.RGBImage import RGBImage -from Granny.Models.IO.RGBImageFile import RGBImageFile -from Granny.Models.Values.StringValue import StringValue -from Granny.Models.Values.ImageListValue import ImageListValue +import numpy as np +import pytest +from Granny.Analyses.StarchArea import StarchArea, StarchScales, load_starch_scales -def test_StarchAnalyses(): +# --------------------------------------------------------------------------- +# load_starch_scales / StarchScales +# --------------------------------------------------------------------------- + +def test_load_starch_scales_returns_dict(): + """YAML loads as a non-empty dictionary.""" + data = load_starch_scales() + assert isinstance(data, dict) + assert len(data) > 0 + + +def test_load_starch_scales_expected_varieties(): + """All expected varieties are present in the YAML.""" + data = load_starch_scales() + expected = { + "HONEYCRISP", "WA38_1", "WA38_2", "ENZA", + "CORNELL", "PURDUE", "DANJOU", + "GOLDEN_DELICIOUS", "GRANNY_SMITH", "JONAGOLD", + } + assert expected.issubset(data.keys()) + + +def test_load_starch_scales_index_rating_same_length(): + """Every variety has equal-length index and rating lists.""" + data = load_starch_scales() + for variety, scale in data.items(): + assert len(scale["index"]) == len(scale["rating"]), ( + f"{variety}: index length {len(scale['index'])} != " + f"rating length {len(scale['rating'])}" + ) + + +def test_load_starch_scales_ratings_between_0_and_1(): + """All rating values are valid percentages (0.0-1.0).""" + data = load_starch_scales() + for variety, scale in data.items(): + for r in scale["rating"]: + assert 0.0 <= r <= 1.0, f"{variety} has out-of-range rating: {r}" + + +def test_starch_scales_class_attributes(): + """StarchScales class exposes variety names as attributes.""" + data = load_starch_scales() + for variety in data: + assert hasattr(StarchScales, variety), f"StarchScales missing attribute: {variety}" + + +# --------------------------------------------------------------------------- +# StarchArea initialisation & parameters +# --------------------------------------------------------------------------- + +def test_starch_area_instantiation(): + analysis = StarchArea() + assert analysis is not None + + +def test_default_threshold_is_140(): + analysis = StarchArea() + assert analysis.starch_threshold.getValue() == 140 + + +def test_threshold_accepts_boundary_values(): + """Threshold should accept the full 0-255 range without error.""" analysis = StarchArea() - # Set up input images from demo directory - analysis.input_images.setValue("demo/cross_section_images/full_masked_images") + analysis.starch_threshold.setValue(0) + assert analysis.starch_threshold.getValue() == 0 + analysis.starch_threshold.setValue(255) + assert analysis.starch_threshold.getValue() == 255 + + +def test_default_blur_kernel_is_7(): + analysis = StarchArea() + assert analysis.blur_kernel.getValue() == 7 + + +def test_default_mask_alpha_is_0_6(): + analysis = StarchArea() + assert analysis.mask_alpha.getValue() == pytest.approx(0.6) + + +# --------------------------------------------------------------------------- +# _calculateStarch - synthetic image tests +# --------------------------------------------------------------------------- + +def _make_bgr(gray_value: int, size: int = 100) -> np.ndarray: + """Create a solid-colour BGR image with a given grayscale brightness.""" + channel = np.full((size, size), gray_value, dtype=np.uint8) + return np.stack([channel, channel, channel], axis=-1) + + +def test_calculate_starch_all_dark_returns_high_ratio(): + """A very dark image should be almost entirely starch.""" + analysis = StarchArea() + img = _make_bgr(10) + ratio, _ = analysis._calculateStarch(img) + assert ratio > 0.8, f"Expected ratio > 0.8 for dark image, got {ratio}" + + +def test_calculate_starch_bright_dominant_image_returns_low_ratio(): + """An image that is mostly bright should detect low starch.""" + size = 100 + img = np.full((size, size, 3), 240, dtype=np.uint8) + # Small dark patch (10x10) in the corner - starch + img[:10, :10] = 10 + + analysis = StarchArea() + ratio, _ = analysis._calculateStarch(img) + assert ratio < 0.2, f"Expected ratio < 0.2 for mostly-bright image, got {ratio}" + - # This test just verifies that StarchArea can be instantiated - # and that input_images can be set without errors - assert analysis.input_images.getValue() == "demo/cross_section_images/full_masked_images" \ No newline at end of file +def test_calculate_starch_returns_ratio_between_0_and_1(): + """Starch ratio must always be a valid proportion.""" + analysis = StarchArea() + img = _make_bgr(128) + ratio, _ = analysis._calculateStarch(img) + assert 0.0 <= ratio <= 1.0 + + +def test_calculate_starch_returns_image_same_shape(): + """The returned image must have the same shape as the input.""" + analysis = StarchArea() + img = _make_bgr(100, size=64) + _, result = analysis._calculateStarch(img) + assert result.shape == img.shape + + +def test_calculate_starch_higher_threshold_gives_higher_ratio(): + """Raising the threshold should classify more pixels as starch.""" + img = _make_bgr(150) + low_analysis = StarchArea() + low_analysis.starch_threshold.setValue(80) # ~31% + high_analysis = StarchArea() + high_analysis.starch_threshold.setValue(200) # ~78% + + low_ratio, _ = low_analysis._calculateStarch(img) + high_ratio, _ = high_analysis._calculateStarch(img) + assert high_ratio >= low_ratio + + +def test_calculate_starch_mixed_image(): + """An image split between dark and bright halves should give ~50% ratio.""" + size = 100 + img = np.zeros((size, size, 3), dtype=np.uint8) + img[:, :50] = 10 # left half: very dark (starch) + img[:, 50:] = 240 # right half: very bright (no starch) + + analysis = StarchArea() + ratio, _ = analysis._calculateStarch(img) + assert 0.3 < ratio < 0.7, f"Expected roughly 0.5, got {ratio}" + + +# --------------------------------------------------------------------------- +# _calculateIndex +# --------------------------------------------------------------------------- + +def test_calculate_index_returns_all_varieties(): + """_calculateIndex should return a key for every loaded variety.""" + analysis = StarchArea() + data = load_starch_scales() + results = analysis._calculateIndex(0.5) + assert set(results.keys()) == set(data.keys()) + + +def test_calculate_index_exact_match(): + """When target exactly matches a rating, that index is selected.""" + analysis = StarchArea() + # CORNELL index 5 -> rating 0.537191447 + target = 0.537191447 + results = analysis._calculateIndex(target) + assert results["CORNELL"] == pytest.approx(5.0) + + +def test_calculate_index_closest_match(): + """A target slightly off a known rating should still pick the nearest index.""" + analysis = StarchArea() + # HONEYCRISP index 1 -> rating 0.981640465 + results = analysis._calculateIndex(0.982) + assert results["HONEYCRISP"] == pytest.approx(1.0) + + +def test_calculate_index_values_are_floats(): + """All returned index values should be numeric.""" + analysis = StarchArea() + results = analysis._calculateIndex(0.5) + for variety, index in results.items(): + assert isinstance(index, (int, float)), f"{variety} index is not numeric" + + +# --------------------------------------------------------------------------- +# _drawMask +# --------------------------------------------------------------------------- + +def test_draw_mask_zeros_darken_pixels(): + """Pixels where mask == 0 should be darkened.""" + analysis = StarchArea() + img = _make_bgr(200, size=10) + mask = np.zeros((10, 10), dtype=np.uint8) # all starch + result = analysis._drawMask(img, mask) + assert result.mean() < img.mean() + + +def test_draw_mask_ones_leave_pixels_unchanged(): + """Pixels where mask == 1 should be unchanged.""" + analysis = StarchArea() + img = _make_bgr(200, size=10) + mask = np.ones((10, 10), dtype=np.uint8) # no starch + result = analysis._drawMask(img, mask) + np.testing.assert_array_equal(result, img) + + +def test_draw_mask_output_shape_matches_input(): + analysis = StarchArea() + img = _make_bgr(128, size=50) + mask = np.ones((50, 50), dtype=np.uint8) + result = analysis._drawMask(img, mask) + assert result.shape == img.shape \ No newline at end of file diff --git a/tests/test_Analyses/test_SuperficialScald.py b/tests/test_Analyses/test_SuperficialScald.py index 5efeea1..ef632a6 100644 --- a/tests/test_Analyses/test_SuperficialScald.py +++ b/tests/test_Analyses/test_SuperficialScald.py @@ -1,15 +1,124 @@ +import numpy as np +import pytest from Granny.Analyses.SuperficialScald import SuperficialScald def test_SuperficialScaldInstantiation(): - """Test that SuperficialScald can be instantiated""" analysis = SuperficialScald() assert analysis is not None assert analysis.__analysis_name__ == "scald" def test_SuperficialScaldInputImages(): - """Test that SuperficialScald input_images can be set""" analysis = SuperficialScald() analysis.input_images.setValue("demo/granny_smith_images/full_masked_images") assert analysis.input_images.getValue() == "demo/granny_smith_images/full_masked_images" + + +# --------------------------------------------------------------------------- +# Default parameter values +# --------------------------------------------------------------------------- + +def test_default_morph_kernel_is_10(): + assert SuperficialScald().morph_kernel.getValue() == 10 + + +def test_default_min_threshold_is_100(): + assert SuperficialScald().min_threshold.getValue() == 100 + + +def test_default_purple_threshold_is_126(): + assert SuperficialScald().purple_threshold.getValue() == 126 + + +def test_default_blur_kernel_is_3(): + assert SuperficialScald().blur_kernel.getValue() == 3 + + +def test_default_hist_factor(): + assert SuperficialScald().hist_factor.getValue() == pytest.approx(0.333) + + +def test_default_hist_top_n_is_10(): + assert SuperficialScald().hist_top_n.getValue() == 10 + + +# --------------------------------------------------------------------------- +# _smoothMask +# --------------------------------------------------------------------------- + +def test_smooth_mask_output_shape(): + a = SuperficialScald() + mask = np.ones((100, 100), dtype=np.uint8) + result = a._smoothMask(mask) + assert result.shape == mask.shape + + +def test_smooth_mask_all_zeros_stays_zero(): + a = SuperficialScald() + mask = np.zeros((100, 100), dtype=np.uint8) + result = a._smoothMask(mask) + assert result.sum() == 0 + + +def test_smooth_mask_all_ones_stays_ones(): + a = SuperficialScald() + mask = np.ones((100, 100), dtype=np.uint8) + result = a._smoothMask(mask) + assert result.sum() > 0 + + +# --------------------------------------------------------------------------- +# _calculateScald +# --------------------------------------------------------------------------- + +def test_calculate_scald_full_mask_returns_zero(): + a = SuperficialScald() + img = np.full((50, 50, 3), 128, dtype=np.uint8) + bw = img.copy() + score = a._calculateScald(bw, img) + assert score == pytest.approx(0.0) + + +def test_calculate_scald_empty_mask_returns_one(): + a = SuperficialScald() + img = np.full((50, 50, 3), 128, dtype=np.uint8) + bw = np.zeros((50, 50, 3), dtype=np.uint8) + score = a._calculateScald(bw, img) + assert score == pytest.approx(1.0) + + +def test_calculate_scald_returns_value_between_0_and_1(): + a = SuperficialScald() + img = np.full((50, 50, 3), 128, dtype=np.uint8) + bw = img.copy() + bw[:25, :] = 0 + score = a._calculateScald(bw, img) + assert 0.0 <= score <= 1.0 + + +def test_calculate_scald_zero_ground_area_returns_one(): + a = SuperficialScald() + img = np.zeros((50, 50, 3), dtype=np.uint8) + bw = np.zeros((50, 50, 3), dtype=np.uint8) + score = a._calculateScald(bw, img) + assert score == 1 + + +# --------------------------------------------------------------------------- +# _removeTrayResidue +# --------------------------------------------------------------------------- + +def test_remove_tray_residue_output_shape(): + a = SuperficialScald() + img = np.full((50, 50, 3), 100, dtype=np.uint8) + result = a._removeTrayResidue(img) + assert result.shape == img.shape + + +def test_remove_tray_residue_does_not_modify_input(): + a = SuperficialScald() + img = np.full((50, 50, 3), 100, dtype=np.uint8) + original = img.copy() + a._removeTrayResidue(img) + np.testing.assert_array_equal(img, original) diff --git a/tests/test_Analyses/test_integration.py b/tests/test_Analyses/test_integration.py new file mode 100644 index 0000000..d58265b --- /dev/null +++ b/tests/test_Analyses/test_integration.py @@ -0,0 +1,224 @@ +""" +Integration tests for analysis pipelines. + +Unlike unit tests that call _calculateStarch / _calculateBlush directly with +in-memory numpy arrays, these tests exercise the full _processImage path: +write a real PNG to disk → create RGBImage from that path → call _preRun + +_processImage → verify the result has the expected structure and values. + +This catches bugs in file I/O, Image/ImageIO wiring, and Value assembly that +unit tests miss (e.g. the FileDirValue validation-order bug documented in +BUGFIXES.md would have surfaced here). +""" + +import os + +import cv2 +import numpy as np +import pytest + +from Granny.Analyses.BlushColor import BlushColor +from Granny.Analyses.StarchArea import StarchArea, StarchScales +from Granny.Models.Images.RGBImage import RGBImage + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_synthetic_png(path: str, bgr_value: tuple, size: int = 80) -> None: + """Write a solid-colour BGR image to *path* as a PNG.""" + img = np.full((size, size, 3), bgr_value, dtype=np.uint8) + cv2.imwrite(path, img) + + +def _write_mixed_starch_png(path: str, dark_fraction: float = 0.5, size: int = 100) -> None: + """ + Write a two-tone image with a controlled dark/bright split. + + Uses values 20 (dark) and 200 (bright) so normalization is never degenerate. + After normalizing to [0, 255], the dark pixels land near 0 and the bright + ones near 255, giving predictable starch classification at threshold=140. + """ + img = np.zeros((size, size, 3), dtype=np.uint8) + split = int(size * dark_fraction) + img[:, :split] = 20 # dark → starch after normalisation + img[:, split:] = 200 # bright → no starch + cv2.imwrite(path, img) + + +# --------------------------------------------------------------------------- +# StarchArea integration +# --------------------------------------------------------------------------- + +class TestStarchAreaIntegration: + def test_processimage_returns_rgb_image(self, tmp_path): + """_processImage must return an Image with the underlying array set.""" + img_path = str(tmp_path / "apple_fruit_01.png") + _write_synthetic_png(img_path, bgr_value=(30, 30, 30)) + + analysis = StarchArea() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + assert result is not None + assert result.getImage() is not None + + def test_processimage_result_has_rating_value(self, tmp_path): + """The result Image must expose a 'rating' FloatValue after processing.""" + img_path = str(tmp_path / "apple_fruit_01.png") + _write_synthetic_png(img_path, bgr_value=(80, 80, 80)) + + analysis = StarchArea() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + assert "rating" in result.getMetaData(), "result is missing 'rating' key" + rating = result.getValue("rating").getValue() + assert 0.0 <= rating <= 1.0, f"rating {rating} is out of [0, 1] range" + + def test_processimage_result_has_all_scale_indices(self, tmp_path): + """The result must include a starch index for every variety in StarchScales.""" + img_path = str(tmp_path / "apple_fruit_01.png") + _write_mixed_starch_png(img_path) + + analysis = StarchArea() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + varieties = {k for k in vars(StarchScales) if not k.startswith("_")} + metadata_keys = set(result.getMetaData().keys()) + missing = varieties - metadata_keys + assert not missing, f"Missing starch scale indices: {missing}" + + def test_processimage_higher_threshold_gives_higher_rating(self, tmp_path): + """Higher threshold should classify more pixels as starch on the same image.""" + img_path = str(tmp_path / "apple_fruit_01.png") + _write_mixed_starch_png(img_path, dark_fraction=0.5) + + low_analysis = StarchArea() + low_analysis.starch_threshold.setValue(80) + low_analysis._preRun() + low_result = low_analysis._processImage(RGBImage(img_path)) + + high_analysis = StarchArea() + high_analysis.starch_threshold.setValue(200) + high_analysis._preRun() + high_result = high_analysis._processImage(RGBImage(img_path)) + + low_rating = low_result.getValue("rating").getValue() + high_rating = high_result.getValue("rating").getValue() + assert high_rating >= low_rating, ( + f"Higher threshold should give higher starch ratio: low={low_rating}, high={high_rating}" + ) + + def test_processimage_result_image_same_shape_as_input(self, tmp_path): + """The annotated output image must have the same spatial dimensions as the input.""" + img_path = str(tmp_path / "apple_fruit_01.png") + size = 64 + _write_synthetic_png(img_path, bgr_value=(100, 100, 100), size=size) + + analysis = StarchArea() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + h, w, _ = result.getImage().shape + assert (h, w) == (size, size) + + def test_processimage_preserves_image_name(self, tmp_path): + """getImageName() on the result should match the input filename.""" + img_path = str(tmp_path / "apple_fruit_01.png") + _write_synthetic_png(img_path, bgr_value=(128, 128, 128)) + + analysis = StarchArea() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + assert result.getImageName() == "apple_fruit_01.png" + + +# --------------------------------------------------------------------------- +# BlushColor integration +# --------------------------------------------------------------------------- + +class TestBlushColorIntegration: + def _yellow_pear_image(self, path: str, size: int = 80) -> None: + """Write a yellow pear image: high B channel (fruit) with some A (blush).""" + # In LAB (OpenCV 0–255 encoding): L~128, A~148 (neutral), B~200 (yellow) + # We create a BGR image that converts to a known LAB range. + # A yellow-ish image: R≈200, G≈180, B≈50 in BGR → yellow pear background + img = np.full((size, size, 3), (50, 180, 200), dtype=np.uint8) + cv2.imwrite(path, img) + + def _blush_pear_image(self, path: str, size: int = 80) -> None: + """Write an image where ~half the pixels will register as blush (high A channel).""" + img = np.zeros((size, size, 3), dtype=np.uint8) + # Left half: reddish (will have high A in LAB) — BGR (0, 80, 200) + img[:, :size // 2] = (0, 80, 200) + # Right half: yellow (low A, high B) — BGR (50, 180, 200) + img[:, size // 2:] = (50, 180, 200) + cv2.imwrite(path, img) + + def test_processimage_returns_image(self, tmp_path): + img_path = str(tmp_path / "pear_fruit_01.png") + self._yellow_pear_image(img_path) + + analysis = BlushColor() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + assert result is not None + assert result.getImage() is not None + + def test_processimage_result_has_rating_value(self, tmp_path): + img_path = str(tmp_path / "pear_fruit_01.png") + self._yellow_pear_image(img_path) + + analysis = BlushColor() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + assert "rating" in result.getMetaData() + rating = result.getValue("rating").getValue() + assert 0.0 <= rating <= 1.0, f"rating {rating} out of range" + + def test_processimage_result_image_same_shape(self, tmp_path): + size = 60 + img_path = str(tmp_path / "pear_fruit_01.png") + img = np.full((size, size, 3), (50, 180, 200), dtype=np.uint8) + cv2.imwrite(img_path, img) + + analysis = BlushColor() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + h, w, _ = result.getImage().shape + assert (h, w) == (size, size) + + def test_processimage_preserves_image_name(self, tmp_path): + img_path = str(tmp_path / "pear_fruit_01.png") + self._yellow_pear_image(img_path) + + analysis = BlushColor() + analysis._preRun() + result = analysis._processImage(RGBImage(img_path)) + + assert result.getImageName() == "pear_fruit_01.png" + + def test_processimage_higher_blush_image_gives_higher_rating(self, tmp_path): + """An image with more reddish pixels should produce a higher blush rating.""" + low_path = str(tmp_path / "low_blush_fruit_01.png") + high_path = str(tmp_path / "high_blush_fruit_01.png") + self._yellow_pear_image(low_path) + self._blush_pear_image(high_path) + + analysis = BlushColor() + analysis._preRun() + low_result = analysis._processImage(RGBImage(low_path)) + high_result = analysis._processImage(RGBImage(high_path)) + + low_rating = low_result.getValue("rating").getValue() + high_rating = high_result.getValue("rating").getValue() + assert high_rating >= low_rating, ( + f"Expected blush image to score higher: low={low_rating}, high={high_rating}" + ) diff --git a/tests/test_Models/test_Utils/__init__.py b/tests/test_Models/test_Utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_Models/test_Utils/test_QRCodeDetector.py b/tests/test_Models/test_Utils/test_QRCodeDetector.py new file mode 100644 index 0000000..b06e4f9 --- /dev/null +++ b/tests/test_Models/test_Utils/test_QRCodeDetector.py @@ -0,0 +1,139 @@ +import cv2 +import numpy as np +from Granny.Utils.QRCodeDetector import QRCodeDetector + + +def test_instantiation(): + detector = QRCodeDetector() + assert detector.detector is not None + assert isinstance(detector.barcode_enabled, bool) + + +def test_detect_returns_none_for_blank_image(): + detector = QRCodeDetector() + blank = np.zeros((100, 100, 3), dtype=np.uint8) + data, points = detector.detect(blank) + assert data is None + assert points is None + + +def test_detect_barcode_rotation_invariant(): + """Barcode detection should work regardless of image rotation.""" + detector = QRCodeDetector() + if not detector.barcode_enabled: + return + + # Create a test image with a Code128 barcode using pyzbar's expected input + # We'll use a real barcode image if available, otherwise test the rotation logic + # by generating a simple barcode-like pattern + from pyzbar import pyzbar + + # Create a synthetic barcode image using python-barcode if available + try: + import barcode + from barcode.writer import ImageWriter + import tempfile + import os + + code = barcode.get("code128", "TEST123", writer=ImageWriter()) + tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + code.save(tmp.name.replace(".png", "")) + barcode_path = tmp.name.replace(".png", "") + ".png" + + img = cv2.imread(barcode_path) + if img is None: + return + + # Test original orientation + data, points = detector._detect_barcode(img) + assert data == "TEST123" + + # Test 90 degree rotation + rotated = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) + data, points = detector._detect_barcode(rotated) + assert data == "TEST123" + + # Test 180 degree rotation + rotated = cv2.rotate(img, cv2.ROTATE_180) + data, points = detector._detect_barcode(rotated) + assert data == "TEST123" + + # Test 270 degree rotation + rotated = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) + data, points = detector._detect_barcode(rotated) + assert data == "TEST123" + + os.unlink(barcode_path) + except ImportError: + # python-barcode not installed, skip + pass + + +def test_extract_variety_info_pipe_format(): + detector = QRCodeDetector() + info = detector.extract_variety_info("APPLE2026|LOT002|2026-01-23|BB-Early") + assert info["project"] == "APPLE2026" + assert info["lot"] == "LOT002" + assert info["date"] == "2026-01-23" + assert info["full"] == "BB-Early" + assert info["variety"] == "BB" + assert info["timing"] == "Early" + + +def test_extract_variety_info_legacy_format(): + detector = QRCodeDetector() + info = detector.extract_variety_info("BB-Late") + assert info["project"] == "UNKNOWN" + assert info["lot"] == "UNKNOWN" + assert info["full"] == "BB-Late" + assert info["variety"] == "BB" + assert info["timing"] == "Late" + + +def test_extract_variety_info_malformed_pipe(): + detector = QRCodeDetector() + info = detector.extract_variety_info("only|two") + assert info["project"] == "UNKNOWN" + assert info["full"] == "only|two" + + +def test_extract_variety_info_raw_field_always_set(): + detector = QRCodeDetector() + raw = "PROJ|LOT|2026-01-01|HC-Late" + info = detector.extract_variety_info(raw) + assert info["raw"] == raw + + +def test_extract_variety_info_no_timing(): + detector = QRCodeDetector() + info = detector.extract_variety_info("HONEYCRISP") + assert info["variety"] == "HONEYCRISP" + assert info["timing"] == "" + + +def test_extract_variety_info_pipe_variety_no_dash(): + detector = QRCodeDetector() + info = detector.extract_variety_info("P|L|D|HONEYCRISP") + assert info["variety"] == "HONEYCRISP" + assert info["timing"] == "" + + +def test_detect_grayscale_image_does_not_crash(): + detector = QRCodeDetector() + gray = np.zeros((100, 100), dtype=np.uint8) + if detector.barcode_enabled: + data, points = detector._detect_barcode(gray) + assert data is None + + +def test_detect_returns_two_values(): + detector = QRCodeDetector() + result = detector.detect(np.zeros((100, 100, 3), dtype=np.uint8)) + assert len(result) == 2 + + +def test_extract_variety_info_three_pipe_parts(): + detector = QRCodeDetector() + info = detector.extract_variety_info("A|B|C") + assert info["project"] == "UNKNOWN" + assert info["lot"] == "UNKNOWN" diff --git a/tests/test_Models/test_Values/test_BoolValue.py b/tests/test_Models/test_Values/test_BoolValue.py index 943ca3d..34c1500 100644 --- a/tests/test_Models/test_Values/test_BoolValue.py +++ b/tests/test_Models/test_Values/test_BoolValue.py @@ -1,6 +1,55 @@ from Granny.Models.Values.BoolValue import BoolValue -# Checks if the value was properly set + def test_validate(): value_1 = BoolValue("name", "label", "help") - assert value_1.validate() is True \ No newline at end of file + assert value_1.validate() is True + + +def test_name_label_help(): + v = BoolValue("myname", "mylabel", "myhelp") + assert v.getName() == "myname" + assert v.getLabel() == "mylabel" + assert v.getHelp() == "myhelp" + + +def test_set_get_true(): + v = BoolValue("b", "b", "b") + v.setValue(True) + assert v.getValue() is True + + +def test_set_get_false(): + v = BoolValue("b", "b", "b") + v.setValue(False) + assert v.getValue() is False + + +def test_type_is_bool(): + v = BoolValue("b", "b", "b") + assert v.type is bool + + +def test_overwrite_value(): + v = BoolValue("b", "b", "b") + v.setValue(True) + v.setValue(False) + assert v.getValue() is False + + +def test_validate_with_value(): + v = BoolValue("b", "b", "b") + assert v.validate(True) is True + assert v.validate(False) is True + + +def test_set_is_required(): + v = BoolValue("b", "b", "b") + v.setIsRequired(True) + assert v.getIsRequired() is True + + +def test_set_is_required_false(): + v = BoolValue("b", "b", "b") + v.setIsRequired(False) + assert v.getIsRequired() is False \ No newline at end of file diff --git a/tests/test_Models/test_Values/test_MetaDataValue.py b/tests/test_Models/test_Values/test_MetaDataValue.py index 25ba651..2117228 100644 --- a/tests/test_Models/test_Values/test_MetaDataValue.py +++ b/tests/test_Models/test_Values/test_MetaDataValue.py @@ -1,5 +1,88 @@ +import os +import tempfile +import pandas as pd from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.FloatValue import FloatValue +from Granny.Models.Values.StringValue import StringValue +from Granny.Models.Images.RGBImage import RGBImage +import numpy as np +def _make_image(name, rating_val, project=None, lot=None, date=None, variety=None): + """Helper to create a test image with metadata.""" + img = RGBImage(name) + img.setImage(np.zeros((10, 10, 3), dtype=np.uint8)) - \ No newline at end of file + rating = FloatValue("rating", "rating", "test rating") + rating.setMin(0.0) + rating.setMax(1.0) + rating.setValue(rating_val) + img.addValue(rating) + + if project: + for key, val in [("project", project), ("lot", lot), ("date", date), ("variety", variety)]: + sv = StringValue(key, key, f"test {key}") + sv.setValue(val) + img.addValue(sv) + + return img + + +def test_write_tray_summary_with_string_columns(): + """tray_summary.csv should include string metadata columns like project, lot, date, variety.""" + with tempfile.TemporaryDirectory() as tmpdir: + mdv = MetaDataValue("results", "results", "test") + mdv.setValue(tmpdir) + + images = [ + _make_image("PROJ_LOT1_2025-01-01_VAR_fruit_01.png", 0.8, "PROJ", "LOT1", "2025-01-01", "VAR"), + _make_image("PROJ_LOT1_2025-01-01_VAR_fruit_02.png", 0.6, "PROJ", "LOT1", "2025-01-01", "VAR"), + ] + mdv.setImageList(images) + mdv.writeValue() + + tray_df = pd.read_csv(os.path.join(tmpdir, "tray_summary.csv")) + assert "project" in tray_df.columns + assert "lot" in tray_df.columns + assert "date" in tray_df.columns + assert "variety" in tray_df.columns + assert tray_df["project"].iloc[0] == "PROJ" + assert tray_df["lot"].iloc[0] == "LOT1" + assert tray_df["rating"].iloc[0] == 0.7 # average of 0.8 and 0.6 + + +def test_write_tray_summary_without_string_columns(): + """tray_summary.csv should still work without string metadata.""" + with tempfile.TemporaryDirectory() as tmpdir: + mdv = MetaDataValue("results", "results", "test") + mdv.setValue(tmpdir) + + images = [ + _make_image("apple_fruit_01.png", 0.9), + _make_image("apple_fruit_02.png", 0.7), + ] + mdv.setImageList(images) + mdv.writeValue() + + tray_df = pd.read_csv(os.path.join(tmpdir, "tray_summary.csv")) + assert "TrayName" in tray_df.columns + assert "rating" in tray_df.columns + assert abs(tray_df["rating"].iloc[0] - 0.8) < 0.001 + + +def test_results_csv_has_all_rows(): + """results.csv should have one row per image.""" + with tempfile.TemporaryDirectory() as tmpdir: + mdv = MetaDataValue("results", "results", "test") + mdv.setValue(tmpdir) + + images = [ + _make_image("PROJ_LOT1_2025-01-01_VAR_fruit_01.png", 0.5, "PROJ", "LOT1", "2025-01-01", "VAR"), + _make_image("PROJ_LOT1_2025-01-01_VAR_fruit_02.png", 0.6, "PROJ", "LOT1", "2025-01-01", "VAR"), + _make_image("PROJ_LOT1_2025-01-01_VAR_fruit_03.png", 0.7, "PROJ", "LOT1", "2025-01-01", "VAR"), + ] + mdv.setImageList(images) + mdv.writeValue() + + results_df = pd.read_csv(os.path.join(tmpdir, "results.csv")) + assert len(results_df) == 3