From f670ae49846bf62411c7cb48ea8cfe0b0287c4a7 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Tue, 2 Dec 2025 14:01:04 -0800 Subject: [PATCH 1/9] 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 2/9] 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 44a715e089fdc11134704eba17fbdfecefa9e524 Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Thu, 18 Dec 2025 14:44:15 -0800 Subject: [PATCH 3/9] 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 4/9] 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 8c5754769340bfa250450703ed813784e66a0dca Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 23 Jan 2026 15:14:24 -0800 Subject: [PATCH 5/9] 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 6/9] 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 7/9] 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 8/9] 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 d62ff0db280c187bca44a65d32dd08b4677a783a Mon Sep 17 00:00:00 2001 From: AdenAthar Date: Fri, 13 Feb 2026 13:47:31 -0800 Subject: [PATCH 9/9] 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