From bd443071873bcd6926ba0cfe5c5503b9e6793152 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 11 Sep 2025 14:11:27 -0700 Subject: [PATCH 01/40] Fix FileDirValue to validate input before setting value - Add input validation before assigning to self.value - Validate that input is a string and not empty - Only set self.value after successful directory creation and validation - Addresses requirement to validate all values before they get set --- Granny/Models/Values/FileDirValue.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Granny/Models/Values/FileDirValue.py b/Granny/Models/Values/FileDirValue.py index 39e8f5b..ce573b2 100644 --- a/Granny/Models/Values/FileDirValue.py +++ b/Granny/Models/Values/FileDirValue.py @@ -25,10 +25,20 @@ def setValue(self, value: str): Raises: ValueError: If the path is not a valid directory after assignment. """ - self.value = value - if not self.validate(): + # Validate input before setting + if not isinstance(value, str): + raise ValueError(f"Directory path must be a string, got {type(value)}") + if not value.strip(): + raise ValueError("Directory path cannot be empty") + + # Create directory first, then set and validate + os.makedirs(value, exist_ok=True) + + # Only set the value after successful directory creation and validation + if os.path.isdir(value): + self.value = value + else: raise ValueError("Not a directory. Please specify a directory.") - os.makedirs(self.value, exist_ok=True) def validate(self) -> bool: """ From 9d1e5c3fe8986add6115a98bab8c9298cdc684f1 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 17 Nov 2025 14:19:10 -0800 Subject: [PATCH 02/40] Add GitHub Actions CI/CD with pytest coverage and QLTY Cloud integration - Created .github/workflows/pytest-coverage.yml for automated testing - Tests run on Python 3.9, 3.10, 3.11, 3.12 for every PR - Added pytest-cov to setup.py dependencies - Coverage reports uploaded to QLTY Cloud (free for open source) - Uses OIDC authentication following QLTY best practices - Current coverage: 43% (48/48 tests passing) --- .github/workflows/pytest-coverage.yml | 50 +++++++++++++++++++++++++++ setup.py | 1 + 2 files changed, 51 insertions(+) create mode 100644 .github/workflows/pytest-coverage.yml diff --git a/.github/workflows/pytest-coverage.yml b/.github/workflows/pytest-coverage.yml new file mode 100644 index 0000000..f42f97a --- /dev/null +++ b/.github/workflows/pytest-coverage.yml @@ -0,0 +1,50 @@ +name: Pytest with Coverage + +on: + pull_request: + branches: + - dev + - main + - pr-testing + push: + branches: + - dev + - main + - pr-testing + +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: lcov:coverage.lcov diff --git a/setup.py b/setup.py index 959b5bf..f5aa14c 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ numpy opencv-python pytest +pytest-cov """.split() From 4c3660215861e7e3035f81320eff34ab66058b68 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 17 Nov 2025 14:31:30 -0800 Subject: [PATCH 03/40] Add pandas to dependencies --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f5aa14c..a90bf82 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ ultralytics numpy opencv-python +pandas pytest pytest-cov """.split() From a2e4f487b3c0c8a6b4f4a2c9d02799c32063ce94 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 17 Nov 2025 14:37:03 -0800 Subject: [PATCH 04/40] Add debug step and fix QLTY coverage file path --- .github/workflows/pytest-coverage.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest-coverage.yml b/.github/workflows/pytest-coverage.yml index f42f97a..0507f32 100644 --- a/.github/workflows/pytest-coverage.yml +++ b/.github/workflows/pytest-coverage.yml @@ -42,9 +42,15 @@ jobs: run: | pytest --cov=Granny --cov-report=lcov:coverage.lcov --cov-report=term-missing + - name: Verify coverage file exists + if: matrix.python-version == '3.12' + run: | + ls -la coverage.lcov + cat coverage.lcov | head -20 + - name: Upload coverage to QLTY Cloud if: matrix.python-version == '3.12' uses: qltysh/qlty-action/coverage@v1 with: oidc: true - files: lcov:coverage.lcov + files: coverage.lcov From 7290a555edd3d0e5d08114b32716ccbcb52f2a29 Mon Sep 17 00:00:00 2001 From: AdenAthar <133624677+AdenAthar@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:26:57 -0800 Subject: [PATCH 05/40] Create test.txt --- test.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test.txt diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +test From 75fcdacdcbe234bf44181385f7b59ea89c78248c Mon Sep 17 00:00:00 2001 From: AdenAthar <133624677+AdenAthar@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:27:14 -0800 Subject: [PATCH 06/40] Delete test.txt --- test.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test.txt diff --git a/test.txt b/test.txt deleted file mode 100644 index 9daeafb..0000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -test From a7c454f38df93b2988fae240b28114d495e0cda7 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 20 Nov 2025 15:59:22 -0800 Subject: [PATCH 07/40] Clean up CI workflow - remove debug step and test branch trigger --- .github/workflows/pytest-coverage.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/pytest-coverage.yml b/.github/workflows/pytest-coverage.yml index 0507f32..df9faba 100644 --- a/.github/workflows/pytest-coverage.yml +++ b/.github/workflows/pytest-coverage.yml @@ -5,12 +5,10 @@ on: branches: - dev - main - - pr-testing push: branches: - dev - main - - pr-testing permissions: contents: read @@ -42,12 +40,6 @@ jobs: run: | pytest --cov=Granny --cov-report=lcov:coverage.lcov --cov-report=term-missing - - name: Verify coverage file exists - if: matrix.python-version == '3.12' - run: | - ls -la coverage.lcov - cat coverage.lcov | head -20 - - name: Upload coverage to QLTY Cloud if: matrix.python-version == '3.12' uses: qltysh/qlty-action/coverage@v1 From f670ae49846bf62411c7cb48ea8cfe0b0287c4a7 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Tue, 2 Dec 2025 14:01:04 -0800 Subject: [PATCH 08/40] Add QR code detection for variety identification - Created QRCodeDetector utility for extracting variety info from QR codes - Integrated QR detection into Segmentation analysis - QR detection is optional - continues normally if no QR code found - Saves variety info (variety_code, timing, full_variety) to variety_info.txt - Expected QR format: 'BB-Late', 'CC-Early', etc. - Tested: Works with and without QR codes in images --- Granny/Analyses/Segmentation.py | 30 +++++++++++++ Granny/Utils/QRCodeDetector.py | 75 +++++++++++++++++++++++++++++++++ Granny/Utils/__init__.py | 1 + 3 files changed, 106 insertions(+) create mode 100644 Granny/Utils/QRCodeDetector.py create mode 100644 Granny/Utils/__init__.py diff --git a/Granny/Analyses/Segmentation.py b/Granny/Analyses/Segmentation.py index 16cd2e8..24f7c94 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 @@ -267,6 +268,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 + self.addInParam( self.model, self.input_images, @@ -610,6 +615,17 @@ 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: {self.variety_info['full']}") + print(f" Variety: {self.variety_info['variety']}, Timing: {self.variety_info['timing']}") + except Exception as e: + # QR detection failed, continue without variety info + print(f"QR detection skipped: {str(e)}") + # predicts fruit instances in the image result = self._segmentInstances(image=image_instance.getImage()) @@ -636,6 +652,20 @@ def performAnalysis(self) -> List[Image]: self.masked_images.setImageList([masked_image]) self.masked_images.writeValue() + + # Save variety info to text file if QR code was detected + if self.variety_info: + variety_file_path = os.path.join( + os.curdir, + "results", + self.__analysis_name__, + self.analysis_time, + "variety_info.txt" + ) + with open(variety_file_path, 'w') as f: + f.write(f"variety_code={self.variety_info['variety']}\n") + f.write(f"timing={self.variety_info['timing']}\n") + f.write(f"full_variety={self.variety_info['full']}\n") except: AttributeError("Error with the results.") diff --git a/Granny/Utils/QRCodeDetector.py b/Granny/Utils/QRCodeDetector.py new file mode 100644 index 0000000..e5a8f9d --- /dev/null +++ b/Granny/Utils/QRCodeDetector.py @@ -0,0 +1,75 @@ +""" +QR Code Detection Utility + +This module provides functionality to detect and decode QR codes 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 + + +class QRCodeDetector: + """ + Detects and decodes QR codes from images. + + This class uses OpenCV's QRCodeDetector to find QR codes in tray images + and extract variety information (e.g., "BB-Late", "CC-Early"). + """ + + def __init__(self): + """Initialize the QR code detector.""" + self.detector = cv2.QRCodeDetector() + + def detect(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np.ndarray]]: + """ + Detect and decode a QR code in an image. + + Args: + image: Input image as numpy array (BGR format from OpenCV) + + Returns: + Tuple of (decoded_data, points) where: + - decoded_data: String containing QR code data, or None if not found + - points: numpy array of QR code corner points, or None if not found + """ + # Detect and decode QR code + data, points, _ = self.detector.detectAndDecode(image) + + # Return data if found, otherwise None + if data: + return data, points + return None, None + + def extract_variety_info(self, qr_data: str) -> dict: + """ + Parse variety information from QR code data. + + Expected format: "BB-Late", "CC-Early", etc. + + Args: + qr_data: Raw QR code string (e.g., "BB-Late") + + Returns: + Dictionary with parsed variety information: + { + 'raw': 'BB-Late', # Original QR code data + 'full': 'BB-Late', # Full variety string + 'variety': 'BB', # Variety code + 'timing': 'Late' # Timing info + } + """ + parts = qr_data.split('-') + + variety_info = { + 'raw': qr_data, + '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 From b5ba85d0bbde4e0655faa03edbbd09ac5ea6a14a Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 12 Dec 2025 16:10:26 -0800 Subject: [PATCH 09/40] Add QR code generator script for tray labels - Interactive script to generate QR codes with experimental metadata - Fields: project code, lot code, date, variety - Format: PROJECT|LOT|DATE|VARIETY (pipe-delimited) - Outputs to qr_codes/ directory with descriptive filenames --- generate_qr_codes.py | 112 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 generate_qr_codes.py diff --git a/generate_qr_codes.py b/generate_qr_codes.py new file mode 100644 index 0000000..87e3f58 --- /dev/null +++ b/generate_qr_codes.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Interactive QR Code Generator for Granny Tray Labels + +Generates QR codes containing: +- Project Code +- Lot Code +- Date +- Variety + +Format: PROJECT|LOT|DATE|VARIETY +Example: APPLE2025|LOT001|2025-12-02|BB-Late +""" + +import qrcode +import os +from datetime import datetime + + +def generate_qr_code(project, lot, date, variety, output_dir="qr_codes"): + """ + Generate a QR code with experimental information. + + Args: + project: Project code (e.g., "APPLE2025") + lot: Lot code (e.g., "LOT001") + date: Date string (e.g., "2025-12-02") + variety: Variety code (e.g., "BB-Late") + output_dir: Directory to save QR codes + """ + # Create output directory if it doesn't exist + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # Create pipe-delimited data string + qr_data = f"{project}|{lot}|{date}|{variety}" + + # Generate QR code + qr = qrcode.QRCode( + version=1, # Controls size (1-40, 1 is smallest) + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, # Size of each box in pixels + border=4, # Border size in boxes + ) + + qr.add_data(qr_data) + qr.make(fit=True) + + # Create image + img = qr.make_image(fill_color="black", back_color="white") + + # Create filename: PROJECT_LOT_DATE.png + filename = f"{project}_{lot}_{date}.png" + filepath = os.path.join(output_dir, filename) + + # Save image + img.save(filepath) + + return filepath, qr_data + + +def main(): + """Interactive QR code generation""" + print("=" * 60) + print("QR Code Generator for Granny Tray Labels") + print("=" * 60) + print() + + while True: + print("\nEnter information for QR code:") + print("-" * 40) + + # Get user input + project = input("Project Code (e.g., APPLE2025): ").strip() + lot = input("Lot Code (e.g., LOT001): ").strip() + + # Date with default option + date_input = input(f"Date (YYYY-MM-DD) [today: {datetime.now().strftime('%Y-%m-%d')}]: ").strip() + date = date_input if date_input else datetime.now().strftime('%Y-%m-%d') + + variety = input("Variety (e.g., BB-Late): ").strip() + + # Validate inputs + if not all([project, lot, date, variety]): + print("\n❌ Error: All fields are required!") + continue + + # Generate QR code + try: + filepath, qr_data = generate_qr_code(project, lot, date, variety) + + print("\n✅ QR Code Generated Successfully!") + print(f" Data: {qr_data}") + print(f" Saved to: {filepath}") + + except Exception as e: + print(f"\n❌ Error generating QR code: {e}") + continue + + # Ask if user wants to generate another + print() + again = input("Generate another QR code? (y/n): ").strip().lower() + if again not in ['y', 'yes']: + break + + print("\n" + "=" * 60) + print("Done! Check the 'qr_codes' folder for your QR codes.") + print("=" * 60) + + +if __name__ == "__main__": + main() From 1fdd30eaa922a165d77868ddde4a091e1759a7b7 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 15 Dec 2025 13:03:52 -0800 Subject: [PATCH 10/40] Refactor starch scales to load from YAML configuration - Moved hardcoded starch scale data from StarchScales class to external YAML file - Added load_starch_scales() function to read Granny/config/starch_scales.yml - Dynamically populate StarchScales class attributes from YAML at module load time - Reduced StarchArea.py by 102 lines by externalizing data - Improves maintainability: starch scales can now be updated without code changes --- Granny/Analyses/StarchArea.py | 144 ++++++++------------------------ Granny/config/starch_scales.yml | 44 ++++++++++ 2 files changed, 78 insertions(+), 110 deletions(-) create mode 100644 Granny/config/starch_scales.yml diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 8f75ae0..1f5043f 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 @@ -31,15 +32,40 @@ from numpy.typing import NDArray +def load_starch_scales() -> Dict[str, Dict[str, List[float]]]: + """ + Load starch scale data from YAML configuration file. + + Reads the starch_scales.yml file from Granny/config/ 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/config/starch_scales.yml + yaml_path = os.path.join(current_dir, '..', 'config', '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 configuration file (Granny/config/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 +75,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): diff --git a/Granny/config/starch_scales.yml b/Granny/config/starch_scales.yml new file mode 100644 index 0000000..632fc5e --- /dev/null +++ b/Granny/config/starch_scales.yml @@ -0,0 +1,44 @@ +# 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. + +HONEY_CRISP: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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] From 4a3bc1768b85dee2ffaef99a8e6cf5ae44ad5efb Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 15 Dec 2025 13:03:52 -0800 Subject: [PATCH 11/40] Refactor starch scales to load from YAML asset file - Moved hardcoded starch scale data from StarchScales class to external YAML file - Added load_starch_scales() function to read Granny/assets/starch_scales.yml - Dynamically populate StarchScales class attributes from YAML at module load time - Reduced StarchArea.py by 102 lines by externalizing data - Improves maintainability: starch scales can now be updated without code changes --- Granny/Analyses/StarchArea.py | 144 ++++++++------------------------ Granny/assets/starch_scales.yml | 44 ++++++++++ 2 files changed, 78 insertions(+), 110 deletions(-) create mode 100644 Granny/assets/starch_scales.yml diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 8f75ae0..471c6e3 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 @@ -31,15 +32,40 @@ 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 +75,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): diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml new file mode 100644 index 0000000..632fc5e --- /dev/null +++ b/Granny/assets/starch_scales.yml @@ -0,0 +1,44 @@ +# 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. + +HONEY_CRISP: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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] From 44a715e089fdc11134704eba17fbdfecefa9e524 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 18 Dec 2025 14:44:15 -0800 Subject: [PATCH 12/40] Add QR code integration for variety tracking Integrate QR code detection throughout the analysis pipeline to automatically capture and propagate experimental metadata (project, lot, date, variety) from tray labels to analysis outputs. Changes: - Update QRCodeDetector to parse pipe-delimited QR format (PROJECT|LOT|DATE|VARIETY) - Modify Segmentation to include QR data in segmented image filenames when detected - Add helper method to Analysis base class for extracting QR data from filenames - Update all downstream analyses (Starch, Blush, PeelColor, SuperficialScald) to extract and include QR metadata in CSV outputs - Fix MetaDataValue to use numeric_only=True when calculating tray averages to handle string metadata columns - Update QR code generator to output to QR_Output directory When QR code is detected, segmented images are named: PROJECT_LOT_DATE_VARIETY_fruit_##.png When no QR code is detected, default naming is used: original_tray_name_fruit_##.png CSV outputs now include project, lot, date, and variety columns populated from QR data when available, otherwise left empty for backward compatibility. --- Granny/Analyses/Analysis.py | 50 ++++++++++++++++++++++ Granny/Analyses/BlushColor.py | 20 +++++++++ Granny/Analyses/PeelColor.py | 20 +++++++++ Granny/Analyses/Segmentation.py | 42 +++++++++--------- Granny/Analyses/StarchArea.py | 20 +++++++++ Granny/Analyses/SuperficialScald.py | 20 +++++++++ Granny/Models/Values/MetaDataValue.py | 2 +- Granny/Utils/QRCodeDetector.py | 61 +++++++++++++++++++++------ generate_qr_codes.py | 4 +- 9 files changed, 203 insertions(+), 36 deletions(-) diff --git a/Granny/Analyses/Analysis.py b/Granny/Analyses/Analysis.py index 49d5021..3b4fce6 100644 --- a/Granny/Analyses/Analysis.py +++ b/Granny/Analyses/Analysis.py @@ -136,6 +136,56 @@ 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 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..38ac68b 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,25 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) + # Extract and add QR metadata from filename (if present) + qr_info = self._parse_qr_from_filename(image_instance.getImageName()) + if qr_info['project']: # Only add if QR data exists + 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) + # 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..9fd4818 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,25 @@ def _processImage(self, image_instance: Image) -> Image: ) b_value.setValue(b) + # Extract and add QR metadata from filename (if present) + qr_info = self._parse_qr_from_filename(image_instance.getImageName()) + if qr_info['project']: # Only add if QR data exists + project_val = StringValue("project", "project", "Project code from QR code") + project_val.setValue(qr_info['project']) + image_instance.addValue(project_val) + + lot_val = StringValue("lot", "lot", "Lot code from QR code") + lot_val.setValue(qr_info['lot']) + image_instance.addValue(lot_val) + + date_val = StringValue("date", "date", "Date from QR code") + date_val.setValue(qr_info['date']) + image_instance.addValue(date_val) + + variety_val = StringValue("variety", "variety", "Variety from QR code") + variety_val.setValue(qr_info['variety']) + image_instance.addValue(variety_val) + # 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 24f7c94..d882665 100644 --- a/Granny/Analyses/Segmentation.py +++ b/Granny/Analyses/Segmentation.py @@ -270,7 +270,7 @@ def __init__(self): # Initialize QR code detector for variety information extraction self.qr_detector = QRCodeDetector() - self.variety_info = None # Will store detected variety information + self.variety_info = None # Will store detected variety information if QR code found self.addInParam( self.model, @@ -547,7 +547,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) @@ -620,11 +632,16 @@ def performAnalysis(self) -> List[Image]: 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: {self.variety_info['full']}") - print(f" Variety: {self.variety_info['variety']}, Timing: {self.variety_info['timing']}") + 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 without variety info - print(f"QR detection skipped: {str(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()) @@ -653,19 +670,6 @@ def performAnalysis(self) -> List[Image]: self.masked_images.setImageList([masked_image]) self.masked_images.writeValue() - # Save variety info to text file if QR code was detected - if self.variety_info: - variety_file_path = os.path.join( - os.curdir, - "results", - self.__analysis_name__, - self.analysis_time, - "variety_info.txt" - ) - with open(variety_file_path, 'w') as f: - f.write(f"variety_code={self.variety_info['variety']}\n") - f.write(f"timing={self.variety_info['timing']}\n") - f.write(f"full_variety={self.variety_info['full']}\n") except: AttributeError("Error with the results.") diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 8f75ae0..167d369 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.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 @@ -420,6 +421,25 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) + # Extract and add QR metadata from filename (if present) + qr_info = self._parse_qr_from_filename(image_instance.getImageName()) + if qr_info['project']: # Only add if QR data exists + 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) + # 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..e739315 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,25 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(binarized_image) + # Extract and add QR metadata from filename (if present) + qr_info = self._parse_qr_from_filename(image_instance.getImageName()) + if qr_info['project']: # Only add if QR data exists + 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) + # 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/MetaDataValue.py b/Granny/Models/Values/MetaDataValue.py index 5ddc1f5..c9b9e47 100644 --- a/Granny/Models/Values/MetaDataValue.py +++ b/Granny/Models/Values/MetaDataValue.py @@ -47,7 +47,7 @@ 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() + tray_avg = tray_avg.groupby("TrayName").mean(numeric_only=True).reset_index() 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 index e5a8f9d..b6a82bc 100644 --- a/Granny/Utils/QRCodeDetector.py +++ b/Granny/Utils/QRCodeDetector.py @@ -49,27 +49,60 @@ def extract_variety_info(self, qr_data: str) -> dict: """ Parse variety information from QR code data. - Expected format: "BB-Late", "CC-Early", etc. + 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 (e.g., "BB-Late") + qr_data: Raw QR code string Returns: Dictionary with parsed variety information: { - 'raw': 'BB-Late', # Original QR code data - 'full': 'BB-Late', # Full variety string - 'variety': 'BB', # Variety code - 'timing': 'Late' # Timing info + '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') } """ - parts = qr_data.split('-') - - variety_info = { - 'raw': qr_data, - 'full': qr_data, - 'variety': parts[0] if len(parts) > 0 else '', - 'timing': parts[1] if len(parts) > 1 else '' - } + 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/generate_qr_codes.py b/generate_qr_codes.py index 87e3f58..49a69fb 100644 --- a/generate_qr_codes.py +++ b/generate_qr_codes.py @@ -17,7 +17,7 @@ from datetime import datetime -def generate_qr_code(project, lot, date, variety, output_dir="qr_codes"): +def generate_qr_code(project, lot, date, variety, output_dir="QR_Output"): """ Generate a QR code with experimental information. @@ -104,7 +104,7 @@ def main(): break print("\n" + "=" * 60) - print("Done! Check the 'qr_codes' folder for your QR codes.") + print("Done! Check the 'QR_Output' folder for your QR codes.") print("=" * 60) From 1241b122be6d1e21ec9bfe5aeec0f596965848bb Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 19 Dec 2025 09:33:35 -0800 Subject: [PATCH 13/40] Remove QR code generator script The QR code generator is a utility tool for creating tray labels and does not need to be part of the core Granny analysis package. Users can create QR codes using standard QR generation tools or libraries as needed for their workflows. --- generate_qr_codes.py | 112 ------------------------------------------- 1 file changed, 112 deletions(-) delete mode 100644 generate_qr_codes.py diff --git a/generate_qr_codes.py b/generate_qr_codes.py deleted file mode 100644 index 49a69fb..0000000 --- a/generate_qr_codes.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -""" -Interactive QR Code Generator for Granny Tray Labels - -Generates QR codes containing: -- Project Code -- Lot Code -- Date -- Variety - -Format: PROJECT|LOT|DATE|VARIETY -Example: APPLE2025|LOT001|2025-12-02|BB-Late -""" - -import qrcode -import os -from datetime import datetime - - -def generate_qr_code(project, lot, date, variety, output_dir="QR_Output"): - """ - Generate a QR code with experimental information. - - Args: - project: Project code (e.g., "APPLE2025") - lot: Lot code (e.g., "LOT001") - date: Date string (e.g., "2025-12-02") - variety: Variety code (e.g., "BB-Late") - output_dir: Directory to save QR codes - """ - # Create output directory if it doesn't exist - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - # Create pipe-delimited data string - qr_data = f"{project}|{lot}|{date}|{variety}" - - # Generate QR code - qr = qrcode.QRCode( - version=1, # Controls size (1-40, 1 is smallest) - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, # Size of each box in pixels - border=4, # Border size in boxes - ) - - qr.add_data(qr_data) - qr.make(fit=True) - - # Create image - img = qr.make_image(fill_color="black", back_color="white") - - # Create filename: PROJECT_LOT_DATE.png - filename = f"{project}_{lot}_{date}.png" - filepath = os.path.join(output_dir, filename) - - # Save image - img.save(filepath) - - return filepath, qr_data - - -def main(): - """Interactive QR code generation""" - print("=" * 60) - print("QR Code Generator for Granny Tray Labels") - print("=" * 60) - print() - - while True: - print("\nEnter information for QR code:") - print("-" * 40) - - # Get user input - project = input("Project Code (e.g., APPLE2025): ").strip() - lot = input("Lot Code (e.g., LOT001): ").strip() - - # Date with default option - date_input = input(f"Date (YYYY-MM-DD) [today: {datetime.now().strftime('%Y-%m-%d')}]: ").strip() - date = date_input if date_input else datetime.now().strftime('%Y-%m-%d') - - variety = input("Variety (e.g., BB-Late): ").strip() - - # Validate inputs - if not all([project, lot, date, variety]): - print("\n❌ Error: All fields are required!") - continue - - # Generate QR code - try: - filepath, qr_data = generate_qr_code(project, lot, date, variety) - - print("\n✅ QR Code Generated Successfully!") - print(f" Data: {qr_data}") - print(f" Saved to: {filepath}") - - except Exception as e: - print(f"\n❌ Error generating QR code: {e}") - continue - - # Ask if user wants to generate another - print() - again = input("Generate another QR code? (y/n): ").strip().lower() - if again not in ['y', 'yes']: - break - - print("\n" + "=" * 60) - print("Done! Check the 'QR_Output' folder for your QR codes.") - print("=" * 60) - - -if __name__ == "__main__": - main() From ac390fae3164831d87212c442b791fbd40a7ff9a Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 5 Jan 2026 12:29:43 -0800 Subject: [PATCH 14/40] Add 8 new starch scale varieties and configurable text color - Added new starch scale calibrations: PURDUE, DANJOU, AMBROSIA, MINNEISKA, PINKLADY, REDDELICIOUS1980, REDDELICIOUS1990, ROYAL_GALA - Implemented configurable RGB text color parameters for segmentation visualization (text_color_r, text_color_g, text_color_b) - All new varieties processed with default threshold 172 - Total starch scales now: 17 varieties --- Granny/Analyses/Segmentation.py | 35 ++++++++++++++++++++++++++++++++- Granny/assets/starch_scales.yml | 32 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/Granny/Analyses/Segmentation.py b/Granny/Analyses/Segmentation.py index 16cd2e8..15945f4 100644 --- a/Granny/Analyses/Segmentation.py +++ b/Granny/Analyses/Segmentation.py @@ -211,6 +211,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", @@ -277,6 +307,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 +421,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( diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index 632fc5e..aecc5f3 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -42,3 +42,35 @@ JONAGOLD: CORNELL: 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] + +PURDUE: + index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + rating: [0.904324121, 0.755849937, 0.675973804, 0.411188084, 0.330359199, 0.160435085, 0.058093191] + +DANJOU: + index: [1.2, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0] + rating: [0.671216439, 0.712379151, 0.716414141, 0.610183800, 0.561413711, 0.587651727, 0.432876712, 0.224843496] + +AMBROSIA: + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.677900982, 0.716849891, 0.706853718, 0.640881459, 0.706108586, 0.649827467, 0.529746181, 0.376089325] + +MINNEISKA: + index: [1.0, 2.0, 3.0, 4.0, 5.0] + rating: [0.947185658, 0.832543984, 0.702287118, 0.754320472, 0.828170081] + +PINKLADY: + index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + rating: [0.688407441, 0.638611615, 0.538377193, 0.508639595, 0.511078342, 0.362220206, 0.087586963] + +REDDELICIOUS1980: + index: [1.2, 1.5, 1.8, 2.0, 2.2, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 6.0] + rating: [0.763414634, 0.759957672, 0.715492063, 0.724126871, 0.755778259, 0.677349957, 0.709361702, 0.766573651, 0.538233966, 0.526342798, 0.563694327, 0.334683983] + +REDDELICIOUS1990: + index: [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] + rating: [0.647573029, 0.680765086, 0.645238881, 0.621514161, 0.626233592, 0.537175434, 0.496598639, 0.410324969, 0.309060403, 0.304480287, 0.566495300, 0.123042506] + +ROYAL_GALA: + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] + rating: [0.585482385, 0.508346765, 0.595485685, 0.655133203, 0.620727075, 0.706972973, 0.699773112, 0.705987842, 0.693452218] From bf790efec46527608843f5d32b2680c5aa2bcf9e Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Wed, 7 Jan 2026 10:58:16 -0800 Subject: [PATCH 15/40] Update PURDUE to default threshold 172, keep threshold 150 as comment - Changed PURDUE calibration to use default threshold 172 - Added threshold 150 data as commented alternative for comparison - All varieties now consistently use threshold 172 --- Granny/assets/starch_scales.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index aecc5f3..f73f68e 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -45,7 +45,10 @@ CORNELL: PURDUE: index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] - rating: [0.904324121, 0.755849937, 0.675973804, 0.411188084, 0.330359199, 0.160435085, 0.058093191] + rating: [0.942025840, 0.858846162, 0.767229201, 0.564366621, 0.484868413, 0.266928900, 0.074313346] + # Threshold 150 alternative (for comparison): + # index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + # rating: [0.904324121, 0.755849937, 0.675973804, 0.411188084, 0.330359199, 0.160435085, 0.058093191] DANJOU: index: [1.2, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0] From 1c74e030cfbc5028cfad1029bcacbf274324f1c1 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Wed, 21 Jan 2026 14:44:25 -0800 Subject: [PATCH 16/40] Add results/ directory to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8b5b402..d2199d5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dist/ 06-Package *logs/ _build/ +results/ # Files *.pyc From 5548a78d586ad978f323b8a7bc3027f1b1baae13 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 22 Jan 2026 12:46:53 -0800 Subject: [PATCH 17/40] Implement percentage-based threshold for starch detection Convert threshold from absolute value to percentage of actual pixel range. User inputs 0-255 (e.g., 172 = 67.45%), applied as: low + (high - low) * percentage. Provides consistent detection across varying lighting conditions. --- Granny/Analyses/StarchArea.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 471c6e3..b476a7c 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.py @@ -118,9 +118,11 @@ 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 172 (67.45% of range).", ) self.starch_threshold.setMin(0) self.starch_threshold.setMax(255) @@ -198,10 +200,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. @@ -244,13 +248,21 @@ def adjustImage(img: NDArray[np.uint8], lIn: int, hIn: int, lOut: int = 0, hOut: img = cast(NDArray[np.uint8], cv2.GaussianBlur(img, (kernel_size, kernel_size), 0)) gray = cast(NDArray[np.uint8], cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) - # re-adjusts the image to [0 255] + # extract actual min/max pixel values from the image low, high = extractImage(gray) - gray = adjustImage(gray, low, high) - # create thresholded matrices + # calculate percentage-based threshold + # User inputs threshold in 0-255 range (e.g., 172) + # 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 + + # create thresholded matrices using percentage-based threshold on original range + mask = np.logical_and((gray > 0), (gray <= threshold_value)).astype(np.uint8) + + # normalize image to [0, 255] for visualization only + gray_normalized = adjustImage(gray, low, high) # creates new image using threshold matrices new_img = self._drawMask(new_img, mask) From b15b5bcc9a79adfe8af679501883f1fc503a7b6b Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 23 Jan 2026 12:27:56 -0800 Subject: [PATCH 18/40] Update 8 starch scale varieties with transparent-background calibration Re-processed reference images using AI background removal (rembg) to fix pixel range detection issues caused by white/gray backgrounds. --- Granny/assets/starch_scales.yml | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index f73f68e..cc271a1 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -36,8 +36,10 @@ GRANNY_SMITH: rating: [0.920742836, 0.890332499, 0.808227909, 0.721813109, 0.595806394, 0.278299256, 0.104111379] JONAGOLD: + # Updated with transparent background images (2026-01-22) + # Note: Minor non-monotonic at indices 7.0 and 9.0 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] + rating: [0.904449699, 0.874103352, 0.836673554, 0.838637605, 0.784962049, 0.594890812, 0.701861145, 0.443807162, 0.596292231] CORNELL: index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] @@ -51,29 +53,44 @@ PURDUE: # rating: [0.904324121, 0.755849937, 0.675973804, 0.411188084, 0.330359199, 0.160435085, 0.058093191] DANJOU: + # Updated with transparent background images (2026-01-22) + # Note: Minor non-monotonic at index 3.5 index: [1.2, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0] - rating: [0.671216439, 0.712379151, 0.716414141, 0.610183800, 0.561413711, 0.587651727, 0.432876712, 0.224843496] + rating: [0.936809998, 0.922681876, 0.872891694, 0.751986331, 0.632509641, 0.675894367, 0.385525954, 0.269095698] AMBROSIA: + # Updated with transparent background images (2026-01-22) + # Ratings averaged across A/B/C variants for each index index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] - rating: [0.677900982, 0.716849891, 0.706853718, 0.640881459, 0.706108586, 0.649827467, 0.529746181, 0.376089325] + rating: [0.938836999, 0.918713937, 0.899148039, 0.800415449, 0.743251611, 0.546193038, 0.434137897, 0.190703881] MINNEISKA: + # Updated with transparent background images (2026-01-22) + # WARNING: Still non-monotonic - source images have quality issues + # Index 6.0 missing (reference card says "apple clear of starch") index: [1.0, 2.0, 3.0, 4.0, 5.0] - rating: [0.947185658, 0.832543984, 0.702287118, 0.754320472, 0.828170081] + rating: [0.941631104, 0.906756575, 0.579645190, 0.643510650, 0.441163061] PINKLADY: + # Updated with transparent background images (2026-01-22) + # Excellent monotonic decrease index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] - rating: [0.688407441, 0.638611615, 0.538377193, 0.508639595, 0.511078342, 0.362220206, 0.087586963] + rating: [0.959550159, 0.699509917, 0.614671054, 0.502103609, 0.491582825, 0.311998962, 0.147608409] REDDELICIOUS1980: + # Updated with transparent background images (2026-01-22) + # Excellent monotonic decrease index: [1.2, 1.5, 1.8, 2.0, 2.2, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 6.0] - rating: [0.763414634, 0.759957672, 0.715492063, 0.724126871, 0.755778259, 0.677349957, 0.709361702, 0.766573651, 0.538233966, 0.526342798, 0.563694327, 0.334683983] + rating: [0.930329713, 0.915044820, 0.856309558, 0.853819411, 0.792795414, 0.761423428, 0.711408319, 0.619893048, 0.495924193, 0.480951636, 0.431083558, 0.203781176] REDDELICIOUS1990: + # Updated with transparent background images (2026-01-22) + # Excellent monotonic decrease index: [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] - rating: [0.647573029, 0.680765086, 0.645238881, 0.621514161, 0.626233592, 0.537175434, 0.496598639, 0.410324969, 0.309060403, 0.304480287, 0.566495300, 0.123042506] + rating: [0.969424567, 0.945761350, 0.935346949, 0.880456871, 0.816953597, 0.736772334, 0.669650642, 0.579715970, 0.442378759, 0.320778000, 0.203632175, 0.181581181] ROYAL_GALA: + # Updated with transparent background images (2026-01-22) + # Note: Minor non-monotonic at index 3.0 index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] - rating: [0.585482385, 0.508346765, 0.595485685, 0.655133203, 0.620727075, 0.706972973, 0.699773112, 0.705987842, 0.693452218] + rating: [0.910532554, 0.839360955, 0.928483858, 0.863534479, 0.774230735, 0.737439732, 0.568495148, 0.480588864, 0.325108538] From 8c5754769340bfa250450703ed813784e66a0dca Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 23 Jan 2026 15:14:24 -0800 Subject: [PATCH 19/40] Add barcode detection support using pyzbar --- Granny/Utils/QRCodeDetector.py | 84 +++++++++++++++++++++++++++++----- gitignore | 40 ++++++++++++++++ setup.py | 1 + 3 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 gitignore diff --git a/Granny/Utils/QRCodeDetector.py b/Granny/Utils/QRCodeDetector.py index b6a82bc..d3d67fa 100644 --- a/Granny/Utils/QRCodeDetector.py +++ b/Granny/Utils/QRCodeDetector.py @@ -1,8 +1,8 @@ """ -QR Code Detection Utility +QR Code and Barcode Detection Utility -This module provides functionality to detect and decode QR codes in images, -primarily used to extract variety information from tray images. +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 @@ -12,37 +12,97 @@ 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 from images. + Detects and decodes QR codes and barcodes from images. - This class uses OpenCV's QRCodeDetector to find QR codes in tray images - and extract variety information (e.g., "BB-Late", "CC-Early"). + 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 detector.""" + """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 in an image. + 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 QR code data, or None if not found - - points: numpy array of QR code corner points, or None if not found + - decoded_data: String containing code data, or None if not found + - points: numpy array of code corner points, or None if not found """ - # Detect and decode QR code + # Try QR code detection first data, points, _ = self.detector.detectAndDecode(image) - # Return data if found, otherwise None 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. + + 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 + + # Detect barcodes + barcodes = pyzbar.decode(gray) + + if barcodes: + # Return the first barcode found + barcode = barcodes[0] + data = barcode.data.decode('utf-8') + + # Convert polygon points to numpy array + points = np.array(barcode.polygon, dtype=np.float32) + + return data, points + return None, None def extract_variety_info(self, qr_data: str) -> dict: 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..0f0c8d0 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ numpy opencv-python pytest +pyzbar """.split() From 1807b33821ec708d6b4b4d76d9e62c77d0d058ef Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 29 Jan 2026 13:42:33 -0800 Subject: [PATCH 20/40] Refactor QR metadata handling into base Analysis class Moved duplicated QR/barcode metadata block into shared _add_qr_metadata() method in Analysis.py. Replaces 12 repeated lines in each of 4 analyses with a single method call. --- Granny/Analyses/Analysis.py | 26 ++++++++++++++++++++++++++ Granny/Analyses/BlushColor.py | 20 ++------------------ Granny/Analyses/PeelColor.py | 20 ++------------------ Granny/Analyses/StarchArea.py | 20 ++------------------ Granny/Analyses/SuperficialScald.py | 20 ++------------------ 5 files changed, 34 insertions(+), 72 deletions(-) diff --git a/Granny/Analyses/Analysis.py b/Granny/Analyses/Analysis.py index 3b4fce6..002677e 100644 --- a/Granny/Analyses/Analysis.py +++ b/Granny/Analyses/Analysis.py @@ -186,6 +186,32 @@ def _parse_qr_from_filename(self, filename: str) -> dict: '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 38ac68b..a4d8e04 100644 --- a/Granny/Analyses/BlushColor.py +++ b/Granny/Analyses/BlushColor.py @@ -267,24 +267,8 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) - # Extract and add QR metadata from filename (if present) - qr_info = self._parse_qr_from_filename(image_instance.getImageName()) - if qr_info['project']: # Only add if QR data exists - 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) + # 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( diff --git a/Granny/Analyses/PeelColor.py b/Granny/Analyses/PeelColor.py index 9fd4818..971531f 100644 --- a/Granny/Analyses/PeelColor.py +++ b/Granny/Analyses/PeelColor.py @@ -494,24 +494,8 @@ def _processImage(self, image_instance: Image) -> Image: ) b_value.setValue(b) - # Extract and add QR metadata from filename (if present) - qr_info = self._parse_qr_from_filename(image_instance.getImageName()) - if qr_info['project']: # Only add if QR data exists - project_val = StringValue("project", "project", "Project code from QR code") - project_val.setValue(qr_info['project']) - image_instance.addValue(project_val) - - lot_val = StringValue("lot", "lot", "Lot code from QR code") - lot_val.setValue(qr_info['lot']) - image_instance.addValue(lot_val) - - date_val = StringValue("date", "date", "Date from QR code") - date_val.setValue(qr_info['date']) - image_instance.addValue(date_val) - - variety_val = StringValue("variety", "variety", "Variety from QR code") - variety_val.setValue(qr_info['variety']) - image_instance.addValue(variety_val) + # 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( diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 167d369..7982b76 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.py @@ -421,24 +421,8 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) - # Extract and add QR metadata from filename (if present) - qr_info = self._parse_qr_from_filename(image_instance.getImageName()) - if qr_info['project']: # Only add if QR data exists - 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) + # 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( diff --git a/Granny/Analyses/SuperficialScald.py b/Granny/Analyses/SuperficialScald.py index e739315..1a5ad45 100644 --- a/Granny/Analyses/SuperficialScald.py +++ b/Granny/Analyses/SuperficialScald.py @@ -368,24 +368,8 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(binarized_image) - # Extract and add QR metadata from filename (if present) - qr_info = self._parse_qr_from_filename(image_instance.getImageName()) - if qr_info['project']: # Only add if QR data exists - 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) + # 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( From d6459039d7394b2958b075ba774b6b600a40a8a3 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 29 Jan 2026 13:50:15 -0800 Subject: [PATCH 21/40] Add venv and coverage files to gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 8b5b402..ade264d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ dist/ 06-Package *logs/ _build/ +venv/ +.coverage +coverage.lcov +results/ # Files *.pyc From 5b94e6a0575d2036c723d7b6301ad1de8c37ef73 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 29 Jan 2026 14:19:13 -0800 Subject: [PATCH 22/40] Add rotation-invariant barcode detection and QR metadata to tray summary --- Granny/Models/Values/MetaDataValue.py | 8 +++++- Granny/Utils/QRCodeDetector.py | 35 +++++++++++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/Granny/Models/Values/MetaDataValue.py b/Granny/Models/Values/MetaDataValue.py index c9b9e47..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(numeric_only=True).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 index d3d67fa..88609e2 100644 --- a/Granny/Utils/QRCodeDetector.py +++ b/Granny/Utils/QRCodeDetector.py @@ -71,7 +71,11 @@ def detect(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np.ndarray] def _detect_barcode(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np.ndarray]]: """ - Detect and decode a barcode using pyzbar. + 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) @@ -90,18 +94,23 @@ def _detect_barcode(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np else: gray = image - # Detect barcodes - barcodes = pyzbar.decode(gray) - - if barcodes: - # Return the first barcode found - barcode = barcodes[0] - data = barcode.data.decode('utf-8') - - # Convert polygon points to numpy array - points = np.array(barcode.polygon, dtype=np.float32) - - return data, points + # 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 From eddeadd39550f67ca18ed39f6dd6fc876022f844 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 2 Feb 2026 11:57:48 -0800 Subject: [PATCH 23/40] Add functional tests for QR detector, metadata parsing, and tray summary --- tests/test_Analyses/test_Analysis.py | 76 +++++++++++++++ tests/test_Models/test_Utils/__init__.py | 0 .../test_Utils/test_QRCodeDetector.py | 97 +++++++++++++++++++ .../test_Values/test_MetaDataValue.py | 85 +++++++++++++++- 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 tests/test_Analyses/test_Analysis.py create mode 100644 tests/test_Models/test_Utils/__init__.py create mode 100644 tests/test_Models/test_Utils/test_QRCodeDetector.py 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_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..a9a8b84 --- /dev/null +++ b/tests/test_Models/test_Utils/test_QRCodeDetector.py @@ -0,0 +1,97 @@ +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" 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 From 8a6651a6364088e12cdd059aa1d5b171817e4222 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 12 Feb 2026 13:00:21 -0800 Subject: [PATCH 24/40] Add 4 new MSU starch scale varieties (EMPIRE, FUJI, IDARED, JONATHAN) All 4 varieties have monotonically decreasing ratings and were calibrated from MSU reference images using threshold 172. --- Granny/assets/starch_scales.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index cc271a1..ce60595 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -94,3 +94,23 @@ ROYAL_GALA: # Note: Minor non-monotonic at index 3.0 index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] rating: [0.910532554, 0.839360955, 0.928483858, 0.863534479, 0.774230735, 0.737439732, 0.568495148, 0.480588864, 0.325108538] + +EMPIRE: + # MSU starch scale (2026-02-10) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + rating: [0.988115593, 0.974479220, 0.876777237, 0.871529845, 0.859762245, 0.845581581, 0.707960720] + +FUJI: + # MSU starch scale (2026-02-10) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.986821463, 0.972244952, 0.960990693, 0.903095521, 0.837198998, 0.750632730, 0.578647255, 0.450858254] + +IDARED: + # MSU starch scale (2026-02-10) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + rating: [0.989654473, 0.984797736, 0.933599956, 0.897179091, 0.806548432, 0.781328571, 0.572557496] + +JONATHAN: + # MSU starch scale (2026-02-10) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + rating: [0.990524245, 0.990540032, 0.976034191, 0.916475538, 0.857157939, 0.572915018, 0.321327449, 0.111317418] From cf74199d2900142d84f474bd4608e786f3285e8e Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 12 Feb 2026 14:12:32 -0800 Subject: [PATCH 25/40] Change default starch threshold from 172 to 140 (55%) Testing on 15 MSU starch reference varieties showed that 55% threshold achieves perfect monotonicity (15/15 varieties) compared to 67.45% which had 8/15 monotonic with 9 violations. The percentage-based threshold adapts to each image's actual pixel range, providing more consistent results across varying lighting conditions. --- Granny/Analyses/StarchArea.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index b476a7c..b8fe588 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.py @@ -122,11 +122,11 @@ def __init__(self): + "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 172 (67.45% of range).", + + "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 From 8e6edbb820f18726c95f9a293e45caa63b7c4d76 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 12 Feb 2026 14:51:48 -0800 Subject: [PATCH 26/40] Update starch scales with 55% threshold calibration data - Updated 12 MSU varieties with new 55% threshold data achieving 15/15 monotonicity with 0 violations - Added 5 new varieties: HONEYCRISP, BRAEBURN, EVERCRISP, GALA, ROME - All MSU varieties now use consistent integer index scales (1-8) - Kept non-MSU varieties unchanged (HONEY_CRISP, WA38, ALLAN_BROS, CORNELL, PURDUE, DANJOU, MINNEISKA, ROYAL_GALA) --- Granny/assets/starch_scales.yml | 93 +++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 40 deletions(-) diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index ce60595..9fecfee 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -10,6 +10,8 @@ # 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) HONEY_CRISP: index: [1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0] @@ -28,18 +30,19 @@ ALLAN_BROS: rating: [0.997783524, 0.988769830, 0.951909478, 0.877526853, 0.721066082, 0.673838851, 0.417864608, 0.091652858] GOLDEN_DELICIOUS: - 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] + # 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: - 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] + # 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: - # Updated with transparent background images (2026-01-22) - # Note: Minor non-monotonic at indices 7.0 and 9.0 - index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] - rating: [0.904449699, 0.874103352, 0.836673554, 0.838637605, 0.784962049, 0.594890812, 0.701861145, 0.443807162, 0.596292231] + # 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: index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] @@ -48,69 +51,79 @@ CORNELL: PURDUE: index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] rating: [0.942025840, 0.858846162, 0.767229201, 0.564366621, 0.484868413, 0.266928900, 0.074313346] - # Threshold 150 alternative (for comparison): - # index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] - # rating: [0.904324121, 0.755849937, 0.675973804, 0.411188084, 0.330359199, 0.160435085, 0.058093191] DANJOU: - # Updated with transparent background images (2026-01-22) # Note: Minor non-monotonic at index 3.5 index: [1.2, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0] rating: [0.936809998, 0.922681876, 0.872891694, 0.751986331, 0.632509641, 0.675894367, 0.385525954, 0.269095698] AMBROSIA: - # Updated with transparent background images (2026-01-22) - # Ratings averaged across A/B/C variants for each index + # 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.938836999, 0.918713937, 0.899148039, 0.800415449, 0.743251611, 0.546193038, 0.434137897, 0.190703881] + rating: [0.971872720, 0.962896555, 0.908653906, 0.802704025, 0.690480695, 0.482492036, 0.341191274, 0.075346564] MINNEISKA: - # Updated with transparent background images (2026-01-22) # WARNING: Still non-monotonic - source images have quality issues # Index 6.0 missing (reference card says "apple clear of starch") index: [1.0, 2.0, 3.0, 4.0, 5.0] rating: [0.941631104, 0.906756575, 0.579645190, 0.643510650, 0.441163061] PINKLADY: - # Updated with transparent background images (2026-01-22) - # Excellent monotonic decrease - index: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] - rating: [0.959550159, 0.699509917, 0.614671054, 0.502103609, 0.491582825, 0.311998962, 0.147608409] - -REDDELICIOUS1980: - # Updated with transparent background images (2026-01-22) - # Excellent monotonic decrease - index: [1.2, 1.5, 1.8, 2.0, 2.2, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 6.0] - rating: [0.930329713, 0.915044820, 0.856309558, 0.853819411, 0.792795414, 0.761423428, 0.711408319, 0.619893048, 0.495924193, 0.480951636, 0.431083558, 0.203781176] + # 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] -REDDELICIOUS1990: - # Updated with transparent background images (2026-01-22) - # Excellent monotonic decrease - index: [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] - rating: [0.969424567, 0.945761350, 0.935346949, 0.880456871, 0.816953597, 0.736772334, 0.669650642, 0.579715970, 0.442378759, 0.320778000, 0.203632175, 0.181581181] +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] ROYAL_GALA: - # Updated with transparent background images (2026-01-22) # Note: Minor non-monotonic at index 3.0 index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] rating: [0.910532554, 0.839360955, 0.928483858, 0.863534479, 0.774230735, 0.737439732, 0.568495148, 0.480588864, 0.325108538] EMPIRE: - # MSU starch scale (2026-02-10) + # 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.988115593, 0.974479220, 0.876777237, 0.871529845, 0.859762245, 0.845581581, 0.707960720] + rating: [0.970695189, 0.948662668, 0.791846554, 0.779420670, 0.745290611, 0.663642314, 0.460208582] FUJI: - # MSU starch scale (2026-02-10) + # 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.986821463, 0.972244952, 0.960990693, 0.903095521, 0.837198998, 0.750632730, 0.578647255, 0.450858254] + rating: [0.974763241, 0.901142588, 0.845687779, 0.805437350, 0.717441404, 0.539812629, 0.333291895, 0.168391585] IDARED: - # MSU starch scale (2026-02-10) + # 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.989654473, 0.984797736, 0.933599956, 0.897179091, 0.806548432, 0.781328571, 0.572557496] + rating: [0.982588090, 0.955356105, 0.874148553, 0.813374837, 0.657680211, 0.612489305, 0.332182826] JONATHAN: - # MSU starch scale (2026-02-10) + # 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.990524245, 0.990540032, 0.976034191, 0.916475538, 0.857157939, 0.572915018, 0.321327449, 0.111317418] + rating: [0.952191921, 0.903481677, 0.776124925, 0.769690669, 0.606057588, 0.411963190, 0.238755173, 0.127008793] From d62ff0db280c187bca44a65d32dd08b4677a783a Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 13 Feb 2026 13:47:31 -0800 Subject: [PATCH 27/40] Remove CLAUDE.md from gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index ade264d..dddd3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,3 @@ test_perf.py *.jpeg *.jpg *.tiff -CLAUDE.md From 6a40abc1115de9f76f762f56134534dfbc35c567 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Mon, 23 Feb 2026 13:51:38 -0800 Subject: [PATCH 28/40] Fix Developer's Guide documentation to match actual codebase Major corrections: - Analysis: Document _preRun(), _processImage(), _postRun() pattern instead of outdated performAnalysis() override pattern - Image class: Fix method names (getImageName not getFileName, addValue not addMetadata) - ImageIO: Clarify it's abstract with loadImage/saveImage for single images; ImageListValue handles lists via readValue/writeValue - GrannyUI: Clarify addProgramArgs() is not abstract (only run() is) - Scheduler: Fix schedule() return type (returns IDs, run() executes) - Update all code examples to use correct patterns --- docs/dev_guide/adding_analysis.rst | 303 +++++++++++-------- docs/dev_guide/adding_interface.rst | 11 +- docs/dev_guide/api_reference.rst | 447 +++++++++++++++++++--------- docs/dev_guide/example_analysis.rst | 327 +++++++++++--------- 4 files changed, 680 insertions(+), 408 deletions(-) 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 From 90171a3fbdb3eb552b5865d36291f922c0230b5e Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Wed, 25 Feb 2026 12:31:12 -0800 Subject: [PATCH 29/40] Rename starch calculation variables for clarity, remove duplicate HONEY_CRISP StarchArea.py: - extractImage() -> getPixelRange() - adjustImage() -> remapToRange() - gray -> grayscale - Removed unused grayscale_normalized variable starch_scales.yml: - Removed duplicate HONEY_CRISP entry (HONEYCRISP with 55% calibration is kept) --- Granny/Analyses/StarchArea.py | 14 +++++++------- Granny/assets/starch_scales.yml | 4 ---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index b8fe588..e391303 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.py @@ -216,16 +216,16 @@ def _calculateStarch(self, img: NDArray[np.uint8]) -> Tuple[float, NDArray[np.ui - NDArray[np.uint8]: The modified image with identified starch regions. """ - def extractImage(img: NDArray[np.uint8]) -> Tuple[int, int]: + def getPixelRange(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)) + hist, _ = np.histogram(grayscale, 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): + def remapToRange(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]. @@ -246,10 +246,10 @@ def adjustImage(img: NDArray[np.uint8], lIn: int, hIn: int, lOut: int = 0, hOut: # blurs the image to remove sharp noises, then converts it to gray scale 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)) # extract actual min/max pixel values from the image - low, high = extractImage(gray) + low, high = getPixelRange(grayscale) # calculate percentage-based threshold # User inputs threshold in 0-255 range (e.g., 172) @@ -259,10 +259,10 @@ def adjustImage(img: NDArray[np.uint8], lIn: int, hIn: int, lOut: int = 0, hOut: threshold_value = low + (high - low) * threshold_percentage # create thresholded matrices using percentage-based threshold on original range - mask = np.logical_and((gray > 0), (gray <= threshold_value)).astype(np.uint8) + mask = np.logical_and((grayscale > 0), (grayscale <= threshold_value)).astype(np.uint8) # normalize image to [0, 255] for visualization only - gray_normalized = adjustImage(gray, low, high) + grayscale_normalized = remapToRange(grayscale, low, high) # creates new image using threshold matrices new_img = self._drawMask(new_img, mask) diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index 9fecfee..20dc015 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -13,10 +13,6 @@ # # MSU varieties calibrated with 55% threshold (2026-02-12) -HONEY_CRISP: - 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: 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] From 16edc46b1b84c96b7399e428b05c9e7a14843fe0 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Wed, 25 Feb 2026 13:01:43 -0800 Subject: [PATCH 30/40] Remove unused nested functions, inline histogram logic in _calculateStarch - Remove getPixelRange nested function and inline its histogram logic - Remove remapToRange function (dead code - was only used for visualization) - Remove unused grayscale_normalized variable - Clean up comments --- Granny/Analyses/StarchArea.py | 45 +++++++---------------------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index e391303..d25a28a 100644 --- a/Granny/Analyses/StarchArea.py +++ b/Granny/Analyses/StarchArea.py @@ -215,56 +215,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 getPixelRange(img: NDArray[np.uint8]) -> Tuple[int, int]: - """ - Extracts minimum and maximum pixel value of an image - """ - hist, _ = np.histogram(grayscale, bins=256, range=(0, 255)) - low = (hist != 0).argmax() - high = 255 - (hist[::-1] != 0).argmax() - return low, high - - def remapToRange(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)) grayscale = cast(NDArray[np.uint8], cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) - # extract actual min/max pixel values from the image - low, high = getPixelRange(grayscale) + # 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() - # calculate percentage-based threshold - # User inputs threshold in 0-255 range (e.g., 172) + # 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() threshold_percentage = image_threshold / 255.0 threshold_value = low + (high - low) * threshold_percentage - # create thresholded matrices using percentage-based threshold on original range + # Create thresholded mask using percentage-based threshold on original range mask = np.logical_and((grayscale > 0), (grayscale <= threshold_value)).astype(np.uint8) - # normalize image to [0, 255] for visualization only - grayscale_normalized = remapToRange(grayscale, low, high) - - # creates new image using threshold matrices + # Apply mask overlay to image new_img = self._drawMask(new_img, mask) ground_truth = np.count_nonzero( From e4f78148d83b5922f5329cfd55a99e249f16ff64 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Wed, 25 Feb 2026 14:29:07 -0800 Subject: [PATCH 31/40] Update starch scales for Cornell, Purdue, Danjou, WA38 with 55% threshold - CORNELL: Updated ratings from new calibration - PURDUE: Updated ratings from new calibration - DANJOU: Updated ratings, interpolated 1.5 and 3.5 for monotonicity, added 6.0 - WA38: Added new entry with averaged A/B sample ratings --- Granny/assets/starch_scales.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index 20dc015..08b9732 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -21,6 +21,11 @@ WA38_2: 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] +WA38: + # Calibrated with 55% threshold (2026-02-25) + index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + rating: [0.980459235, 0.946203334, 0.811620149, 0.650315140, 0.248849024, 0.101848725] + ALLAN_BROS: 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] @@ -41,17 +46,20 @@ JONAGOLD: 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.990554095, 0.915430492, 0.822470328, 0.726896529, 0.610745795, 0.338955981, 0.150869695, 0.041547982] + 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.942025840, 0.858846162, 0.767229201, 0.564366621, 0.484868413, 0.266928900, 0.074313346] + rating: [0.881028019, 0.658026584, 0.598684959, 0.313197178, 0.235314219, 0.115934565, 0.044768709] DANJOU: - # Note: Minor non-monotonic at index 3.5 - index: [1.2, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0] - rating: [0.936809998, 0.922681876, 0.872891694, 0.751986331, 0.632509641, 0.675894367, 0.385525954, 0.269095698] + # 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) From 88cd50b600873820c9c19ea132da25011c28a94b Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 27 Feb 2026 13:37:14 -0800 Subject: [PATCH 32/40] Update starch scales: add ENZA, new WA38_1/WA38_2, remove old entries - WA38_1: New floral pattern scale (55% threshold), interpolated for monotonicity - WA38_2: New radial pattern scale (55% threshold), interpolated for monotonicity - ENZA: New scale (replaces incorrectly labeled ALLAN_BROS) - Removed: old WA38_1, WA38_2, WA38, ALLAN_BROS, MINNEISKA, ROYAL_GALA --- Granny/assets/starch_scales.yml | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/Granny/assets/starch_scales.yml b/Granny/assets/starch_scales.yml index 08b9732..be38aa8 100644 --- a/Granny/assets/starch_scales.yml +++ b/Granny/assets/starch_scales.yml @@ -14,21 +14,21 @@ # MSU varieties calibrated with 55% threshold (2026-02-12) WA38_1: - 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] + # 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.950925926, 0.912917454, 0.839858059, 0.749211356, 0.770660718, 0.634160550, 0.571832210, 0.522944438, 0.178909419, 0.017493382, 0.075675075] - -WA38: - # Calibrated with 55% threshold (2026-02-25) - index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] - rating: [0.980459235, 0.946203334, 0.811620149, 0.650315140, 0.248849024, 0.101848725] + rating: [0.880123743, 0.860423233, 0.818344566, 0.705450409, 0.690472869, 0.675495329, 0.504455394, 0.257632831, 0.212304108, 0.113385348, 0.112093828] -ALLAN_BROS: +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.997783524, 0.988769830, 0.951909478, 0.877526853, 0.721066082, 0.673838851, 0.417864608, 0.091652858] + 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) @@ -66,12 +66,6 @@ AMBROSIA: 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] -MINNEISKA: - # WARNING: Still non-monotonic - source images have quality issues - # Index 6.0 missing (reference card says "apple clear of starch") - index: [1.0, 2.0, 3.0, 4.0, 5.0] - rating: [0.941631104, 0.906756575, 0.579645190, 0.643510650, 0.441163061] - PINKLADY: # MSU starch scale - 55% threshold (2026-02-12) index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] @@ -82,11 +76,6 @@ REDDELICIOUS: 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] -ROYAL_GALA: - # Note: Minor non-monotonic at index 3.0 - index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] - rating: [0.910532554, 0.839360955, 0.928483858, 0.863534479, 0.774230735, 0.737439732, 0.568495148, 0.480588864, 0.325108538] - EMPIRE: # MSU starch scale - 55% threshold (2026-02-12) index: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] From 0d8e24ad7b36a289bb60cff07461a47989265309 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 6 Mar 2026 15:09:11 -0800 Subject: [PATCH 33/40] Add comprehensive unit tests for StarchArea Tests cover: YAML scale loading, StarchScales class attributes, parameter defaults and validation, _calculateStarch threshold math with synthetic images, _calculateIndex closest-match logic, and _drawMask pixel overlay behaviour. Grows test count from 54 to 76. --- tests/test_Analyses/test_StarchArea.py | 228 +++++++++++++++++++++++-- 1 file changed, 217 insertions(+), 11 deletions(-) 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 From 09b8497ae520722691d72b79fcbe3c4700a5f112 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Wed, 11 Mar 2026 14:34:49 -0700 Subject: [PATCH 34/40] Increase default segmentation confidence threshold to 0.7 Raises the default YOLO confidence threshold from 0.25 to 0.7 to reduce false positive detections during segmentation. Co-Authored-By: Claude Sonnet 4.6 --- Granny/Analyses/Segmentation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Granny/Analyses/Segmentation.py b/Granny/Analyses/Segmentation.py index 16cd2e8..79469c0 100644 --- a/Granny/Analyses/Segmentation.py +++ b/Granny/Analyses/Segmentation.py @@ -142,7 +142,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 From c50a9e94a78c642fa2c218b647f136858d9ab10a Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 26 Mar 2026 14:23:19 -0700 Subject: [PATCH 35/40] Remove duplicate starch_scales.yml from config directory The canonical file is Granny/assets/starch_scales.yml which has the current 55% threshold calibration data. The config/ copy was an old leftover from the initial YAML refactor. --- Granny/config/starch_scales.yml | 44 --------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 Granny/config/starch_scales.yml diff --git a/Granny/config/starch_scales.yml b/Granny/config/starch_scales.yml deleted file mode 100644 index 632fc5e..0000000 --- a/Granny/config/starch_scales.yml +++ /dev/null @@ -1,44 +0,0 @@ -# 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. - -HONEY_CRISP: - 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: - 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: - 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: - 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: - 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: - 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: - 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: - 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] From 9d8fc79e46861eedb3927eec4bd90387414212c3 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 3 Apr 2026 15:19:33 -0700 Subject: [PATCH 36/40] Expand test suite from 92 to 156 tests - BlushColor: add 12 tests covering defaults, _calculateBlush shape/ratio/threshold - PeelColor: add 12 tests covering defaults, remove_purple, calculate_bin_distance - SuperficialScald: add 14 tests covering defaults, _smoothMask, _calculateScald, _removeTrayResidue - Segmentation: add 9 tests covering defaults, text color params, QR detector init - QRCodeDetector: add 8 tests covering variety info parsing edge cases - BoolValue: add 8 tests covering get/set/type/validate; fix validate() signature to accept optional value argument --- Granny/Models/Values/BoolValue.py | 2 +- tests/test_Analyses/test_BlushColor.py | 96 ++++++++++++++- tests/test_Analyses/test_PeelColor.py | 92 +++++++++++++- tests/test_Analyses/test_Segmentation.py | 63 +++++++++- tests/test_Analyses/test_SuperficialScald.py | 113 +++++++++++++++++- .../test_Utils/test_QRCodeDetector.py | 42 +++++++ .../test_Models/test_Values/test_BoolValue.py | 53 +++++++- 7 files changed, 451 insertions(+), 10 deletions(-) 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/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_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_Models/test_Utils/test_QRCodeDetector.py b/tests/test_Models/test_Utils/test_QRCodeDetector.py index a9a8b84..b06e4f9 100644 --- a/tests/test_Models/test_Utils/test_QRCodeDetector.py +++ b/tests/test_Models/test_Utils/test_QRCodeDetector.py @@ -95,3 +95,45 @@ def test_extract_variety_info_malformed_pipe(): 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 From 2b4b9c068c7fb5e08121facc2f2db1b673f4ef1a Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 24 Apr 2026 14:02:26 -0700 Subject: [PATCH 37/40] Clean up .gitignore: remove duplicate *.csv, add venv/ and coverage artifacts Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8b5b402..1758f93 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dist/ 06-Package *logs/ _build/ +venv/ # Files *.pyc @@ -26,7 +27,6 @@ output.out *.sh *.zip test_perf.py -*.csv *.onnx *.pt *.lock @@ -38,3 +38,5 @@ test_perf.py *.jpg *.tiff CLAUDE.md +.coverage +coverage.lcov From bcd9cf2dfede30d551ee6ebffc53bc2465efc1cb Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 24 Apr 2026 14:02:36 -0700 Subject: [PATCH 38/40] Update Pipfile python_version from 3.9 to 3.12 to match CI matrix Co-Authored-By: Claude Sonnet 4.6 --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 73042380da222478523445d222a893aef74b5b4f Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 24 Apr 2026 14:02:49 -0700 Subject: [PATCH 39/40] Add version pins to dependencies, move pytest to dev extras Bare package names with no constraints mean installs silently break when upstream APIs change. Pins floor at currently-tested versions. pytest and pytest-cov are dev-only tools and moved to extras_require[dev]. Co-Authored-By: Claude Sonnet 4.6 --- setup.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) 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", From 3a76f588314c7b5c16d25867f2c2ff0b98b628b7 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 24 Apr 2026 14:14:13 -0700 Subject: [PATCH 40/40] Add integration tests for StarchArea and BlushColor pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover the full _preRun → _processImage path with real PNG files written to tmp_path, verifying file I/O wiring, result structure, image shape preservation, metadata completeness, and directional behaviour. Complements unit tests that call _calculateStarch/_calculateBlush with in-memory arrays. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_Analyses/test_integration.py | 224 ++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tests/test_Analyses/test_integration.py 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}" + )