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 diff --git a/Granny/Analyses/Analysis.py b/Granny/Analyses/Analysis.py index 49d5021..002677e 100644 --- a/Granny/Analyses/Analysis.py +++ b/Granny/Analyses/Analysis.py @@ -136,6 +136,82 @@ def resetRetValues(self): """ self.ret_values = {} + def _parse_qr_from_filename(self, filename: str) -> dict: + """ + Extract QR code information from segmented image filename. + + Expected format: PROJECT_LOT_DATE_VARIETY_fruit_##.png + Example: APPLE2025_LOT001_2025-12-02_BB-Late_fruit_01.png + + Args: + filename: Image filename (with or without path) + + Returns: + Dictionary with QR information: + { + 'project': project code or empty string, + 'lot': lot code or empty string, + 'date': date string or empty string, + 'variety': variety string or empty string + } + + Notes: + - Returns empty strings for all fields if parsing fails + - Handles legacy filenames gracefully (no QR data) + """ + import re + from pathlib import Path + + # Extract just the filename without path + filename_only = Path(filename).name + + # Pattern: PROJECT_LOT_DATE_VARIETY_fruit_##.png + # Use regex to match everything before "_fruit_##" + pattern = r'^(.+?)_(.+?)_(.+?)_(.+?)_fruit_\d+\.(?:png|jpg|jpeg)$' + match = re.match(pattern, filename_only) + + if match: + return { + 'project': match.group(1), + 'lot': match.group(2), + 'date': match.group(3), + 'variety': match.group(4) + } + else: + # Parsing failed - return empty strings (no QR data) + return { + 'project': '', + 'lot': '', + 'date': '', + 'variety': '' + } + + def _add_qr_metadata(self, result_img, filename: str): + """ + Parse QR/barcode metadata from filename and add to result image. + + Args: + result_img: Image instance to add metadata values to + filename: Image filename to parse + """ + qr_info = self._parse_qr_from_filename(filename) + if qr_info['project']: + project_val = StringValue("project", "project", "Project code from QR code") + project_val.setValue(qr_info['project']) + result_img.addValue(project_val) + + lot_val = StringValue("lot", "lot", "Lot code from QR code") + lot_val.setValue(qr_info['lot']) + result_img.addValue(lot_val) + + date_val = StringValue("date", "date", "Date from QR code") + date_val.setValue(qr_info['date']) + result_img.addValue(date_val) + + variety_val = StringValue("variety", "variety", "Variety from QR code") + variety_val.setValue(qr_info['variety']) + result_img.addValue(variety_val) + def performAnalysis(self) -> List[Image]: """ Once all required parameters have been set, this function is used diff --git a/Granny/Analyses/BlushColor.py b/Granny/Analyses/BlushColor.py index 1db637b..a4d8e04 100644 --- a/Granny/Analyses/BlushColor.py +++ b/Granny/Analyses/BlushColor.py @@ -25,6 +25,7 @@ from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.StringValue import StringValue from numpy.typing import NDArray @@ -266,6 +267,9 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(result_img, image_instance.getImageName()) + # saves the calculated score to the image_instance as a parameter rating = FloatValue( "rating", "rating", "Granny calculated rating of total blush area." diff --git a/Granny/Analyses/PeelColor.py b/Granny/Analyses/PeelColor.py index 8ed2aef..971531f 100644 --- a/Granny/Analyses/PeelColor.py +++ b/Granny/Analyses/PeelColor.py @@ -27,6 +27,7 @@ from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.StringValue import StringValue from numpy.typing import NDArray @@ -493,6 +494,9 @@ def _processImage(self, image_instance: Image) -> Image: ) b_value.setValue(b) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(image_instance, image_instance.getImageName()) + # adds ratings to to the image_instance as parameters image_instance.addValue( bin_value, diff --git a/Granny/Analyses/Segmentation.py b/Granny/Analyses/Segmentation.py index 16cd2e8..d882665 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 if QR code found + self.addInParam( self.model, self.input_images, @@ -542,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) @@ -610,6 +627,22 @@ def performAnalysis(self) -> List[Image]: if h > w: image_instance.rotateImage() + # Detect QR code to extract variety information (optional) + try: + qr_data, qr_points = self.qr_detector.detect(image_instance.getImage()) + if qr_data: + self.variety_info = self.qr_detector.extract_variety_info(qr_data) + print(f"QR Code detected: {qr_data}") + print(f" Project: {self.variety_info['project']}, Lot: {self.variety_info['lot']}") + print(f" Date: {self.variety_info['date']}, Variety: {self.variety_info['full']}") + else: + print("No QR code detected - using default naming") + self.variety_info = None + except Exception as e: + # QR detection failed, continue with default naming + print(f"QR detection error: {str(e)} - using default naming") + self.variety_info = None + # predicts fruit instances in the image result = self._segmentInstances(image=image_instance.getImage()) @@ -636,6 +669,7 @@ def performAnalysis(self) -> List[Image]: self.masked_images.setImageList([masked_image]) self.masked_images.writeValue() + except: AttributeError("Error with the results.") diff --git a/Granny/Analyses/StarchArea.py b/Granny/Analyses/StarchArea.py index 8f75ae0..7982b76 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,9 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(result) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(result_img, image_instance.getImageName()) + # saves the calculated score to the image_instance as a parameter rating = FloatValue( "rating", "rating", "Granny calculated rating of total starch area." diff --git a/Granny/Analyses/SuperficialScald.py b/Granny/Analyses/SuperficialScald.py index c838079..1a5ad45 100644 --- a/Granny/Analyses/SuperficialScald.py +++ b/Granny/Analyses/SuperficialScald.py @@ -28,6 +28,7 @@ from Granny.Models.Values.ImageListValue import ImageListValue from Granny.Models.Values.IntValue import IntValue from Granny.Models.Values.MetaDataValue import MetaDataValue +from Granny.Models.Values.StringValue import StringValue from numpy.typing import NDArray @@ -367,6 +368,9 @@ def _processImage(self, image_instance: Image) -> Image: result_img: Image = RGBImage(image_instance.getImageName()) result_img.setImage(binarized_image) + # Extract and add QR/barcode metadata from filename (if present) + self._add_qr_metadata(result_img, image_instance.getImageName()) + # saves the calculated score to the image_instance as a parameter rating = FloatValue( "rating", "rating", "Granny calculated rating of total starch area." diff --git a/Granny/Models/Values/MetaDataValue.py b/Granny/Models/Values/MetaDataValue.py index 5ddc1f5..70bbf00 100644 --- a/Granny/Models/Values/MetaDataValue.py +++ b/Granny/Models/Values/MetaDataValue.py @@ -47,7 +47,13 @@ def writeValue(self): ) image_rating.to_csv(os.path.join(self.value, "results.csv"), header=True, index=False) tray_avg = image_rating.drop(columns=["Name"]) - tray_avg = tray_avg.groupby("TrayName").mean().reset_index() + string_cols = tray_avg.select_dtypes(include=["object"]).columns.difference(["TrayName"]).tolist() + tray_numeric = tray_avg.groupby("TrayName").mean(numeric_only=True).reset_index() + if string_cols: + tray_strings = tray_avg.groupby("TrayName")[string_cols].first().reset_index() + tray_avg = tray_strings.merge(tray_numeric, on="TrayName") + else: + tray_avg = tray_numeric tray_avg.to_csv(os.path.join(self.value, "tray_summary.csv"), header=True, index=False) def getImageList(self): diff --git a/Granny/Utils/QRCodeDetector.py b/Granny/Utils/QRCodeDetector.py new file mode 100644 index 0000000..88609e2 --- /dev/null +++ b/Granny/Utils/QRCodeDetector.py @@ -0,0 +1,177 @@ +""" +QR Code and Barcode Detection Utility + +This module provides functionality to detect and decode QR codes and barcodes +in images, primarily used to extract variety information from tray images. + +date: November 18, 2025 +author: Aden Athar +""" + +import cv2 +import numpy as np +from typing import Optional, Tuple + +try: + from pyzbar import pyzbar + PYZBAR_AVAILABLE = True +except ImportError as e: + PYZBAR_AVAILABLE = False + PYZBAR_ERROR = str(e) + + +class QRCodeDetector: + """ + Detects and decodes QR codes and barcodes from images. + + This class uses OpenCV's QRCodeDetector for QR codes and pyzbar for + 1D barcodes (Code128, Code39, EAN, UPC, etc.) to find codes in tray + images and extract variety information (e.g., "BB-Late", "CC-Early"). + """ + + def __init__(self): + """Initialize the QR code and barcode detector.""" + self.detector = cv2.QRCodeDetector() + self.barcode_enabled = PYZBAR_AVAILABLE + + if not PYZBAR_AVAILABLE: + print("WARNING: Barcode detection unavailable. Install libzbar0:") + print(" Ubuntu/Debian: sudo apt-get install libzbar0") + print(" macOS: brew install zbar") + print(" Windows: Download from http://zbar.sourceforge.net/") + + def detect(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np.ndarray]]: + """ + Detect and decode a QR code or barcode in an image. + + Tries QR code detection first, then falls back to barcode detection + if no QR code is found and pyzbar is available. + + Args: + image: Input image as numpy array (BGR format from OpenCV) + + Returns: + Tuple of (decoded_data, points) where: + - decoded_data: String containing code data, or None if not found + - points: numpy array of code corner points, or None if not found + """ + # Try QR code detection first + data, points, _ = self.detector.detectAndDecode(image) + + if data: + return data, points + + # Fall back to barcode detection if pyzbar is available + if self.barcode_enabled: + barcode_data, barcode_points = self._detect_barcode(image) + if barcode_data: + return barcode_data, barcode_points + + return None, None + + def _detect_barcode(self, image: np.ndarray) -> Tuple[Optional[str], Optional[np.ndarray]]: + """ + Detect and decode a barcode using pyzbar, trying multiple rotations. + + Barcodes may appear at any angle in the image. This method tries the + original orientation first, then rotates by 90, 180, and 270 degrees + to ensure detection regardless of how the image was captured. + + Args: + image: Input image as numpy array (BGR format from OpenCV) + + Returns: + Tuple of (decoded_data, points) where: + - decoded_data: String containing barcode data, or None if not found + - points: numpy array of barcode corner points, or None if not found + """ + if not PYZBAR_AVAILABLE: + return None, None + + # Convert to grayscale for better detection + if len(image.shape) == 3: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + else: + gray = image + + # Try original and 3 rotations (0, 90, 180, 270 degrees) + rotations = [ + None, + cv2.ROTATE_90_CLOCKWISE, + cv2.ROTATE_180, + cv2.ROTATE_90_COUNTERCLOCKWISE, + ] + + for rotation in rotations: + rotated = gray if rotation is None else cv2.rotate(gray, rotation) + barcodes = pyzbar.decode(rotated) + + if barcodes: + barcode = barcodes[0] + data = barcode.data.decode('utf-8') + points = np.array(barcode.polygon, dtype=np.float32) + return data, points + + return None, None + + def extract_variety_info(self, qr_data: str) -> dict: + """ + Parse variety information from QR code data. + + Supports two formats: + 1. New format: "PROJECT|LOT|DATE|VARIETY" (pipe-delimited) + 2. Legacy format: "BB-Late" (dash-separated variety only) + + Args: + qr_data: Raw QR code string + + Returns: + Dictionary with parsed variety information: + { + 'raw': original QR string, + 'project': project code or 'UNKNOWN', + 'lot': lot code or 'UNKNOWN', + 'date': date string or 'UNKNOWN', + 'variety': variety code (e.g., 'BB'), + 'timing': timing info (e.g., 'Late'), + 'full': full variety string (e.g., 'BB-Late') + } + """ + variety_info = {'raw': qr_data} + + # Check if new pipe-delimited format + if '|' in qr_data: + parts = qr_data.split('|') + if len(parts) >= 4: + variety_info['project'] = parts[0] + variety_info['lot'] = parts[1] + variety_info['date'] = parts[2] + variety_info['full'] = parts[3] + + # Parse variety and timing from full variety string + variety_parts = parts[3].split('-') + variety_info['variety'] = variety_parts[0] if len(variety_parts) > 0 else '' + variety_info['timing'] = variety_parts[1] if len(variety_parts) > 1 else '' + else: + # Malformed pipe-delimited format + variety_info.update({ + 'project': 'UNKNOWN', + 'lot': 'UNKNOWN', + 'date': 'UNKNOWN', + 'full': qr_data, + 'variety': '', + 'timing': '' + }) + else: + # Legacy format (just variety, e.g., "BB-Late") + parts = qr_data.split('-') + variety_info.update({ + 'project': 'UNKNOWN', + 'lot': 'UNKNOWN', + 'date': 'UNKNOWN', + 'full': qr_data, + 'variety': parts[0] if len(parts) > 0 else '', + 'timing': parts[1] if len(parts) > 1 else '' + }) + + return variety_info diff --git a/Granny/Utils/__init__.py b/Granny/Utils/__init__.py new file mode 100644 index 0000000..feddb93 --- /dev/null +++ b/Granny/Utils/__init__.py @@ -0,0 +1 @@ +# Utils module diff --git a/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()