Procházet zdrojové kódy

Initial commit: DuDONG v2 clean repository

JunnielRome před 1 měsícem
revize
96898e59fb
100 změnil soubory, kde provedl 20835 přidání a 0 odebrání
  1. 61 0
      .gitignore
  2. 320 0
      README.md
  3. 11 0
      __init__.py
  4. binární
      assets/loading-gif.gif
  5. binární
      assets/logos/Belviz-logo-1.png
  6. binární
      assets/logos/DOST-PCAARRD.png
  7. binární
      assets/logos/Rosario-Background-Removed.png
  8. binární
      assets/logos/UPMin.png
  9. binární
      assets/logos/VJT-Enterprise.jpeg
  10. binární
      assets/logos/dost.png
  11. binární
      assets/logos/dudong_logo copy.png
  12. binární
      assets/logos/dudong_logo.png
  13. binární
      assets/logos/durian.png
  14. binární
      assets/logos/eng-seng.png
  15. binární
      assets/logos/logo_final.png
  16. 63 0
      main.py
  17. binární
      model_files/audio/best_model_mel_spec_grouped.keras
  18. binární
      model_files/audio/label_encoder.pkl
  19. 6 0
      model_files/audio/preprocessing_stats.json
  20. binární
      model_files/best.pt
  21. binární
      model_files/locule.pt
  22. binární
      model_files/multispectral/maturity/final_model.pt
  23. binární
      model_files/shape.pt
  24. 16 0
      models/__init__.py
  25. 696 0
      models/audio_model.py
  26. 115 0
      models/base_model.py
  27. 295 0
      models/defect_model.py
  28. 326 0
      models/locule_model.py
  29. 551 0
      models/maturity_model.py
  30. 308 0
      models/shape_model.py
  31. 42 0
      requirements.txt
  32. 8 0
      resources/__init__.py
  33. 491 0
      resources/styles.py
  34. 8 0
      ui/__init__.py
  35. 64 0
      ui/components/__init__.py
  36. 249 0
      ui/components/pdf_exporter.py
  37. 222 0
      ui/components/report_generator.py
  38. 216 0
      ui/components/report_printer.py
  39. 476 0
      ui/components/report_sections.py
  40. 428 0
      ui/components/report_visualizations.py
  41. 150 0
      ui/components/visualization_widgets.py
  42. 17 0
      ui/dialogs/__init__.py
  43. 304 0
      ui/dialogs/about_dialog.py
  44. 544 0
      ui/dialogs/help_dialog.py
  45. 267 0
      ui/dialogs/image_preview_dialog.py
  46. 372 0
      ui/dialogs/manual_input_dialog.py
  47. 138 0
      ui/dialogs/print_options_dialog.py
  48. 296 0
      ui/dialogs/spectrogram_preview_dialog.py
  49. 1451 0
      ui/main_window.py
  50. 32 0
      ui/panels/__init__.py
  51. 266 0
      ui/panels/analysis_timeline_panel.py
  52. 179 0
      ui/panels/audio_spectrogram_panel.py
  53. 153 0
      ui/panels/live_feed.py
  54. 274 0
      ui/panels/maturity_control_panel.py
  55. 212 0
      ui/panels/maturity_results_panel.py
  56. 249 0
      ui/panels/multispectral_panel.py
  57. 498 0
      ui/panels/quality_control_panel.py
  58. 483 0
      ui/panels/quality_defects_panel.py
  59. 285 0
      ui/panels/quality_history_panel.py
  60. 395 0
      ui/panels/quality_results_panel.py
  61. 252 0
      ui/panels/quality_rgb_side_panel.py
  62. 277 0
      ui/panels/quality_rgb_top_panel.py
  63. 257 0
      ui/panels/quality_thermal_panel.py
  64. 150 0
      ui/panels/quick_actions.py
  65. 444 0
      ui/panels/recent_results.py
  66. 132 0
      ui/panels/rgb_preview_panel.py
  67. 281 0
      ui/panels/ripeness_control_panel.py
  68. 201 0
      ui/panels/ripeness_results_panel.py
  69. 126 0
      ui/panels/system_info.py
  70. 265 0
      ui/panels/system_status.py
  71. 22 0
      ui/tabs/__init__.py
  72. 227 0
      ui/tabs/maturity_tab.py
  73. 51 0
      ui/tabs/parameters_tab.py
  74. 859 0
      ui/tabs/quality_tab.py
  75. 641 0
      ui/tabs/reports_tab.py
  76. 214 0
      ui/tabs/ripeness_tab.py
  77. 29 0
      ui/widgets/__init__.py
  78. 55 0
      ui/widgets/coming_soon_overlay.py
  79. 84 0
      ui/widgets/confidence_bar.py
  80. 185 0
      ui/widgets/loading_screen.py
  81. 120 0
      ui/widgets/mode_toggle.py
  82. 75 0
      ui/widgets/panel_header.py
  83. 87 0
      ui/widgets/parameter_slider.py
  84. 177 0
      ui/widgets/spinner.py
  85. 63 0
      ui/widgets/status_indicator.py
  86. 108 0
      ui/widgets/timeline_entry.py
  87. 37 0
      utils/__init__.py
  88. 1495 0
      utils/camera_automation.py
  89. 251 0
      utils/config.py
  90. 69 0
      utils/data_export.py
  91. 661 0
      utils/data_manager.py
  92. 174 0
      utils/db_schema.py
  93. 93 0
      utils/grade_calculator.py
  94. 83 0
      utils/process_utils.py
  95. 364 0
      utils/quality_sample_data.py
  96. 159 0
      utils/session_manager.py
  97. 242 0
      utils/system_monitor.py
  98. 28 0
      workers/__init__.py
  99. 136 0
      workers/audio_worker.py
  100. 124 0
      workers/base_worker.py

+ 61 - 0
.gitignore

@@ -0,0 +1,61 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# Virtual environments
+venv/
+env/
+ENV/
+env.bak/
+venv.bak/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# Runtime data
+data/
+*.db
+*.log
+
+# # Large model files (consider using Git LFS for these)
+# model_files/*.pt
+# model_files/**/*.pt
+# model_files/**/*.keras
+# model_files/**/*.pkl
+# model_files/**/*.json
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Project specific
+md-files/
+.cursor/
+.env
+*.cfg

+ 320 - 0
README.md

@@ -0,0 +1,320 @@
+# DuDONG Grading System v2
+
+![DuDONG Logo](assets/logos/dudong_logo.png)
+
+## About DuDONG
+
+**Durian Desktop-Oriented Non-Invasive Grading System**
+
+DuDONG is a robust desktop application developed by the AIDurian project using Python, designed for advanced assessment of durian ripeness and quality. Utilizing advanced AI models and multiple sensor inputs, the software delivers precise predictions of durian fruit ripeness, quality assessment, and maturity classification.
+
+The application supports both audio analysis and multispectral imaging for comprehensive durian evaluation. Through multi-model analysis including defect detection, shape assessment, and locule counting, DuDONG provides detailed insights into durian quality characteristics. All analysis results are persisted in a comprehensive database for historical tracking and performance monitoring.
+
+**Version**: 2.1.0
+
+## Features
+
+### Core Analysis Capabilities
+- **Ripeness Classification**: Durian ripeness detection using audio analysis and multispectral imaging
+- **Quality Assessment**: Defect detection and shape analysis for comprehensive quality grading
+- **Locule Counting**: Automated locule segmentation and counting with visual segmentation
+- **Maturity Classification**: Multispectral image analysis for maturity determination
+- **Shape Classification**: Durian shape recognition and assessment
+
+### System Features
+- **Real-time Processing**: GPU acceleration with NVIDIA support (CUDA 12.8+)
+- **Multi-Model Analysis**: Comprehensive analysis combining multiple AI models
+- **Manual Input Mode**: Support for multi-source file processing
+- **Modern UI**: Professional PyQt5 dashboard with real-time status monitoring
+- **GPU Acceleration**: Optimized for NVIDIA GPUs with automatic CPU fallback
+- **Data Persistence**: Comprehensive database storage of all analysis results and history tracking
+- **Report Generation**: Generate detailed reports and export analysis results
+
+## Project Structure
+
+```
+dudong-v2/
+├── main.py                          # Application entry point
+├── requirements.txt                 # Python dependencies
+├── README.md                        # This file
+├── .gitignore                       # Git ignore rules
+│
+├── models/                          # AI Model wrappers (Python)
+│   ├── base_model.py               # Abstract base class
+│   ├── audio_model.py              # Ripeness classification model
+│   ├── defect_model.py             # Defect detection model
+│   ├── locule_model.py             # Locule counting model
+│   ├── maturity_model.py           # Maturity analysis model
+│   └── shape_model.py              # Shape classification model
+│
+├── model_files/                     # Actual ML model files
+│   ├── audio/                       # TensorFlow/Keras audio model
+│   ├── multispectral/maturity/      # PyTorch maturity model
+│   ├── best.pt                      # YOLOv8 defect detection
+│   ├── locule.pt                    # YOLOv8 locule segmentation
+│   └── shape.pt                     # YOLOv8 shape classification (optional)
+│
+├── workers/                         # Async Processing Workers
+│   ├── base_worker.py              # QRunnable base class
+│   ├── audio_worker.py             # Audio processing thread
+│   ├── defect_worker.py            # Defect detection thread
+│   ├── locule_worker.py            # Locule counting thread
+│   ├── maturity_worker.py          # Maturity analysis thread
+│   └── shape_worker.py             # Shape classification thread
+│
+├── ui/                              # User Interface Components
+│   ├── main_window.py              # Main application window
+│   ├── panels/                      # Dashboard panels
+│   ├── tabs/                        # Analysis tabs
+│   ├── dialogs/                     # Dialog windows
+│   ├── widgets/                     # Custom widgets
+│   └── components/                  # Report generation components
+│
+├── utils/                           # Utility Modules
+│   ├── config.py                   # Configuration and constants
+│   ├── data_manager.py             # Data persistence
+│   ├── db_schema.py                # Database schema
+│   ├── camera_automation.py        # Camera control (Windows)
+│   ├── system_monitor.py           # System metrics monitoring
+│   └── other_utilities...          # Additional utilities
+│
+├── resources/                       # Styling and Resources
+│   └── styles.py                   # Centralized stylesheets
+│
+└── assets/                          # Image Assets
+    ├── logos/                       # Logo images
+    └── loading-gif.gif              # Loading animation (optional)
+```
+
+## Installation
+
+### Prerequisites
+
+- **Python**: 3.9 or higher
+- **NVIDIA GPU**: (Optional) For faster inference with CUDA 12.8
+- **Windows 10/11**: Required for full camera automation support
+
+### Step 1: Clone or Extract Repository
+
+Extract or clone the dudong-v2 repository to your desired location:
+
+```bash
+git clone <repository-url>
+cd dudong-v2
+```
+
+### Step 2: Create Virtual Environment
+
+```bash
+# Create virtual environment
+python -m venv venv
+
+# Activate virtual environment
+# On Windows:
+venv\Scripts\activate
+# On Linux/Mac:
+source venv/bin/activate
+```
+
+### Step 3: Install Dependencies
+
+```bash
+pip install -r requirements.txt
+```
+
+### Step 4: Verify Model Files
+
+Ensure all required model files are present in the `model_files/` directory:
+
+- ✓ `model_files/audio/best_model_mel_spec_grouped.keras`
+- ✓ `model_files/audio/label_encoder.pkl`
+- ✓ `model_files/audio/preprocessing_stats.json`
+- ✓ `model_files/best.pt` (Defect detection)
+- ✓ `model_files/locule.pt` (Locule counting)
+- ✓ `model_files/multispectral/maturity/final_model.pt` (Maturity)
+- ○ `model_files/shape.pt` (Shape classification - optional)
+
+If any files are missing, the application will still run but those models will not be available.
+
+## Usage
+
+### Running the Application
+
+```bash
+python main.py
+```
+
+The application will:
+1. Initialize the PyQt5 application
+2. Load configuration and set up paths
+3. Create the main window
+4. Load AI models in background (with automatic fallback to CPU if needed)
+5. Display the dashboard
+
+### Using the Interface
+
+#### Ripeness Tab
+- Click "Ripeness Classifier" button
+- Select a WAV audio file
+- View spectrogram and ripeness classification with confidence scores
+
+#### Quality Tab
+- Click "Quality Classifier" button
+- Select an image file (JPG, PNG)
+- View annotated image with defect detection results
+
+#### Maturity Tab
+- Click "Maturity Analysis" button
+- Select a multispectral TIFF image
+- View maturity classification results
+
+#### Reports Tab
+- View analysis history and previous results
+- Generate PDF reports
+- Print results
+- Export data
+
+## Configuration
+
+Edit `utils/config.py` to customize:
+
+- Window dimensions and UI colors
+- Model confidence thresholds
+- Threading and performance settings
+- Audio processing parameters
+- Device selection (CUDA/CPU)
+
+### Environment Variables (Optional)
+
+Create a `.env` file in the root directory:
+
+```
+DEVICE_ID=MAIN-001
+LOG_LEVEL=INFO
+CUDA_VISIBLE_DEVICES=0
+```
+
+## GPU Support
+
+The application automatically detects and uses NVIDIA GPUs when available.
+
+To check GPU availability:
+
+```bash
+python -c "import torch; print(f'CUDA Available: {torch.cuda.is_available()}'); print(f'GPU: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else \"None\"}')"
+```
+
+## Troubleshooting
+
+### Models not loading
+
+- Verify model files exist in `model_files/` directory
+- Check that file paths match exactly (case-sensitive on Linux/Mac)
+- Review application console output for specific error messages
+
+### GPU not detected
+
+- Install NVIDIA drivers (latest version)
+- Verify CUDA 12.8+ is installed
+- Run the GPU check command above
+- Restart application after installing GPU drivers
+
+### UI rendering issues
+
+- Ensure PyQt5 is properly installed: `pip install --upgrade PyQt5`
+- Check display scaling settings on Windows (may need to disable DPI scaling)
+- Try running with `QT_QPA_PLATFORM=windows` on Windows
+
+### Camera automation not working (Windows)
+
+- Ensure supported camera software is installed (SecondLook, EOS Utility, AnalyzIR)
+- Check Windows Process Automation (pywinauto) is properly installed
+- Run application with administrator privileges if needed
+
+## Development
+
+### Code Style
+
+- Follow PEP 8 guidelines
+- Use type hints for all functions
+- Include docstrings (Google style)
+- Keep modules focused and under 500 lines
+
+### Adding New Models
+
+1. Create model wrapper in `models/` directory inheriting from `BaseModel`
+2. Create corresponding worker in `workers/` directory inheriting from `BaseWorker`
+3. Add UI panel in `ui/panels/` for displaying results
+4. Integrate worker connection in `ui/main_window.py`
+
+## Key Dependencies
+
+- **PyQt5**: GUI framework
+- **PyTorch**: YOLO models and GPU acceleration
+- **TensorFlow**: Audio classification model
+- **OpenCV**: Image processing
+- **Ultralytics**: YOLOv8 implementation
+- **NumPy/SciPy**: Numerical computing
+- **Matplotlib**: Visualizations
+- **ReportLab**: PDF generation
+- **psutil**: System monitoring
+
+## Database
+
+The application automatically creates and manages an SQLite database at `data/database.db` which stores:
+
+- Analysis metadata and timestamps
+- Input file information
+- Model prediction results
+- Visualization outputs
+
+This database is created automatically on first run.
+
+## Development & Support
+
+### Development Team
+
+Developed by researchers at the Department of Math, Physics, and Computer Science in UP Mindanao, specifically the **AIDurian Project**, under the Department of Science and Technology's (DOST) i-CRADLE program.
+
+The project aims to bridge the gap between manual practices of durian farming and introduce it to the various technological advancements available today.
+
+### Supported By
+
+- **University of the Philippines Mindanao**
+- **Department of Science and Technology (DOST)**
+- **DOST-PCAARRD i-CRADLE Program**
+
+![Institutions](assets/logos/UPMin.png)
+![DOST](assets/logos/dost.png)
+![DOST-PCAARRD](assets/logos/DOST-PCAARRD.png)
+
+### Industry Partners
+
+Special thanks to AIDurian's partners:
+- **Belviz Farms**
+- **D'Farmers Market**
+- **EngSeng Food Products**
+- **Rosario's Delicacies**
+- **VJT Enterprises**
+
+![Partners](assets/logos/Belviz-logo-1.png)
+![Partners](assets/logos/logo_final.png)
+![Partners](assets/logos/eng-seng.png)
+![Partners](assets/logos/Rosario-Background-Removed.png)
+![Partners](assets/logos/VJT-Enterprise.jpeg)
+
+---
+
+## Support
+
+For issues, questions, or contributions, please contact the AIDurian development team.
+
+## Version
+
+- **Application Version**: 2.1.0
+- **Repository**: dudong-v2
+- **Last Updated**: February 2026
+
+## License
+
+© 2023-2026 AIDurian Project. All rights reserved.

+ 11 - 0
__init__.py

@@ -0,0 +1,11 @@
+"""
+DuDONG Grading System - Modern UI Application
+
+A professional durian grading system using multi-modal AI analysis.
+"""
+
+__version__ = "2.1.0"
+__author__ = "AIDurian Team"
+
+
+

binární
assets/loading-gif.gif


binární
assets/logos/Belviz-logo-1.png


binární
assets/logos/DOST-PCAARRD.png


binární
assets/logos/Rosario-Background-Removed.png


binární
assets/logos/UPMin.png


binární
assets/logos/VJT-Enterprise.jpeg


binární
assets/logos/dost.png


binární
assets/logos/dudong_logo copy.png


binární
assets/logos/dudong_logo.png


binární
assets/logos/durian.png


binární
assets/logos/eng-seng.png


binární
assets/logos/logo_final.png


+ 63 - 0
main.py

@@ -0,0 +1,63 @@
+"""
+DuDONG Application Entry Point
+
+Main entry point for the DuDONG Grading System application.
+Initializes the Qt application and displays the main window.
+"""
+
+import sys
+from PyQt5.QtWidgets import QApplication
+from PyQt5.QtGui import QIcon
+from pathlib import Path
+
+from ui.main_window import DuDONGMainWindow
+
+
+def main():
+    """
+    Main application entry point.
+    
+    Initializes QApplication, creates and shows the main window,
+    and starts the event loop.
+    """
+    # Create Qt application
+    app = QApplication(sys.argv)
+    
+    # Set application metadata
+    app.setApplicationName("DuDONG")
+    app.setOrganizationName("AIDurian")
+    app.setOrganizationDomain("aidurian.org")
+    
+    # Set application icon (if exists)
+    icon_path = Path(__file__).parent / "assets" / "logos" / "durian.png"
+    if icon_path.exists():
+        app.setWindowIcon(QIcon(str(icon_path)))
+    
+    # Set application-wide style
+    app.setStyleSheet("""
+        QApplication {
+            font-family: Arial, sans-serif;
+        }
+    """)
+    
+    # Create and show main window
+    print("=" * 80)
+    print("DuDONG Grading System")
+    print("=" * 80)
+    print("Initializing application...")
+    
+    window = DuDONGMainWindow()
+    window.show()
+    
+    print("Application started successfully!")
+    print("=" * 80)
+    
+    # Start event loop
+    sys.exit(app.exec_())
+
+
+if __name__ == "__main__":
+    main()
+
+
+

binární
model_files/audio/best_model_mel_spec_grouped.keras


binární
model_files/audio/label_encoder.pkl


+ 6 - 0
model_files/audio/preprocessing_stats.json

@@ -0,0 +1,6 @@
+{
+  "max_length": 13,
+  "feature_min": -5.043799420044562,
+  "feature_max": 6.560277191954449,
+  "normalization_type": "per_knock_standardization"
+}

binární
model_files/best.pt


binární
model_files/locule.pt


binární
model_files/multispectral/maturity/final_model.pt


binární
model_files/shape.pt


+ 16 - 0
models/__init__.py

@@ -0,0 +1,16 @@
+"""
+Models Package
+
+Contains AI model wrappers for audio, defect detection, locule counting, maturity classification, and shape classification.
+"""
+
+from .audio_model import AudioModel
+from .defect_model import DefectModel
+from .locule_model import LoculeModel
+from .maturity_model import MaturityModel
+from .shape_model import ShapeModel
+
+__all__ = ['AudioModel', 'DefectModel', 'LoculeModel', 'MaturityModel', 'ShapeModel']
+
+
+

+ 696 - 0
models/audio_model.py

@@ -0,0 +1,696 @@
+"""
+Audio Model Module
+
+Audio classification model for durian ripeness detection using knock detection and mel-spectrogram features.
+Detects knocks in audio using librosa, extracts mel-spectrograms, and averages predictions across knocks.
+"""
+
+import os
+import tempfile
+import pickle
+import json
+from pathlib import Path
+from typing import Dict, Any, Tuple, Optional, List
+import logging
+
+import numpy as np
+import librosa
+import librosa.display
+import matplotlib
+matplotlib.use('agg')  # Use non-interactive backend
+import matplotlib.pyplot as plt
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from PyQt5.QtGui import QImage, QPixmap
+
+from models.base_model import BaseModel
+from utils.config import (
+    AUDIO_MODEL_PATH,
+    SPECTROGRAM_FIG_SIZE,
+    RIPENESS_CLASSES,
+)
+
+# Import TensorFlow
+try:
+    import tensorflow as tf
+except ImportError:
+    tf = None
+
+logger = logging.getLogger(__name__)
+
+
+class AudioModel(BaseModel):
+    """
+    Audio-based ripeness classification model.
+
+    Detects knocks in durian audio using onset detection, extracts mel-spectrogram features,
+    and averages predictions across all detected knocks for robust ripeness classification.
+
+    Attributes:
+        model: Keras model for mel-spectrogram classification
+        label_encoder: Scikit-learn label encoder for class names
+        preprocessing_stats: JSON statistics (max_length, normalization params)
+        class_names: List of class names (unripe, ripe, overripe)
+    """
+
+    # Mel-spectrogram parameters (must match training)
+    MEL_PARAMS = {
+        'n_mels': 64,
+        'hop_length': 512,
+        'n_fft': 2048,
+        'sr': 22050
+    }
+    
+    # Knock detection parameters
+    KNOCK_DETECTION = {
+        'delta': 0.3,           # Onset detection delta
+        'wait': 10,             # Onset detection wait frames
+        'onset_shift': 0.05,    # Shift onsets back by 50ms
+        'knock_duration': 0.2   # Extract 200ms per knock
+    }
+
+    def __init__(self, model_path: Optional[str] = None, device: str = "cpu"):
+        """
+        Initialize the audio model.
+
+        Args:
+            model_path: Path to model directory (optional)
+            device: Device to use (cpu/gpu - not used for TensorFlow)
+        """
+        if model_path is None:
+            # AUDIO_MODEL_PATH points to models/audio/ which contains our files
+            model_path = str(AUDIO_MODEL_PATH)
+
+        super().__init__(model_path, device)
+        self.class_names = RIPENESS_CLASSES
+        self.model = None
+        self.label_encoder = None
+        self.preprocessing_stats = None
+        
+        logger.info(f"AudioModel initialized with model_path: {model_path}")
+    
+    def load(self) -> bool:
+        """
+        Load the model, label encoder, and preprocessing statistics.
+
+        Returns:
+            bool: True if loaded successfully, False otherwise
+        """
+        try:
+            base_dir = Path(self.model_path)
+            
+            # Try two possible paths: direct path or voice_memos_ripeness subdirectory
+            possible_dirs = [
+                base_dir,  # Files directly in model_path
+                base_dir / "voice_memos_ripeness"  # Files in voice_memos_ripeness subdir
+            ]
+            
+            model_dir = None
+            for possible_dir in possible_dirs:
+                if possible_dir.exists():
+                    # Check if this directory has the required files
+                    model_file = possible_dir / "best_model_mel_spec_grouped.keras"
+                    if model_file.exists():
+                        model_dir = possible_dir
+                        break
+            
+            if model_dir is None:
+                logger.error(f"Could not find model files in: {base_dir} or {base_dir / 'voice_memos_ripeness'}")
+                return False
+
+            logger.info(f"Loading audio model from {model_dir}")
+
+            # Load Keras model
+            model_path = model_dir / "best_model_mel_spec_grouped.keras"
+            if not model_path.exists():
+                logger.error(f"Model file not found: {model_path}")
+                return False
+            
+            logger.info(f"Loading TensorFlow model from {model_path}...")
+            self.model = tf.keras.models.load_model(str(model_path))
+            logger.info(f"✓ TensorFlow model loaded successfully")
+
+            # Load label encoder
+            encoder_path = model_dir / "label_encoder.pkl"
+            if not encoder_path.exists():
+                logger.error(f"Label encoder not found: {encoder_path}")
+                return False
+            
+            logger.info(f"Loading label encoder from {encoder_path}...")
+            with open(encoder_path, 'rb') as f:
+                self.label_encoder = pickle.load(f)
+            logger.info(f"✓ Label encoder loaded with classes: {list(self.label_encoder.classes_)}")
+
+            # Load preprocessing stats
+            stats_path = model_dir / "preprocessing_stats.json"
+            if not stats_path.exists():
+                logger.error(f"Preprocessing stats not found: {stats_path}")
+                return False
+            
+            logger.info(f"Loading preprocessing stats from {stats_path}...")
+            with open(stats_path, 'r') as f:
+                self.preprocessing_stats = json.load(f)
+            logger.info(f"✓ Preprocessing stats loaded, max_length: {self.preprocessing_stats.get('max_length')}")
+
+            self._is_loaded = True
+            logger.info("✓ Audio model loaded successfully")
+            return True
+
+        except Exception as e:
+            logger.error(f"Failed to load audio model: {e}", exc_info=True)
+            self._is_loaded = False
+            return False
+    def predict(self, audio_path: str) -> Dict[str, Any]:
+        """
+        Predict ripeness from an audio file using knock detection and mel-spectrogram analysis.
+
+        Args:
+            audio_path: Path to audio file (supports WAV and other formats via librosa)
+
+        Returns:
+            Dict containing:
+                - 'class_name': Predicted class name (Ripe/Unripe/Overripe)
+                - 'class_index': Predicted class index
+                - 'probabilities': Dictionary of class probabilities (0-1 range)
+                - 'confidence': Confidence score (0-1 range, averaged across knocks)
+                - 'spectrogram_image': QPixmap of mel-spectrogram with knocks marked
+                - 'waveform_image': QPixmap of waveform with knocks marked
+                - 'knock_count': Number of knocks detected
+                - 'knock_times': List of knock onset times in seconds
+                - 'success': Whether prediction succeeded
+                - 'error': Error message if failed
+        """
+        if not self._is_loaded or self.model is None:
+            raise RuntimeError("Model not loaded. Call load() first.")
+
+        try:
+            # Ensure audio is in WAV format
+            wav_path = self._ensure_wav_format(audio_path)
+            
+            # Load audio
+            logger.info(f"Loading audio from {audio_path}")
+            y, sr = librosa.load(wav_path, sr=self.MEL_PARAMS['sr'], mono=True)
+            
+            # Trim silence from beginning and end
+            cut_samples = int(0.5 * sr)
+            if len(y) > 2 * cut_samples:
+                y = y[cut_samples:-cut_samples]
+            elif len(y) > cut_samples:
+                y = y[cut_samples:]
+            
+            # Detect knocks and extract features
+            logger.info("Detecting knocks in audio...")
+            features, knock_times = self._extract_knock_features(y, sr)
+            
+            logger.info(f"DEBUG: Detected {len(features)} knocks")
+            
+            if len(features) == 0:
+                logger.error("❌ No knocks detected in audio file - returning error")
+                return {
+                    'success': False,
+                    'class_name': None,
+                    'class_index': None,
+                    'probabilities': {},
+                    'confidence': 0.0,
+                    'spectrogram_image': None,
+                    'waveform_image': None,
+                    'knock_count': 0,
+                    'knock_times': [],
+                    'error': 'No knocks detected in audio file'
+                }
+            
+            logger.info(f"Detected {len(features)} knocks at times: {knock_times}")
+            
+            # Prepare features for model
+            max_length = self.preprocessing_stats['max_length']
+            X = np.array([
+                np.pad(f, ((0, max_length - f.shape[0]), (0, 0)), mode='constant')
+                for f in features
+            ])
+            
+            if len(X.shape) == 3:
+                X = np.expand_dims(X, -1)
+            
+            # Run inference
+            logger.info(f"Running model inference on {len(features)} knocks...")
+            probs = self.model.predict(X, verbose=0)
+            
+            # Get per-knock predictions
+            per_knock_preds = []
+            for i, knock_probs in enumerate(probs):
+                knock_pred_idx = np.argmax(knock_probs)
+                knock_pred_class = self.label_encoder.classes_[knock_pred_idx]
+                knock_confidence = float(knock_probs[knock_pred_idx])
+                per_knock_preds.append({
+                    'class': knock_pred_class,
+                    'class_idx': knock_pred_idx,
+                    'confidence': knock_confidence,
+                    'probabilities': {self.label_encoder.classes_[j]: float(knock_probs[j]) for j in range(len(self.label_encoder.classes_))}
+                })
+            
+            logger.info(f"Per-knock predictions: {[p['class'] for p in per_knock_preds]}")
+            
+            # Average predictions across all knocks (CONFIDENCE LOGIC)
+            avg_probs = np.mean(probs, axis=0)
+            predicted_idx = np.argmax(avg_probs)
+            predicted_class = self.label_encoder.classes_[predicted_idx]
+            confidence = float(avg_probs[predicted_idx])
+            
+            logger.info(f"Average probabilities: {dict(zip(self.label_encoder.classes_, avg_probs))}")
+            logger.info(f"Final prediction: {predicted_class} ({confidence:.2%})")
+            
+            # Create probability dictionary
+            prob_dict = {
+                self.label_encoder.classes_[i]: float(avg_probs[i])
+                for i in range(len(self.label_encoder.classes_))
+            }
+            
+            # Capitalize class name for display
+            predicted_class_display = predicted_class.capitalize() if isinstance(predicted_class, str) else predicted_class
+            
+            # Generate visualizations with knock annotations
+            spectrogram_image = self._generate_mel_spectrogram_with_knocks(y, sr, knock_times)
+            waveform_image = self._generate_waveform_with_knocks(y, sr, knock_times)
+            
+            logger.info(f"Prediction: {predicted_class_display} ({confidence:.2%}) from {len(features)} knocks")
+            
+            return {
+                'success': True,
+                'class_name': predicted_class_display,
+                'class_index': predicted_idx,
+                'probabilities': prob_dict,
+                'confidence': confidence,
+                'spectrogram_image': spectrogram_image,
+                'waveform_image': waveform_image,
+                'knock_count': len(features),
+                'knock_times': knock_times,
+                'per_knock_predictions': per_knock_preds,
+                'error': None
+            }
+
+        except Exception as e:
+            error_msg = str(e)
+            logger.error(f"Prediction failed: {error_msg}", exc_info=True)
+            
+            # Provide helpful error message for audio format issues
+            if 'convert' in error_msg.lower() or 'format' in error_msg.lower():
+                error_msg += " - Please ensure ffmpeg is installed: conda install -c conda-forge ffmpeg"
+            
+            return {
+                'success': False,
+                'class_name': None,
+                'class_index': None,
+                'probabilities': {},
+                'confidence': 0.0,
+                'spectrogram_image': None,
+                'waveform_image': None,
+                'knock_count': 0,
+                'knock_times': [],
+                'per_knock_predictions': [],
+                'error': error_msg
+            }
+    
+    def _ensure_wav_format(self, audio_path: str) -> str:
+        """
+        Ensure audio file is in WAV format, converting if necessary.
+        Supports M4A, MP3, OGG, FLAC, WMA and other formats.
+
+        Args:
+            audio_path: Path to audio file
+
+        Returns:
+            Path to WAV file (original or converted)
+        """
+        ext = os.path.splitext(audio_path)[1].lower()
+        if ext == '.wav':
+            return audio_path
+        
+        logger.info(f"Converting {ext} to WAV format...")
+        
+        # Try ffmpeg first (most reliable for various formats)
+        try:
+            import subprocess
+            tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
+            tmp_path = tmp_file.name
+            tmp_file.close()
+            
+            # Use ffmpeg to convert
+            cmd = [
+                'ffmpeg',
+                '-i', audio_path,
+                '-acodec', 'pcm_s16le',
+                '-ar', str(self.MEL_PARAMS['sr']),
+                '-ac', '1',  # mono
+                '-y',  # overwrite
+                tmp_path
+            ]
+            
+            logger.info(f"Using ffmpeg to convert {ext}")
+            subprocess.run(cmd, capture_output=True, check=True, timeout=30)
+            logger.info(f"Converted to temporary WAV: {tmp_path}")
+            return tmp_path
+        except Exception as e:
+            logger.warning(f"ffmpeg conversion failed: {e}, trying pydub...")
+        
+        # Try pydub second (handles most formats if installed)
+        try:
+            from pydub import AudioSegment
+            logger.info(f"Using pydub to convert {ext}")
+            audio = AudioSegment.from_file(audio_path)
+            
+            tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
+            audio.export(tmp_file.name, format='wav')
+            logger.info(f"Converted to temporary WAV: {tmp_file.name}")
+            return tmp_file.name
+        except Exception as e:
+            logger.warning(f"pydub conversion failed: {e}, trying librosa...")
+        
+        # Try librosa as final fallback
+        try:
+            import soundfile as sf
+        except ImportError:
+            logger.warning("soundfile not available, using scipy for conversion")
+            sf = None
+        
+        try:
+            logger.info("Using librosa to load and convert audio")
+            # Load with librosa (requires ffmpeg backend for non-WAV)
+            y, sr = librosa.load(audio_path, sr=self.MEL_PARAMS['sr'], mono=True)
+            
+            tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
+            if sf is not None:
+                sf.write(tmp_file.name, y, sr)
+            else:
+                # Fallback: use scipy
+                import scipy.io.wavfile as wavfile
+                # Normalize to 16-bit range
+                y_int16 = np.clip(y * 32767, -32768, 32767).astype(np.int16)
+                wavfile.write(tmp_file.name, sr, y_int16)
+            
+            logger.info(f"Converted to temporary WAV: {tmp_file.name}")
+            return tmp_file.name
+        except Exception as e:
+            logger.error(f"Audio conversion failed with all methods: {e}")
+            logger.error(f"Please ensure ffmpeg is installed or install pydub: pip install pydub")
+            raise RuntimeError(
+                f"Failed to convert {ext} audio file. "
+                "Install ffmpeg or pydub: 'pip install pydub' and 'pip install ffmpeg'"
+            ) from e
+    
+    def _extract_knock_features(self, audio: np.ndarray, sr: int) -> Tuple[List[np.ndarray], List[float]]:
+        """
+        Detect knocks in audio and extract mel-spectrogram features.
+
+        Args:
+            audio: Audio time series
+            sr: Sample rate
+
+        Returns:
+            Tuple of:
+                - List of mel-spectrogram arrays (one per knock)
+                - List of knock onset times in seconds
+        """
+        # Detect onsets (knock starts)
+        logger.info("Detecting onset times...")
+        onset_frames = librosa.onset.onset_detect(
+            y=audio, sr=sr,
+            delta=self.KNOCK_DETECTION['delta'],
+            wait=self.KNOCK_DETECTION['wait'],
+            units='frames'
+        )
+        onset_times = librosa.frames_to_time(onset_frames, sr=sr)
+        
+        if len(onset_times) == 0:
+            logger.warning("No onsets detected")
+            return [], []
+        
+        # Shift onsets slightly back
+        shifted_times = [max(0, t - self.KNOCK_DETECTION['onset_shift']) for t in onset_times]
+        logger.info(f"Detected {len(shifted_times)} onset times")
+        
+        # Extract knock segments
+        knock_samples = int(round(self.KNOCK_DETECTION['knock_duration'] * sr))
+        knocks = []
+        valid_times = []
+        
+        for onset in shifted_times:
+            start = int(round(onset * sr))
+            end = start + knock_samples
+            
+            if start >= len(audio):
+                continue
+            
+            if end <= len(audio):
+                knock = audio[start:end]
+            else:
+                # Pad with zeros if at end
+                knock = np.zeros(knock_samples, dtype=audio.dtype)
+                available = len(audio) - start
+                if available > 0:
+                    knock[:available] = audio[start:]
+                else:
+                    continue
+            
+            knocks.append(knock)
+            valid_times.append(onset)
+        
+        # Extract mel-spectrograms
+        logger.info(f"Extracting mel-spectrograms from {len(knocks)} knocks...")
+        features = []
+        for knock in knocks:
+            mel_spec = self._extract_mel_spectrogram(knock, sr)
+            features.append(mel_spec)
+        
+        return features, valid_times
+    
+    def _extract_mel_spectrogram(self, audio: np.ndarray, sr: int) -> np.ndarray:
+        """
+        Extract normalized mel-spectrogram from audio.
+
+        Args:
+            audio: Audio segment
+            sr: Sample rate
+
+        Returns:
+            Normalized mel-spectrogram (time, n_mels)
+        """
+        # Compute mel-spectrogram
+        S = librosa.feature.melspectrogram(
+            y=audio, sr=sr,
+            n_mels=self.MEL_PARAMS['n_mels'],
+            hop_length=self.MEL_PARAMS['hop_length'],
+            n_fft=self.MEL_PARAMS['n_fft']
+        )
+        
+        # Convert to dB scale
+        S_db = librosa.power_to_db(S, ref=np.max)
+        
+        # Normalize
+        std = np.std(S_db)
+        if std != 0:
+            S_db = (S_db - np.mean(S_db)) / std
+        else:
+            S_db = S_db - np.mean(S_db)
+        
+        return S_db.T  # (time, n_mels)
+    
+    def predict_batch(self, audio_paths: list) -> list:
+        """
+        Predict ripeness for multiple audio files.
+
+        Args:
+            audio_paths: List of paths to audio files
+
+        Returns:
+            List[Dict]: List of prediction results
+        """
+        results = []
+        for audio_path in audio_paths:
+            result = self.predict(audio_path)
+            results.append(result)
+        return results
+    
+    def _generate_waveform_with_knocks(self, audio: np.ndarray, sr: int, knock_times: List[float]) -> Optional[QPixmap]:
+        """
+        Generate waveform visualization with knock locations marked.
+
+        Args:
+            audio: Audio time series
+            sr: Sample rate
+            knock_times: List of knock onset times in seconds
+
+        Returns:
+            QPixmap: Waveform plot with knock markers
+        """
+        try:
+            fig, ax = plt.subplots(figsize=SPECTROGRAM_FIG_SIZE)
+            
+            # Plot waveform
+            librosa.display.waveshow(audio, sr=sr, alpha=0.6, ax=ax)
+            
+            # Mark knock locations
+            knock_duration = self.KNOCK_DETECTION['knock_duration']
+            for knock_time in knock_times:
+                # Vertical line at onset
+                ax.axvline(knock_time, color='red', linestyle='--', alpha=0.8, linewidth=1.5)
+                # Span showing knock duration
+                ax.axvspan(knock_time, knock_time + knock_duration, color='orange', alpha=0.2)
+            
+            ax.set_title(f'Waveform with {len(knock_times)} Detected Knocks')
+            ax.set_xlabel('Time (s)')
+            ax.set_ylabel('Amplitude')
+            ax.grid(True, alpha=0.3)
+            
+            # Convert to QPixmap
+            canvas = FigureCanvas(fig)
+            canvas.draw()
+            
+            width_px, height_px = fig.get_size_inches() * fig.get_dpi()
+            width_px, height_px = int(width_px), int(height_px)
+            
+            img = QImage(canvas.buffer_rgba(), width_px, height_px, QImage.Format_ARGB32)
+            img = img.rgbSwapped()
+            pixmap = QPixmap(img)
+            
+            plt.close(fig)
+            
+            return pixmap
+            
+        except Exception as e:
+            logger.error(f"Failed to generate waveform: {e}")
+            return None
+    
+    def _generate_mel_spectrogram_with_knocks(self, audio: np.ndarray, sr: int, knock_times: List[float]) -> Optional[QPixmap]:
+        """
+        Generate mel-spectrogram visualization with knock locations marked.
+
+        Args:
+            audio: Audio time series
+            sr: Sample rate
+            knock_times: List of knock onset times in seconds
+
+        Returns:
+            QPixmap: Mel-spectrogram plot with knock markers
+        """
+        try:
+            # Compute mel-spectrogram
+            S = librosa.feature.melspectrogram(
+                y=audio, sr=sr,
+                n_mels=self.MEL_PARAMS['n_mels'],
+                hop_length=self.MEL_PARAMS['hop_length'],
+                n_fft=self.MEL_PARAMS['n_fft']
+            )
+            
+            # Convert to dB scale
+            S_db = librosa.power_to_db(S, ref=np.max)
+            
+            # Create figure with tight layout
+            fig = plt.figure(figsize=SPECTROGRAM_FIG_SIZE)
+            ax = fig.add_subplot(111)
+            
+            # Display mel-spectrogram
+            img = librosa.display.specshow(
+                S_db,
+                x_axis='time',
+                y_axis='mel',
+                sr=sr,
+                hop_length=self.MEL_PARAMS['hop_length'],
+                cmap='magma',
+                ax=ax
+            )
+            
+            # Mark knock locations
+            knock_duration = self.KNOCK_DETECTION['knock_duration']
+            for knock_time in knock_times:
+                # Vertical line at onset
+                ax.axvline(knock_time, color='cyan', linestyle='--', alpha=0.8, linewidth=1.5)
+                # Span showing knock duration
+                ax.axvspan(knock_time, knock_time + knock_duration, color='cyan', alpha=0.15)
+            
+            ax.set_title(f'Mel Spectrogram with {len(knock_times)} Detected Knocks (64 Coefficients)')
+            ax.set_xlabel('Time (s)')
+            ax.set_ylabel('Mel Frequency')
+            
+            # Add colorbar properly
+            plt.colorbar(img, ax=ax, format='%+2.0f dB', label='Power (dB)')
+            
+            plt.tight_layout()
+            
+            # Convert to QPixmap
+            canvas = FigureCanvas(fig)
+            canvas.draw()
+            
+            width_px, height_px = fig.get_size_inches() * fig.get_dpi()
+            width_px, height_px = int(width_px), int(height_px)
+            
+            img_qimage = QImage(canvas.buffer_rgba(), width_px, height_px, QImage.Format_ARGB32)
+            img_qimage = img_qimage.rgbSwapped()
+            pixmap = QPixmap(img_qimage)
+            
+            plt.close(fig)
+            
+            return pixmap
+            
+        except Exception as e:
+            logger.error(f"Failed to generate mel-spectrogram: {e}", exc_info=True)
+            return None
+    
+    def _generate_spectrogram_image(self, audio: np.ndarray, sr: int) -> Optional[QPixmap]:
+        """
+        Generate a mel-spectrogram visualization from audio.
+
+        Args:
+            audio: Audio time series
+            sr: Sample rate
+
+        Returns:
+            QPixmap: Rendered mel-spectrogram image or None if failed
+        """
+        try:
+            # Compute mel-spectrogram
+            S = librosa.feature.melspectrogram(
+                y=audio, sr=sr,
+                n_mels=self.MEL_PARAMS['n_mels'],
+                hop_length=self.MEL_PARAMS['hop_length'],
+                n_fft=self.MEL_PARAMS['n_fft']
+            )
+            
+            # Convert to dB scale
+            S_db = librosa.power_to_db(S, ref=np.max)
+            
+            # Create figure
+            fig, ax = plt.subplots(figsize=SPECTROGRAM_FIG_SIZE)
+            
+            # Display mel-spectrogram
+            librosa.display.specshow(
+                S_db,
+                x_axis='time',
+                y_axis='mel',
+                sr=sr,
+                hop_length=self.MEL_PARAMS['hop_length'],
+                cmap='magma',
+                ax=ax
+            )
+            
+            ax.set_title('Mel Spectrogram (64 coefficients)')
+            ax.set_xlabel('Time (s)')
+            ax.set_ylabel('Mel Frequency')
+            
+            # Convert to QPixmap
+            canvas = FigureCanvas(fig)
+            canvas.draw()
+            
+            width_px, height_px = fig.get_size_inches() * fig.get_dpi()
+            width_px, height_px = int(width_px), int(height_px)
+            
+            img = QImage(canvas.buffer_rgba(), width_px, height_px, QImage.Format_ARGB32)
+            img = img.rgbSwapped()
+            pixmap = QPixmap(img)
+            
+            plt.close(fig)
+            
+            return pixmap
+            
+        except Exception as e:
+            logger.error(f"Failed to generate spectrogram image: {e}")
+            return None
+

+ 115 - 0
models/base_model.py

@@ -0,0 +1,115 @@
+"""
+Base Model Module
+
+Abstract base class for all AI models in the DuDONG system.
+Provides common interface and functionality for model loading and prediction.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, Optional
+import logging
+
+# Setup logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+class BaseModel(ABC):
+    """
+    Abstract base class for AI models.
+    
+    All model wrappers should inherit from this class and implement
+    the required abstract methods.
+    
+    Attributes:
+        model_path (str): Path to the model file/directory
+        device (str): Device to run model on ('cuda' or 'cpu')
+        model (Any): The loaded model object
+        _is_loaded (bool): Flag indicating if model is loaded
+    """
+    
+    def __init__(self, model_path: str, device: str = "cpu"):
+        """
+        Initialize the base model.
+        
+        Args:
+            model_path: Path to the model file or directory
+            device: Device to use for inference ('cuda' or 'cpu')
+        """
+        self.model_path = model_path
+        self.device = device
+        self.model: Optional[Any] = None
+        self._is_loaded = False
+        
+        logger.info(f"Initializing {self.__class__.__name__} with device: {device}")
+    
+    @abstractmethod
+    def load(self) -> bool:
+        """
+        Load the model from disk.
+        
+        This method must be implemented by all subclasses.
+        
+        Returns:
+            bool: True if model loaded successfully, False otherwise
+        """
+        pass
+    
+    @abstractmethod
+    def predict(self, input_data: Any) -> Dict[str, Any]:
+        """
+        Run inference on the input data.
+        
+        This method must be implemented by all subclasses.
+        
+        Args:
+            input_data: Input data for prediction (format varies by model)
+        
+        Returns:
+            Dict[str, Any]: Dictionary containing prediction results
+        """
+        pass
+    
+    @property
+    def is_loaded(self) -> bool:
+        """
+        Check if the model is loaded (property accessor).
+        
+        Returns:
+            bool: True if model is loaded, False otherwise
+        """
+        return self._is_loaded
+    
+    def unload(self) -> None:
+        """
+        Unload the model from memory.
+        
+        This can be overridden by subclasses if special cleanup is needed.
+        """
+        if self.model is not None:
+            del self.model
+            self.model = None
+            self._is_loaded = False
+            logger.info(f"{self.__class__.__name__} unloaded")
+    
+    def get_device(self) -> str:
+        """
+        Get the device the model is running on.
+        
+        Returns:
+            str: Device name ('cuda' or 'cpu')
+        """
+        return self.device
+    
+    def __repr__(self) -> str:
+        """
+        String representation of the model.
+        
+        Returns:
+            str: Model information
+        """
+        status = "loaded" if self._is_loaded else "not loaded"
+        return f"{self.__class__.__name__}(device={self.device}, status={status})"
+
+
+

+ 295 - 0
models/defect_model.py

@@ -0,0 +1,295 @@
+"""
+Defect Model Module
+
+YOLO-based defect detection model for durian quality assessment.
+Detects and classifies defects: minor defects, no defects, reject.
+"""
+
+from pathlib import Path
+from typing import Dict, Any, Optional, Tuple
+import logging
+
+import cv2
+import numpy as np
+from ultralytics import YOLO
+from PyQt5.QtGui import QImage
+
+from models.base_model import BaseModel
+from utils.config import (
+    DEFECT_MODEL_PATH,
+    DEFECT_CLASS_COLORS,
+    DEFECT_CLASS_NAMES,
+    YOLO_CONFIDENCE_THRESHOLD,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class DefectModel(BaseModel):
+    """
+    YOLO-based defect detection model.
+    
+    Detects defects in durian images and classifies them into:
+    - Minor defects (Pink)
+    - No defects (Cyan)
+    - Reject (Purple)
+    
+    Attributes:
+        model: YOLO model instance
+        class_colors: BGR color mapping for each class
+        class_names: Name mapping for each class
+        confidence_threshold: Minimum confidence for detections
+    """
+    
+    def __init__(
+        self,
+        model_path: Optional[str] = None,
+        device: str = "cuda",
+        confidence_threshold: float = YOLO_CONFIDENCE_THRESHOLD
+    ):
+        """
+        Initialize the defect detection model.
+        
+        Args:
+            model_path: Path to YOLO .pt model file (optional)
+            device: Device to use ('cuda' or 'cpu')
+            confidence_threshold: Minimum confidence for detections (0.0-1.0)
+        """
+        if model_path is None:
+            model_path = str(DEFECT_MODEL_PATH)
+        
+        super().__init__(model_path, device)
+        self.class_colors = DEFECT_CLASS_COLORS
+        self.class_names = DEFECT_CLASS_NAMES
+        self.confidence_threshold = confidence_threshold
+    
+    def load(self) -> bool:
+        """
+        Load the YOLO model.
+        
+        Returns:
+            bool: True if loaded successfully, False otherwise
+        """
+        try:
+            model_path = Path(self.model_path)
+            
+            if not model_path.exists():
+                logger.error(f"Model file does not exist: {model_path}")
+                return False
+            
+            logger.info(f"Loading defect model from {model_path}")
+            self.model = YOLO(str(model_path))
+            
+            # Move model to specified device
+            self.model.to(self.device)
+            
+            self._is_loaded = True
+            logger.info(f"Defect model loaded on {self.device}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Failed to load defect model: {e}")
+            self._is_loaded = False
+            return False
+    
+    def _draw_detections(
+        self,
+        image: np.ndarray,
+        boxes: np.ndarray,
+        confidences: np.ndarray,
+        class_ids: np.ndarray
+    ) -> Tuple[np.ndarray, list]:
+        """
+        Draw bounding boxes and labels on the image.
+        
+        Args:
+            image: Input image (BGR format)
+            boxes: Bounding boxes [N, 4] (xmin, ymin, xmax, ymax)
+            confidences: Confidence scores [N]
+            class_ids: Class IDs [N]
+        
+        Returns:
+            Tuple[np.ndarray, list]: (annotated image, detected class names)
+        """
+        annotated_image = image.copy()
+        detected_classes = []
+        
+        for box, confidence, class_id in zip(boxes, confidences, class_ids):
+            # Skip low confidence detections
+            if confidence < self.confidence_threshold:
+                continue
+            
+            xmin, ymin, xmax, ymax = map(int, box)
+            class_id = int(class_id)
+            
+            # Get class information
+            class_name = self.class_names.get(class_id, f"Class {class_id}")
+            color = self.class_colors.get(class_id, (255, 255, 255))
+            
+            detected_classes.append(class_name)
+            
+            # Draw bounding box
+            cv2.rectangle(
+                annotated_image,
+                (xmin, ymin),
+                (xmax, ymax),
+                color,
+                2
+            )
+            
+            # Draw label with confidence
+            label = f"{class_name}: {confidence:.2f}"
+            cv2.putText(
+                annotated_image,
+                label,
+                (xmin, ymin - 5),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                0.8,
+                color,
+                2,
+                lineType=cv2.LINE_AA
+            )
+        
+        return annotated_image, detected_classes
+    
+    def predict(self, image_path: str) -> Dict[str, Any]:
+        """
+        Detect defects in an image.
+        
+        Args:
+            image_path: Path to input image
+        
+        Returns:
+            Dict containing:
+                - 'success': Whether prediction succeeded
+                - 'annotated_image': QImage with bounding boxes
+                - 'detections': List of detection dictionaries
+                - 'class_counts': Count of each detected class
+                - 'primary_class': Most prevalent class
+                - 'error': Error message if failed
+        
+        Raises:
+            RuntimeError: If model is not loaded
+        """
+        if not self._is_loaded:
+            raise RuntimeError("Model not loaded. Call load() first.")
+        
+        try:
+            # Load image
+            image = cv2.imread(image_path)
+            if image is None:
+                raise ValueError(f"Could not load image: {image_path}")
+            
+            # Run YOLO inference
+            results = self.model.predict(image)
+            
+            detections = []
+            all_boxes = []
+            all_confidences = []
+            all_class_ids = []
+            
+            # Process results
+            for result in results:
+                if result.boxes is None or len(result.boxes) == 0:
+                    continue
+                
+                boxes = result.boxes.cpu().numpy()
+                
+                for box in boxes:
+                    confidence = float(box.conf[0])
+                    
+                    if confidence < self.confidence_threshold:
+                        continue
+                    
+                    xmin, ymin, xmax, ymax = map(float, box.xyxy[0])
+                    class_id = int(box.cls[0])
+                    class_name = self.class_names.get(class_id, f"Class {class_id}")
+                    
+                    detections.append({
+                        'bbox': [xmin, ymin, xmax, ymax],
+                        'confidence': confidence,
+                        'class_id': class_id,
+                        'class_name': class_name
+                    })
+                    
+                    all_boxes.append([xmin, ymin, xmax, ymax])
+                    all_confidences.append(confidence)
+                    all_class_ids.append(class_id)
+            
+            # Draw detections
+            if len(all_boxes) > 0:
+                annotated_image, detected_class_names = self._draw_detections(
+                    image,
+                    np.array(all_boxes),
+                    np.array(all_confidences),
+                    np.array(all_class_ids)
+                )
+            else:
+                annotated_image = image
+                detected_class_names = []
+            
+            # Count classes
+            class_counts = {}
+            for class_name in detected_class_names:
+                class_counts[class_name] = class_counts.get(class_name, 0) + 1
+            
+            # Determine primary class
+            if class_counts:
+                primary_class = max(class_counts.items(), key=lambda x: x[1])[0]
+            else:
+                primary_class = "No detections"
+            
+            # Convert to QImage
+            rgb_image = cv2.cvtColor(annotated_image, cv2.COLOR_BGR2RGB)
+            h, w, ch = rgb_image.shape
+            bytes_per_line = ch * w
+            q_image = QImage(
+                rgb_image.data,
+                w,
+                h,
+                bytes_per_line,
+                QImage.Format_RGB888
+            )
+            
+            logger.info(f"Detected {len(detections)} objects. Primary class: {primary_class}")
+            
+            return {
+                'success': True,
+                'annotated_image': q_image,
+                'detections': detections,
+                'class_counts': class_counts,
+                'primary_class': primary_class,
+                'total_detections': len(detections),
+                'error': None
+            }
+            
+        except Exception as e:
+            logger.error(f"Prediction failed: {e}")
+            return {
+                'success': False,
+                'annotated_image': None,
+                'detections': [],
+                'class_counts': {},
+                'primary_class': None,
+                'total_detections': 0,
+                'error': str(e)
+            }
+    
+    def predict_batch(self, image_paths: list) -> list:
+        """
+        Detect defects in multiple images.
+        
+        Args:
+            image_paths: List of paths to images
+        
+        Returns:
+            List[Dict]: List of prediction results
+        """
+        results = []
+        for image_path in image_paths:
+            result = self.predict(image_path)
+            results.append(result)
+        return results
+
+
+

+ 326 - 0
models/locule_model.py

@@ -0,0 +1,326 @@
+"""
+Locule Model Module
+
+YOLO-based segmentation model for durian locule counting.
+Detects and counts locules with colored mask overlays.
+"""
+
+from pathlib import Path
+from typing import Dict, Any, Optional, Tuple
+import logging
+
+import cv2
+import numpy as np
+from ultralytics import YOLO
+from PyQt5.QtGui import QImage
+
+from models.base_model import BaseModel
+from utils.config import (
+    LOCULE_MODEL_PATH,
+    LOCULE_COLORS,
+    YOLO_CONFIDENCE_THRESHOLD,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class LoculeModel(BaseModel):
+    """
+    YOLO-based locule segmentation and counting model.
+    
+    Detects individual locules in durian cross-section images
+    and applies colored masks using ROYGBIV color scheme.
+    
+    Attributes:
+        model: YOLO segmentation model instance
+        colors: ROYGBIV color scheme for masks (BGR format)
+        confidence_threshold: Minimum confidence for detections
+    """
+    
+    def __init__(
+        self,
+        model_path: Optional[str] = None,
+        device: str = "cuda",
+        confidence_threshold: float = YOLO_CONFIDENCE_THRESHOLD
+    ):
+        """
+        Initialize the locule counting model.
+        
+        Args:
+            model_path: Path to YOLO .pt segmentation model (optional)
+            device: Device to use ('cuda' or 'cpu')
+            confidence_threshold: Minimum confidence for detections (0.0-1.0)
+        """
+        if model_path is None:
+            model_path = str(LOCULE_MODEL_PATH)
+        
+        super().__init__(model_path, device)
+        self.colors = LOCULE_COLORS
+        self.confidence_threshold = confidence_threshold
+    
+    def load(self) -> bool:
+        """
+        Load the YOLO segmentation model.
+        
+        Returns:
+            bool: True if loaded successfully, False otherwise
+        """
+        try:
+            model_path = Path(self.model_path)
+            
+            if not model_path.exists():
+                logger.error(f"Model file does not exist: {model_path}")
+                return False
+            
+            logger.info(f"Loading locule model from {model_path}")
+            self.model = YOLO(str(model_path))
+            
+            # Move model to specified device
+            self.model.to(self.device)
+            
+            self._is_loaded = True
+            logger.info(f"Locule model loaded on {self.device}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Failed to load locule model: {e}")
+            self._is_loaded = False
+            return False
+    
+    def _apply_colored_masks(
+        self,
+        image: np.ndarray,
+        masks: Optional[np.ndarray],
+        boxes: np.ndarray,
+        confidences: np.ndarray,
+        class_names: list
+    ) -> Tuple[np.ndarray, int]:
+        """
+        Apply colored masks and bounding boxes to the image.
+        
+        Args:
+            image: Input image (BGR format)
+            masks: Segmentation masks [N, H, W] or None
+            boxes: Bounding boxes [N, 4]
+            confidences: Confidence scores [N]
+            class_names: Class names for each detection
+        
+        Returns:
+            Tuple[np.ndarray, int]: (masked image, valid detection count)
+        """
+        masked_image = image.copy()
+        valid_count = 0
+        
+        for i, (box, confidence, name) in enumerate(zip(boxes, confidences, class_names)):
+            # Skip low confidence detections
+            if confidence < self.confidence_threshold:
+                continue
+            
+            valid_count += 1
+            
+            xmin, ymin, xmax, ymax = map(int, box)
+            
+            # Get color from ROYGBIV (cycle if more than 7)
+            color = self.colors[i % len(self.colors)]
+            
+            # Draw bounding box
+            cv2.rectangle(
+                masked_image,
+                (xmin, ymin),
+                (xmax, ymax),
+                color,
+                2
+            )
+            
+            # Draw label with confidence
+            label = f"{name}: {confidence:.2f}"
+            cv2.putText(
+                masked_image,
+                label,
+                (xmin, ymin - 5),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                0.8,
+                color,
+                2,
+                lineType=cv2.LINE_AA
+            )
+            
+            # Apply mask if available
+            if masks is not None and i < len(masks):
+                mask = masks[i]
+                
+                # Resize mask to match image dimensions if needed
+                if mask.shape[:2] != masked_image.shape[:2]:
+                    mask = cv2.resize(
+                        mask,
+                        (masked_image.shape[1], masked_image.shape[0])
+                    )
+                
+                # Convert mask to binary
+                mask_binary = (mask * 255).astype(np.uint8)
+                
+                # Create colored overlay
+                colored_mask = np.zeros_like(masked_image, dtype=np.uint8)
+                for c in range(3):
+                    colored_mask[:, :, c] = mask_binary * (color[c] / 255)
+                
+                # Blend mask with image
+                masked_image = cv2.addWeighted(
+                    masked_image,
+                    1.0,
+                    colored_mask,
+                    0.5,
+                    0
+                )
+        
+        return masked_image, valid_count
+    
+    def predict(self, image_path: str) -> Dict[str, Any]:
+        """
+        Count locules in a durian cross-section image.
+        
+        Args:
+            image_path: Path to input image
+        
+        Returns:
+            Dict containing:
+                - 'success': Whether prediction succeeded
+                - 'annotated_image': QImage with colored masks
+                - 'locule_count': Number of detected locules
+                - 'detections': List of detection details
+                - 'error': Error message if failed
+        
+        Raises:
+            RuntimeError: If model is not loaded
+        """
+        if not self._is_loaded:
+            raise RuntimeError("Model not loaded. Call load() first.")
+        
+        try:
+            # Load image
+            image = cv2.imread(image_path)
+            if image is None:
+                raise ValueError(f"Could not load image: {image_path}")
+            
+            # Get original dimensions and preserve aspect ratio
+            orig_height, orig_width = image.shape[:2]
+            
+            # Resize to a standard size while maintaining aspect ratio
+            # Use 640 as max dimension (common YOLO input size)
+            max_dim = 640
+            if orig_width > orig_height:
+                new_width = max_dim
+                new_height = int((max_dim / orig_width) * orig_height)
+            else:
+                new_height = max_dim
+                new_width = int((max_dim / orig_height) * orig_width)
+            
+            image_resized = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
+            
+            # Run YOLO segmentation
+            results = self.model.predict(image_resized)
+            
+            detections = []
+            all_boxes = []
+            all_confidences = []
+            all_class_names = []
+            all_masks = None
+            
+            # Process results
+            for result in results:
+                if result.boxes is None or len(result.boxes) == 0:
+                    continue
+                
+                boxes = result.boxes.cpu().numpy()
+                masks = result.masks.data.cpu().numpy() if result.masks is not None else None
+                
+                if masks is not None:
+                    # Ensure mask count matches box count
+                    masks = masks[:len(boxes)]
+                    all_masks = masks
+                
+                for idx, box in enumerate(boxes):
+                    confidence = float(box.conf[0])
+                    
+                    if confidence < self.confidence_threshold:
+                        continue
+                    
+                    xmin, ymin, xmax, ymax = map(float, box.xyxy[0])
+                    class_id = int(box.cls[0])
+                    class_name = self.model.names.get(class_id, f"Locule {idx + 1}")
+                    
+                    detections.append({
+                        'bbox': [xmin, ymin, xmax, ymax],
+                        'confidence': confidence,
+                        'class_id': class_id,
+                        'class_name': class_name,
+                        'index': idx
+                    })
+                    
+                    all_boxes.append([xmin, ymin, xmax, ymax])
+                    all_confidences.append(confidence)
+                    all_class_names.append(class_name)
+            
+            # Apply colored masks
+            if len(all_boxes) > 0:
+                masked_image, locule_count = self._apply_colored_masks(
+                    image_resized,
+                    all_masks,
+                    np.array(all_boxes),
+                    np.array(all_confidences),
+                    all_class_names
+                )
+            else:
+                masked_image = image_resized
+                locule_count = 0
+            
+            # Convert to QImage
+            rgb_image = cv2.cvtColor(masked_image, cv2.COLOR_BGR2RGB)
+            h, w, ch = rgb_image.shape
+            bytes_per_line = ch * w
+            q_image = QImage(
+                rgb_image.data,
+                w,
+                h,
+                bytes_per_line,
+                QImage.Format_RGB888
+            )
+            
+            logger.info(f"Detected {locule_count} locules")
+            
+            return {
+                'success': True,
+                'annotated_image': q_image,
+                'locule_count': locule_count,
+                'detections': detections,
+                'error': None
+            }
+            
+        except Exception as e:
+            logger.error(f"Prediction failed: {e}")
+            return {
+                'success': False,
+                'annotated_image': None,
+                'locule_count': 0,
+                'detections': [],
+                'error': str(e)
+            }
+    
+    def predict_batch(self, image_paths: list) -> list:
+        """
+        Count locules in multiple images.
+        
+        Args:
+            image_paths: List of paths to images
+        
+        Returns:
+            List[Dict]: List of prediction results
+        """
+        results = []
+        for image_path in image_paths:
+            result = self.predict(image_path)
+            results.append(result)
+        return results
+
+
+

+ 551 - 0
models/maturity_model.py

@@ -0,0 +1,551 @@
+"""
+Maturity Model Module
+
+Multispectral maturity classification model for durian ripeness detection.
+Uses ResNet18 with 8-channel input for multispectral TIFF image analysis.
+"""
+
+from pathlib import Path
+from typing import Dict, Any, Optional, Tuple
+import logging
+
+import torch
+import torch.nn as nn
+import torchvision.models as M
+import numpy as np
+import cv2
+import tifffile
+from PyQt5.QtGui import QImage, QPixmap
+
+from models.base_model import BaseModel
+from utils.config import get_device
+
+logger = logging.getLogger(__name__)
+
+
+# ==================== MODEL ARCHITECTURE ====================
+
+def make_resnet18_8ch(num_classes: int):
+    """Initializes a ResNet18 model with an 8-channel input Conv2d layer."""
+    try:
+        m = M.resnet18(weights=None)
+    except TypeError:
+        m = M.resnet18(pretrained=False)
+    # Modify input convolution to accept 8 channels
+    m.conv1 = nn.Conv2d(8, 64, kernel_size=7, stride=2, padding=3, bias=False)
+    # Modify final fully-connected layer for the number of classes
+    m.fc = nn.Linear(m.fc.in_features, num_classes)
+    return m
+
+
+# ==================== TIFF LOADING HELPERS ====================
+
+def _read_first_page(path):
+    """Reads the first (or only) page from a TIFF file."""
+    with tifffile.TiffFile(path) as tif:
+        if len(tif.pages) > 1:
+            arr = tif.pages[0].asarray()
+        else:
+            arr = tif.asarray()
+    return arr
+
+
+def _split_2x4_mosaic_to_cube(img2d):
+    """Splits a 2D mosaic (H, W) into an 8-channel cube (h, w, 8)."""
+    if img2d.ndim != 2:
+        raise ValueError(f"Expected 2D mosaic, got {img2d.shape}.")
+    H, W = img2d.shape
+    if (H % 2 != 0) or (W % 4 != 0):
+        raise ValueError(f"Image shape {img2d.shape} not divisible by (2,4).")
+    h2, w4 = H // 2, W // 4
+    # Tiles are ordered row-by-row
+    tiles = [img2d[r * h2:(r + 1) * h2, c * w4:(c + 1) * w4] for r in range(2) for c in range(4)]
+    cube = np.stack(tiles, axis=-1)
+    return cube
+
+
+def load_and_split_mosaic(path, *, as_float=True, normalize="uint16", eps=1e-6):
+    """
+    Loads an 8-band TIFF. If it's a 2D mosaic, it splits it.
+    Applies 'uint16' normalization (divide by 65535.0) as used in training.
+    """
+    arr = _read_first_page(path)
+    if arr.ndim == 3 and arr.shape[-1] == 8:
+        cube = arr  # Already a cube
+    elif arr.ndim == 2:
+        cube = _split_2x4_mosaic_to_cube(arr)  # Mosaic, needs splitting
+    else:
+        raise ValueError(f"Unsupported TIFF shape {arr.shape}. Expect 2D mosaic or (H,W,8).")
+
+    cube = np.ascontiguousarray(cube)
+
+    if normalize == "uint16":
+        if cube.dtype == np.uint16:
+            cube = cube.astype(np.float32) / 65535.0
+        else:
+            # Fallback for non-uint16
+            cmin, cmax = float(cube.min()), float(cube.max())
+            denom = (cmax - cmin) if (cmax - cmin) > eps else 1.0
+            cube = (cube.astype(np.float32) - cmin) / denom
+
+    if as_float and cube.dtype != np.float32:
+        cube = cube.astype(np.float32)
+
+    return cube
+
+
+# ==================== MASKING PIPELINE ====================
+
+def _odd(k):
+    k = int(k)
+    return k if k % 2 == 1 else k + 1
+
+
+def _robust_u8(x, p_lo=1, p_hi=99, eps=1e-6):
+    lo, hi = np.percentile(x, [p_lo, p_hi])
+    if hi - lo < eps:
+        lo, hi = float(x.min()), float(x.max()) if float(x.max()) > float(x.min()) else (0.0, 1.0)
+    y = (x - lo) / (hi - lo + eps)
+    return (np.clip(y, 0, 1) * 255).astype(np.uint8)
+
+
+def _clear_border(mask255):
+    lab_n, lab, _, _ = cv2.connectedComponentsWithStats((mask255 > 0).astype(np.uint8), connectivity=4)
+    if lab_n <= 1:
+        return mask255
+    H, W = mask255.shape
+    edge = set(np.concatenate([lab[0, :], lab[H-1, :], lab[:, 0], lab[:, W-1]]).tolist())
+    edge.discard(0)
+    out = mask255.copy()
+    if edge:
+        out[np.isin(lab, list(edge))] = 0
+    return out
+
+
+def _largest_cc(mask255):
+    num, lab, stats, _ = cv2.connectedComponentsWithStats((mask255 > 0).astype(np.uint8), connectivity=4)
+    if num <= 1:
+        return mask255
+    largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
+    return ((lab == largest).astype(np.uint8) * 255)
+
+
+def _fill_holes(mask255):
+    inv = (mask255 == 0).astype(np.uint8)
+    num, lab, _, _ = cv2.connectedComponentsWithStats(inv, connectivity=4)
+    if num <= 1:
+        return mask255
+    H, W = mask255.shape
+    border_labels = set(np.concatenate([lab[0, :], lab[H-1, :], lab[:, 0], lab[:, W-1]]).tolist())
+    out = mask255.copy()
+    for k in range(1, num):
+        if k not in border_labels:
+            out[lab == k] = 255
+    return out
+
+
+def otsu_filled_robust_mask(
+    cube, *, band_index=4, blur_ksize=81, p_lo=1, p_hi=99,
+    close_ksize=5, close_iters=1, clear_border=False, auto_invert=True,
+):
+    """The full masking function from Cell 4."""
+    band = cube[..., band_index].astype(np.float32)
+    k = _odd(blur_ksize) if blur_ksize and blur_ksize > 1 else 0
+    bg = cv2.GaussianBlur(band, (k, k), 0) if k else np.median(band)
+    diff = band - bg
+    diff8 = _robust_u8(diff, p_lo=p_lo, p_hi=p_hi)
+    T, _ = cv2.threshold(diff8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
+    mask = (diff8 >= T).astype(np.uint8) * 255
+    if auto_invert:
+        H, W = mask.shape
+        b = max(1, min(H, W) // 10)
+        border_pixels = np.concatenate([
+            mask[:b, :].ravel(), mask[-b:, :].ravel(),
+            mask[:, :b].ravel(), mask[:, -b:].ravel()
+        ])
+        if border_pixels.size > 0 and float(border_pixels.mean()) > 127:
+            mask = 255 - mask
+    if close_ksize and close_ksize > 1:
+        se = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (_odd(close_ksize), _odd(close_ksize)))
+        for _ in range(max(1, int(close_iters))):
+            mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, se)
+    mask = _largest_cc(mask)
+    if clear_border:
+        mask = _clear_border(mask)
+    mask = _fill_holes(mask)
+    return mask.astype(np.uint8)
+
+
+def make_mask_filled(cube, *, band_index=4, blur_ksize=81, close_ksize=4, **kwargs):
+    """The 'SOT mask maker' from Cell 4b, used in your dataset."""
+    mask = otsu_filled_robust_mask(
+        cube,
+        band_index=band_index,
+        blur_ksize=blur_ksize,
+        close_ksize=close_ksize,
+        **kwargs
+    )
+    return mask
+
+
+# ==================== CROPPING HELPERS ====================
+
+def crop_square_from_mask(cube, mask, pad=8):
+    """Finds mask bbox, adds padding, and makes it square. Returns (crop, (y0,y1,x0,x1)))."""
+    H, W = mask.shape
+    ys, xs = np.where(mask > 0)
+    if len(ys) == 0:
+        side = int(0.8 * min(H, W))
+        y0 = (H - side) // 2
+        x0 = (W - side) // 2
+        y1, x1 = y0 + side, x0 + side
+    else:
+        y0, y1 = ys.min(), ys.max()
+        x0, x1 = xs.min(), xs.max()
+        side = max(y1 - y0 + 1, x1 - x0 + 1) + 2 * pad
+        cy, cx = (y0 + y1) // 2, (x0 + x1) // 2
+        y0 = max(0, cy - side // 2)
+        x0 = max(0, cx - side // 2)
+        y1 = min(H, y0 + side)
+        x1 = min(W, x0 + side)
+        y0 = max(0, y1 - side)
+        x0 = max(0, x1 - side)
+    return cube[y0:y1, x0:x1, :], (y0, y1, x0, x1)
+
+
+def crop_and_resize_square(cube, mask, size=256, pad=8):
+    """Crops and resizes to the final (size, size) square."""
+    crop, (y0, y1, x0, x1) = crop_square_from_mask(cube, mask, pad=pad)
+    crop = cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA)
+    return crop, (y0, y1, x0, x1)
+
+
+# ==================== GRAD-CAM ====================
+
+def gradcam(model, img_tensor, target_class=None, layer_name='layer4'):
+    """Computes Grad-CAM heatmap for the model."""
+    model.eval()
+    activations, gradients = {}, {}
+
+    def f_hook(_, __, out):
+        activations['v'] = out
+
+    def b_hook(_, grad_in, grad_out):
+        gradients['v'] = grad_out[0]
+
+    try:
+        layer = dict([*model.named_modules()])[layer_name]
+    except KeyError:
+        raise ValueError(f"Layer '{layer_name}' not found in model.")
+
+    fh = layer.register_forward_hook(f_hook)
+    bh = layer.register_backward_hook(b_hook)
+
+    # Forward pass
+    out = model(img_tensor)
+    if target_class is None:
+        target_class = int(out.argmax(1).item())
+
+    # Backward pass
+    loss = out[0, target_class]
+    model.zero_grad()
+    loss.backward()
+
+    # Get activations and gradients
+    acts = activations['v'][0]      # (C, H, W)
+    grads = gradients['v'][0]       # (C, H, W)
+
+    # Compute weights and CAM
+    weights = grads.mean(dim=(1, 2))  # (C,)
+    cam = (weights[:, None, None] * acts).sum(0).detach().cpu().numpy()
+    cam = np.maximum(cam, 0)
+    cam /= (cam.max() + 1e-6)
+
+    fh.remove()
+    bh.remove()
+    return cam, target_class
+
+
+# ==================== MATURITY MODEL CLASS ====================
+
+class MaturityModel(BaseModel):
+    """
+    Multispectral maturity classification model.
+    
+    Uses ResNet18 with 8-channel input for multispectral TIFF image analysis.
+    Processes 8-band multispectral images and predicts maturity/ripeness classes.
+    
+    Attributes:
+        model: ResNet18 model instance
+        class_names: List of class names
+        mean: Mean values for normalization (1, 1, 8)
+        std: Std values for normalization (1, 1, 8)
+        mask_band_index: Band index used for masking (default: 4, 860nm)
+        img_size: Target image size after preprocessing (default: 256)
+        img_pad: Padding for cropping (default: 8)
+    """
+    
+    def __init__(
+        self,
+        model_path: Optional[str] = None,
+        device: Optional[str] = None,
+        mask_band_index: int = 4,
+        img_size: int = 256,
+        img_pad: int = 8
+    ):
+        """
+        Initialize the maturity model.
+        
+        Args:
+            model_path: Path to the .pt model file (optional)
+            device: Device to use ('cuda' or 'cpu', optional)
+            mask_band_index: Band index for masking (default: 4, 860nm)
+            img_size: Target image size (default: 256)
+            img_pad: Padding for cropping (default: 8)
+        """
+        from utils.config import MATURITY_MODEL_PATH
+        
+        if model_path is None:
+            model_path = str(MATURITY_MODEL_PATH)
+        
+        if device is None:
+            device = get_device()
+        
+        super().__init__(model_path, device)
+        self.mask_band_index = mask_band_index
+        self.img_size = img_size
+        self.img_pad = img_pad
+        self.class_names = []
+        self.mean = None
+        self.std = None
+    
+    def load(self) -> bool:
+        """
+        Load the maturity model from checkpoint.
+        
+        Returns:
+            bool: True if loaded successfully, False otherwise
+        """
+        try:
+            model_path = Path(self.model_path)
+            
+            if not model_path.exists():
+                logger.error(f"Model file does not exist: {model_path}")
+                return False
+            
+            logger.info(f"Loading maturity model from {model_path}")
+            
+            # Load checkpoint
+            ckpt = torch.load(model_path, map_location=self.device)
+            
+            # Extract class names, mean, and std
+            self.class_names = ckpt['class_names']
+            # Reshape mean/std for broadcasting (1, 1, 8)
+            self.mean = ckpt['mean'].reshape(1, 1, 8)
+            self.std = ckpt['std'].reshape(1, 1, 8)
+            
+            # Re-create model architecture
+            self.model = make_resnet18_8ch(num_classes=len(self.class_names))
+            self.model.load_state_dict(ckpt['state_dict'])
+            self.model.to(self.device)
+            self.model.eval()
+            
+            self._is_loaded = True
+            logger.info(f"Maturity model loaded on {self.device}. Classes: {self.class_names}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Failed to load maturity model: {e}")
+            import traceback
+            logger.error(traceback.format_exc())
+            self._is_loaded = False
+            return False
+    
+    def _preprocess(self, tif_path):
+        """
+        Runs the full preprocessing pipeline.
+        
+        1. Load 8-band cube
+        2. Get mask
+        3. Crop and resize
+        4. Apply pixel mask
+        5. Normalize
+        6. Convert to Tensor
+        
+        Returns:
+            Tuple[torch.Tensor, np.ndarray]: (tensor for model, visual crop for display)
+        """
+        # 1. Load 8-band cube
+        cube = load_and_split_mosaic(str(tif_path), as_float=True, normalize="uint16")
+        
+        # 2. Get mask (using the exact params from training)
+        mask_full = make_mask_filled(
+            cube,
+            band_index=self.mask_band_index,
+            blur_ksize=81,
+            close_ksize=4
+        )
+        
+        # 3. Crop and resize
+        crop, (y0, y1, x0, x1) = crop_and_resize_square(
+            cube,
+            mask_full,
+            size=self.img_size,
+            pad=self.img_pad
+        )
+        
+        # 4. Apply pixel mask (critical step from training)
+        mask_crop = mask_full[y0:y1, x0:x1]
+        mask_resz = cv2.resize(mask_crop, (self.img_size, self.img_size), interpolation=cv2.INTER_NEAREST)
+        # Create a {0, 1} mask and broadcast it
+        m = (mask_resz > 0).astype(crop.dtype)
+        # Zero out the background pixels in all 8 channels
+        crop_masked = crop * m[..., None]
+        
+        # 5. Normalize
+        norm_crop = (crop_masked - self.mean) / self.std
+        
+        # 6. To Tensor
+        # (H, W, C) -> (C, H, W) -> (B, C, H, W)
+        x = torch.from_numpy(norm_crop).permute(2, 0, 1).unsqueeze(0).float().to(self.device)
+        
+        # Return tensor for model and the visible (un-normalized, masked) crop for visualization
+        return x, crop_masked
+    
+    def predict(self, tif_path: str) -> Dict[str, Any]:
+        """
+        Run prediction on a multispectral TIFF file.
+        
+        Args:
+            tif_path: Path to .tif file (8-band multispectral)
+        
+        Returns:
+            Dict containing:
+                - 'success': Whether prediction succeeded
+                - 'prediction': Predicted class name
+                - 'confidence': Confidence score (0-1)
+                - 'probabilities': Dictionary of class probabilities
+                - 'error': Error message if failed
+        
+        Raises:
+            RuntimeError: If model is not loaded
+        """
+        if not self._is_loaded:
+            raise RuntimeError("Model not loaded. Call load() first.")
+        
+        try:
+            # Preprocess
+            img_tensor, _ = self._preprocess(tif_path)
+            
+            # Run inference
+            with torch.no_grad():
+                logits = self.model(img_tensor)
+                probs = torch.softmax(logits, dim=1)[0]
+                pred_idx = logits.argmax(1).item()
+            
+            probs_cpu = probs.cpu().numpy()
+            
+            return {
+                'success': True,
+                'prediction': self.class_names[pred_idx],
+                'confidence': float(probs_cpu[pred_idx]),
+                'probabilities': {name: float(prob) for name, prob in zip(self.class_names, probs_cpu)},
+                'error': None
+            }
+            
+        except Exception as e:
+            logger.error(f"Prediction failed: {e}")
+            import traceback
+            logger.error(traceback.format_exc())
+            return {
+                'success': False,
+                'prediction': None,
+                'confidence': 0.0,
+                'probabilities': {},
+                'error': str(e)
+            }
+    
+    def run_gradcam(self, tif_path: str, band_to_show: Optional[int] = None) -> Tuple[Optional[QImage], Optional[str]]:
+        """
+        Run Grad-CAM visualization on a .tif file.
+        
+        Args:
+            tif_path: Path to input .tif file
+            band_to_show: Which of the 8 bands to use as background (default: mask_band_index)
+        
+        Returns:
+            Tuple[QImage, str]: (overlay_image, predicted_class_name) or (None, None) if failed
+        """
+        if not self._is_loaded:
+            raise RuntimeError("Model not loaded. Call load() first.")
+        
+        if band_to_show is None:
+            band_to_show = self.mask_band_index
+        
+        try:
+            img_tensor, crop_img = self._preprocess(tif_path)
+            
+            # Run Grad-CAM
+            heatmap, pred_idx = gradcam(
+                self.model,
+                img_tensor,
+                target_class=None,
+                layer_name='layer4'
+            )
+            
+            pred_name = self.class_names[pred_idx]
+            
+            # Create visualization
+            # Get the specific band to show (it's already float [0,1] and masked)
+            band_img = crop_img[..., band_to_show]
+            
+            # Normalize band to [0,255] uint8 for display
+            band_u8 = (np.clip(band_img, 0, 1) * 255).astype(np.uint8)
+            vis_img = cv2.cvtColor(band_u8, cv2.COLOR_GRAY2BGR)
+            
+            # Resize heatmap and apply colormap
+            hm_resized = (cv2.resize(heatmap, (self.img_size, self.img_size)) * 255).astype(np.uint8)
+            hm_color = cv2.applyColorMap(hm_resized, cv2.COLORMAP_JET)
+            
+            # Create overlay
+            overlay = cv2.addWeighted(vis_img, 0.6, hm_color, 0.4, 0)
+            
+            # Apply overlay only to fruit pixels
+            mask = (band_u8 > 0)
+            final_vis = np.zeros_like(vis_img)
+            final_vis[mask] = overlay[mask]
+            
+            # Convert to QImage
+            rgb_image = cv2.cvtColor(final_vis, cv2.COLOR_BGR2RGB)
+            rgb_image = np.ascontiguousarray(rgb_image)  # Ensure contiguous memory
+            h, w, ch = rgb_image.shape
+            bytes_per_line = ch * w
+            
+            # Create QImage with copied data to prevent memory issues
+            q_image = QImage(rgb_image.tobytes(), w, h, bytes_per_line, QImage.Format_RGB888)
+            q_image = q_image.copy()  # Make a deep copy to own the memory
+            
+            return q_image, pred_name
+            
+        except Exception as e:
+            logger.error(f"Grad-CAM failed: {e}")
+            import traceback
+            logger.error(traceback.format_exc())
+            return None, None
+    
+    def predict_batch(self, tif_paths: list) -> list:
+        """
+        Predict maturity for multiple TIFF files.
+        
+        Args:
+            tif_paths: List of paths to .tif files
+        
+        Returns:
+            List[Dict]: List of prediction results
+        """
+        results = []
+        for tif_path in tif_paths:
+            result = self.predict(tif_path)
+            results.append(result)
+        return results
+

+ 308 - 0
models/shape_model.py

@@ -0,0 +1,308 @@
+"""
+Shape Model Module
+
+YOLO-based shape classification model for durian quality assessment.
+Detects and classifies durian shape: Regular vs Irregular.
+"""
+
+from pathlib import Path
+from typing import Dict, Any, Optional
+import logging
+
+import cv2
+import numpy as np
+from ultralytics import YOLO
+from PyQt5.QtGui import QImage
+
+from models.base_model import BaseModel
+from utils.config import (
+    YOLO_CONFIDENCE_THRESHOLD,
+)
+
+logger = logging.getLogger(__name__)
+
+# Shape class constants
+SHAPE_CLASS_NAMES = {
+    0: "Irregular",
+    1: "Regular",
+}
+
+SHAPE_CLASS_COLORS = {
+    0: (86, 0, 254),      # Irregular - Purple
+    1: (0, 252, 199),     # Regular - Cyan
+}
+
+
+class ShapeModel(BaseModel):
+    """
+    YOLO-based shape classification model.
+    
+    Classifies durian shape into:
+    - Regular (Class 1)
+    - Irregular (Class 0)
+    
+    Attributes:
+        model: YOLO model instance
+        class_names: Name mapping for each class
+        class_colors: BGR color mapping for visualization
+        confidence_threshold: Minimum confidence for classifications
+    """
+    
+    def __init__(
+        self,
+        model_path: str,
+        device: str = "cuda",
+        confidence_threshold: float = YOLO_CONFIDENCE_THRESHOLD
+    ):
+        """
+        Initialize the shape classification model.
+        
+        Args:
+            model_path: Path to YOLO .pt model file (shape.pt)
+            device: Device to use ('cuda' or 'cpu')
+            confidence_threshold: Minimum confidence threshold (0.0-1.0)
+        """
+        super().__init__(model_path, device)
+        self.class_names = SHAPE_CLASS_NAMES
+        self.class_colors = SHAPE_CLASS_COLORS
+        self.confidence_threshold = confidence_threshold
+    
+    def load(self) -> bool:
+        """
+        Load the YOLO model.
+        
+        Returns:
+            bool: True if loaded successfully, False otherwise
+        """
+        try:
+            model_path = Path(self.model_path)
+            
+            if not model_path.exists():
+                logger.error(f"Shape model file does not exist: {model_path}")
+                return False
+            
+            logger.info(f"Loading shape model from {model_path}")
+            self.model = YOLO(str(model_path))
+            
+            # Move model to specified device
+            self.model.to(self.device)
+            
+            self._is_loaded = True
+            logger.info(f"Shape model loaded on {self.device}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Failed to load shape model: {e}")
+            self._is_loaded = False
+            return False
+    
+    def _draw_bounding_box(
+        self,
+        image: np.ndarray,
+        box: Any,
+        class_id: int,
+        confidence: float,
+        shape_class: str
+    ) -> np.ndarray:
+        """
+        Draw bounding box and label on the image.
+        
+        Args:
+            image: Input image (BGR format)
+            box: YOLO box object with coordinates
+            class_id: Class ID (0=Irregular, 1=Regular)
+            confidence: Confidence score
+            shape_class: Shape class name
+        
+        Returns:
+            Annotated image with bounding box
+        """
+        annotated = image.copy()
+        
+        # Get bounding box coordinates
+        xmin, ymin, xmax, ymax = map(int, box.xyxy[0])
+        
+        # Get color based on class
+        color = self.class_colors.get(class_id, (255, 255, 255))
+        
+        # Draw bounding box
+        cv2.rectangle(
+            annotated,
+            (xmin, ymin),
+            (xmax, ymax),
+            color,
+            2
+        )
+        
+        # Draw label with confidence
+        label = f"{shape_class}: {confidence:.2f}"
+        cv2.putText(
+            annotated,
+            label,
+            (xmin, ymin - 5),
+            cv2.FONT_HERSHEY_SIMPLEX,
+            0.8,
+            color,
+            2,
+            lineType=cv2.LINE_AA
+        )
+        
+        return annotated
+    
+    def predict(self, image_path: str) -> Dict[str, Any]:
+        """
+        Classify the shape of a durian in an image.
+        
+        Args:
+            image_path: Path to input image
+        
+        Returns:
+            Dict containing:
+                - 'success': Whether prediction succeeded
+                - 'shape_class': Detected shape (Regular/Irregular)
+                - 'class_id': Numeric class ID (0=Irregular, 1=Regular)
+                - 'confidence': Confidence score (0.0-1.0)
+                - 'annotated_image': QImage with bounding box (if detection model)
+                - 'error': Error message if failed
+        
+        Raises:
+            RuntimeError: If model is not loaded
+        """
+        if not self._is_loaded:
+            raise RuntimeError("Model not loaded. Call load() first.")
+        
+        try:
+            # Load image
+            image = cv2.imread(image_path)
+            if image is None:
+                raise ValueError(f"Could not load image: {image_path}")
+            
+            # Run YOLO inference
+            results = self.model.predict(image)
+            
+            shape_class = None
+            confidence = 0.0
+            class_id = None
+            annotated_image = None
+            
+            # Process results - shape.pt is a classification model with probs
+            for result in results:
+                # Check if results have classification probabilities
+                if result.probs is not None:
+                    logger.info(f"Shape model returned probs: {result.probs}")
+                    
+                    # Get top class
+                    class_id = int(result.probs.top1)  # Index of highest probability
+                    confidence = float(result.probs.top1conf.cpu().item())
+                    
+                    # Get class name
+                    shape_class = self.class_names.get(
+                        class_id,
+                        f"Unknown({class_id})"
+                    )
+                    
+                    logger.info(
+                        f"Shape classification (via probs): {shape_class} "
+                        f"(confidence: {confidence:.3f})"
+                    )
+                    
+                    return {
+                        'success': True,
+                        'shape_class': shape_class,
+                        'class_id': class_id,
+                        'confidence': confidence,
+                        'annotated_image': None,
+                        'error': None
+                    }
+                
+                # Fallback: Check for detection results with class names
+                # (in case shape.pt is detection model instead of classification)
+                if result.boxes is not None and len(result.boxes) > 0:
+                    logger.info(f"Shape model returned detection boxes")
+                    
+                    boxes = result.boxes.cpu().numpy()
+                    if len(boxes) > 0:
+                        box = boxes[0]
+                        class_id = int(box.cls[0])
+                        confidence = float(box.conf[0])
+                        
+                        # Get class name
+                        shape_class = self.class_names.get(
+                            class_id,
+                            f"Unknown({class_id})"
+                        )
+                        
+                        # Draw bounding box on image
+                        annotated_image_np = self._draw_bounding_box(
+                            image,
+                            box,
+                            class_id,
+                            confidence,
+                            shape_class
+                        )
+                        
+                        # Convert to QImage
+                        rgb_image = cv2.cvtColor(annotated_image_np, cv2.COLOR_BGR2RGB)
+                        h, w, ch = rgb_image.shape
+                        bytes_per_line = ch * w
+                        annotated_image = QImage(
+                            rgb_image.data,
+                            w,
+                            h,
+                            bytes_per_line,
+                            QImage.Format_RGB888
+                        )
+                        
+                        logger.info(
+                            f"Shape classification (via boxes): {shape_class} "
+                            f"(confidence: {confidence:.3f})"
+                        )
+                        
+                        return {
+                            'success': True,
+                            'shape_class': shape_class,
+                            'class_id': class_id,
+                            'confidence': confidence,
+                            'annotated_image': annotated_image,
+                            'error': None
+                        }
+            
+            # No results found
+            logger.warning(f"No shape classification results from model. Results: {results}")
+            return {
+                'success': False,
+                'shape_class': None,
+                'class_id': None,
+                'confidence': 0.0,
+                'annotated_image': None,
+                'error': 'No classification result from model'
+            }
+            
+        except Exception as e:
+            logger.error(f"Shape prediction failed: {e}")
+            import traceback
+            logger.error(traceback.format_exc())
+            return {
+                'success': False,
+                'shape_class': None,
+                'class_id': None,
+                'confidence': 0.0,
+                'annotated_image': None,
+                'error': str(e)
+            }
+    
+    def predict_batch(self, image_paths: list) -> list:
+        """
+        Classify shapes in multiple images.
+        
+        Args:
+            image_paths: List of paths to images
+        
+        Returns:
+            List[Dict]: List of prediction results
+        """
+        results = []
+        for image_path in image_paths:
+            result = self.predict(image_path)
+            results.append(result)
+        return results

+ 42 - 0
requirements.txt

@@ -0,0 +1,42 @@
+# Deep Learning Frameworks
+--extra-index-url https://download.pytorch.org/whl/cu128
+torch>=2.0.0
+torchvision>=0.15.0
+torchaudio>=2.0.0
+tensorflow>=2.17.0
+
+# Computer Vision & ML
+ultralytics>=8.3.36
+opencv-python>=4.10.0
+opencv-python-headless>=4.11.0
+
+# Scientific Computing
+numpy>=1.26.4,<2.0.0
+scipy>=1.14.1
+matplotlib>=3.7.0
+tifffile>=2024.0.0
+
+# GUI Framework
+PyQt5>=5.15.0
+PyQt5-Qt5>=5.15.0
+PyQt5-sip>=12.0.0
+
+# Platform Specific (Windows)
+pywin32>=308; sys_platform == 'win32'
+
+# System Utilities
+psutil>=5.9.0
+
+# GUI Automation for Camera Control
+pywinauto>=0.6.8; sys_platform == 'win32'
+pyautogui>=0.9.54
+
+# Report Generation & PDF Export
+reportlab>=4.0.0
+
+# Optional but recommended
+roboflow>=1.0.0
+Pillow>=10.0.0
+
+
+

+ 8 - 0
resources/__init__.py

@@ -0,0 +1,8 @@
+"""
+Resources Package
+
+Contains styling, assets, and other resources.
+"""
+
+
+

+ 491 - 0
resources/styles.py

@@ -0,0 +1,491 @@
+"""
+Styles Module
+
+Centralized stylesheet definitions for the application.
+"""
+
+from utils.config import UI_COLORS
+
+
+# ==================== COLOR CONSTANTS ====================
+
+# Export COLORS dictionary for tab modules
+COLORS = {
+    # Primary colors
+    'primary': '#3498db',
+    'primary_dark': '#2c3e50',
+    'primary_light': '#34495e',
+    
+    # Status colors
+    'success': '#27ae60',
+    'warning': '#f39c12',
+    'error': '#e74c3c',
+    'info': '#3498db',
+    
+    # Background colors
+    'bg_light': '#f8f9fa',
+    'bg_white': '#ffffff',
+    'bg_dark': '#2c3e50',
+    'card_bg': '#ffffff',
+    
+    # Text colors
+    'text_primary': '#2c3e50',
+    'text_secondary': '#7f8c8d',
+    'text_light': '#bdc3c7',
+    
+    # Border colors
+    'border': '#ddd',
+    'border_light': '#ecf0f1',
+    
+    # Phase 8: Classification colors
+    'ripeness_unripe': '#95a5a6',
+    'ripeness_midripe': '#f39c12',
+    'ripeness_ripe': '#27ae60',
+    'ripeness_overripe': '#e74c3c',
+    
+    # Phase 8: Panel header colors
+    'panel_rgb': '#3498db',
+    'panel_multispectral': '#8e44ad',
+    'panel_audio': '#16a085',
+    'panel_results': '#27ae60',
+    'panel_control': '#34495e',
+    
+    # Phase 8: Status indicators
+    'status_online': '#27ae60',
+    'status_offline': '#e74c3c',
+    'status_processing': '#f39c12',
+}
+
+# Export STYLES dictionary for common button styles
+STYLES = {
+    'button_primary': f"""
+        QPushButton {{
+            background-color: {COLORS['primary']};
+            border: 2px solid {COLORS['primary']};
+            border-radius: 8px;
+            color: white;
+            font-weight: bold;
+            font-size: 14px;
+            padding: 10px 20px;
+        }}
+        QPushButton:hover {{
+            background-color: #2980b9;
+        }}
+        QPushButton:pressed {{
+            background-color: #2471a3;
+        }}
+        QPushButton:disabled {{
+            background-color: {COLORS['text_secondary']};
+            border-color: {COLORS['text_secondary']};
+        }}
+    """,
+    'button_success': f"""
+        QPushButton {{
+            background-color: {COLORS['success']};
+            border: 2px solid {COLORS['success']};
+            border-radius: 8px;
+            color: white;
+            font-weight: bold;
+            font-size: 14px;
+            padding: 10px 20px;
+        }}
+        QPushButton:hover {{
+            background-color: #229954;
+        }}
+        QPushButton:pressed {{
+            background-color: #1e8449;
+        }}
+    """,
+}
+
+
+# ==================== MAIN WINDOW STYLES ====================
+
+MAIN_WINDOW_STYLE = f"""
+QMainWindow {{
+    background-color: {UI_COLORS['bg_light']};
+}}
+"""
+
+
+# ==================== GROUP BOX STYLES ====================
+
+GROUP_BOX_STYLE = f"""
+QGroupBox {{
+    font-weight: bold;
+    font-size: 16px;
+    border: 2px solid #ddd;
+    border-radius: 5px;
+    margin-top: 10px;
+    padding-top: 10px;
+    background-color: {UI_COLORS['bg_white']};
+}}
+QGroupBox::title {{
+    subcontrol-origin: margin;
+    left: 10px;
+    padding: 0 10px 0 10px;
+    color: {UI_COLORS['text_dark']};
+    background-color: {UI_COLORS['bg_panel']};
+}}
+"""
+
+
+# ==================== BUTTON STYLES ====================
+
+def get_button_style(
+    bg_color: str,
+    hover_color: str,
+    pressed_color: str = None,
+    border_color: str = None,
+    text_color: str = "white"
+) -> str:
+    """
+    Generate a button stylesheet with the given colors.
+    
+    Args:
+        bg_color: Background color
+        hover_color: Hover state color
+        pressed_color: Pressed state color (defaults to hover_color)
+        border_color: Border color (defaults to hover_color)
+        text_color: Text color (defaults to white)
+    
+    Returns:
+        str: Complete button stylesheet
+    """
+    if pressed_color is None:
+        pressed_color = hover_color
+    if border_color is None:
+        border_color = hover_color
+    
+    return f"""
+    QPushButton {{
+        background-color: {bg_color};
+        border: 2px solid {border_color};
+        border-radius: 8px;
+        color: {text_color};
+        font-weight: bold;
+        font-size: 16px;
+        padding: 15px;
+    }}
+    QPushButton:hover {{
+        background-color: {hover_color};
+    }}
+    QPushButton:pressed {{
+        background-color: {pressed_color};
+    }}
+    """
+
+
+RIPENESS_BUTTON_STYLE = get_button_style(
+    UI_COLORS['btn_green'],
+    UI_COLORS['btn_green_hover'],
+    "#1e8449"
+)
+
+QUALITY_BUTTON_STYLE = get_button_style(
+    UI_COLORS['btn_blue'],
+    UI_COLORS['btn_blue_hover'],
+    "#2471a3"
+)
+
+CALIBRATION_BUTTON_STYLE = get_button_style(
+    UI_COLORS['btn_orange'],
+    UI_COLORS['btn_orange_hover'],
+    "#d68910"
+)
+
+BATCH_BUTTON_STYLE = get_button_style(
+    UI_COLORS['btn_purple'],
+    UI_COLORS['btn_purple_hover'],
+    "#7d3c98"
+)
+
+LOCULE_BUTTON_STYLE = get_button_style(
+    "#16a085",  # Teal
+    "#138d75",  # Darker teal
+    "#117864"   # Even darker teal
+)
+
+EMERGENCY_BUTTON_STYLE = f"""
+QPushButton {{
+    background-color: {UI_COLORS['btn_red']};
+    border: 2px solid {UI_COLORS['btn_red_hover']};
+    border-radius: 15px;
+    color: white;
+    font-weight: bold;
+    font-size: 14px;
+}}
+QPushButton:hover {{
+    background-color: {UI_COLORS['btn_red_hover']};
+}}
+"""
+
+STANDARD_BUTTON_STYLE = f"""
+QPushButton {{
+    background-color: {UI_COLORS['primary_light']};
+    border: 1px solid {UI_COLORS['primary_dark']};
+    border-radius: 4px;
+    color: white;
+    padding: 10px;
+    font-size: 16px;
+}}
+QPushButton:hover {{
+    background-color: {UI_COLORS['primary_dark']};
+}}
+"""
+
+
+# ==================== TABLE STYLES ====================
+
+TABLE_STYLE = f"""
+QTableWidget {{
+    gridline-color: #ddd;
+    background-color: {UI_COLORS['bg_white']};
+    alternate-background-color: {UI_COLORS['bg_light']};
+    font-size: 16px;
+}}
+QHeaderView::section {{
+    background-color: {UI_COLORS['bg_light']};
+    padding: 8px;
+    border: 1px solid #ddd;
+    font-weight: bold;
+    font-size: 16px;
+}}
+"""
+
+
+# ==================== TAB WIDGET STYLES ====================
+
+TAB_WIDGET_STYLE = f"""
+QTabWidget::pane {{
+    border: 1px solid {UI_COLORS['primary_light']};
+    background-color: {UI_COLORS['bg_white']};
+}}
+QTabBar::tab {{
+    background-color: {UI_COLORS['primary_light']};
+    color: {UI_COLORS['bg_panel']};
+    padding: 12px 16px;
+    margin-right: 2px;
+    font-size: 14px;
+    font-weight: bold;
+    min-width: 120px;
+    max-width: 180px;
+}}
+QTabBar::tab:selected {{
+    background-color: {UI_COLORS['accent_blue']};
+    color: white;
+}}
+QTabBar::tab:hover {{
+    background-color: {UI_COLORS['accent_blue']};
+    opacity: 0.8;
+}}
+QTabBar::left-button, QTabBar::right-button {{
+    background-color: {UI_COLORS['primary_light']};
+    border: none;
+}}
+"""
+
+
+# ==================== HEADER STYLES ====================
+
+HEADER_STYLE = f"""
+QFrame {{
+    background-color: {UI_COLORS['primary_dark']};
+}}
+QLabel {{
+    color: white;
+}}
+"""
+
+HEADER_ICON_BUTTON_STYLE = f"""
+QPushButton {{
+    background-color: transparent;
+    border: 2px solid transparent;
+    border-radius: 8px;
+    color: white;
+    font-size: 20px;
+    padding: 8px 12px;
+    min-width: 45px;
+    min-height: 45px;
+}}
+QPushButton:hover {{
+    background-color: rgba(255, 255, 255, 0.1);
+    border: 2px solid rgba(255, 255, 255, 0.3);
+}}
+QPushButton:pressed {{
+    background-color: rgba(255, 255, 255, 0.2);
+}}
+"""
+
+
+# ==================== STATUS BAR STYLES ====================
+
+STATUS_BAR_STYLE = f"""
+QFrame {{
+    background-color: {UI_COLORS['primary_light']};
+}}
+QLabel {{
+    color: {UI_COLORS['bg_panel']};
+    font-size: 16px;
+}}
+"""
+
+
+# ==================== LABEL STYLES ====================
+
+def get_label_style(
+    color: str = None,
+    font_size: int = 16,
+    font_weight: str = "normal",
+    background: str = None
+) -> str:
+    """
+    Generate a label stylesheet.
+    
+    Args:
+        color: Text color
+        font_size: Font size in pixels
+        font_weight: Font weight (normal, bold)
+        background: Background color
+    
+    Returns:
+        str: Label stylesheet
+    """
+    styles = []
+    
+    if color:
+        styles.append(f"color: {color};")
+    if font_size:
+        styles.append(f"font-size: {font_size}px;")
+    if font_weight:
+        styles.append(f"font-weight: {font_weight};")
+    if background:
+        styles.append(f"background-color: {background};")
+    
+    return " ".join(styles)
+
+
+HEADER_LABEL_STYLE = get_label_style(
+    color=UI_COLORS['text_dark'],
+    font_size=16,
+    font_weight="bold"
+)
+
+INFO_LABEL_STYLE = get_label_style(
+    color=UI_COLORS['text_medium'],
+    font_size=16
+)
+
+STATUS_LABEL_STYLE = get_label_style(
+    color=UI_COLORS['text_dark'],
+    font_size=16
+)
+
+
+# ==================== FEED/DISPLAY STYLES ====================
+
+def get_feed_style(bg_color: str, border_color: str) -> str:
+    """
+    Generate a camera feed display stylesheet.
+    
+    Args:
+        bg_color: Background color
+        border_color: Border color
+    
+    Returns:
+        str: Feed display stylesheet
+    """
+    return f"""
+    background-color: {bg_color};
+    border: 1px solid {border_color};
+    color: white;
+    font-size: 16px;
+    """
+
+
+RGB_FEED_STYLE = get_feed_style(UI_COLORS['primary_dark'], UI_COLORS['primary_light'])
+MS_FEED_STYLE = get_feed_style(UI_COLORS['btn_purple'], "#9b59b6")
+THERMAL_FEED_STYLE = get_feed_style(UI_COLORS['text_medium'], "#95a5a6")
+AUDIO_FEED_STYLE = get_feed_style("#16a085", "#1abc9c")
+
+
+# ==================== APPLICATION STYLE ====================
+
+APPLICATION_STYLE = """
+QApplication {
+    font-family: Arial, sans-serif;
+}
+"""
+
+
+# ==================== LOADING OVERLAY STYLE ====================
+
+LOADING_STYLE = """
+background-color: rgba(255, 255, 255, 0%);
+"""
+
+
+# ==================== UTILITY FUNCTIONS ====================
+
+def get_status_indicator_style(status: str) -> str:
+    """
+    Get the stylesheet for a status indicator.
+    
+    Args:
+        status: Status type ('online', 'offline', 'updating')
+    
+    Returns:
+        str: Stylesheet for the status indicator
+    """
+    status_colors = {
+        "online": UI_COLORS['online'],
+        "offline": UI_COLORS['offline'],
+        "updating": UI_COLORS['updating'],
+    }
+    
+    color = status_colors.get(status, UI_COLORS['text_medium'])
+    
+    return f"background-color: {color}; border-radius: 6px;"
+
+
+def get_grade_color(grade: str) -> str:
+    """
+    Get the color for a quality grade.
+    
+    Args:
+        grade: Grade letter ('A', 'B', 'C', etc.)
+    
+    Returns:
+        str: Color hex code
+    """
+    # Handle None or empty grades
+    if not grade:
+        return UI_COLORS['text_dark']
+    
+    grade_colors = {
+        "A": UI_COLORS['grade_a'],
+        "B": UI_COLORS['grade_b'],
+        "C": UI_COLORS['grade_c'],
+    }
+    
+    return grade_colors.get(str(grade).upper(), UI_COLORS['text_dark'])
+
+
+def get_ripeness_color(ripeness: str) -> str:
+    """
+    Get the color for a ripeness classification.
+    
+    Args:
+        ripeness: Ripeness type ('Ripe', 'Overripe', 'Unripe')
+    
+    Returns:
+        str: Color hex code
+    """
+    ripeness_colors = {
+        "Ripe": UI_COLORS['online'],
+        "Overripe": UI_COLORS['updating'],
+        "Unripe": UI_COLORS['text_medium'],
+    }
+    
+    return ripeness_colors.get(ripeness, UI_COLORS['text_dark'])
+

+ 8 - 0
ui/__init__.py

@@ -0,0 +1,8 @@
+"""
+UI Components Package
+
+Contains all user interface components including main window, panels, and widgets.
+"""
+
+
+

+ 64 - 0
ui/components/__init__.py

@@ -0,0 +1,64 @@
+"""
+UI Components Module
+
+Reusable components for building user interfaces.
+"""
+
+# Report generation components
+from ui.components.report_generator import (
+    generate_basic_report,
+    generate_model_report,
+    generate_comprehensive_report,
+    extract_report_content
+)
+
+# Report sections builder
+from ui.components.report_sections import (
+    create_report_info_section,
+    create_empty_results_section,
+    create_model_results_section,
+    create_analysis_results_section,
+    create_input_data_section,
+    create_input_data_with_gradcam,
+    create_visualizations_section
+)
+
+# Visualization widgets
+from ui.components.report_visualizations import (
+    create_gradcam_widget,
+    create_image_preview_widget,
+    create_thermal_widget,
+    create_audio_spectrogram_widget,
+    create_clickable_image_widget,
+    convert_pixmap_to_pil
+)
+
+# Export/Print functionality
+from ui.components.pdf_exporter import PDFExporter
+from ui.components.report_printer import ReportPrinter
+
+__all__ = [
+    # Report generation
+    'generate_basic_report',
+    'generate_model_report',
+    'generate_comprehensive_report',
+    'extract_report_content',
+    # Report sections
+    'create_report_info_section',
+    'create_empty_results_section',
+    'create_model_results_section',
+    'create_analysis_results_section',
+    'create_input_data_section',
+    'create_input_data_with_gradcam',
+    'create_visualizations_section',
+    # Visualizations
+    'create_gradcam_widget',
+    'create_image_preview_widget',
+    'create_thermal_widget',
+    'create_audio_spectrogram_widget',
+    'create_clickable_image_widget',
+    'convert_pixmap_to_pil',
+    # Export/Print
+    'PDFExporter',
+    'ReportPrinter',
+]

+ 249 - 0
ui/components/pdf_exporter.py

@@ -0,0 +1,249 @@
+"""
+PDF Exporter
+
+Handles PDF report generation and export functionality using ReportLab.
+"""
+
+import os
+import time
+import tempfile
+from datetime import datetime
+from typing import Dict, List, Tuple, Optional
+
+from PyQt5.QtWidgets import QMessageBox, QFileDialog
+from PyQt5.QtGui import QPixmap
+
+try:
+    from reportlab.lib import colors
+    from reportlab.lib.pagesizes import A4
+    from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+    from reportlab.lib.units import inch
+    from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak
+    from reportlab.lib.enums import TA_CENTER, TA_LEFT
+    HAS_REPORTLAB = True
+except ImportError:
+    HAS_REPORTLAB = False
+
+
+class PDFExporter:
+    """
+    Handles PDF report generation and export.
+    
+    Attributes:
+        has_reportlab: Boolean indicating if ReportLab is available
+    """
+    
+    def __init__(self):
+        """Initialize the PDF exporter."""
+        self.has_reportlab = HAS_REPORTLAB
+    
+    def export_report(
+        self,
+        parent_widget,
+        report_id: str,
+        report_content: Dict,
+        visualizations: List[Tuple[str, QPixmap]],
+        include_visualizations: bool = True
+    ) -> bool:
+        """
+        Export report as PDF.
+        
+        Args:
+            parent_widget: Parent widget for dialogs
+            report_id: Report ID for filename
+            report_content: Dictionary with report text content
+            visualizations: List of (title, QPixmap) tuples
+            include_visualizations: Whether to include visualizations
+            
+        Returns:
+            bool: True if export succeeded, False otherwise
+        """
+        if not self.has_reportlab:
+            QMessageBox.critical(
+                parent_widget,
+                "Library Not Available",
+                "ReportLab is not installed. Please install it to use PDF export:\n\n"
+                "pip install reportlab"
+            )
+            return False
+        
+        # Get file path from user
+        file_path, _ = QFileDialog.getSaveFileName(
+            parent_widget,
+            "Save PDF Report",
+            f"Report_{report_id or datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf",
+            "PDF Files (*.pdf);;All Files (*.*)",
+            options=QFileDialog.DontUseNativeDialog
+        )
+        
+        if not file_path:
+            return False  # User cancelled
+        
+        try:
+            # Create PDF document
+            doc = SimpleDocTemplate(file_path, pagesize=A4)
+            story = []
+            
+            # Add title
+            styles = getSampleStyleSheet()
+            title_style = ParagraphStyle(
+                'CustomTitle',
+                parent=styles['Heading1'],
+                fontSize=18,
+                textColor=colors.HexColor('#2c3e50'),
+                spaceAfter=20,
+                alignment=TA_CENTER
+            )
+            story.append(Paragraph("Durian Analysis Report", title_style))
+            story.append(Spacer(1, 0.2*inch))
+            
+            # Report Information
+            heading_style = ParagraphStyle(
+                'CustomHeading',
+                parent=styles['Heading2'],
+                fontSize=12,
+                textColor=colors.HexColor('#34495e'),
+                spaceAfter=10
+            )
+            story.append(Paragraph("Report Information", heading_style))
+            
+            info_data = [
+                ['Report ID:', report_content['report_id']],
+                ['Generated:', report_content['generated']]
+            ]
+            info_table = Table(info_data, colWidths=[2*inch, 3.5*inch])
+            info_table.setStyle(TableStyle([
+                ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#ecf0f1')),
+                ('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
+                ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+                ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
+                ('FONTSIZE', (0, 0), (-1, -1), 10),
+                ('BOTTOMPADDING', (0, 0), (-1, -1), 8),
+                ('TOPPADDING', (0, 0), (-1, -1), 8),
+                ('GRID', (0, 0), (-1, -1), 1, colors.grey)
+            ]))
+            story.append(info_table)
+            story.append(Spacer(1, 0.3*inch))
+            
+            # Analysis Results
+            story.append(Paragraph("Analysis Results", heading_style))
+            
+            results = report_content['results']
+            results_data = [['Metric', 'Value']]
+            
+            if results.get('locule_count') is not None:
+                results_data.append(['Locule Count', f"{results['locule_count']} locules"])
+            
+            if results.get('defect_status'):
+                results_data.append(['Defect Status', f"{results['defect_status']} ({results.get('total_detections', 0)} detections)"])
+            
+            if results.get('shape_class'):
+                results_data.append(['Shape', f"{results['shape_class']} ({results.get('shape_confidence', 0)*100:.1f}%)"])
+            
+            if results.get('maturity_class'):
+                results_data.append(['Maturity', f"{results['maturity_class']} ({results.get('maturity_confidence', 0)*100:.1f}%)"])
+            
+            if results.get('ripeness_class'):
+                results_data.append(['Ripeness', f"{results['ripeness_class']} ({results.get('ripeness_confidence', 0)*100:.1f}%)"])
+            
+            results_table = Table(results_data, colWidths=[2*inch, 3.5*inch])
+            results_table.setStyle(TableStyle([
+                ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')),
+                ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
+                ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+                ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
+                ('FONTSIZE', (0, 0), (-1, -1), 10),
+                ('BOTTOMPADDING', (0, 0), (-1, -1), 8),
+                ('TOPPADDING', (0, 0), (-1, -1), 8),
+                ('GRID', (0, 0), (-1, -1), 1, colors.grey),
+                ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ecf0f1')])
+            ]))
+            story.append(results_table)
+            story.append(Spacer(1, 0.3*inch))
+            
+            # Overall Grade
+            grade_color = {'A': '#27ae60', 'B': '#f39c12', 'C': '#e74c3c'}[report_content['grade']]
+            grade_data = [
+                [f"Overall Grade: Class {report_content['grade']}"],
+                [report_content['grade_description']]
+            ]
+            grade_table = Table(grade_data, colWidths=[5.5*inch])
+            grade_table.setStyle(TableStyle([
+                ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor(grade_color)),
+                ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
+                ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+                ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
+                ('FONTSIZE', (0, 0), (-1, 0), 14),
+                ('FONTSIZE', (0, 1), (-1, 1), 10),
+                ('BOTTOMPADDING', (0, 0), (-1, -1), 10),
+                ('TOPPADDING', (0, 0), (-1, -1), 10),
+                ('GRID', (0, 0), (-1, -1), 2, colors.HexColor(grade_color))
+            ]))
+            story.append(grade_table)
+            story.append(Spacer(1, 0.3*inch))
+            
+            # Add visualizations if available
+            if include_visualizations and visualizations:
+                story.append(PageBreak())
+                story.append(Paragraph("Visualizations", heading_style))
+                story.append(Spacer(1, 0.2*inch))
+                
+                for viz_idx, (viz_title, viz_pixmap) in enumerate(visualizations):
+                    # Scale pixmap to PDF size
+                    max_width_points = 4.5*inch
+                    max_height_points = 4*inch
+                    pixmap = viz_pixmap
+                    
+                    # Scale if necessary, maintaining aspect ratio
+                    if pixmap.width() > max_width_points or pixmap.height() > max_height_points:
+                        aspect_ratio = pixmap.width() / pixmap.height()
+                        # Scale to fit within bounds
+                        new_height = int(max_height_points)
+                        new_width = int(new_height * aspect_ratio)
+                        if new_width > max_width_points:
+                            new_width = int(max_width_points)
+                            new_height = int(new_width / aspect_ratio)
+                        pixmap = pixmap.scaled(new_width, new_height, 1, 1)  # Qt.KeepAspectRatio, Qt.SmoothTransformation
+                    
+                    # Save pixmap to temporary image file
+                    temp_img_path = os.path.join(tempfile.gettempdir(), f"report_viz_{int(time.time()*1000000)}_{viz_idx}.png")
+                    pixmap.save(temp_img_path, "PNG")
+                    
+                    try:
+                        # Add to PDF with calculated dimensions to fit properly
+                        story.append(Paragraph(viz_title, heading_style))
+                        # Use width with height=None to let ReportLab calculate proportional height
+                        img = RLImage(temp_img_path, width=4*inch, height=None)
+                        story.append(img)
+                        story.append(Spacer(1, 0.2*inch))
+                    except Exception as e:
+                        print(f"Error adding image to PDF: {e}")
+            
+            # Build PDF
+            doc.build(story)
+            
+            # Auto-open the PDF file
+            try:
+                if os.name == 'nt':  # Windows
+                    os.startfile(file_path)
+                elif os.name == 'posix':  # macOS/Linux
+                    import subprocess
+                    subprocess.Popen(['open', file_path])
+            except Exception:
+                pass
+            
+            QMessageBox.information(
+                parent_widget,
+                "PDF Exported Successfully",
+                f"Report exported to:\n{file_path}"
+            )
+            
+            return True
+        
+        except Exception as e:
+            QMessageBox.critical(
+                parent_widget,
+                "PDF Export Error",
+                f"Error creating PDF:\n{str(e)}"
+            )
+            return False

+ 222 - 0
ui/components/report_generator.py

@@ -0,0 +1,222 @@
+"""
+Report Generator
+
+Orchestrates report generation by combining section builders and visualizations.
+"""
+
+from typing import Dict, Optional
+from datetime import datetime
+
+from PyQt5.QtWidgets import QVBoxLayout
+from PyQt5.QtGui import QImage
+
+from ui.components.report_sections import (
+    create_report_info_section,
+    create_empty_results_section,
+    create_model_results_section,
+    create_analysis_results_section,
+    create_input_data_section,
+    create_input_data_with_gradcam,
+    create_visualizations_section,
+    get_local_datetime_string
+)
+
+
+def generate_basic_report(
+    layout: QVBoxLayout,
+    input_data: Dict[str, str]
+) -> Dict:
+    """
+    Generate a basic analysis report without model results.
+    
+    Args:
+        layout: QVBoxLayout to add sections to
+        input_data: Dictionary with input file paths
+        
+    Returns:
+        Dictionary with current grade and description
+    """
+    # Report Info Section
+    info_group = create_report_info_section()
+    layout.addWidget(info_group)
+    
+    # Analysis Results Section (Empty)
+    results_group = create_empty_results_section()
+    layout.addWidget(results_group)
+    
+    # Input Data Section
+    data_group = create_input_data_section(input_data)
+    layout.addWidget(data_group)
+    
+    return {
+        'grade': 'B',
+        'description': 'Basic report generated without model analysis'
+    }
+
+
+def generate_model_report(
+    layout: QVBoxLayout,
+    input_data: Dict[str, str],
+    gradcam_image: QImage,
+    predicted_class: str,
+    confidence: float,
+    probabilities: Dict[str, float]
+) -> Dict:
+    """
+    Generate analysis report with actual multispectral model results.
+    
+    Args:
+        layout: QVBoxLayout to add sections to
+        input_data: Dictionary with input file paths
+        gradcam_image: QImage of the Grad-CAM visualization
+        predicted_class: Predicted maturity class from model
+        confidence: Model confidence (0-1 scale)
+        probabilities: Dictionary of class probabilities
+        
+    Returns:
+        Dictionary with current grade and description
+    """
+    # Report Info Section
+    info_group = create_report_info_section()
+    layout.addWidget(info_group)
+    
+    # Analysis Results Section (WITH REAL MODEL DATA)
+    results_group = create_model_results_section(
+        predicted_class,
+        confidence,
+        probabilities
+    )
+    layout.addWidget(results_group)
+    
+    # Input Data Section (WITH GRADCAM VISUALIZATION)
+    data_group = create_input_data_with_gradcam(input_data, gradcam_image)
+    layout.addWidget(data_group)
+    
+    # Determine grade
+    grade_map = {
+        'Immature': 'C',
+        'Mature': 'A',
+        'Overmature': 'B'
+    }
+    grade = grade_map.get(predicted_class, 'B')
+    
+    return {
+        'grade': grade,
+        'description': f'Model prediction: {predicted_class} ({confidence*100:.1f}% confidence)'
+    }
+
+
+def generate_comprehensive_report(
+    layout: QVBoxLayout,
+    input_data: Dict[str, str],
+    results: Dict,
+    report_id: Optional[str] = None
+) -> Dict:
+    """
+    Generate comprehensive report with RGB models and multispectral analysis.
+    
+    Args:
+        layout: QVBoxLayout to add sections to
+        input_data: Dictionary with input file paths
+        results: Dictionary with processing results from all models
+                 Keys: 'defect', 'locule', 'maturity', 'shape', 'audio'
+        report_id: Optional report ID to display
+        
+    Returns:
+        Dictionary with current grade and description
+    """
+    # Report Info Section
+    info_group = create_report_info_section(report_id)
+    layout.addWidget(info_group)
+    
+    # Combined Analysis Results Section
+    results_group = create_analysis_results_section(results)
+    layout.addWidget(results_group)
+    
+    # Extract grade information from results group
+    # (Grade is calculated within create_analysis_results_section)
+    locule_count = 0
+    has_defects = False
+    shape_class = None
+    maturity_class = None
+    
+    if 'locule' in results and not results['locule'].get('error'):
+        locule_count = results['locule'].get('locule_count', 0)
+    
+    if 'defect' in results and not results['defect'].get('error'):
+        primary_class = results['defect'].get('primary_class', 'Unknown')
+        has_defects = (primary_class != "No Defects")
+    
+    if 'shape' in results and not results['shape'].get('error'):
+        shape_class = results['shape'].get('shape_class', 'Unknown')
+    
+    if 'maturity' in results and not results['maturity'].get('error'):
+        maturity_class = results['maturity'].get('class_name', 'Unknown')
+    
+    # Import here to avoid circular import
+    from utils.grade_calculator import calculate_durian_grade
+    grade, grade_description = calculate_durian_grade(locule_count, has_defects, shape_class, maturity_class)
+    
+    # Input Data with Visualizations Section
+    data_group = create_visualizations_section(input_data, results)
+    layout.addWidget(data_group)
+    
+    return {
+        'grade': grade,
+        'description': grade_description
+    }
+
+
+def extract_report_content(
+    report_id: str,
+    grade: str,
+    grade_description: str,
+    results: Optional[Dict] = None
+) -> Dict:
+    """
+    Extract report content for export/print.
+    
+    Args:
+        report_id: Report ID
+        grade: Overall grade letter (A, B, or C)
+        grade_description: Grade description text
+        results: Optional results dictionary from models
+        
+    Returns:
+        Dictionary with all report text content
+    """
+    content = {
+        'report_id': report_id or f"DUR-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
+        'generated': get_local_datetime_string(),
+        'grade': grade,
+        'grade_description': grade_description,
+        'results': {}
+    }
+    
+    if results:
+        # Locule analysis
+        if 'locule' in results and not results['locule'].get('error'):
+            content['results']['locule_count'] = results['locule'].get('locule_count', 0)
+        
+        # Defect analysis
+        if 'defect' in results and not results['defect'].get('error'):
+            content['results']['defect_status'] = results['defect'].get('primary_class', 'Unknown')
+            content['results']['total_detections'] = results['defect'].get('total_detections', 0)
+        
+        # Shape analysis
+        if 'shape' in results and not results['shape'].get('error'):
+            content['results']['shape_class'] = results['shape'].get('shape_class', 'Unknown')
+            content['results']['shape_confidence'] = results['shape'].get('confidence', 0)
+        
+        # Maturity analysis
+        if 'maturity' in results and not results['maturity'].get('error'):
+            content['results']['maturity_class'] = results['maturity'].get('class_name', 'Unknown')
+            content['results']['maturity_confidence'] = results['maturity'].get('confidence', 0)
+        
+        # Audio ripeness analysis
+        if 'audio' in results and not results['audio'].get('error'):
+            content['results']['ripeness_class'] = results['audio'].get('ripeness_class', 'Unknown')
+            content['results']['ripeness_confidence'] = results['audio'].get('confidence', 0)
+            content['results']['knock_count'] = results['audio'].get('knock_count', 0)
+    
+    return content

+ 216 - 0
ui/components/report_printer.py

@@ -0,0 +1,216 @@
+"""
+Report Printer
+
+Handles printing of reports to physical printers using QPrinter.
+"""
+
+from typing import Dict, List, Tuple
+
+from PyQt5.QtPrintSupport import QPrinter
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QPixmap, QPainter
+from PyQt5.QtWidgets import QMessageBox
+
+from reportlab.lib.units import inch
+
+
+class ReportPrinter:
+    """
+    Handles report printing to physical printers.
+    """
+    
+    def print_report(
+        self,
+        parent_widget,
+        report_content: Dict,
+        visualizations: List[Tuple[str, QPixmap]],
+        include_visualizations: bool = True
+    ) -> bool:
+        """
+        Print report to default printer.
+        
+        Args:
+            parent_widget: Parent widget for error dialogs
+            report_content: Dictionary with report text content
+            visualizations: List of (title, QPixmap) tuples
+            include_visualizations: Whether to include visualizations
+            
+        Returns:
+            bool: True if print succeeded, False otherwise
+        """
+        try:
+            # Create QPrinter with default printer
+            printer = QPrinter(QPrinter.HighResolution)
+            
+            # Create painter and render report
+            painter = QPainter()
+            painter.begin(printer)
+            
+            # Get page rectangle
+            page_rect = printer.pageRect(QPrinter.DevicePixel)
+            
+            # Render report content
+            self._render_report_to_painter(painter, page_rect, report_content, include_visualizations)
+            
+            # Add visualizations if selected
+            if include_visualizations and visualizations:
+                printer.newPage()
+                
+                margin = 0.5 * 72  # 0.5 inch in pixels
+                x_pos = int(margin)
+                y_pos = int(margin)
+                max_height = int(page_rect.height() - 2 * margin)
+                max_width = int(page_rect.width() - 2 * margin)
+                
+                title_font = QFont("Arial", 12, QFont.Bold)
+                painter.setFont(title_font)
+                
+                painter.drawText(x_pos, y_pos, max_width, 30,
+                                Qt.AlignLeft, "Visualizations")
+                y_pos += 40
+                
+                for viz_title, viz_pixmap in visualizations:
+                    # Check if we need a new page
+                    if y_pos + 250 > page_rect.height():
+                        printer.newPage()
+                        y_pos = int(margin)
+                    
+                    # Draw title
+                    painter.drawText(x_pos, y_pos, max_width, 25,
+                                    Qt.AlignLeft, viz_title)
+                    y_pos += 30
+                    
+                    # Scale and draw image
+                    pixmap = viz_pixmap
+                    if pixmap.width() > max_width:
+                        aspect = pixmap.height() / pixmap.width()
+                        pixmap = pixmap.scaledToWidth(max_width, Qt.SmoothTransformation)
+                        pixmap_height = int(pixmap.width() * aspect)
+                        if pixmap_height > max_height / 2:
+                            pixmap = pixmap.scaledToHeight(int(max_height / 2), Qt.SmoothTransformation)
+                    
+                    painter.drawPixmap(x_pos, y_pos, pixmap)
+                    y_pos += pixmap.height() + 20
+            
+            painter.end()
+            
+            QMessageBox.information(
+                parent_widget,
+                "Print Sent",
+                "Report sent to default printer successfully."
+            )
+            
+            return True
+        
+        except Exception as e:
+            QMessageBox.critical(
+                parent_widget,
+                "Print Error",
+                f"Error printing report:\n{str(e)}"
+            )
+            return False
+    
+    def _render_report_to_painter(
+        self,
+        painter: QPainter,
+        page_rect,
+        report_content: Dict,
+        include_visualizations: bool = True
+    ) -> int:
+        """
+        Render report content to a QPainter (for printing).
+        
+        Args:
+            painter: QPainter to render to
+            page_rect: Rectangle defining the printable area
+            report_content: Dictionary with report text content
+            include_visualizations: Whether to include images
+            
+        Returns:
+            Number of pages needed
+        """
+        margin = 0.5 * inch
+        content_width = page_rect.width() - 2 * margin
+        y_pos = margin
+        line_height = 20
+        
+        # Font setup
+        title_font = QFont("Arial", 16, QFont.Bold)
+        heading_font = QFont("Arial", 12, QFont.Bold)
+        normal_font = QFont("Arial", 10)
+        
+        # Title
+        painter.setFont(title_font)
+        painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                        Qt.AlignLeft, "Durian Analysis Report")
+        y_pos += line_height + 10
+        
+        # Report Info
+        painter.setFont(heading_font)
+        painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                        Qt.AlignLeft, "Report Information")
+        y_pos += line_height
+        
+        painter.setFont(normal_font)
+        painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                        Qt.AlignLeft, f"Report ID: {report_content['report_id']}")
+        y_pos += line_height
+        
+        painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                        Qt.AlignLeft, f"Generated: {report_content['generated']}")
+        y_pos += line_height + 10
+        
+        # Analysis Results
+        painter.setFont(heading_font)
+        painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                        Qt.AlignLeft, "Analysis Results")
+        y_pos += line_height
+        
+        painter.setFont(normal_font)
+        results = report_content['results']
+        
+        if results.get('locule_count') is not None:
+            painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                            Qt.AlignLeft, f"Locule Count: {results['locule_count']} locules")
+            y_pos += line_height
+        
+        if results.get('defect_status'):
+            painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                            Qt.AlignLeft, f"Defect Status: {results['defect_status']} ({results.get('total_detections', 0)} detections)")
+            y_pos += line_height
+        
+        if results.get('shape_class'):
+            painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                            Qt.AlignLeft, f"Shape: {results['shape_class']} ({results.get('shape_confidence', 0)*100:.1f}%)")
+            y_pos += line_height
+        
+        if results.get('maturity_class'):
+            painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                            Qt.AlignLeft, f"Maturity: {results['maturity_class']} ({results.get('maturity_confidence', 0)*100:.1f}%)")
+            y_pos += line_height
+        
+        if results.get('ripeness_class'):
+            painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                            Qt.AlignLeft, f"Ripeness: {results['ripeness_class']} ({results.get('ripeness_confidence', 0)*100:.1f}%)")
+            y_pos += line_height
+        
+        # Grade
+        painter.setFont(heading_font)
+        y_pos += 10
+        painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                        Qt.AlignLeft, f"Overall Grade: Class {report_content['grade']}")
+        y_pos += line_height
+        
+        painter.setFont(normal_font)
+        painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height * 2),
+                        Qt.AlignLeft | Qt.TextWordWrap, report_content['grade_description'])
+        y_pos += line_height * 2 + 10
+        
+        # Visualizations
+        if include_visualizations:
+            painter.setFont(heading_font)
+            painter.drawText(int(margin), int(y_pos), int(content_width), int(line_height),
+                            Qt.AlignLeft, "Visualizations")
+            y_pos += line_height + 10
+        
+        return 1

+ 476 - 0
ui/components/report_sections.py

@@ -0,0 +1,476 @@
+"""
+Report Section Builders
+
+Functions for building individual report sections (info, results, visualizations, etc).
+Each function returns a QGroupBox or QWidget ready to be added to the report layout.
+"""
+
+from datetime import datetime
+from typing import Dict, Optional
+
+from PyQt5.QtWidgets import QGroupBox, QVBoxLayout, QGridLayout, QLabel, QWidget, QHBoxLayout
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+from resources.styles import GROUP_BOX_STYLE
+from utils.grade_calculator import (
+    calculate_durian_grade,
+    get_ripeness_color,
+    get_maturity_color,
+    get_grade_color
+)
+from ui.components.report_visualizations import create_clickable_image_widget
+
+
+def get_local_datetime_string() -> str:
+    """Get current datetime string formatted in the computer's local timezone."""
+    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def create_report_info_section(report_id: Optional[str] = None) -> QGroupBox:
+    """
+    Create report information section with local timezone.
+    
+    Args:
+        report_id: Report ID to display (or None to generate one)
+        
+    Returns:
+        QGroupBox containing report information
+    """
+    group = QGroupBox("Report Information")
+    group.setStyleSheet(GROUP_BOX_STYLE)
+    
+    layout = QGridLayout()
+    
+    # Report ID (use provided ID or generate one)
+    layout.addWidget(QLabel("<b>Report ID:</b>"), 0, 0)
+    if report_id:
+        report_id_label = QLabel(report_id)
+    else:
+        report_id_label = QLabel(f"DUR-{datetime.now().strftime('%Y%m%d-%H%M%S')}")
+    layout.addWidget(report_id_label, 0, 1)
+    
+    # Date/Time in local timezone
+    layout.addWidget(QLabel("<b>Generated:</b>"), 0, 2)
+    layout.addWidget(QLabel(get_local_datetime_string()), 0, 3)
+    
+    group.setLayout(layout)
+    return group
+
+
+def create_empty_results_section() -> QGroupBox:
+    """
+    Create analysis results section - shows message when no data available.
+    
+    Returns:
+        QGroupBox with placeholder message
+    """
+    group = QGroupBox("Analysis Results")
+    group.setStyleSheet(GROUP_BOX_STYLE + """
+        QGroupBox {
+            background-color: #ecf0f1;
+            font-weight: bold;
+            font-size: 14px;
+        }
+    """)
+    
+    layout = QVBoxLayout()
+    
+    # Display message when no analysis data is available
+    message_label = QLabel("Not enough information")
+    message_label.setFont(QFont("Arial", 14))
+    message_label.setAlignment(Qt.AlignCenter)
+    message_label.setStyleSheet("color: #7f8c8d; padding: 20px;")
+    layout.addWidget(message_label)
+    
+    group.setLayout(layout)
+    return group
+
+
+def create_model_results_section(
+    predicted_class: str,
+    confidence: float,
+    probabilities: Dict[str, float]
+) -> QGroupBox:
+    """
+    Create analysis results section with actual model predictions.
+    
+    Args:
+        predicted_class: Predicted maturity class from model
+        confidence: Model confidence (0-1 scale)
+        probabilities: Dictionary of class probabilities
+        
+    Returns:
+        QGroupBox containing model results
+    """
+    group = QGroupBox("Analysis Results")
+    group.setStyleSheet(GROUP_BOX_STYLE + """
+        QGroupBox {
+            background-color: #ecf0f1;
+            font-weight: bold;
+            font-size: 14px;
+        }
+    """)
+    
+    layout = QVBoxLayout()
+    
+    # Determine overall grade based on maturity class
+    grade_map = {
+        'Immature': 'C',
+        'Mature': 'A',
+        'Overmature': 'B'
+    }
+    grade = grade_map.get(predicted_class, 'B')
+    
+    # Overall Grade
+    grade_layout = QHBoxLayout()
+    grade_label = QLabel("Overall Grade:")
+    grade_label.setFont(QFont("Arial", 16, QFont.Bold))
+    grade_layout.addWidget(grade_label)
+    
+    grade_value = QLabel(f"Class {grade}")
+    grade_value.setFont(QFont("Arial", 24, QFont.Bold))
+    grade_color = get_grade_color(grade)
+    grade_value.setStyleSheet(f"color: {grade_color};")
+    grade_layout.addWidget(grade_value)
+    grade_layout.addStretch()
+    layout.addLayout(grade_layout)
+    
+    # Results grid
+    results_grid = QGridLayout()
+    
+    # Maturity (from model)
+    results_grid.addWidget(QLabel("<b>Maturity Status:</b>"), 0, 0)
+    maturity_label = QLabel(predicted_class)
+    maturity_label.setStyleSheet(f"font-size: 14px; color: {get_maturity_color(predicted_class)};")
+    results_grid.addWidget(maturity_label, 0, 1)
+    
+    # Confidence (from model)
+    confidence_pct = confidence * 100 if confidence <= 1.0 else confidence
+    results_grid.addWidget(QLabel(f"({confidence_pct:.1f}% confidence)"), 0, 2)
+    
+    # All class probabilities
+    results_grid.addWidget(QLabel("<b>Class Probabilities:</b>"), 1, 0)
+    prob_text = "  ".join([f"{k}: {v:.1%}" for k, v in probabilities.items()])
+    prob_label = QLabel(prob_text)
+    prob_label.setStyleSheet("font-size: 11px; color: #666;")
+    results_grid.addWidget(prob_label, 1, 1, 1, 2)
+    
+    layout.addLayout(results_grid)
+    
+    group.setLayout(layout)
+    return group
+
+
+def create_analysis_results_section(results: Dict) -> QGroupBox:
+    """
+    Create combined analysis results from all models.
+    
+    Args:
+        results: Dictionary with processing results from all models
+                 Keys: 'defect', 'locule', 'maturity', 'shape', 'audio'
+                 
+    Returns:
+        QGroupBox containing combined analysis results
+    """
+    group = QGroupBox("Combined Analysis Results")
+    group.setStyleSheet(GROUP_BOX_STYLE + """
+        QGroupBox {
+            background-color: #ecf0f1;
+            font-weight: bold;
+            font-size: 14px;
+        }
+    """)
+    
+    layout = QVBoxLayout()
+    
+    # Extract data
+    locule_count = 0
+    has_defects = False
+    shape_class = None
+    maturity_class = None
+    
+    # Locule analysis from top view
+    if 'locule' in results:
+        if results['locule'].get('error'):
+            layout.addWidget(QLabel(f"<b>Locule Count (Top View):</b> <font color='#e74c3c'>Error: {results['locule'].get('error_msg', 'Unknown error')}</font>"))
+        else:
+            locule_count = results['locule'].get('locule_count', 0)
+            layout.addWidget(QLabel(f"<b>Locule Count (Top View):</b> {locule_count} locules"))
+    
+    # Defect analysis from side view
+    if 'defect' in results:
+        if results['defect'].get('error'):
+            layout.addWidget(QLabel(f"<b>Defect Status (Side View):</b> <font color='#e74c3c'>Error: {results['defect'].get('error_msg', 'Unknown error')}</font>"))
+        else:
+            primary_class = results['defect'].get('primary_class', 'Unknown')
+            total_detections = results['defect'].get('total_detections', 0)
+            has_defects = (primary_class != "No Defects")
+            layout.addWidget(QLabel(f"<b>Defect Status (Side View):</b> {primary_class} ({total_detections} detections)"))
+    
+    # Shape analysis from side view
+    if 'shape' in results:
+        if results['shape'].get('error'):
+            layout.addWidget(QLabel(f"<b>Shape Classification (Side View):</b> <font color='#e74c3c'>Error: {results['shape'].get('error_msg', 'Unknown error')}</font>"))
+        else:
+            shape_class = results['shape'].get('shape_class', 'Unknown')
+            confidence = results['shape'].get('confidence', 0)
+            layout.addWidget(QLabel(f"<b>Shape Classification (Side View):</b> {shape_class} ({confidence*100:.1f}%)"))
+    
+    # Maturity analysis from multispectral (PART OF QUALITY GRADE)
+    if 'maturity' in results:
+        if results['maturity'].get('error'):
+            layout.addWidget(QLabel(f"<b>Maturity (Multispectral):</b> <font color='#e74c3c'>Error: {results['maturity'].get('error_msg', 'Unknown error')}</font>"))
+        else:
+            maturity_class = results['maturity'].get('class_name', 'Unknown')
+            confidence = results['maturity'].get('confidence', 0)
+            layout.addWidget(QLabel(f"<b>Maturity (Multispectral):</b> {maturity_class} ({confidence*100:.1f}%)"))
+    
+    # Audio ripeness analysis (SEPARATE FROM QUALITY GRADE)
+    ripeness_class = None
+    if 'audio' in results:
+        if results['audio'].get('error'):
+            layout.addWidget(QLabel(f"<b>Ripeness Classification (Audio):</b> <font color='#e74c3c'>Error: {results['audio'].get('error_msg', 'Unknown error')}</font>"))
+        else:
+            ripeness_class = results['audio'].get('ripeness_class', 'Unknown')
+            confidence = results['audio'].get('confidence', 0)
+            knock_count = results['audio'].get('knock_count', 0)
+            
+            # Main result
+            layout.addWidget(QLabel(f"<b>Ripeness Classification (Audio):</b> {ripeness_class} ({confidence*100:.1f}%)"))
+            
+            # Detailed analysis
+            per_knock_preds = results['audio'].get('per_knock_predictions', [])
+            if per_knock_preds:
+                # Per-knock predictions
+                per_knock_classes = [p['class'].capitalize() for p in per_knock_preds]
+                per_knock_confidences = [p['confidence'] for p in per_knock_preds]
+                layout.addWidget(QLabel(f"<i>Per-knock predictions: {', '.join(per_knock_classes)}</i>"))
+                
+                # Per-knock confidences
+                per_knock_conf_str = ', '.join([f"{c*100:.1f}%" for c in per_knock_confidences])
+                layout.addWidget(QLabel(f"<i>Per-knock confidence: {per_knock_conf_str}</i>"))
+            
+            # Average probabilities
+            probabilities = results['audio'].get('probabilities', {})
+            if probabilities:
+                prob_str = ', '.join([f"{k}: {v*100:.1f}%" for k, v in probabilities.items()])
+                layout.addWidget(QLabel(f"<i>Average probabilities: {prob_str}</i>"))
+    
+    # Calculate and display grade (includes maturity, excludes ripeness)
+    grade, grade_description = calculate_durian_grade(locule_count, has_defects, shape_class, maturity_class)
+    
+    # Note: Ripeness is displayed separately and NOT included in quality grade
+    
+    grade_color = get_grade_color(grade)
+    grade_widget = QLabel(f"<b>Overall Grade:</b> <font color='{grade_color}' size='5'><b>Class {grade}</b></font>")
+    grade_widget.setStyleSheet(f"padding: 10px; background-color: white; border: 2px solid {grade_color};")
+    layout.addWidget(grade_widget)
+    
+    layout.addWidget(QLabel(f"<i>{grade_description}</i>"))
+    
+    group.setLayout(layout)
+    return group
+
+
+def create_input_data_section(input_data: Dict[str, str]) -> QGroupBox:
+    """
+    Create section showing input data (only for provided inputs).
+    
+    Args:
+        input_data: Dictionary with input file paths
+        
+    Returns:
+        QGroupBox containing input data previews
+    """
+    group = QGroupBox("Input Data")
+    group.setStyleSheet(GROUP_BOX_STYLE)
+    
+    layout = QVBoxLayout()
+    
+    has_content = False
+    
+    # Side View (Plain DSLR)
+    if input_data.get('dslr_side'):
+        dslr_side_widget = create_image_preview_widget("DSLR Side View", input_data['dslr_side'])
+        layout.addWidget(dslr_side_widget)
+        has_content = True
+    
+    # Top View (RGB DSLR)
+    if input_data.get('dslr_top'):
+        dslr_top_widget = create_image_preview_widget("DSLR Top View (RGB)", input_data['dslr_top'])
+        layout.addWidget(dslr_top_widget)
+        has_content = True
+    
+    # Thermal data (if provided)
+    if input_data.get('thermal'):
+        from ui.components.report_visualizations import create_thermal_widget
+        thermal_widget = create_thermal_widget(input_data['thermal'])
+        layout.addWidget(thermal_widget)
+        has_content = True
+    
+    if not has_content:
+        layout.addWidget(QLabel("No input data provided"))
+    
+    group.setLayout(layout)
+    return group
+
+
+def create_input_data_with_gradcam(input_data: Dict[str, str], gradcam_image) -> QGroupBox:
+    """
+    Create section showing input data with Grad-CAM visualization.
+    
+    Args:
+        input_data: Dictionary with input file paths
+        gradcam_image: QImage of Grad-CAM visualization
+        
+    Returns:
+        QGroupBox containing input data and Grad-CAM
+    """
+    from ui.components.report_visualizations import create_gradcam_widget
+    
+    group = QGroupBox("Input Data & Analysis Visualization")
+    group.setStyleSheet(GROUP_BOX_STYLE)
+    
+    layout = QVBoxLayout()
+    
+    # Grad-CAM visualization (from multispectral model)
+    if gradcam_image:
+        gradcam_widget = create_gradcam_widget(gradcam_image)
+        layout.addWidget(gradcam_widget)
+    
+    # Side View (Plain DSLR)
+    if input_data.get('dslr_side'):
+        dslr_side_widget = create_image_preview_widget("DSLR Side View", input_data['dslr_side'])
+        layout.addWidget(dslr_side_widget)
+    
+    # Top View (RGB DSLR)
+    if input_data.get('dslr_top'):
+        dslr_top_widget = create_image_preview_widget("DSLR Top View (RGB)", input_data['dslr_top'])
+        layout.addWidget(dslr_top_widget)
+    
+    # Thermal data (if provided)
+    if input_data.get('thermal'):
+        from ui.components.report_visualizations import create_thermal_widget
+        thermal_widget = create_thermal_widget(input_data['thermal'])
+        layout.addWidget(thermal_widget)
+    
+    if not gradcam_image and not any([
+        input_data.get('dslr_side'),
+        input_data.get('dslr_top'),
+        input_data.get('thermal')
+    ]):
+        layout.addWidget(QLabel("No visualizations to display"))
+    
+    group.setLayout(layout)
+    return group
+
+
+def create_visualizations_section(input_data: Dict[str, str], results: Dict) -> QGroupBox:
+    """
+    Create input data section with all model visualizations and clickable images.
+    
+    Args:
+        input_data: Dictionary with input file paths
+        results: Dictionary with processing results from all models
+        
+    Returns:
+        QGroupBox containing all visualizations
+    """
+    group = QGroupBox("Analysis Visualizations")
+    group.setStyleSheet(GROUP_BOX_STYLE)
+    
+    layout = QVBoxLayout()
+    
+    # Grad-CAM from multispectral
+    if results.get('maturity') and results['maturity'].get('gradcam_image'):
+        gradcam_img = results['maturity']['gradcam_image']
+        gradcam_widget = create_clickable_image_widget(
+            gradcam_img,
+            "Grad-CAM Visualization (Maturity)",
+            "Model attention heatmap overlaid on 860nm NIR band",
+            max_width=500
+        )
+        layout.addWidget(gradcam_widget)
+    
+    # Locule analysis image
+    if results.get('locule') and results['locule'].get('annotated_image'):
+        locule_img = results['locule']['annotated_image']
+        locule_widget = create_clickable_image_widget(
+            locule_img,
+            "Locule Analysis (Top View)",
+            "Detected and counted locules with color coding",
+            max_width=500
+        )
+        layout.addWidget(locule_widget)
+    
+    # Defect analysis image
+    if results.get('defect') and results['defect'].get('annotated_image'):
+        defect_img = results['defect']['annotated_image']
+        defect_widget = create_clickable_image_widget(
+            defect_img,
+            "Defect Analysis (Side View)",
+            f"{results['defect'].get('primary_class', 'Unknown')} - {results['defect'].get('total_detections', 0)} detections",
+            max_width=500
+        )
+        layout.addWidget(defect_widget)
+    
+    # Shape analysis image
+    if results.get('shape') and results['shape'].get('annotated_image'):
+        shape_img = results['shape']['annotated_image']
+        confidence = results['shape'].get('confidence', 0) * 100
+        shape_widget = create_clickable_image_widget(
+            shape_img,
+            "Shape Classification (Side View)",
+            f"{results['shape'].get('shape_class', 'Unknown')} ({confidence:.1f}% confidence)",
+            max_width=500
+        )
+        layout.addWidget(shape_widget)
+    
+    # Audio ripeness - Waveform with knocks
+    if results.get('audio') and results['audio'].get('waveform_image'):
+        waveform_img = results['audio']['waveform_image']
+        knock_count = results['audio'].get('knock_count', 0)
+        waveform_widget = create_clickable_image_widget(
+            waveform_img,
+            f"Waveform with {knock_count} Detected Knocks",
+            "Audio waveform with knock detection markers",
+            max_width=600
+        )
+        layout.addWidget(waveform_widget)
+    
+    # Audio ripeness - Mel Spectrogram with knocks
+    if results.get('audio') and results['audio'].get('spectrogram_image'):
+        spectrogram_img = results['audio']['spectrogram_image']
+        knock_count = results['audio'].get('knock_count', 0)
+        audio_widget = create_clickable_image_widget(
+            spectrogram_img,
+            f"Mel Spectrogram (64 Coefficients) - {knock_count} Knocks",
+            "Frequency-time representation of knock sounds",
+            max_width=600
+        )
+        layout.addWidget(audio_widget)
+    
+    # Thermal if provided
+    if input_data.get('thermal'):
+        from ui.components.report_visualizations import create_thermal_widget
+        thermal_widget = create_thermal_widget(input_data['thermal'])
+        layout.addWidget(thermal_widget)
+    
+    group.setLayout(layout)
+    return group
+
+
+def create_image_preview_widget(title: str, file_path: str) -> QWidget:
+    """
+    Create image preview widget from file path.
+    
+    Args:
+        title: Title for the image
+        file_path: Path to the image file
+        
+    Returns:
+        QWidget containing the image preview
+    """
+    from ui.components.report_visualizations import create_image_preview_widget as _create_image
+    return _create_image(title, file_path)

+ 428 - 0
ui/components/report_visualizations.py

@@ -0,0 +1,428 @@
+"""
+Report Visualization Components
+
+Reusable visualization widgets for creating thermal heatmaps, audio spectrograms,
+and image previews for report generation.
+"""
+
+from pathlib import Path
+from typing import Optional
+
+from PyQt5.QtWidgets import QWidget, QFrame, QVBoxLayout, QLabel
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QPixmap, QImage
+import numpy as np
+import matplotlib
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from matplotlib.colors import LinearSegmentedColormap
+
+try:
+    import tensorflow as tf
+except:
+    tf = None
+
+try:
+    from PIL import Image as PILImage
+    HAS_PIL = True
+except ImportError:
+    HAS_PIL = False
+
+from ui.components.visualization_widgets import ClickableImageWidget
+from ui.dialogs.image_preview_dialog import ImagePreviewDialog
+
+
+def create_gradcam_widget(gradcam_image: QImage) -> QWidget:
+    """
+    Create Grad-CAM visualization preview widget.
+    
+    Args:
+        gradcam_image: QImage of the Grad-CAM visualization
+        
+    Returns:
+        QWidget containing the Grad-CAM visualization
+    """
+    widget = QFrame()
+    widget.setFrameStyle(QFrame.Box)
+    widget.setStyleSheet("background-color: white; border: 1px solid #bdc3c7;")
+    
+    layout = QVBoxLayout(widget)
+    
+    # Title
+    title_label = QLabel("<b>Grad-CAM Visualization</b>")
+    title_label.setFont(QFont("Arial", 12))
+    layout.addWidget(title_label)
+    
+    # Description
+    desc_label = QLabel("Model attention heatmap overlaid on 860nm NIR band")
+    desc_label.setStyleSheet("color: #7f8c8d; font-size: 10px;")
+    layout.addWidget(desc_label)
+    
+    # Image preview
+    try:
+        if not gradcam_image.isNull():
+            # Scale to reasonable size
+            scaled_image = gradcam_image.scaledToWidth(400, Qt.SmoothTransformation)
+            pixmap = QPixmap.fromImage(scaled_image)
+            image_label = QLabel()
+            image_label.setPixmap(pixmap)
+            image_label.setAlignment(Qt.AlignCenter)
+            layout.addWidget(image_label)
+        else:
+            layout.addWidget(QLabel("Grad-CAM image is empty"))
+    except Exception as e:
+        layout.addWidget(QLabel(f"Error displaying Grad-CAM: {e}"))
+    
+    return widget
+
+
+def create_image_preview_widget(title: str, file_path: str) -> QWidget:
+    """
+    Create image preview widget from file path.
+    
+    Args:
+        title: Title for the image
+        file_path: Path to the image file
+        
+    Returns:
+        QWidget containing the image preview
+    """
+    widget = QFrame()
+    widget.setFrameStyle(QFrame.Box)
+    widget.setStyleSheet("background-color: white; border: 1px solid #bdc3c7;")
+    
+    layout = QVBoxLayout(widget)
+    
+    # Title
+    title_label = QLabel(f"<b>{title}</b>")
+    title_label.setFont(QFont("Arial", 12))
+    layout.addWidget(title_label)
+    
+    # File path
+    path_label = QLabel(f"File: {Path(file_path).name}")
+    path_label.setStyleSheet("color: #7f8c8d; font-size: 10px;")
+    layout.addWidget(path_label)
+    
+    # Image preview
+    try:
+        pixmap = QPixmap(file_path)
+        if not pixmap.isNull():
+            # Scale to reasonable size
+            scaled_pixmap = pixmap.scaled(400, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+            image_label = QLabel()
+            image_label.setPixmap(scaled_pixmap)
+            image_label.setAlignment(Qt.AlignCenter)
+            layout.addWidget(image_label)
+        else:
+            layout.addWidget(QLabel("Could not load image"))
+    except Exception as e:
+        layout.addWidget(QLabel(f"Error loading image: {e}"))
+    
+    return widget
+
+
+def create_thermal_widget(csv_path: str) -> QWidget:
+    """
+    Create thermal CSV data preview widget with heatmap visualization.
+    
+    Args:
+        csv_path: Path to thermal CSV file
+        
+    Returns:
+        QWidget containing the thermal heatmap visualization
+    """
+    widget = QFrame()
+    widget.setFrameStyle(QFrame.Box)
+    widget.setStyleSheet("background-color: white; border: 1px solid #bdc3c7;")
+    
+    layout = QVBoxLayout(widget)
+    
+    # Title
+    title_label = QLabel("<b>Thermal Image Analysis</b>")
+    title_label.setFont(QFont("Arial", 12))
+    layout.addWidget(title_label)
+    
+    # File path
+    path_label = QLabel(f"File: {Path(csv_path).name}")
+    path_label.setStyleSheet("color: #7f8c8d; font-size: 10px;")
+    layout.addWidget(path_label)
+    
+    # Load and visualize thermal data
+    try:
+        # Load CSV file - FLIR Analyzer exports temperature data as comma-separated values
+        data = []
+        with open(csv_path, 'r') as f:
+            for line in f:
+                try:
+                    row = [float(x.strip()) for x in line.strip().split(',')]
+                    if row:  # Skip empty rows
+                        data.append(row)
+                except ValueError:
+                    # Skip rows that can't be converted to float
+                    continue
+        
+        if not data:
+            layout.addWidget(QLabel("No valid thermal data found in CSV"))
+            return widget
+        
+        # Convert to numpy array
+        thermal_data = np.array(data)
+        
+        # Calculate statistics
+        temp_min = thermal_data.min()
+        temp_max = thermal_data.max()
+        temp_mean = thermal_data.mean()
+        temp_std = thermal_data.std()
+        
+        # Create heatmap visualization
+        fig, ax = plt.subplots(figsize=(8, 6), dpi=100)
+        
+        # Create FLIR-style colormap: dark blue (cold) -> purple -> red -> yellow (hot)
+        colors_flir = ['#000040', '#0000ff', '#0080ff', '#00ffff', 
+                      '#00ff00', '#ffff00', '#ff8000', '#ff0000']
+        cmap_flir = LinearSegmentedColormap.from_list('flir_style', colors_flir, N=256)
+        
+        # Create the heatmap
+        im = ax.imshow(thermal_data, cmap=cmap_flir, origin='upper', aspect='auto')
+        
+        # Add colorbar
+        cbar = plt.colorbar(im, ax=ax, label='Temperature (°C)')
+        
+        # Labels and title
+        ax.set_xlabel('X Pixels')
+        ax.set_ylabel('Y Pixels')
+        ax.set_title('Thermal Camera Heatmap', fontsize=12, fontweight='bold')
+        
+        # Adjust layout
+        plt.tight_layout()
+        
+        # Convert to QImage
+        canvas = FigureCanvas(fig)
+        canvas.draw()
+        
+        width_px, height_px = fig.get_size_inches() * fig.get_dpi()
+        width_px, height_px = int(width_px), int(height_px)
+        
+        img = QImage(canvas.buffer_rgba(), width_px, height_px, QImage.Format_ARGB32)
+        img = img.rgbSwapped()
+        
+        # Display image
+        image_label = QLabel()
+        pixmap = QPixmap.fromImage(img)
+        # Scale to fit reasonable size
+        if pixmap.width() > 600:
+            scaled_pixmap = pixmap.scaledToWidth(600, Qt.SmoothTransformation)
+        else:
+            scaled_pixmap = pixmap
+        image_label.setPixmap(scaled_pixmap)
+        image_label.setAlignment(Qt.AlignCenter)
+        layout.addWidget(image_label)
+        
+        plt.close(fig)
+        
+        # Add statistics below the heatmap
+        stats_label = QLabel(
+            f"<b>Temperature Statistics:</b><br>"
+            f"Min: {temp_min:.2f}°C | Max: {temp_max:.2f}°C | "
+            f"Mean: {temp_mean:.2f}°C | Std Dev: {temp_std:.2f}°C<br>"
+            f"<i>Image dimensions: {thermal_data.shape[0]} × {thermal_data.shape[1]} pixels</i>"
+        )
+        stats_label.setStyleSheet("color: #555; font-size: 11px; padding: 5px;")
+        stats_label.setAlignment(Qt.AlignCenter)
+        layout.addWidget(stats_label)
+        
+    except Exception as e:
+        layout.addWidget(QLabel(f"Error processing thermal data: {str(e)}"))
+    
+    return widget
+
+
+def create_audio_spectrogram_widget(audio_path: str) -> QWidget:
+    """
+    Create audio spectrogram widget.
+    
+    Args:
+        audio_path: Path to audio WAV file
+        
+    Returns:
+        QWidget containing the audio spectrogram
+    """
+    widget = QFrame()
+    widget.setFrameStyle(QFrame.Box)
+    widget.setStyleSheet("background-color: white; border: 1px solid #bdc3c7;")
+    
+    layout = QVBoxLayout(widget)
+    
+    # Title
+    title_label = QLabel("<b>Audio Spectrogram</b>")
+    title_label.setFont(QFont("Arial", 12))
+    layout.addWidget(title_label)
+    
+    # File path
+    path_label = QLabel(f"File: {Path(audio_path).name}")
+    path_label.setStyleSheet("color: #7f8c8d; font-size: 10px;")
+    layout.addWidget(path_label)
+    
+    # Generate spectrogram
+    try:
+        if tf is not None:
+            # Load and process audio
+            x = tf.io.read_file(str(audio_path))
+            x, sample_rate = tf.audio.decode_wav(x, desired_channels=1, desired_samples=16000)
+            x = tf.squeeze(x, axis=-1)
+            waveform = x
+            
+            # Generate spectrogram
+            spectrogram = tf.signal.stft(waveform, frame_length=255, frame_step=128)
+            spectrogram = tf.abs(spectrogram)
+            spectrogram = spectrogram[..., tf.newaxis]
+            
+            # Plot spectrogram
+            fig, ax = plt.subplots(1, 1, figsize=(8, 3))
+            
+            if len(spectrogram.shape) > 2:
+                spectrogram = np.squeeze(spectrogram, axis=-1)
+            log_spec = np.log(spectrogram.T + np.finfo(float).eps)
+            height = log_spec.shape[0]
+            width = log_spec.shape[1]
+            X = np.linspace(0, np.size(spectrogram), num=width, dtype=int)
+            Y = range(height)
+            ax.pcolormesh(X, Y, log_spec)
+            ax.set_ylabel('Frequency')
+            ax.set_xlabel('Time')
+            ax.set_title('Audio Spectrogram')
+            
+            # Convert to QPixmap
+            canvas = FigureCanvas(fig)
+            canvas.draw()
+            
+            width_px, height_px = fig.get_size_inches() * fig.get_dpi()
+            width_px, height_px = int(width_px), int(height_px)
+            
+            img = QImage(canvas.buffer_rgba(), width_px, height_px, QImage.Format_ARGB32)
+            img = img.rgbSwapped()
+            pixmap = QPixmap(img)
+            
+            image_label = QLabel()
+            image_label.setPixmap(pixmap)
+            image_label.setAlignment(Qt.AlignCenter)
+            layout.addWidget(image_label)
+            
+            plt.close(fig)
+        else:
+            layout.addWidget(QLabel("TensorFlow not available - cannot generate spectrogram"))
+    except Exception as e:
+        layout.addWidget(QLabel(f"Error generating spectrogram: {e}"))
+    
+    return widget
+
+
+def create_clickable_image_widget(
+    image_data,
+    title: str,
+    description: str = "",
+    max_width: int = 500
+) -> QWidget:
+    """
+    Create a clickable image widget that opens preview dialog on click.
+    
+    Args:
+        image_data: QPixmap or QImage to display
+        title: Title for the visualization
+        description: Optional description text
+        max_width: Maximum width for display image
+        
+    Returns:
+        QWidget containing the clickable image
+    """
+    # Convert QImage to QPixmap if needed
+    if isinstance(image_data, QImage):
+        pixmap = QPixmap.fromImage(image_data)
+    else:
+        pixmap = image_data
+    
+    widget = QFrame()
+    widget.setFrameStyle(QFrame.Box)
+    widget.setStyleSheet("background-color: white; border: 1px solid #bdc3c7;")
+    
+    layout = QVBoxLayout(widget)
+    layout.setAlignment(Qt.AlignCenter)
+    layout.setContentsMargins(10, 10, 10, 10)
+    
+    # Title
+    title_label = QLabel(f"<b>{title}</b>")
+    title_label.setFont(QFont("Arial", 12))
+    layout.addWidget(title_label)
+    
+    # Description (if provided)
+    if description:
+        desc_label = QLabel(description)
+        desc_label.setStyleSheet("color: #7f8c8d; font-size: 10px;")
+        layout.addWidget(desc_label)
+    
+    # Image label (clickable)
+    image_label = QLabel()
+    image_label.setCursor(Qt.PointingHandCursor)
+    
+    # Scale pixmap to fit within max_width and max_height, maintaining aspect ratio
+    max_height = 600  # Set max height to prevent stretching
+    scaled_pixmap = pixmap.scaledToWidth(max_width, Qt.SmoothTransformation) if pixmap.width() > max_width else pixmap
+    
+    # If height exceeds max_height after width scaling, scale by height instead
+    if scaled_pixmap.height() > max_height:
+        scaled_pixmap = pixmap.scaledToHeight(max_height, Qt.SmoothTransformation)
+    
+    image_label.setPixmap(scaled_pixmap)
+    image_label.setAlignment(Qt.AlignCenter)
+    
+    # Store original pixmap for preview
+    image_label.original_pixmap = pixmap
+    image_label.preview_title = title
+    
+    # Connect click event
+    def show_preview(event):
+        dialog = ImagePreviewDialog(image_label.original_pixmap, title=image_label.preview_title, parent=widget)
+        dialog.exec_()
+    
+    image_label.mousePressEvent = show_preview
+    
+    layout.addWidget(image_label, alignment=Qt.AlignCenter)
+    
+    return widget
+
+
+def convert_pixmap_to_pil(pixmap: QPixmap) -> Optional['PILImage.Image']:
+    """
+    Convert QPixmap to PIL Image for PDF generation.
+    
+    Args:
+        pixmap: QPixmap to convert
+        
+    Returns:
+        PIL Image or None if conversion fails
+    """
+    if not HAS_PIL or pixmap.isNull():
+        return None
+    
+    try:
+        # Convert QPixmap to QImage
+        qimage = pixmap.toImage()
+        width = qimage.width()
+        height = qimage.height()
+        
+        # Convert QImage to numpy array
+        ptr = qimage.bits()
+        ptr.setsize(qimage.byteCount())
+        arr = np.array(ptr).reshape(height, width, 4)
+        
+        # Convert RGBA to RGB if needed
+        if arr.shape[2] == 4:
+            arr = arr[:, :, :3]
+        
+        # Create PIL Image
+        pil_image = PILImage.fromarray(arr, 'RGB')
+        return pil_image
+    except Exception as e:
+        print(f"Error converting QPixmap to PIL: {e}")
+        return None

+ 150 - 0
ui/components/visualization_widgets.py

@@ -0,0 +1,150 @@
+"""
+Visualization Widgets
+
+Reusable components for displaying analysis visualizations with click-to-enlarge functionality.
+"""
+
+from typing import Optional, Callable
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QFrame
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QPixmap, QFont, QCursor
+
+from ui.dialogs.image_preview_dialog import ImagePreviewDialog
+
+
+class ClickableImageWidget(QFrame):
+    """
+    A frame widget displaying an image that opens a preview dialog when clicked.
+    
+    Features:
+    - Displays scaled image with aspect ratio preserved
+    - Click to open full-size preview dialog
+    - Visual indicator (cursor change, optional hover effect)
+    - Configurable maximum dimensions
+    """
+    
+    def __init__(self, pixmap: QPixmap, title: str, max_width: int = 500, parent: QWidget = None):
+        """
+        Initialize the clickable image widget.
+        
+        Args:
+            pixmap: QPixmap to display
+            title: Title for the preview dialog
+            max_width: Maximum width in pixels for the scaled display image
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.original_pixmap = pixmap  # Store original for preview dialog
+        self.title = title
+        self.max_width = max_width
+        
+        # Setup widget styling
+        self.setFrameStyle(QFrame.Box)
+        self.setStyleSheet("background-color: white; border: 1px solid #bdc3c7;")
+        
+        # Create layout
+        layout = QVBoxLayout(self)
+        layout.setAlignment(Qt.AlignCenter)
+        
+        # Title label
+        title_label = QLabel(f"<b>{title}</b>")
+        title_label.setFont(QFont("Arial", 12))
+        layout.addWidget(title_label)
+        
+        # Image label
+        self.image_label = QLabel()
+        self.image_label.setAlignment(Qt.AlignCenter)
+        self.image_label.setCursor(Qt.PointingHandCursor)
+        
+        # Scale pixmap for display
+        scaled_pixmap = self._scale_pixmap(pixmap, max_width)
+        self.image_label.setPixmap(scaled_pixmap)
+        self.image_label.setFixedSize(scaled_pixmap.width(), scaled_pixmap.height())
+        
+        # Connect click event
+        self.image_label.mousePressEvent = self._on_image_clicked
+        
+        layout.addWidget(self.image_label, alignment=Qt.AlignCenter)
+        layout.addStretch()
+    
+    def _scale_pixmap(self, pixmap: QPixmap, max_width: int) -> QPixmap:
+        """Scale pixmap to fit max_width while preserving aspect ratio."""
+        if pixmap.width() > max_width:
+            return pixmap.scaledToWidth(max_width, Qt.SmoothTransformation)
+        return pixmap
+    
+    def _on_image_clicked(self, event):
+        """Handle image click to show preview dialog."""
+        dialog = ImagePreviewDialog(self.original_pixmap, title=self.title, parent=self)
+        dialog.exec_()
+
+
+class VisualizationPanel(QFrame):
+    """
+    A panel for displaying a single analysis visualization with metadata.
+    
+    Features:
+    - Title and description
+    - Clickable image
+    - Optional metadata display
+    """
+    
+    def __init__(self, title: str, description: str = "", parent: QWidget = None):
+        """
+        Initialize the visualization panel.
+        
+        Args:
+            title: Panel title
+            description: Optional description text
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.setFrameStyle(QFrame.Box)
+        self.setStyleSheet("background-color: white; border: 1px solid #bdc3c7;")
+        
+        layout = QVBoxLayout(self)
+        layout.setAlignment(Qt.AlignCenter)
+        
+        # Title
+        title_label = QLabel(f"<b>{title}</b>")
+        title_label.setFont(QFont("Arial", 12))
+        layout.addWidget(title_label)
+        
+        # Description (if provided)
+        if description:
+            desc_label = QLabel(description)
+            desc_label.setStyleSheet("color: #7f8c8d; font-size: 10px;")
+            layout.addWidget(desc_label)
+        
+        # Store reference to layout for adding image label later
+        self.layout_ref = layout
+    
+    def add_image(self, pixmap: QPixmap, max_width: int = 500):
+        """Add an image to the visualization panel."""
+        # Scale image
+        if pixmap.width() > max_width:
+            scaled_pixmap = pixmap.scaledToWidth(max_width, Qt.SmoothTransformation)
+        else:
+            scaled_pixmap = pixmap
+        
+        # Create clickable image label
+        image_label = QLabel()
+        image_label.setPixmap(scaled_pixmap)
+        image_label.setFixedSize(scaled_pixmap.width(), scaled_pixmap.height())
+        image_label.setAlignment(Qt.AlignCenter)
+        image_label.setCursor(Qt.PointingHandCursor)
+        
+        # Store pixmaps for click handler
+        image_label.original_pixmap = pixmap
+        image_label.title = self.layout_ref.itemAt(0).widget().text() if self.layout_ref.count() > 0 else "Image"
+        
+        # Add click handler
+        def on_click(event):
+            dialog = ImagePreviewDialog(image_label.original_pixmap, title=image_label.title, parent=self)
+            dialog.exec_()
+        
+        image_label.mousePressEvent = on_click
+        
+        # Add to layout
+        self.layout_ref.addWidget(image_label, alignment=Qt.AlignCenter)
+        self.layout_ref.addStretch()

+ 17 - 0
ui/dialogs/__init__.py

@@ -0,0 +1,17 @@
+"""
+Dialogs Package
+
+Contains dialog windows for About, Help, and other utilities.
+"""
+
+from .about_dialog import AboutDialog
+from .help_dialog import HelpDialog
+from .spectrogram_preview_dialog import SpectrogramPreviewDialog
+from .manual_input_dialog import ManualInputDialog, CameraAppCheckDialog
+from .print_options_dialog import PrintOptionsDialog
+
+__all__ = ['AboutDialog', 'HelpDialog', 'SpectrogramPreviewDialog', 
+           'ManualInputDialog', 'CameraAppCheckDialog', 'PrintOptionsDialog']
+
+
+

+ 304 - 0
ui/dialogs/about_dialog.py

@@ -0,0 +1,304 @@
+"""
+About Dialog
+
+Displays information about the DuDONG application, including:
+- Project description
+- Version information
+- Development team
+- Partners and acknowledgments
+- Partner logos
+"""
+
+from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, 
+                              QPushButton, QScrollArea, QWidget)
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QPixmap
+
+from resources.styles import STANDARD_BUTTON_STYLE
+from utils.config import PROJECT_ROOT
+
+
+class AboutDialog(QDialog):
+    """
+    About dialog window showing project information.
+    
+    Displays:
+    - Application name and version
+    - Project description
+    - Development team information
+    - Partner organizations
+    - Acknowledgments
+    """
+    
+    def __init__(self, parent=None):
+        """
+        Initialize the about dialog.
+        
+        Args:
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.setWindowTitle("About DuDONG")
+        self.setMinimumSize(600, 700)
+        self.setMaximumSize(700, 800)
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout()
+        
+        # Create scroll area for content
+        scroll = QScrollArea()
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        
+        # Content widget
+        content_widget = QWidget()
+        content_layout = QVBoxLayout(content_widget)
+        content_layout.setSpacing(12)
+        
+        # DuDONG Logo (moved to top, larger size)
+        logo_label = QLabel()
+        logo_path = PROJECT_ROOT / "assets" / "logos" / "dudong_logo.png"
+        try:
+            if logo_path.exists():
+                pixmap = QPixmap(str(logo_path))
+                if not pixmap.isNull():
+                    scaled_pixmap = pixmap.scaledToHeight(300, Qt.SmoothTransformation)
+                    logo_label.setPixmap(scaled_pixmap)
+                    logo_label.setAlignment(Qt.AlignCenter)
+                    content_layout.addWidget(logo_label)
+        except Exception as e:
+            print(f"Warning: Could not load DuDONG logo: {e}")
+        
+        # Application Title
+        title = QLabel("DuDONG")
+        title.setFont(QFont("Arial", 24, QFont.Bold))
+        title.setAlignment(Qt.AlignCenter)
+        title.setStyleSheet("color: #2c3e50; margin: 8px;")
+        content_layout.addWidget(title)
+        
+        # Subtitle
+        subtitle = QLabel("Durian Desktop-Oriented Non-Invasive Grading System")
+        subtitle.setFont(QFont("Arial", 12))
+        subtitle.setAlignment(Qt.AlignCenter)
+        subtitle.setStyleSheet("color: #7f8c8d; margin-bottom: 10px;")
+        subtitle.setWordWrap(True)
+        content_layout.addWidget(subtitle)
+        
+        # Version
+        version = QLabel("Version 2.1.0")
+        version.setFont(QFont("Arial", 10))
+        version.setAlignment(Qt.AlignCenter)
+        version.setStyleSheet("color: #95a5a6; margin-bottom: 20px;")
+        content_layout.addWidget(version)
+        
+        # Description
+        description = QLabel(
+            "DuDONG is a robust desktop application developed by the AIDurian project "
+            "using Python, designed for advanced assessment of durian ripeness and quality. "
+            "Utilizing advanced AI models and multiple sensor inputs, the software delivers "
+            "precise predictions of durian fruit ripeness, quality assessment, and maturity classification. "
+            "The application supports both audio analysis and multispectral imaging for comprehensive "
+            "durian evaluation. Through multi-model analysis including defect detection, shape assessment, "
+            "and locule counting, DuDONG provides detailed insights into durian quality characteristics. "
+            "All analysis results are persisted in a comprehensive database for historical tracking "
+            "and performance monitoring."
+        )
+        description.setWordWrap(True)
+        description.setFont(QFont("Arial", 10))
+        description.setStyleSheet("color: #2c3e50; padding: 10px; line-height: 1.5;")
+        content_layout.addWidget(description)
+        
+        # Features Section
+        features_title = QLabel("Key Features")
+        features_title.setFont(QFont("Arial", 14, QFont.Bold))
+        features_title.setStyleSheet("color: #2c3e50; margin-top: 20px;")
+        content_layout.addWidget(features_title)
+        
+        features_text = QLabel(
+            "• Durian Ripeness Classification (Audio Analysis & Multispectral Imaging)\n"
+            "• Quality Assessment (Defect Detection, Shape Analysis)\n"
+            "• Locule Counting with Segmentation\n"
+            "• Maturity Classification (Multispectral Analysis)\n"
+            "• Real-time Processing with GPU Acceleration\n"
+            "• Comprehensive Multi-Model Analysis Reports\n"
+            "• Manual Input Mode (Multi-Source File Processing)\n"
+            "• Database Persistence (Analysis History Tracking)"
+        )
+        features_text.setWordWrap(True)
+        features_text.setFont(QFont("Arial", 10))
+        features_text.setStyleSheet("color: #2c3e50; padding: 10px;")
+        content_layout.addWidget(features_text)
+        
+        # Development Team
+        team_title = QLabel("Development Team")
+        team_title.setFont(QFont("Arial", 14, QFont.Bold))
+        team_title.setStyleSheet("color: #2c3e50; margin-top: 20px;")
+        content_layout.addWidget(team_title)
+        
+        team_text = QLabel(
+            "Developed by researchers at the Department of Math, Physics, and Computer Science "
+            "in UP Mindanao, specifically, the AIDurian Project, under the Department of "
+            "Science and Technology's (DOST) i-CRADLE program.\n\n"
+            "The project aims to bridge the gap between manual practices of durian farming "
+            "and introduce it to the various technological advancements available today."
+        )
+        team_text.setWordWrap(True)
+        team_text.setFont(QFont("Arial", 10))
+        team_text.setStyleSheet("color: #2c3e50; padding: 10px;")
+        content_layout.addWidget(team_text)
+        
+        # Institutions
+        institutions_title = QLabel("Supported By")
+        institutions_title.setFont(QFont("Arial", 14, QFont.Bold))
+        institutions_title.setStyleSheet("color: #2c3e50; margin-top: 20px;")
+        content_layout.addWidget(institutions_title)
+        
+        institutions_text = QLabel(
+            "• University of the Philippines Mindanao\n"
+            "• Department of Science and Technology (DOST)\n"
+            "• DOST-PCAARRD i-CRADLE Program"
+        )
+        institutions_text.setWordWrap(True)
+        institutions_text.setFont(QFont("Arial", 10))
+        institutions_text.setStyleSheet("color: #2c3e50; padding: 10px;")
+        content_layout.addWidget(institutions_text)
+        
+        # Institution logos
+        inst_logos_layout = QHBoxLayout()
+        inst_logos_layout.setSpacing(15)
+        inst_logos_layout.setContentsMargins(10, 10, 10, 10)
+        
+        institution_data = [
+            ("UPMin.png", "UP Mindanao"),
+            ("dost.png", "DOST"),
+            ("DOST-PCAARRD.png", "DOST-PCAARRD"),
+        ]
+        
+        for image_file, inst_name in institution_data:
+            image_path = PROJECT_ROOT / "assets" / "logos" / image_file
+            inst_logo_container = QVBoxLayout()
+            inst_logo_label = QLabel()
+            
+            try:
+                if image_path.exists():
+                    pixmap = QPixmap(str(image_path))
+                    if not pixmap.isNull():
+                        # Scale to 50px height, maintain aspect ratio
+                        scaled_pixmap = pixmap.scaledToHeight(50, Qt.SmoothTransformation)
+                        inst_logo_label.setPixmap(scaled_pixmap)
+                        inst_logo_label.setAlignment(Qt.AlignCenter)
+                    else:
+                        inst_logo_label.setText(inst_name)
+                        inst_logo_label.setFont(QFont("Arial", 8))
+                        inst_logo_label.setAlignment(Qt.AlignCenter)
+                        inst_logo_label.setStyleSheet("color: #95a5a6; padding: 5px;")
+                else:
+                    inst_logo_label.setText(inst_name)
+                    inst_logo_label.setFont(QFont("Arial", 8))
+                    inst_logo_label.setAlignment(Qt.AlignCenter)
+                    inst_logo_label.setStyleSheet("color: #95a5a6; padding: 5px;")
+            except Exception as e:
+                print(f"Warning: Could not load institution logo {image_file}: {e}")
+                inst_logo_label.setText(inst_name)
+                inst_logo_label.setFont(QFont("Arial", 8))
+                inst_logo_label.setAlignment(Qt.AlignCenter)
+                inst_logo_label.setStyleSheet("color: #95a5a6; padding: 5px;")
+            
+            inst_logo_container.addWidget(inst_logo_label)
+            inst_logos_layout.addLayout(inst_logo_container)
+        
+        content_layout.addLayout(inst_logos_layout)
+        
+        # Partners
+        partners_title = QLabel("Industry Partners")
+        partners_title.setFont(QFont("Arial", 14, QFont.Bold))
+        partners_title.setStyleSheet("color: #2c3e50; margin-top: 20px;")
+        content_layout.addWidget(partners_title)
+        
+        partners_text = QLabel(
+            "Special thanks to AIDurian's partners:\n"
+            "• Belviz Farms\n"
+            "• D'Farmers Market\n"
+            "• EngSeng Food Products\n"
+            "• Rosario's Delicacies\n"
+            "• VJT Enterprises"
+        )
+        partners_text.setWordWrap(True)
+        partners_text.setFont(QFont("Arial", 10))
+        partners_text.setStyleSheet("color: #2c3e50; padding: 10px;")
+        content_layout.addWidget(partners_text)
+        
+        # Partner logos
+        logos_layout = QHBoxLayout()
+        logos_layout.setSpacing(15)
+        logos_layout.setContentsMargins(10, 10, 10, 10)
+        
+        partner_data = [
+            ("Belviz-logo-1.png", "Belviz Farms"),
+            ("logo_final.png", "D'Farmers Market"),
+            ("eng-seng.png", "EngSeng Food Products"),
+            ("Rosario-Background-Removed.png", "Rosario's Delicacies"),
+            ("VJT-Enterprise.jpeg", "VJT Enterprises"),
+        ]
+        
+        for image_file, partner_name in partner_data:
+            image_path = PROJECT_ROOT / "assets" / "logos" / image_file
+            logo_container = QVBoxLayout()
+            logo_label = QLabel()
+            
+            try:
+                if image_path.exists():
+                    pixmap = QPixmap(str(image_path))
+                    if not pixmap.isNull():
+                        # Scale to 50px height, maintain aspect ratio
+                        scaled_pixmap = pixmap.scaledToHeight(50, Qt.SmoothTransformation)
+                        logo_label.setPixmap(scaled_pixmap)
+                        logo_label.setAlignment(Qt.AlignCenter)
+                    else:
+                        logo_label.setText(partner_name)
+                        logo_label.setFont(QFont("Arial", 8))
+                        logo_label.setAlignment(Qt.AlignCenter)
+                        logo_label.setStyleSheet("color: #95a5a6; padding: 5px;")
+                else:
+                    logo_label.setText(partner_name)
+                    logo_label.setFont(QFont("Arial", 8))
+                    logo_label.setAlignment(Qt.AlignCenter)
+                    logo_label.setStyleSheet("color: #95a5a6; padding: 5px;")
+            except Exception as e:
+                print(f"Warning: Could not load partner logo {image_file}: {e}")
+                logo_label.setText(partner_name)
+                logo_label.setFont(QFont("Arial", 8))
+                logo_label.setAlignment(Qt.AlignCenter)
+                logo_label.setStyleSheet("color: #95a5a6; padding: 5px;")
+            
+            logo_container.addWidget(logo_label)
+            logos_layout.addLayout(logo_container)
+        
+        content_layout.addLayout(logos_layout)
+        
+        # Copyright
+        copyright_text = QLabel("© 2024 AIDurian Project. All rights reserved.")
+        copyright_text.setAlignment(Qt.AlignCenter)
+        copyright_text.setFont(QFont("Arial", 9))
+        copyright_text.setStyleSheet("color: #95a5a6; margin-top: 30px;")
+        content_layout.addWidget(copyright_text)
+        
+        # Add stretch to push content to top
+        content_layout.addStretch()
+        
+        # Set content widget to scroll area
+        scroll.setWidget(content_widget)
+        layout.addWidget(scroll)
+        
+        # Close button
+        close_btn = QPushButton("Close")
+        close_btn.setStyleSheet(STANDARD_BUTTON_STYLE)
+        close_btn.clicked.connect(self.accept)
+        layout.addWidget(close_btn, alignment=Qt.AlignRight)
+        
+        self.setLayout(layout)
+
+
+

+ 544 - 0
ui/dialogs/help_dialog.py

@@ -0,0 +1,544 @@
+"""
+Help Dialog
+
+Displays usage instructions and help information for the DuDONG application.
+"""
+
+from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QLabel, QPushButton, 
+                              QScrollArea, QWidget, QTabWidget)
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+from resources.styles import STANDARD_BUTTON_STYLE, TAB_WIDGET_STYLE
+
+
+class HelpDialog(QDialog):
+    """
+    Help dialog window showing usage instructions.
+    
+    Displays:
+    - Getting started guide
+    - Feature descriptions
+    - Usage instructions for each tool
+    - Troubleshooting tips
+    - Keyboard shortcuts
+    """
+    
+    def __init__(self, parent=None):
+        """
+        Initialize the help dialog.
+        
+        Args:
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.setWindowTitle("DuDONG Help")
+        self.setMinimumSize(950, 650)
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout()
+        layout.setContentsMargins(10, 10, 10, 10)
+        layout.setSpacing(10)
+        
+        # Title
+        title = QLabel("DuDONG Help & Documentation")
+        title.setFont(QFont("Arial", 16, QFont.Bold))
+        title.setAlignment(Qt.AlignCenter)
+        title.setStyleSheet("color: #2c3e50; margin: 5px;")
+        layout.addWidget(title)
+        
+        # Tab widget for different help sections
+        tabs = QTabWidget()
+        tabs.setStyleSheet(TAB_WIDGET_STYLE)
+        tabs.setTabPosition(QTabWidget.North)
+        tabs.setMovable(False)
+        tabs.setDocumentMode(False)
+        
+        # Getting Started Tab
+        tabs.addTab(self._create_getting_started(), "Getting Started")
+        
+        # Comprehensive Analysis Tab (NEW - Primary workflow)
+        tabs.addTab(self._create_comprehensive_analysis(), "Analyze Durian")
+        
+        # System Info Tab
+        tabs.addTab(self._create_system_info(), "System Requirements")
+        
+        # Troubleshooting Tab
+        tabs.addTab(self._create_troubleshooting(), "Troubleshooting")
+        
+        layout.addWidget(tabs, 1)
+        
+        # Close button
+        close_btn = QPushButton("Close")
+        close_btn.setStyleSheet(STANDARD_BUTTON_STYLE)
+        close_btn.clicked.connect(self.accept)
+        layout.addWidget(close_btn, alignment=Qt.AlignRight)
+        
+        self.setLayout(layout)
+    
+    def _create_scrollable_content(self, content_text: str) -> QScrollArea:
+        """
+        Create a scrollable text area.
+        
+        Args:
+            content_text: HTML formatted text content
+        
+        Returns:
+            QScrollArea with content
+        """
+        scroll = QScrollArea()
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        
+        label = QLabel(content_text)
+        label.setWordWrap(True)
+        label.setTextFormat(Qt.RichText)
+        label.setStyleSheet("padding: 20px; color: #2c3e50; line-height: 1.6;")
+        
+        scroll.setWidget(label)
+        return scroll
+    
+    def _create_comprehensive_analysis(self) -> QWidget:
+        """Create the comprehensive analysis (Analyze Durian) help tab."""
+        content = """
+        <h2>Comprehensive Durian Analysis</h2>
+        
+        <h3>Overview</h3>
+        <p>The <b>"Analyze Durian"</b> feature is DuDONG's primary workflow for comprehensive fruit grading. It combines multiple AI models to analyze different aspects of durian quality, ripeness, and maturity in a single analysis session.</p>
+        
+        <h3>Two Analysis Modes</h3>
+        
+        <h4>1. Manual Entry Mode (Current)</h4>
+        <p>Select data files from different camera and sensor sources:</p>
+        <ul>
+            <li><b>DSLR Side View</b> (JPG/PNG/BMP) - Plain durian image for defect detection</li>
+            <li><b>DSLR Top View</b> (JPG/PNG/BMP) - Cross-section or top view for locule counting</li>
+            <li><b>Multispectral TIFF</b> (TIF/TIFF) - 12-band multispectral image for maturity analysis</li>
+            <li><b>Thermal CSV</b> (CSV) - Thermal imaging data (future integration)</li>
+            <li><b>Audio WAV</b> (WAV) - Audio recording of knock sound for ripeness classification</li>
+        </ul>
+        <p><i>At least one input is required. All other inputs are optional.</i></p>
+        
+        <h4>2. Auto Mode (Future)</h4>
+        <p>Automatically detects and captures data from running camera applications:</p>
+        <ul>
+            <li><b>EOS Utility</b> - Canon DSLR capture</li>
+            <li><b>2nd Look</b> - Multispectral camera capture</li>
+            <li><b>AnalyzIR</b> - Thermal imaging capture</li>
+        </ul>
+        
+        <h3>Available Analysis Models</h3>
+        
+        <p><b>1. Ripeness Classification (Audio)</b></p>
+        <ul>
+            <li><b>Input:</b> Audio WAV file of knock sound</li>
+            <li><b>Processing:</b> Detects individual knocks and averages predictions</li>
+            <li><b>Output:</b> Unripe, Ripe, or Overripe (with confidence %)</li>
+        </ul>
+        
+        <p><b>2. Defect Detection (RGB Side View)</b></p>
+        <ul>
+            <li><b>Input:</b> Image of durian side view</li>
+            <li><b>Processing:</b> YOLOv8-based defect detection</li>
+            <li><b>Output:</b> No Defects, Minor Defects, or Reject classification</li>
+        </ul>
+        
+        <p><b>3. Locule Counting (RGB Top View)</b></p>
+        <ul>
+            <li><b>Input:</b> Cross-section or top view image</li>
+            <li><b>Processing:</b> YOLOv8 segmentation to identify and count locules</li>
+            <li><b>Output:</b> Total locule count with color-coded visualization</li>
+        </ul>
+        
+        <p><b>4. Maturity Classification (Multispectral)</b></p>
+        <ul>
+            <li><b>Input:</b> 12-band multispectral TIFF image</li>
+            <li><b>Processing:</b> Deep learning model analyzes spectral signatures</li>
+            <li><b>Output:</b> Immature, Mature, or Overmature classification</li>
+        </ul>
+        
+        <p><b>5. Shape Classification (RGB)</b></p>
+        <ul>
+            <li><b>Input:</b> Image of durian</li>
+            <li><b>Processing:</b> YOLO classification model</li>
+            <li><b>Output:</b> Regular or Irregular shape classification</li>
+        </ul>
+        
+        <h3>Workflow Steps</h3>
+        <ol>
+            <li>Click <b>"Manual Entry"</b> button on the Dashboard</li>
+            <li>In the dialog that opens, select files for desired analyses (at least one required)</li>
+            <li>Click <b>"Confirm"</b> to start processing</li>
+            <li>System automatically navigates to <b>Reports</b> tab</li>
+            <li>Models process inputs in parallel using available GPU</li>
+            <li>View comprehensive results with:
+                <ul>
+                    <li>Overall grade (A/B/C)</li>
+                    <li>Individual model results with confidence scores</li>
+                    <li>Annotated images and visualizations</li>
+                </ul>
+            </li>
+            <li>Click <b>"Export PDF"</b> to save detailed report</li>
+        </ol>
+        
+        <h3>Understanding the Report</h3>
+        <ul>
+            <li><b>Overall Grade</b> - Combined assessment: A (best), B (good), C (acceptable)</li>
+            <li><b>Analysis ID</b> - Unique identifier stored in database for future reference</li>
+            <li><b>Individual Results</b> - Each model's prediction with confidence percentage</li>
+            <li><b>Visualizations</b> - Annotated images, spectrograms, and Grad-CAM explanations</li>
+            <li><b>Processing Time</b> - Total time to complete all analyses</li>
+        </ul>
+        
+        <h3>Grade Calculation</h3>
+        <p>Overall grade is determined by combining results from all available analyses:</p>
+        <ul>
+            <li><b>Grade A:</b> Premium quality - optimal ripeness, no defects, ideal maturity, regular shape</li>
+            <li><b>Grade B:</b> Good quality - acceptable ripeness, minor defects, good maturity</li>
+            <li><b>Grade C:</b> Standard quality - mixed indicators, some defects, acceptable maturity</li>
+        </ul>
+        
+        <h3>Tips for Best Results</h3>
+        <ul>
+            <li>Use high-quality images (1920x1080 or higher resolution)</li>
+            <li>Ensure good lighting and avoid shadows on durian</li>
+            <li>Record audio in a quiet environment with consistent knocking force</li>
+            <li>Position durian clearly in the center of images</li>
+            <li>Use proper file formats: JPG/PNG for images, WAV for audio, TIFF for multispectral</li>
+        </ul>
+        """
+        return self._create_scrollable_content(content)
+    
+    def _create_getting_started(self) -> QWidget:
+        """Create the getting started help tab."""
+        content = """
+        <h2>Getting Started with DuDONG</h2>
+        
+        <h3>Dashboard Overview</h3>
+        <p>The main dashboard is your control center for all durian analysis:</p>
+        <ul>
+            <li><b>System Status Panel</b> - View status of all AI models (Ripeness, Quality, Defect, Maturity)</li>
+            <li><b>Quick Actions Panel</b> - Two main buttons:
+                <ul>
+                    <li><b>Auto Analyze Durian</b> - Automatically detects and captures from camera applications</li>
+                    <li><b>Manual Entry</b> - Manually select files from different camera sources</li>
+                </ul>
+            </li>
+            <li><b>Recent Results Panel</b> - View and reload your last 5 analyses</li>
+            <li><b>System Information Panel</b> - Monitor performance: uptime, throughput, processing time, and model accuracy</li>
+            <li><b>Live Feeds Panel</b> - Placeholder for future camera integration</li>
+        </ul>
+        
+        <h3>Quick Start Workflow</h3>
+        <ol>
+            <li>Click <b>"Manual Entry"</b> on the Dashboard (Auto mode requires running camera apps)</li>
+            <li>Select data from available camera sources in the dialog:
+                <ul>
+                    <li>DSLR Side View (optional) - Image for defect detection</li>
+                    <li>DSLR Top View (optional) - Image for locule counting</li>
+                    <li>Multispectral TIFF (optional) - For maturity classification</li>
+                    <li>Audio WAV (optional) - For ripeness classification</li>
+                </ul>
+            </li>
+            <li>At least one input is required. All other inputs are optional.</li>
+            <li>Click <b>Confirm</b> to start analysis</li>
+            <li>View comprehensive results in the <b>Reports</b> tab</li>
+        </ol>
+        
+        <h3>Navigation</h3>
+        <p>The application has two main tabs:</p>
+        <ul>
+            <li><b>Dashboard</b> - Main overview and quick action buttons</li>
+            <li><b>Reports</b> - Detailed analysis results and PDF export</li>
+        </ul>
+        <p><i>Note: Ripeness, Quality, and Maturity tabs are available for individual model testing.</i></p>
+        
+        <h3>Understanding Results</h3>
+        <ul>
+            <li><b>Overall Grade</b> - A/B/C classification based on all analysis results</li>
+            <li><b>Ripeness</b> - Audio-based classification: Unripe, Ripe, or Overripe</li>
+            <li><b>Quality</b> - Defect detection: No Defects, Minor Defects, or Reject</li>
+            <li><b>Maturity</b> - Multispectral-based classification: Immature, Mature, or Overmature</li>
+            <li><b>Shape</b> - Shape classification: Regular or Irregular</li>
+            <li><b>Locule Count</b> - Number of segments detected in top view</li>
+        </ul>
+        """
+        return self._create_scrollable_content(content)
+    
+    def _create_system_info(self) -> QWidget:
+        """Create the system requirements tab."""
+        content = """
+        <h2>System Requirements</h2>
+        
+        <h3>Minimum Requirements</h3>
+        <ul>
+            <li><b>Operating System:</b> Windows 10/11, Linux, or macOS</li>
+            <li><b>Python:</b> 3.9 or higher</li>
+            <li><b>RAM:</b> 8GB minimum, 16GB recommended</li>
+            <li><b>Storage:</b> 10GB free space (includes models and database)</li>
+            <li><b>Display:</b> 1920x1080 resolution or higher for optimal UI</li>
+        </ul>
+        
+        <h3>Recommended Configuration</h3>
+        <ul>
+            <li><b>GPU:</b> NVIDIA GPU with CUDA 12.8 support (highly recommended)</li>
+            <li><b>VRAM:</b> 4GB VRAM or more for fast processing</li>
+            <li><b>RAM:</b> 32GB for batch processing multiple analyses</li>
+            <li><b>CPU:</b> Multi-core processor (8+ cores) for parallel processing</li>
+            <li><b>SSD:</b> NVMe SSD for faster model loading</li>
+        </ul>
+        
+        <h3>GPU Acceleration Setup</h3>
+        <p>For optimal performance with NVIDIA GPU:</p>
+        <ol>
+            <li>Install latest NVIDIA GPU drivers</li>
+            <li>Install CUDA Toolkit 12.8</li>
+            <li>Install cuDNN 9.x for CUDA 12.x</li>
+            <li>Verify installation: Check System Info panel shows GPU status as "Active"</li>
+        </ol>
+        
+        <h3>Required Dependencies</h3>
+        <ul>
+            <li><b>PyQt5</b> - GUI framework</li>
+            <li><b>TensorFlow 2.17+</b> - Deep learning (audio model)</li>
+            <li><b>PyTorch</b> - Deep learning (YOLO models)</li>
+            <li><b>Ultralytics YOLO</b> - Object detection and segmentation</li>
+            <li><b>OpenCV 4.10+</b> - Image processing</li>
+            <li><b>NumPy</b> - Numerical computations</li>
+            <li><b>SciPy</b> - Signal processing</li>
+            <li><b>Matplotlib</b> - Visualization</li>
+            <li><b>Librosa</b> - Audio analysis</li>
+            <li><b>Pillow</b> - Image handling</li>
+        </ul>
+        
+        <h3>Model Files Required</h3>
+        <p>All model files must be present in the project directory:</p>
+        <ul>
+            <li><b>models/audio/best_model_mel_spec_grouped.keras</b> - Ripeness audio model</li>
+            <li><b>best.pt</b> - Defect detection model (YOLOv8)</li>
+            <li><b>locule.pt</b> - Locule segmentation model (YOLOv8)</li>
+            <li><b>models/multispectral/maturity/final_model.pt</b> - Maturity classification model</li>
+            <li><b>shape.pt</b> - Shape classification model (YOLOv8)</li>
+        </ul>
+        
+        <h3>Database Setup</h3>
+        <ul>
+            <li><b>Location:</b> data/database.db</li>
+            <li><b>Automatic:</b> Database is created automatically on first run</li>
+            <li><b>Storage:</b> Stores all analysis results, input files, and visualizations</li>
+            <li><b>Size:</b> Grows with number of analyses (typically 100MB per 1000 analyses)</li>
+        </ul>
+        
+        <h3>Disk Space Estimates</h3>
+        <ul>
+            <li><b>Model files:</b> ~2GB</li>
+            <li><b>Application:</b> ~500MB</li>
+            <li><b>Database:</b> Grows with analyses (~100MB per 1000 analyses)</li>
+            <li><b>Temporary files:</b> ~100MB</li>
+            <li><b>Total:</b> Plan for 10GB+ free space for safe operation</li>
+        </ul>
+        
+        <h3>Performance Tips</h3>
+        <ul>
+            <li><b>GPU:</b> Use GPU for 5-10x faster processing (if available)</li>
+            <li><b>Memory:</b> Close other applications to free RAM</li>
+            <li><b>SSD:</b> Install on SSD for faster model loading</li>
+            <li><b>Cooling:</b> Ensure adequate ventilation for GPU during batch processing</li>
+            <li><b>Batch:</b> Process multiple analyses sequentially, not simultaneously</li>
+        </ul>
+        
+        <h3>Troubleshooting System Issues</h3>
+        <ul>
+            <li><b>GPU Not Detected:</b> Check CUDA installation and PyTorch GPU support</li>
+            <li><b>Out of Memory:</b> Close other applications or process smaller batches</li>
+            <li><b>Slow Processing:</b> Enable GPU acceleration or reduce image resolution</li>
+            <li><b>Models Not Loading:</b> Verify model files exist and have correct permissions</li>
+            <li><b>Database Errors:</b> Check disk space and file system permissions</li>
+        </ul>
+        
+        <h3>Checking System Status</h3>
+        <p>In the Dashboard, the <b>System Info Panel</b> displays:</p>
+        <ul>
+            <li>Application uptime</li>
+            <li>Daily analysis count</li>
+            <li>Average processing time</li>
+            <li>Model accuracy statistics</li>
+            <li>GPU status (in status bar)</li>
+            <li>Number of loaded models</li>
+        </ul>
+        """
+        return self._create_scrollable_content(content)
+    
+    def _create_troubleshooting(self) -> QWidget:
+        """Create the troubleshooting tab."""
+        content = """
+        <h2>Troubleshooting</h2>
+        
+        <h3>Common Issues</h3>
+        
+        <h4>1. Models Not Loading at Startup</h4>
+        <p><b>Problem:</b> System Status shows models as "offline" or displays error messages</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Verify all model files exist in correct locations</li>
+            <li>Check file permissions (models should be readable)</li>
+            <li>Ensure sufficient disk space (at least 10GB free)</li>
+            <li>Try deleting and re-downloading model files</li>
+            <li>Check console output for specific error messages</li>
+        </ul>
+        
+        <h4>2. GPU Not Detected</h4>
+        <p><b>Problem:</b> Status bar shows "GPU: N/A" instead of "GPU: Active"</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Install/update NVIDIA GPU drivers to latest version</li>
+            <li>Install CUDA Toolkit 12.8 (matching TensorFlow/PyTorch versions)</li>
+            <li>Reinstall PyTorch with CUDA support: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128</li>
+            <li>Verify CUDA availability in Python: <code>import torch; print(torch.cuda.is_available())</code></li>
+            <li>Check GPU memory usage with nvidia-smi command</li>
+        </ul>
+        
+        <h4>3. "Manual Entry" Dialog Won't Open</h4>
+        <p><b>Problem:</b> Clicking Manual Entry button has no effect</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Ensure no processing is currently active</li>
+            <li>Try clicking the button again (sometimes needs 2 clicks)</li>
+            <li>Restart the application</li>
+            <li>Check console for error messages</li>
+        </ul>
+        
+        <h4>4. Processing Hangs or Freezes</h4>
+        <p><b>Problem:</b> Application becomes unresponsive during analysis</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Wait longer (first run can take 30+ seconds for model loading)</li>
+            <li>Close other memory-heavy applications</li>
+            <li>Reduce image resolution or use smaller files</li>
+            <li>Check available RAM (needs at least 4GB free)</li>
+            <li>If truly frozen, force close and restart</li>
+        </ul>
+        
+        <h4>5. Reports Tab Shows "No Analysis Yet"</h4>
+        <p><b>Problem:</b> After clicking Confirm, Reports tab doesn't update with results</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Wait for processing to complete (check status bar)</li>
+            <li>Verify at least one input file was selected</li>
+            <li>Check console for model processing errors</li>
+            <li>Try with a different input file</li>
+            <li>Ensure input files are valid and uncorrupted</li>
+        </ul>
+        
+        <h4>6. Inaccurate Predictions</h4>
+        <p><b>Problem:</b> Results don't match expected output</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Verify input quality (good lighting, clear images, quality audio)</li>
+            <li>Follow input guidelines (resolution, format, positioning)</li>
+            <li>Use consistent data format and preprocessing</li>
+            <li>Try multiple samples to verify consistency</li>
+            <li>Report systematic errors to development team with examples</li>
+        </ul>
+        
+        <h4>7. PDF Export Fails</h4>
+        <p><b>Problem:</b> "Export PDF" button doesn't work or file doesn't save</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Ensure reportlab library is installed: <code>pip install reportlab</code></li>
+            <li>Check disk space for output file</li>
+            <li>Verify write permissions in destination folder</li>
+            <li>Try saving to a different location (e.g., Documents folder)</li>
+            <li>Close any open PDF files that might have the same name</li>
+        </ul>
+        
+        <h4>8. "Processing" Status Won't Clear</h4>
+        <p><b>Problem:</b> Status bar shows "Processing" but nothing is happening</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Wait a full minute (models can take time)</li>
+            <li>Check if only one model failed to respond</li>
+            <li>Try restarting the application</li>
+            <li>Check console for worker thread errors</li>
+        </ul>
+        
+        <h4>9. Audio File Won't Process</h4>
+        <p><b>Problem:</b> Selected WAV file causes error or crash</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Verify file is valid WAV format (not corrupted)</li>
+            <li>Ensure sample rate is 44.1kHz or higher</li>
+            <li>Try re-exporting the file from audio software</li>
+            <li>Use ffmpeg to convert: <code>ffmpeg -i input.wav -acodec pcm_s16le -ar 44100 output.wav</code></li>
+            <li>Check file size (should be under 100MB)</li>
+        </ul>
+        
+        <h4>10. Image Processing Errors</h4>
+        <p><b>Problem:</b> Image file causes crash or error message</p>
+        <p><b>Solutions:</b></p>
+        <ul>
+            <li>Verify image format (JPG, PNG, or BMP only)</li>
+            <li>Check image resolution (should be 1920x1080 or higher)</li>
+            <li>Try re-saving image with image editor</li>
+            <li>Ensure image is not corrupted: open in viewer first</li>
+            <li>Convert format if needed: <code>ffmpeg -i input.png -q:v 5 output.jpg</code></li>
+        </ul>
+        
+        <h3>Advanced Troubleshooting</h3>
+        
+        <h4>Checking Logs</h4>
+        <ul>
+            <li>Console output shows real-time processing logs</li>
+            <li>Look for "[Error]" or "[Exception]" messages</li>
+            <li>Save console output for debugging</li>
+            <li>Report with full error stack trace to development team</li>
+        </ul>
+        
+        <h4>Database Issues</h4>
+        <ul>
+            <li><b>Problem:</b> "Database error" when loading analyses</li>
+            <li><b>Solution:</b> Database corrupted - back up data/database.db and delete it</li>
+            <li>Application will recreate fresh database on next run</li>
+            <li>Previous analyses will be lost (unless backup exists)</li>
+        </ul>
+        
+        <h4>Memory Issues</h4>
+        <ul>
+            <li>Monitor RAM usage in System Info panel</li>
+            <li>If approaching maximum, close other applications</li>
+            <li>Reduce batch size or process one analysis at a time</li>
+            <li>Consider upgrading system RAM</li>
+        </ul>
+        
+        <h3>Getting Help</h3>
+        <p>If problems persist:</p>
+        <ul>
+            <li>Check the full documentation in each help tab</li>
+            <li>Review system requirements and verify compliance</li>
+            <li>Try reproducing the issue with sample data</li>
+            <li>Collect console output and error messages</li>
+            <li>Contact AIDurian development team with:
+                <ul>
+                    <li>Detailed description of the problem</li>
+                    <li>Steps to reproduce</li>
+                    <li>Console output/error messages</li>
+                    <li>System information (OS, Python version, GPU info)</li>
+                    <li>Input files if possible</li>
+                </ul>
+            </li>
+        </ul>
+        
+        <h3>Performance Optimization</h3>
+        <ul>
+            <li><b>First Run:</b> Models are cached after first load, subsequent runs are faster</li>
+            <li><b>GPU Warmup:</b> First GPU inference can be slow, later ones are faster</li>
+            <li><b>Batch Processing:</b> Process multiple files sequentially for best performance</li>
+            <li><b>Resource Monitoring:</b> Watch System Info for bottlenecks</li>
+        </ul>
+        """
+        return self._create_scrollable_content(content)
+
+
+

+ 267 - 0
ui/dialogs/image_preview_dialog.py

@@ -0,0 +1,267 @@
+"""
+Image Preview Dialog
+
+Dialog for displaying enlarged view of images/spectrograms with zoom functionality.
+"""
+
+from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QScrollArea
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QPixmap, QFont
+
+
+class ImagePreviewDialog(QDialog):
+    """
+    Dialog for displaying enlarged image previews with zoom functionality.
+    
+    Features:
+    - Enlarged view of spectrograms/images
+    - Zoom in/out with buttons or keyboard (+/- or scroll wheel)
+    - Click anywhere or press ESC to close
+    - Clean, professional appearance
+    """
+    
+    def __init__(self, pixmap: QPixmap, title: str = "Preview", parent=None):
+        """
+        Initialize the preview dialog.
+        
+        Args:
+            pixmap: QPixmap to display
+            title: Dialog title
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.original_pixmap = pixmap
+        self.setWindowTitle(title)
+        self.setModal(True)
+        self.zoom_level = 1.0
+        self.min_zoom = 0.25
+        self.max_zoom = 4.0
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the dialog UI."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Header
+        header = QLabel(self.windowTitle())
+        header.setAlignment(Qt.AlignCenter)
+        header.setStyleSheet("""
+            QLabel {
+                background-color: #2c3e50;
+                color: white;
+                font-weight: bold;
+                font-size: 12px;
+                padding: 10px;
+            }
+        """)
+        layout.addWidget(header)
+        
+        # Scroll area for image with zoom
+        scroll_area = QScrollArea()
+        scroll_area.setStyleSheet("""
+            QScrollArea {
+                background-color: #2c3e50;
+                border: none;
+            }
+        """)
+        scroll_area.setWidgetResizable(True)
+        
+        # Image display label
+        self.image_label = QLabel()
+        self.image_label.setAlignment(Qt.AlignCenter)
+        self.image_label.setStyleSheet("""
+            QLabel {
+                background-color: #2c3e50;
+                padding: 20px;
+            }
+        """)
+        
+        # Initial scaling to fit dialog (max 1200x800)
+        initial_pixmap = self.original_pixmap.scaled(
+            1200, 800, Qt.KeepAspectRatio, Qt.SmoothTransformation
+        )
+        self.image_label.setPixmap(initial_pixmap)
+        self.zoom_level = 1.0
+        self.current_display_pixmap = initial_pixmap
+        
+        scroll_area.setWidget(self.image_label)
+        layout.addWidget(scroll_area)
+        
+        # Control buttons and zoom info
+        controls_layout = QHBoxLayout()
+        controls_layout.setContentsMargins(10, 10, 10, 10)
+        controls_layout.setSpacing(10)
+        
+        # Zoom out button
+        zoom_out_button = QPushButton("🔍−  Zoom Out")
+        zoom_out_button.setFont(QFont("Arial", 10))
+        zoom_out_button.setFixedHeight(35)
+        zoom_out_button.setStyleSheet("""
+            QPushButton {
+                background-color: #34495e;
+                color: white;
+                border: none;
+                font-weight: bold;
+                border-radius: 4px;
+            }
+            QPushButton:hover {
+                background-color: #2c3e50;
+            }
+            QPushButton:pressed {
+                background-color: #1a252f;
+            }
+        """)
+        zoom_out_button.clicked.connect(self.zoom_out)
+        controls_layout.addWidget(zoom_out_button)
+        
+        # Zoom level label
+        self.zoom_label = QLabel("100%")
+        self.zoom_label.setAlignment(Qt.AlignCenter)
+        self.zoom_label.setFont(QFont("Arial", 10, QFont.Bold))
+        self.zoom_label.setStyleSheet("color: white; min-width: 60px;")
+        controls_layout.addWidget(self.zoom_label)
+        
+        # Zoom in button
+        zoom_in_button = QPushButton("Zoom In  🔍+")
+        zoom_in_button.setFont(QFont("Arial", 10))
+        zoom_in_button.setFixedHeight(35)
+        zoom_in_button.setStyleSheet("""
+            QPushButton {
+                background-color: #34495e;
+                color: white;
+                border: none;
+                font-weight: bold;
+                border-radius: 4px;
+            }
+            QPushButton:hover {
+                background-color: #2c3e50;
+            }
+            QPushButton:pressed {
+                background-color: #1a252f;
+            }
+        """)
+        zoom_in_button.clicked.connect(self.zoom_in)
+        controls_layout.addWidget(zoom_in_button)
+        
+        # Reset zoom button
+        reset_button = QPushButton("Reset (R)")
+        reset_button.setFont(QFont("Arial", 10))
+        reset_button.setFixedHeight(35)
+        reset_button.setStyleSheet("""
+            QPushButton {
+                background-color: #7f8c8d;
+                color: white;
+                border: none;
+                font-weight: bold;
+                border-radius: 4px;
+            }
+            QPushButton:hover {
+                background-color: #6c7a7d;
+            }
+            QPushButton:pressed {
+                background-color: #5a6667;
+            }
+        """)
+        reset_button.clicked.connect(self.reset_zoom)
+        controls_layout.addWidget(reset_button)
+        
+        # Close button
+        close_button = QPushButton("Close (ESC)")
+        close_button.setFont(QFont("Arial", 10))
+        close_button.setFixedHeight(35)
+        close_button.setStyleSheet("""
+            QPushButton {
+                background-color: #e74c3c;
+                color: white;
+                border: none;
+                font-weight: bold;
+                border-radius: 4px;
+            }
+            QPushButton:hover {
+                background-color: #c0392b;
+            }
+            QPushButton:pressed {
+                background-color: #a93226;
+            }
+        """)
+        close_button.clicked.connect(self.accept)
+        controls_layout.addWidget(close_button)
+        
+        layout.addLayout(controls_layout)
+        
+        # Set dialog size
+        self.resize(1280, 900)
+        
+    def zoom_in(self):
+        """Increase zoom level."""
+        if self.zoom_level < self.max_zoom:
+            self.zoom_level *= 1.25
+            if self.zoom_level > self.max_zoom:
+                self.zoom_level = self.max_zoom
+            self._update_display()
+    
+    def zoom_out(self):
+        """Decrease zoom level."""
+        if self.zoom_level > self.min_zoom:
+            self.zoom_level /= 1.25
+            if self.zoom_level < self.min_zoom:
+                self.zoom_level = self.min_zoom
+            self._update_display()
+    
+    def reset_zoom(self):
+        """Reset to initial zoom level."""
+        self.zoom_level = 1.0
+        # Re-scale to fit dialog
+        scaled_pixmap = self.original_pixmap.scaled(
+            1200, 800, Qt.KeepAspectRatio, Qt.SmoothTransformation
+        )
+        self.image_label.setPixmap(scaled_pixmap)
+        self.current_display_pixmap = scaled_pixmap
+        self._update_zoom_label()
+    
+    def _update_display(self):
+        """Update the displayed image based on current zoom level."""
+        # Calculate new size based on original pixmap and zoom level
+        new_width = int(self.original_pixmap.width() * self.zoom_level)
+        new_height = int(self.original_pixmap.height() * self.zoom_level)
+        
+        # Scale the image
+        scaled_pixmap = self.original_pixmap.scaled(
+            new_width, new_height, Qt.KeepAspectRatio, Qt.SmoothTransformation
+        )
+        self.image_label.setPixmap(scaled_pixmap)
+        self.current_display_pixmap = scaled_pixmap
+        self._update_zoom_label()
+    
+    def _update_zoom_label(self):
+        """Update zoom percentage label."""
+        zoom_percentage = int(self.zoom_level * 100)
+        self.zoom_label.setText(f"{zoom_percentage}%")
+        
+    def mousePressEvent(self, event):
+        """Close dialog on mouse click outside the scroll area."""
+        # Only close if clicking on dialog background, not on scroll area
+        if not self.image_label.geometry().contains(self.mapFromGlobal(event.globalPos())):
+            self.accept()
+        
+    def wheelEvent(self, event):
+        """Handle mouse wheel for zooming."""
+        if event.angleDelta().y() > 0:
+            self.zoom_in()
+        else:
+            self.zoom_out()
+        
+    def keyPressEvent(self, event):
+        """Handle keyboard shortcuts for zooming."""
+        if event.key() == Qt.Key_Escape:
+            self.accept()
+        elif event.key() == Qt.Key_Plus or event.key() == Qt.Key_Equal:
+            self.zoom_in()
+        elif event.key() == Qt.Key_Minus:
+            self.zoom_out()
+        elif event.key() == Qt.Key_R:
+            self.reset_zoom()
+        else:
+            super().keyPressEvent(event)

+ 372 - 0
ui/dialogs/manual_input_dialog.py

@@ -0,0 +1,372 @@
+"""
+Manual Input Dialog
+
+Dialog for manual camera input from different sources.
+"""
+
+from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, 
+                              QPushButton, QLineEdit, QFileDialog, QGroupBox,
+                              QFormLayout, QMessageBox, QCheckBox, QWidget)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QPixmap
+from pathlib import Path
+
+from resources.styles import GROUP_BOX_STYLE
+
+
+class ManualInputDialog(QDialog):
+    """
+    Dialog for collecting manual camera inputs.
+    
+    Allows users to select files from different camera sources:
+    - DSLR (RGB images)
+    - Multispectral camera
+    - Thermal camera
+    
+    Signals:
+        inputs_confirmed: Emitted when user confirms inputs with paths dict
+    """
+    
+    inputs_confirmed = pyqtSignal(dict)  # Emits dict of {source: file_path}
+    
+    def __init__(self, parent=None):
+        """Initialize the manual input dialog."""
+        super().__init__(parent)
+        self.setWindowTitle("Manual Camera Input")
+        self.setModal(True)
+        self.setMinimumWidth(900)
+        self.setMinimumHeight(700)
+        
+        self.inputs = {
+            'dslr_side': '',  # Side view for defect model
+            'dslr_top': '',   # Top view for locule counter
+            'multispectral': '',
+            'thermal': '',
+            'audio': ''
+        }
+        
+        # Get project root directory
+        from pathlib import Path
+        self.project_root = Path(__file__).parent.parent.parent  # Navigate to project root
+        
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout(self)
+        
+        # Instructions
+        instructions = QLabel(
+            "Please select data from camera and sensor sources.\n"
+            "At least one source is required to proceed. All inputs are optional."
+        )
+        instructions.setWordWrap(True)
+        instructions.setStyleSheet("font-size: 16px; color: #555; padding: 10px;")
+        layout.addWidget(instructions)
+        
+        # DSLR Input Group
+        dslr_group = QGroupBox("DSLR Camera (RGB)")
+        dslr_group.setStyleSheet(GROUP_BOX_STYLE)
+        dslr_layout = QFormLayout()
+        
+        # Side View (Plain) - for defect model
+        self.dslr_side_path_edit = QLineEdit()
+        self.dslr_side_path_edit.setPlaceholderText("No file selected...")
+        self.dslr_side_path_edit.setReadOnly(True)
+        self.dslr_side_path_edit.setMinimumHeight(30)
+        self.dslr_side_path_edit.setStyleSheet("font-size: 14px; padding: 5px;")
+        
+        dslr_side_btn = QPushButton("Browse...")
+        dslr_side_btn.clicked.connect(lambda: self.select_file('dslr_side'))
+        dslr_side_btn.setMinimumHeight(35)
+        dslr_side_btn.setStyleSheet("font-size: 14px; font-weight: bold;")
+        
+        dslr_side_layout = QHBoxLayout()
+        dslr_side_layout.addWidget(self.dslr_side_path_edit, 1)
+        dslr_side_layout.addWidget(dslr_side_btn)
+        
+        side_label = QLabel("Side View (Plain):")
+        side_label.setStyleSheet("font-size: 15px; font-weight: bold;")
+        dslr_layout.addRow(side_label, dslr_side_layout)
+        
+        # Top View (RGB) - for locule counter
+        self.dslr_top_path_edit = QLineEdit()
+        self.dslr_top_path_edit.setPlaceholderText("No file selected...")
+        self.dslr_top_path_edit.setReadOnly(True)
+        self.dslr_top_path_edit.setMinimumHeight(30)
+        self.dslr_top_path_edit.setStyleSheet("font-size: 14px; padding: 5px;")
+        
+        dslr_top_btn = QPushButton("Browse...")
+        dslr_top_btn.clicked.connect(lambda: self.select_file('dslr_top'))
+        dslr_top_btn.setMinimumHeight(35)
+        dslr_top_btn.setStyleSheet("font-size: 14px; font-weight: bold;")
+        
+        dslr_top_layout = QHBoxLayout()
+        dslr_top_layout.addWidget(self.dslr_top_path_edit, 1)
+        dslr_top_layout.addWidget(dslr_top_btn)
+        
+        top_label = QLabel("Top View (RGB):")
+        top_label.setStyleSheet("font-size: 15px; font-weight: bold;")
+        dslr_layout.addRow(top_label, dslr_top_layout)
+        
+        dslr_group.setLayout(dslr_layout)
+        layout.addWidget(dslr_group)
+        
+        # Multispectral Input Group
+        multi_group = QGroupBox("Multispectral Camera (2nd Look)")
+        multi_group.setStyleSheet(GROUP_BOX_STYLE)
+        multi_layout = QFormLayout()
+        
+        self.multi_path_edit = QLineEdit()
+        self.multi_path_edit.setPlaceholderText("No file selected...")
+        self.multi_path_edit.setReadOnly(True)
+        self.multi_path_edit.setMinimumHeight(30)
+        self.multi_path_edit.setStyleSheet("font-size: 14px; padding: 5px;")
+        
+        multi_btn = QPushButton("Browse...")
+        multi_btn.clicked.connect(lambda: self.select_file('multispectral'))
+        multi_btn.setMinimumHeight(35)
+        multi_btn.setStyleSheet("font-size: 14px; font-weight: bold;")
+        
+        multi_input_layout = QHBoxLayout()
+        multi_input_layout.addWidget(self.multi_path_edit, 1)
+        multi_input_layout.addWidget(multi_btn)
+        
+        multi_label = QLabel("TIFF File:")
+        multi_label.setStyleSheet("font-size: 15px; font-weight: bold;")
+        multi_layout.addRow(multi_label, multi_input_layout)
+        multi_group.setLayout(multi_layout)
+        layout.addWidget(multi_group)
+        
+        # Thermal Input Group
+        thermal_group = QGroupBox("Thermal Camera (AnalyzIR)")
+        thermal_group.setStyleSheet(GROUP_BOX_STYLE)
+        thermal_layout = QFormLayout()
+        
+        self.thermal_path_edit = QLineEdit()
+        self.thermal_path_edit.setPlaceholderText("No file selected...")
+        self.thermal_path_edit.setReadOnly(True)
+        self.thermal_path_edit.setMinimumHeight(30)
+        self.thermal_path_edit.setStyleSheet("font-size: 14px; padding: 5px;")
+        
+        thermal_btn = QPushButton("Browse...")
+        thermal_btn.clicked.connect(lambda: self.select_file('thermal'))
+        thermal_btn.setMinimumHeight(35)
+        thermal_btn.setStyleSheet("font-size: 14px; font-weight: bold;")
+        
+        thermal_input_layout = QHBoxLayout()
+        thermal_input_layout.addWidget(self.thermal_path_edit, 1)
+        thermal_input_layout.addWidget(thermal_btn)
+        
+        thermal_label = QLabel("CSV File:")
+        thermal_label.setStyleSheet("font-size: 15px; font-weight: bold;")
+        thermal_layout.addRow(thermal_label, thermal_input_layout)
+        thermal_group.setLayout(thermal_layout)
+        layout.addWidget(thermal_group)
+        
+        # Audio Input Group
+        audio_group = QGroupBox("Audio/Sound Sensor")
+        audio_group.setStyleSheet(GROUP_BOX_STYLE)
+        audio_layout = QFormLayout()
+        
+        self.audio_path_edit = QLineEdit()
+        self.audio_path_edit.setPlaceholderText("No file selected...")
+        self.audio_path_edit.setReadOnly(True)
+        self.audio_path_edit.setMinimumHeight(30)
+        self.audio_path_edit.setStyleSheet("font-size: 14px; padding: 5px;")
+        
+        audio_btn = QPushButton("Browse...")
+        audio_btn.clicked.connect(lambda: self.select_file('audio'))
+        audio_btn.setMinimumHeight(35)
+        audio_btn.setStyleSheet("font-size: 14px; font-weight: bold;")
+        
+        audio_input_layout = QHBoxLayout()
+        audio_input_layout.addWidget(self.audio_path_edit, 1)
+        audio_input_layout.addWidget(audio_btn)
+        
+        audio_label = QLabel("WAV File:")
+        audio_label.setStyleSheet("font-size: 15px; font-weight: bold;")
+        audio_layout.addRow(audio_label, audio_input_layout)
+        audio_group.setLayout(audio_layout)
+        layout.addWidget(audio_group)
+        
+        layout.addStretch()
+        
+        # Button box
+        button_layout = QHBoxLayout()
+        button_layout.addStretch()
+        
+        cancel_btn = QPushButton("Cancel")
+        cancel_btn.clicked.connect(self.reject)
+        cancel_btn.setMinimumWidth(100)
+        cancel_btn.setMinimumHeight(40)
+        cancel_btn.setStyleSheet("font-size: 15px; font-weight: bold;")
+        button_layout.addWidget(cancel_btn)
+        
+        confirm_btn = QPushButton("Confirm")
+        confirm_btn.clicked.connect(self.confirm_inputs)
+        confirm_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #27ae60;
+                color: white;
+                font-weight: bold;
+                font-size: 15px;
+                padding: 10px 16px;
+                border-radius: 4px;
+            }
+            QPushButton:hover {
+                background-color: #229954;
+            }
+        """)
+        confirm_btn.setMinimumWidth(100)
+        confirm_btn.setMinimumHeight(40)
+        button_layout.addWidget(confirm_btn)
+        
+        layout.addLayout(button_layout)
+    
+    def select_file(self, source: str):
+        """
+        Open file dialog for specific camera source.
+        
+        Args:
+            source: Camera source ('dslr_side', 'dslr_top', 'multispectral', 'thermal', 'audio')
+        """
+        if source == 'dslr_side':
+            title = "Select DSLR Side View Image"
+            filters = "Image Files (*.jpg *.jpeg *.png *.bmp);;All Files (*.*)"
+            edit_widget = self.dslr_side_path_edit
+        elif source == 'dslr_top':
+            title = "Select DSLR Top View Image (RGB)"
+            filters = "Image Files (*.jpg *.jpeg *.png *.bmp);;All Files (*.*)"
+            edit_widget = self.dslr_top_path_edit
+        elif source == 'multispectral':
+            title = "Select Multispectral TIFF"
+            filters = "TIFF Files (*.tif *.tiff);;All Files (*.*)"
+            edit_widget = self.multi_path_edit
+        elif source == 'thermal':
+            title = "Select Thermal CSV"
+            filters = "CSV Files (*.csv);;All Files (*.*)"
+            edit_widget = self.thermal_path_edit
+        elif source == 'audio':
+            title = "Select Audio File"
+            filters = "Audio Files (*.wav);;All Files (*.*)"
+            edit_widget = self.audio_path_edit
+        else:
+            return
+        
+        try:
+            file_path, _ = QFileDialog.getOpenFileName(
+                self,
+                title,
+                str(self.project_root),
+                filters,
+                options=QFileDialog.DontUseNativeDialog
+            )
+            
+            if file_path and Path(file_path).exists():
+                self.inputs[source] = file_path
+                edit_widget.setText(file_path)
+        except Exception as e:
+            print(f"Error in file dialog: {e}")
+    
+    def confirm_inputs(self):
+        """Validate and confirm inputs."""
+        # Check if at least one input is provided (side view or multispectral counts)
+        has_input = (
+            bool(self.inputs.get('dslr_side')) or 
+            bool(self.inputs.get('dslr_top')) or 
+            bool(self.inputs.get('multispectral')) or 
+            bool(self.inputs.get('thermal')) or 
+            bool(self.inputs.get('audio'))
+        )
+        
+        if not has_input:
+            QMessageBox.warning(
+                self,
+                "No Input Selected",
+                "Please select at least one camera input before confirming."
+            )
+            return
+        
+        # Emit signal with inputs
+        self.inputs_confirmed.emit(self.inputs)
+        self.accept()
+
+
+class CameraAppCheckDialog(QDialog):
+    """
+    Dialog to inform user about missing camera applications.
+    """
+    
+    def __init__(self, missing_apps: list, parent=None):
+        """
+        Initialize the dialog.
+        
+        Args:
+            missing_apps: List of missing application names
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.setWindowTitle("Camera Applications Not Found")
+        self.setModal(True)
+        self.setMinimumWidth(500)
+        self.setMinimumHeight(350)
+        
+        self.missing_apps = missing_apps
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout(self)
+        
+        # Warning icon and message
+        message = QLabel(
+            "The following camera applications are not currently running:\n"
+        )
+        message.setStyleSheet("font-size: 15px; font-weight: bold; color: #e74c3c;")
+        layout.addWidget(message)
+        
+        # List of missing apps
+        for app in self.missing_apps:
+            app_label = QLabel(f"  • {app}")
+            app_label.setStyleSheet("font-size: 14px; color: #555; padding-left: 20px;")
+            layout.addWidget(app_label)
+        
+        # Instructions
+        instructions = QLabel(
+            "\nPlease ensure these applications are opened and running "
+            "before attempting automated camera capture.\n\n"
+            "You can either:\n"
+            "1. Open the required applications and try again\n"
+            "2. Use Manual Input mode instead"
+        )
+        instructions.setWordWrap(True)
+        instructions.setStyleSheet("font-size: 13px; color: #666; padding: 10px;")
+        layout.addWidget(instructions)
+        
+        layout.addStretch()
+        
+        # OK button
+        button_layout = QHBoxLayout()
+        button_layout.addStretch()
+        
+        ok_btn = QPushButton("OK")
+        ok_btn.clicked.connect(self.accept)
+        ok_btn.setMinimumWidth(100)
+        ok_btn.setStyleSheet("""
+            QPushButton {
+                padding: 8px 16px;
+                background-color: #3498db;
+                color: white;
+                font-weight: bold;
+                font-size: 14px;
+                border-radius: 4px;
+            }
+            QPushButton:hover {
+                background-color: #2980b9;
+            }
+        """)
+        button_layout.addWidget(ok_btn)
+        
+        layout.addLayout(button_layout)
+

+ 138 - 0
ui/dialogs/print_options_dialog.py

@@ -0,0 +1,138 @@
+"""
+Print Options Dialog
+
+Dialog for selecting print options before printing a report.
+"""
+
+from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, 
+                              QPushButton, QRadioButton, QButtonGroup, QGroupBox)
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+
+class PrintOptionsDialog(QDialog):
+    """
+    Dialog for selecting print options.
+    
+    Allows user to choose whether to include visualizations in print.
+    """
+    
+    def __init__(self, parent=None):
+        """Initialize the print options dialog."""
+        super().__init__(parent)
+        self.setWindowTitle("Print Options")
+        self.setModal(True)
+        self.setMinimumWidth(400)
+        self.setMinimumHeight(250)
+        
+        self.include_visualizations = True  # Default: include visualizations
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(20, 20, 20, 20)
+        layout.setSpacing(15)
+        
+        # Title
+        title = QLabel("Print Report")
+        title_font = QFont("Arial", 14, QFont.Bold)
+        title.setFont(title_font)
+        layout.addWidget(title)
+        
+        # Description
+        description = QLabel("How would you like to print this report?")
+        description.setStyleSheet("color: #555; font-size: 12px;")
+        layout.addWidget(description)
+        
+        # Options group
+        options_group = QGroupBox("Print Options")
+        options_layout = QVBoxLayout()
+        
+        # Button group for radio buttons
+        self.button_group = QButtonGroup()
+        
+        # Option 1: Include visualizations
+        self.include_viz_radio = QRadioButton("Include All Visualizations (Recommended)")
+        self.include_viz_radio.setChecked(True)
+        self.include_viz_radio.setStyleSheet("font-size: 12px; padding: 8px;")
+        self.button_group.addButton(self.include_viz_radio, 0)
+        options_layout.addWidget(self.include_viz_radio)
+        
+        # Description for option 1
+        desc1 = QLabel("  • Prints all analysis results and visualizations\n"
+                       "  • Best for comprehensive reports\n"
+                       "  • May span multiple pages")
+        desc1.setStyleSheet("color: #777; font-size: 11px; margin-left: 25px; margin-bottom: 10px;")
+        options_layout.addWidget(desc1)
+        
+        # Option 2: Text only
+        self.text_only_radio = QRadioButton("Text Only (Compact)")
+        self.text_only_radio.setStyleSheet("font-size: 12px; padding: 8px;")
+        self.button_group.addButton(self.text_only_radio, 1)
+        options_layout.addWidget(self.text_only_radio)
+        
+        # Description for option 2
+        desc2 = QLabel("  • Prints analysis results and text data only\n"
+                       "  • More compact format\n"
+                       "  • Faster to print")
+        desc2.setStyleSheet("color: #777; font-size: 11px; margin-left: 25px; margin-bottom: 15px;")
+        options_layout.addWidget(desc2)
+        
+        options_group.setLayout(options_layout)
+        layout.addWidget(options_group)
+        
+        layout.addStretch()
+        
+        # Button layout
+        button_layout = QHBoxLayout()
+        button_layout.addStretch()
+        
+        # Cancel button
+        cancel_btn = QPushButton("Cancel")
+        cancel_btn.setMinimumWidth(100)
+        cancel_btn.setStyleSheet("""
+            QPushButton {
+                padding: 8px 16px;
+                background-color: #95a5a6;
+                color: white;
+                font-weight: bold;
+                border-radius: 4px;
+                border: none;
+            }
+            QPushButton:hover {
+                background-color: #7f8c8d;
+            }
+        """)
+        cancel_btn.clicked.connect(self.reject)
+        button_layout.addWidget(cancel_btn)
+        
+        # Print button
+        print_btn = QPushButton("Print")
+        print_btn.setMinimumWidth(100)
+        print_btn.setStyleSheet("""
+            QPushButton {
+                padding: 8px 16px;
+                background-color: #27ae60;
+                color: white;
+                font-weight: bold;
+                border-radius: 4px;
+                border: none;
+            }
+            QPushButton:hover {
+                background-color: #229954;
+            }
+        """)
+        print_btn.clicked.connect(self.accept)
+        button_layout.addWidget(print_btn)
+        
+        layout.addLayout(button_layout)
+    
+    def get_include_visualizations(self) -> bool:
+        """
+        Get whether visualizations should be included.
+        
+        Returns:
+            bool: True if visualizations should be included, False for text only
+        """
+        return self.include_viz_radio.isChecked()

+ 296 - 0
ui/dialogs/spectrogram_preview_dialog.py

@@ -0,0 +1,296 @@
+"""
+Spectrogram Preview Dialog
+
+Enlarged view for the audio spectrogram with waveform and audio playback controls.
+"""
+
+from PyQt5.QtWidgets import (
+    QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QWidget, QGridLayout
+)
+from PyQt5.QtCore import Qt, QUrl, QTime
+from PyQt5.QtGui import QPixmap, QImage, QFont
+from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
+
+import numpy as np
+from scipy.io import wavfile
+import os
+import time
+
+# Matplotlib for waveform rendering
+import matplotlib
+matplotlib.use('agg')
+from matplotlib.figure import Figure
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+
+
+class SpectrogramPreviewDialog(QDialog):
+    """
+    Dialog that shows an enlarged spectrogram, a waveform visualization,
+    and audio playback controls (play/pause + seek slider).
+    """
+
+    def __init__(self, spectrogram_pixmap: QPixmap, audio_path: str = None, parent: QWidget = None):
+        super().__init__(parent)
+        self.setWindowTitle("Audio Preview")
+        self.setModal(True)
+        self.spectrogram_pixmap = spectrogram_pixmap
+        self.audio_path = audio_path
+
+        self.player = None
+        if self.audio_path:
+            self.player = QMediaPlayer(self)
+            self.player.setMedia(QMediaContent(QUrl.fromLocalFile(self.audio_path)))
+
+        self._init_ui()
+
+    def _get_audio_metadata(self, path: str) -> dict:
+        """Extract comprehensive audio metadata"""
+        metadata = {}
+
+        try:
+            # Basic file info
+            metadata['filename'] = os.path.basename(path)
+            metadata['file_size'] = self._format_file_size(os.path.getsize(path))
+
+            # Audio file info
+            sr, data = wavfile.read(path)
+            metadata['sample_rate'] = "{:,} Hz".format(sr)
+            metadata['duration'] = len(data) / sr
+            metadata['duration_str'] = self._format_duration(metadata['duration'])
+            metadata['channels'] = 2 if data.ndim > 1 else 1
+            metadata['bit_depth'] = data.dtype.itemsize * 8
+            metadata['total_samples'] = len(data)
+            metadata['bitrate'] = self._calculate_bitrate(path, metadata['duration'])
+
+        except Exception as e:
+            metadata['error'] = str(e)
+
+        return metadata
+
+    def _format_file_size(self, size_bytes: int) -> str:
+        """Format file size in human readable format"""
+        for unit in ['B', 'KB', 'MB', 'GB']:
+            if size_bytes < 1024.0:
+                return f"{size_bytes:.1f} {unit}"
+            size_bytes /= 1024.0
+        return f"{size_bytes:.1f} TB"
+
+    def _format_duration(self, seconds: float) -> str:
+        """Format duration in human readable format"""
+        hours = int(seconds // 3600)
+        minutes = int((seconds % 3600) // 60)
+        seconds = seconds % 60
+
+        if hours > 0:
+            return f"{hours}:{minutes:02d}:{seconds:02.1f}"
+        else:
+            return f"{minutes}:{seconds:02.1f}"
+
+    def _calculate_bitrate(self, path: str, duration: float) -> str:
+        """Calculate approximate bitrate"""
+        try:
+            file_size_bits = os.path.getsize(path) * 8
+            bitrate = file_size_bits / duration
+            return f"{bitrate/1000:.0f} kbps"
+        except:
+            return "Unknown"
+
+    def _create_metadata_widget(self) -> QWidget:
+        """Create a widget displaying audio metadata in a grid layout"""
+        widget = QWidget()
+        widget.setStyleSheet("background-color: #f8f9fa; padding: 15px; border-bottom: 1px solid #dee2e6;")
+        layout = QGridLayout(widget)
+        layout.setSpacing(10)
+
+        # Define metadata fields to display
+        fields = [
+            ("File Size", self.metadata.get('file_size', 'Unknown')),
+            ("Duration", self.metadata.get('duration_str', 'Unknown')),
+            ("Sample Rate", self.metadata.get('sample_rate', 'Unknown')),
+            ("Channels", str(self.metadata.get('channels', 'Unknown'))),
+            ("Bit Depth", f"{self.metadata.get('bit_depth', 'Unknown')} bits"),
+            ("Total Samples", "{:,}".format(self.metadata.get('total_samples', 'Unknown'))),
+            ("Bitrate", self.metadata.get('bitrate', 'Unknown')),
+        ]
+
+        # Create labels for each field
+        for i, (label, value) in enumerate(fields):
+            row = i // 2
+            col = (i % 2) * 2
+
+            # Field label
+            field_label = QLabel(f"{label}:")
+            field_label.setStyleSheet("font-weight: bold; color: #495057; font-size: 11px;")
+            layout.addWidget(field_label, row, col)
+
+            # Field value
+            value_label = QLabel(str(value))
+            value_label.setStyleSheet("color: #212529; font-size: 11px; background-color: white; padding: 2px 6px; border-radius: 3px;")
+            layout.addWidget(value_label, row, col + 1)
+
+        return widget
+
+    def _init_ui(self):
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Header with filename
+        self.metadata = self._get_audio_metadata(self.audio_path) if self.audio_path else {}
+        filename = self.metadata.get('filename', 'Unknown Audio File')
+
+        # Main header with filename
+        header = QLabel(f"Audio Spectrogram Preview - {filename}")
+        header.setAlignment(Qt.AlignCenter)
+        header.setStyleSheet(
+            "QLabel { background-color: #2c3e50; color: white; font-weight: bold; font-size: 14px; padding: 12px; }"
+        )
+        layout.addWidget(header)
+
+        # Metadata section
+        if self.audio_path:
+            # Metadata section title
+            metadata_title = QLabel("📊 Audio File Information")
+            metadata_title.setStyleSheet(
+                "QLabel { color: #2c3e50; font-weight: bold; font-size: 13px; padding: 8px; background-color: #ecf0f1; }"
+            )
+            layout.addWidget(metadata_title)
+
+            if 'error' not in self.metadata:
+                metadata_widget = self._create_metadata_widget()
+                layout.addWidget(metadata_widget)
+            else:
+                # Show error message if metadata extraction failed
+                error_label = QLabel(f"Could not read audio file: {self.metadata['error']}")
+                error_label.setStyleSheet("color: #e74c3c; background-color: #fdf2f2; padding: 10px; border: 1px solid #f5c6cb;")
+                error_label.setAlignment(Qt.AlignCenter)
+                layout.addWidget(error_label)
+
+        # Spectrogram section
+        spectrogram_title = QLabel("🔍 Spectrogram Analysis")
+        spectrogram_title.setStyleSheet(
+            "QLabel { color: #2c3e50; font-weight: bold; font-size: 13px; padding: 8px; background-color: #ecf0f1; }"
+        )
+        layout.addWidget(spectrogram_title)
+
+        # Spectrogram (enlarged)
+        self.spectrogram_label = QLabel()
+        self.spectrogram_label.setAlignment(Qt.AlignCenter)
+        self.spectrogram_label.setStyleSheet("background-color: #2c3e50; padding: 10px;")
+        spec_scaled = self.spectrogram_pixmap.scaled(1100, 500, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+        self.spectrogram_label.setPixmap(spec_scaled)
+        layout.addWidget(self.spectrogram_label)
+
+        # Waveform section
+        waveform_title = QLabel("📈 Waveform Visualization")
+        waveform_title.setStyleSheet(
+            "QLabel { color: #2c3e50; font-weight: bold; font-size: 13px; padding: 8px; background-color: #ecf0f1; }"
+        )
+        layout.addWidget(waveform_title)
+
+        # Waveform area
+        self.waveform_label = QLabel()
+        self.waveform_label.setAlignment(Qt.AlignCenter)
+        self.waveform_label.setStyleSheet("background-color: #ffffff; padding: 10px; border-top: 1px solid #ecf0f1;")
+        layout.addWidget(self.waveform_label)
+
+        # Controls section
+        controls_title = QLabel("🎵 Audio Playback Controls")
+        controls_title.setStyleSheet(
+            "QLabel { color: #2c3e50; font-weight: bold; font-size: 13px; padding: 8px; background-color: #ecf0f1; }"
+        )
+        layout.addWidget(controls_title)
+
+        # Controls
+        controls = QHBoxLayout()
+        controls.setContentsMargins(10, 10, 10, 10)
+        controls.setSpacing(10)
+
+        self.play_btn = QPushButton("Play")
+        self.play_btn.setFixedHeight(30)
+        self.play_btn.setFont(QFont("Arial", 10, QFont.Bold))
+        self.play_btn.setStyleSheet("QPushButton { background-color: #27ae60; color: white; border: none; padding: 6px 12px; }")
+        self.play_btn.clicked.connect(self._toggle_play)
+        controls.addWidget(self.play_btn)
+
+        self.position_slider = QSlider(Qt.Horizontal)
+        self.position_slider.setRange(0, 0)
+        self.position_slider.sliderMoved.connect(self._set_position)
+        controls.addWidget(self.position_slider, 1)
+
+        layout.addLayout(controls)
+
+        # Footer
+        close_btn = QPushButton("Close (ESC)")
+        close_btn.setFixedHeight(28)
+        close_btn.clicked.connect(self.accept)
+        layout.addWidget(close_btn)
+
+        # Load waveform and connect player
+        if self.audio_path:
+            self._render_waveform(self.audio_path)
+            if self.player is not None:
+                self.player.positionChanged.connect(self._on_position_changed)
+                self.player.durationChanged.connect(self._on_duration_changed)
+
+        # Size
+        self.resize(1200, 900)
+
+    def _render_waveform(self, path: str):
+        try:
+            sr, data = wavfile.read(path)
+            if data.ndim > 1:
+                data = data.mean(axis=1)
+            # Normalize for plotting
+            data = data.astype(np.float64)
+            if np.max(np.abs(data)) > 0:
+                data = data / np.max(np.abs(data))
+
+            # Create matplotlib figure
+            fig = Figure(figsize=(11, 2.8), dpi=100)
+            ax = fig.add_subplot(111)
+            times = np.linspace(0, len(data) / sr, num=len(data))
+            ax.plot(times, data, color="#3498db", linewidth=0.6)
+            ax.set_xlim(0, times[-1] if len(times) > 0 else 1)
+            ax.set_ylim(-1.05, 1.05)
+            ax.set_xlabel("Time (s)")
+            ax.set_ylabel("Amplitude")
+            ax.grid(True, alpha=0.2)
+            fig.tight_layout()
+
+            canvas = FigureCanvas(fig)
+            canvas.draw()
+            w, h = fig.get_size_inches() * fig.get_dpi()
+            w, h = int(w), int(h)
+            img = QImage(canvas.buffer_rgba(), w, h, QImage.Format_ARGB32)
+            img = img.rgbSwapped()
+            self.waveform_label.setPixmap(QPixmap(img))
+        except Exception:
+            # Fallback text if waveform can't be rendered
+            self.waveform_label.setText("Waveform preview unavailable.")
+
+    def _toggle_play(self):
+        if not self.player:
+            return
+        if self.player.state() == QMediaPlayer.PlayingState:
+            self.player.pause()
+            self.play_btn.setText("Play")
+        else:
+            self.player.play()
+            self.play_btn.setText("Pause")
+
+    def _set_position(self, position: int):
+        if self.player:
+            self.player.setPosition(position)
+
+    def _on_position_changed(self, position: int):
+        self.position_slider.setValue(position)
+
+    def _on_duration_changed(self, duration: int):
+        self.position_slider.setRange(0, duration)
+
+    def keyPressEvent(self, event):
+        if event.key() == Qt.Key_Escape:
+            self.accept()
+        else:
+            super().keyPressEvent(event)

+ 1451 - 0
ui/main_window.py

@@ -0,0 +1,1451 @@
+"""
+Main Window Module
+
+The main application window that integrates all components:
+- Dashboard with all panels
+- Tab navigation
+- Menu bar
+- Worker thread management
+- File dialogs and processing
+"""
+
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Optional, Dict
+
+from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+                              QTabWidget, QFrame, QLabel, QFileDialog, QMessageBox, QPushButton, QStyle)
+from PyQt5.QtCore import Qt, QThreadPool, QTimer, QSize
+from PyQt5.QtGui import QFont, QPixmap, QIcon
+
+from ui.panels import (SystemStatusPanel, QuickActionsPanel, RecentResultsPanel,
+                       SystemInfoPanel, LiveFeedPanel)
+from ui.tabs import RipenessTab, QualityTab, MaturityTab, ParametersTab, ReportsTab
+from ui.dialogs import AboutDialog, HelpDialog, ManualInputDialog, CameraAppCheckDialog
+from models import AudioModel, DefectModel, LoculeModel, MaturityModel, ShapeModel
+from workers import AudioWorker, DefectWorker, LoculeWorker, MaturityWorker, ShapeWorker
+from utils.config import (WINDOW_TITLE, WINDOW_WIDTH, WINDOW_HEIGHT, DEVICE_ID,
+                           get_device, DEFAULT_DIRS, FILE_FILTERS, PROJECT_ROOT)
+from utils.data_manager import DataManager
+from utils.process_utils import get_missing_camera_apps, get_running_camera_apps
+from utils.camera_automation import SecondLookAutomation, EOSUtilityAutomation, AnalyzIRAutomation, CameraAutomationError
+from resources.styles import MAIN_WINDOW_STYLE, TAB_WIDGET_STYLE, HEADER_ICON_BUTTON_STYLE
+
+
+class DuDONGMainWindow(QMainWindow):
+    """
+    Main application window for DuDONG Grading System.
+    
+    Integrates all UI components, manages worker threads, and handles
+    user interactions for ripeness and quality classification.
+    
+    Attributes:
+        thread_pool: QThreadPool for managing worker threads
+        models: Dict of loaded AI models
+        status_panels: Dict of UI panel references
+    """
+    
+    def __init__(self):
+        """Initialize the main window."""
+        super().__init__()
+        self.setWindowTitle(WINDOW_TITLE)
+        self.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT)
+        
+        # Initialize thread pool
+        self.thread_pool = QThreadPool()
+        print(f"Thread pool initialized with {self.thread_pool.maxThreadCount()} threads")
+        
+        # Initialize data manager for persistence
+        self.data_manager = DataManager()
+        print("Data manager initialized")
+        
+        # Initialize models (will be loaded on demand)
+        self.models = {
+            'audio': None,
+            'defect': None,
+            'locule': None,
+            'maturity': None,
+            'shape': None
+        }
+        
+        # Track processing state
+        self.is_processing = False
+        self.report_results = {}  # Store results for reports tab
+        self.current_analysis_id = None  # Track current analysis for saving
+        self.analysis_start_time = None  # Track when analysis started
+        
+        # Track application start time for uptime calculation
+        self.app_start_time = datetime.now()
+        
+        # Cache GPU status to avoid repeated checks
+        self._gpu_status_cache = None
+        self._last_model_count = 0
+        
+        # Initialize UI
+        self.init_ui()
+        
+        # Load models in background
+        self.load_models()
+        
+        # Start timer for status updates
+        self.init_timer()
+    
+    def init_ui(self):
+        """Initialize the user interface."""
+        # Set window style
+        self.setStyleSheet(MAIN_WINDOW_STYLE)
+        
+        # Central widget
+        central_widget = QWidget()
+        self.setCentralWidget(central_widget)
+        
+        main_layout = QVBoxLayout(central_widget)
+        main_layout.setContentsMargins(0, 0, 0, 0)
+        main_layout.setSpacing(0)
+        
+        # Header
+        header = self.create_header()
+        main_layout.addWidget(header)
+        
+        # Tab widget for different views
+        self.tab_widget = QTabWidget()
+        self.tab_widget.setStyleSheet(TAB_WIDGET_STYLE)
+        
+        # Dashboard tab (main)
+        dashboard = self.create_dashboard()
+        self.tab_widget.addTab(dashboard, "Dashboard")
+        
+        # Processing tabs - HIDDEN FOR NOW
+        self.ripeness_tab = RipenessTab()
+        self.ripeness_tab.load_audio_requested.connect(self.on_ripeness_load_audio)
+        ripeness_index = self.tab_widget.addTab(self.ripeness_tab, "Ripeness")
+        self.tab_widget.setTabVisible(ripeness_index, False)
+        
+        self.quality_tab = QualityTab()
+        self.quality_tab.load_image_requested.connect(self.on_quality_load_image)
+        quality_index = self.tab_widget.addTab(self.quality_tab, "Quality")
+        self.tab_widget.setTabVisible(quality_index, False)
+        
+        self.maturity_tab = MaturityTab()
+        self.maturity_tab.load_tiff_requested.connect(self.on_maturity_load_tiff)
+        maturity_index = self.tab_widget.addTab(self.maturity_tab, "Maturity")
+        self.tab_widget.setTabVisible(maturity_index, False)
+        
+        # Placeholder tabs (to be implemented in future) - HIDDEN FOR NOW
+        self.parameters_tab = ParametersTab()
+        parameters_index = self.tab_widget.addTab(self.parameters_tab, "Parameters")
+        self.tab_widget.setTabVisible(parameters_index, False)
+        
+        self.reports_tab = ReportsTab()
+        self.reports_tab.go_to_dashboard.connect(self.on_go_to_dashboard)
+        self.tab_widget.addTab(self.reports_tab, "Reports")
+        
+        main_layout.addWidget(self.tab_widget)
+        
+        # Status bar
+        status_bar = self.create_status_bar()
+        main_layout.addWidget(status_bar)
+        
+        # Initial update of system info panel with existing data
+        self.update_system_info_panel()
+    
+    def create_header(self) -> QFrame:
+        """
+        Create the application header.
+        
+        Returns:
+            QFrame: Header widget
+        """
+        header = QFrame()
+        header.setFixedHeight(80)
+        header.setStyleSheet("background-color: #2c3e50;")
+        
+        layout = QHBoxLayout(header)
+        layout.setContentsMargins(20, 10, 20, 10)
+        
+        # Logo on the left
+        logo_path = PROJECT_ROOT / "assets" / "logos" / "dudong_logo.png"
+        if logo_path.exists():
+            logo_label = QLabel()
+            logo_pixmap = QPixmap(str(logo_path))
+            # Scale logo to fit header height (80px - margins)
+            scaled_logo = logo_pixmap.scaledToHeight(60, Qt.SmoothTransformation)
+            logo_label.setPixmap(scaled_logo)
+            logo_label.setFixedWidth(60)
+            layout.addWidget(logo_label)
+            layout.addSpacing(15)
+        else:
+            # Debug: print path not found
+            print(f"[DEBUG] Logo not found at: {logo_path}")
+        
+        # Title
+        title = QLabel(WINDOW_TITLE)
+        title.setStyleSheet("color: white; font-size: 22px; font-weight: bold;")
+        layout.addWidget(title)
+        
+        layout.addStretch()
+        
+        # Icon buttons on the right
+        # Help/Support button
+        help_btn = QPushButton()
+        help_icon = self.style().standardIcon(QStyle.SP_MessageBoxQuestion)
+        help_btn.setIcon(help_icon)
+        help_btn.setIconSize(QSize(24, 24))
+        help_btn.setStyleSheet(HEADER_ICON_BUTTON_STYLE)
+        help_btn.setToolTip("Help & Support (F1)")
+        help_btn.clicked.connect(self.show_help)
+        layout.addWidget(help_btn)
+        
+        # About/Info button
+        about_btn = QPushButton()
+        about_icon = self.style().standardIcon(QStyle.SP_MessageBoxInformation)
+        about_btn.setIcon(about_icon)
+        about_btn.setIconSize(QSize(24, 24))
+        about_btn.setStyleSheet(HEADER_ICON_BUTTON_STYLE)
+        about_btn.setToolTip("About DuDONG")
+        about_btn.clicked.connect(self.show_about)
+        layout.addWidget(about_btn)
+        
+        # Exit button
+        exit_btn = QPushButton()
+        exit_icon = self.style().standardIcon(QStyle.SP_MessageBoxCritical)
+        exit_btn.setIcon(exit_icon)
+        exit_btn.setIconSize(QSize(24, 24))
+        exit_btn.setStyleSheet(HEADER_ICON_BUTTON_STYLE)
+        exit_btn.setToolTip("Exit Application (Ctrl+Q)")
+        exit_btn.clicked.connect(self.close)
+        layout.addWidget(exit_btn)
+        
+        return header
+    
+    def create_dashboard(self) -> QWidget:
+        """
+        Create the main dashboard view.
+        
+        Returns:
+            QWidget: Dashboard widget
+        """
+        dashboard = QWidget()
+        layout = QVBoxLayout(dashboard)
+        
+        # Top row: Status, Actions, and Results
+        top_layout = QHBoxLayout()
+        
+        # System Status Panel (left)
+        self.status_panel = SystemStatusPanel()
+        self.status_panel.setMinimumWidth(360)
+        # Pass models reference to status panel
+        self.status_panel.set_models_reference(self.models)
+        top_layout.addWidget(self.status_panel, 1)
+        
+        # Middle column: Quick Actions and Recent Results
+        middle_layout = QVBoxLayout()
+        
+        # Quick Actions Panel
+        self.actions_panel = QuickActionsPanel()
+        self.actions_panel.setMinimumWidth(380)
+        self.actions_panel.setMaximumHeight(350)  # Increased for new button
+        
+        # Connect signals
+        self.actions_panel.analyze_durian_clicked.connect(self.on_analyze_durian_clicked)
+        self.actions_panel.ripeness_clicked.connect(self.on_ripeness_clicked)
+        self.actions_panel.quality_clicked.connect(self.on_quality_clicked)
+        self.actions_panel.calibration_clicked.connect(self.on_calibration_clicked)
+        self.actions_panel.batch_clicked.connect(self.on_batch_clicked)
+        
+        middle_layout.addWidget(self.actions_panel)
+        
+        # Recent Results Panel (pass DataManager for database integration)
+        self.results_panel = RecentResultsPanel(data_manager=self.data_manager)
+        self.results_panel.setMinimumWidth(380)
+        # Connect view button signal to handler
+        self.results_panel.view_analysis_requested.connect(self.on_view_analysis)
+        middle_layout.addWidget(self.results_panel)
+        
+        top_layout.addLayout(middle_layout, 2)
+        
+        layout.addLayout(top_layout)
+        
+        # Bottom row: System Info and Live Feeds
+        bottom_layout = QHBoxLayout()
+        
+        # System Information Panel
+        self.info_panel = SystemInfoPanel()
+        self.info_panel.setMinimumWidth(560)
+        bottom_layout.addWidget(self.info_panel, 2)
+        
+        # Live Feed Panel
+        self.feed_panel = LiveFeedPanel()
+        bottom_layout.addWidget(self.feed_panel, 1)
+        
+        layout.addLayout(bottom_layout)
+        
+        return dashboard
+    
+    def create_status_bar(self) -> QFrame:
+        """
+        Create the application status bar.
+        
+        Returns:
+            QFrame: Status bar widget
+        """
+        status_bar = QFrame()
+        status_bar.setFixedHeight(40)
+        status_bar.setStyleSheet("background-color: #34495e;")
+        
+        layout = QHBoxLayout(status_bar)
+        layout.setContentsMargins(20, 0, 20, 0)
+        
+        # Left side: detailed status
+        self.status_text = QLabel("Ripeness Classifier Active | Model: RipeNet | GPU: -- | Processing: IDLE")
+        self.status_text.setStyleSheet("color: #ecf0f1; font-size: 12px;")
+        layout.addWidget(self.status_text)
+        
+        layout.addStretch()
+        
+        # Right side: ready indicator
+        self.ready_indicator = QLabel("● READY FOR TESTING")
+        self.ready_indicator.setStyleSheet("color: #27ae60; font-size: 12px; font-weight: bold;")
+        layout.addWidget(self.ready_indicator)
+        
+        return status_bar
+    
+    def init_timer(self):
+        """Initialize update timer."""
+        self.timer = QTimer()
+        self.timer.timeout.connect(self.update_status_bar)
+        self.timer.start(1000)  # Update every second
+    
+    def update_status_bar(self):
+        """
+        Update status bar with current time and info.
+        
+        Optimized to minimize overhead:
+        - GPU status is cached and only checked once at startup
+        - Model count is cached until it changes
+        - Only text updates when status actually changes
+        """
+        # Only update if footer components exist
+        if not hasattr(self, 'status_text') or not hasattr(self, 'ready_indicator'):
+            return
+        
+        # Get model load status (lightweight check)
+        model_status = self.get_model_load_status()
+        loaded_count = sum(1 for status in model_status.values() if status)
+        
+        # Cache GPU status after first check (it won't change during runtime)
+        if self._gpu_status_cache is None:
+            try:
+                import torch
+                self._gpu_status_cache = "Active" if torch.cuda.is_available() else "N/A"
+            except:
+                self._gpu_status_cache = "N/A"
+        
+        gpu_status = self._gpu_status_cache
+        
+        # Get processing status - only show "Processing" when actually processing
+        if self.is_processing:
+            processing_status = "Processing"
+            ready_text = "● PROCESSING"
+            ready_color = "#f39c12"  # Orange
+        else:
+            processing_status = "IDLE"
+            ready_text = "● READY FOR TESTING"
+            ready_color = "#27ae60"  # Green
+        
+        # Build status text (only if something changed to reduce UI updates)
+        models_info = f"{loaded_count}/5"
+        status = f"DuDONG Active | Model: {models_info} | GPU: {gpu_status} | Processing: {processing_status}"
+        
+        # Only update text if it actually changed
+        if self.status_text.text() != status:
+            self.status_text.setText(status)
+        
+        if self.ready_indicator.text() != ready_text:
+            self.ready_indicator.setText(ready_text)
+            self.ready_indicator.setStyleSheet(f"color: {ready_color}; font-size: 12px; font-weight: bold;")
+    
+    def load_models(self):
+        """Load AI models in background."""
+        device = get_device()
+        
+        try:
+            # Audio model (CPU for TensorFlow)
+            print("Loading audio model...")
+            self.models['audio'] = AudioModel(device='cpu')
+            if self.models['audio'].load():
+                self.status_panel.update_model_status('ripeness', 'online', 'Loaded')
+                print("✓ Audio model loaded")
+            else:
+                self.status_panel.update_model_status('ripeness', 'offline', 'Failed')
+                print("✗ Audio model failed to load")
+        except Exception as e:
+            print(f"Error loading audio model: {e}")
+            self.status_panel.update_model_status('ripeness', 'offline', 'Error')
+        
+        try:
+            # Defect model (GPU)
+            print("Loading defect model...")
+            self.models['defect'] = DefectModel(device=device)
+            if self.models['defect'].load():
+                self.status_panel.update_model_status('quality', 'online', 'Loaded')
+                print("✓ Defect model loaded")
+            else:
+                self.status_panel.update_model_status('quality', 'offline', 'Failed')
+                print("✗ Defect model failed to load")
+        except Exception as e:
+            print(f"Error loading defect model: {e}")
+            self.status_panel.update_model_status('quality', 'offline', 'Error')
+        
+        try:
+            # Locule model (GPU)
+            print("Loading locule model...")
+            self.models['locule'] = LoculeModel(device=device)
+            if self.models['locule'].load():
+                self.status_panel.update_model_status('defect', 'online', 'Loaded')
+                print("✓ Locule model loaded")
+            else:
+                self.status_panel.update_model_status('defect', 'offline', 'Failed')
+                print("✗ Locule model failed to load")
+        except Exception as e:
+            print(f"Error loading locule model: {e}")
+            self.status_panel.update_model_status('defect', 'offline', 'Error')
+        
+        try:
+            # Maturity model (GPU)
+            print("Loading maturity model...")
+            self.models['maturity'] = MaturityModel(device=device)
+            if self.models['maturity'].load():
+                print("✓ Maturity model loaded")
+            else:
+                print("✗ Maturity model failed to load")
+        except Exception as e:
+            print(f"Error loading maturity model: {e}")
+        
+        try:
+            # Shape model (GPU)
+            print("Loading shape model...")
+            shape_model_path = PROJECT_ROOT / "model_files" / "shape.pt"
+            self.models['shape'] = ShapeModel(str(shape_model_path), device=device)
+            if self.models['shape'].load():
+                print("✓ Shape model loaded")
+            else:
+                print("✗ Shape model failed to load")
+        except Exception as e:
+            print(f"Error loading shape model: {e}")
+        
+        # Refresh system status display after all models are loaded
+        if hasattr(self, 'status_panel'):
+            self.status_panel.refresh_status()
+            print("✓ System status panel updated")
+    
+    def get_model_load_status(self) -> Dict[str, bool]:
+        """
+        Get the load status of all AI models.
+        
+        Returns:
+            Dict mapping model key to loaded status
+        """
+        return {
+            'audio': self.models['audio'].is_loaded if self.models['audio'] else False,
+            'defect': self.models['defect'].is_loaded if self.models['defect'] else False,
+            'locule': self.models['locule'].is_loaded if self.models['locule'] else False,
+            'maturity': self.models['maturity'].is_loaded if self.models['maturity'] else False,
+            'shape': self.models['shape'].is_loaded if self.models['shape'] else False,
+        }
+    
+    def update_system_info_panel(self):
+        """
+        Update System Information Panel with real-time statistics.
+        
+        Gathers data from:
+        - DataManager: daily count, average processing time, model accuracy stats
+        - App state: uptime
+        
+        Then calls update methods on self.info_panel to display the values.
+        
+        Note: Memory info is displayed in SystemStatusPanel, not here.
+        """
+        try:
+            if not hasattr(self, 'info_panel'):
+                return
+            
+            # Calculate uptime
+            uptime_delta = datetime.now() - self.app_start_time
+            uptime_hours = uptime_delta.seconds // 3600
+            uptime_minutes = (uptime_delta.seconds % 3600) // 60
+            
+            # Get statistics from DataManager
+            daily_count = self.data_manager.get_daily_analysis_count()
+            avg_processing_time = self.data_manager.get_average_processing_time()
+            accuracy_stats = self.data_manager.get_model_accuracy_stats()
+            
+            # Update panel with all values
+            self.info_panel.update_uptime(uptime_hours, uptime_minutes)
+            self.info_panel.update_throughput(daily_count)
+            self.info_panel.update_processing_time(avg_processing_time)
+            
+            # Update model accuracy stats
+            for model_name, accuracy in accuracy_stats.items():
+                self.info_panel.update_accuracy(model_name, accuracy)
+            
+            print("✓ System info panel updated")
+            
+        except Exception as e:
+            print(f"Error updating system info panel: {e}")
+            import traceback
+            traceback.print_exc()
+    
+    # ==================== Quick Action Handlers ====================
+    
+    def on_analyze_durian_clicked(self, manual_mode: bool):
+        """
+        Handle analyze durian button click.
+        
+        Args:
+            manual_mode: True if manual input mode is selected, False for auto mode
+        """
+        if manual_mode:
+            # Manual mode: Show dialog for file selection
+            self._handle_manual_input_mode()
+        else:
+            # Auto mode: Check if camera apps are running
+            self._handle_auto_mode()
+    
+    def _handle_auto_mode(self):
+        """Handle automatic camera control mode."""
+        print("Checking for camera applications...")
+        
+        # Check which camera apps are running
+        missing_apps = get_missing_camera_apps()
+        running_apps = get_running_camera_apps()
+        
+        print(f"Running camera apps: {running_apps}")
+        print(f"Missing camera apps: {missing_apps}")
+        
+        if missing_apps:
+            # Show dialog about missing apps
+            dialog = CameraAppCheckDialog(missing_apps, self)
+            dialog.exec_()
+            print("Auto mode requires all camera applications to be running.")
+            return
+        
+        # All apps are running - proceed with automated capture
+        print("All camera applications detected. Attempting automated capture...")
+        
+        # Start with 2nd Look (multispectral) automation
+        self._attempt_second_look_capture()
+    
+    def _attempt_second_look_capture(self):
+        """Attempt to capture multispectral image from 2nd Look."""
+        try:
+            print("Initializing 2nd Look automation...")
+            
+            # Create automation instance
+            second_look = SecondLookAutomation()
+            
+            # Check if window is open
+            if not second_look.is_window_open():
+                QMessageBox.warning(
+                    self,
+                    "2nd Look Not Responsive",
+                    "2nd Look window is not open or not responding.\n\n"
+                    "Please ensure 2nd Look is properly running before attempting automated capture."
+                )
+                print("2nd Look window not found or not responsive")
+                return
+            
+            print("Capturing multispectral image from 2nd Look...")
+            
+            # Perform capture
+            captured_file = second_look.capture()
+            
+            if captured_file:
+                print(f"Successfully captured: {captured_file}")
+                
+                # Create inputs dict with captured multispectral file
+                inputs = {
+                    'dslr': '',
+                    'multispectral': captured_file,
+                    'thermal': '',
+                    'audio': ''
+                }
+                
+                # Process the capture
+                self._process_manual_inputs(inputs)
+                
+                # Cleanup automation instance
+                try:
+                    second_look.cleanup()
+                except Exception as e:
+                    print(f"Warning during cleanup: {e}")
+            else:
+                QMessageBox.warning(
+                    self,
+                    "Capture Failed",
+                    "Failed to capture multispectral image from 2nd Look.\n\n"
+                    "Please verify:\n"
+                    "1. 2nd Look has an image loaded\n"
+                    "2. The camera is properly connected\n"
+                    "3. File system has write permissions"
+                )
+                print("Capture failed - file not created")
+        
+        except CameraAutomationError as e:
+            QMessageBox.critical(
+                self,
+                "Camera Automation Error",
+                f"Error during automated capture:\n\n{str(e)}"
+            )
+            print(f"Automation error: {e}")
+        
+        except Exception as e:
+            QMessageBox.critical(
+                self,
+                "Unexpected Error",
+                f"Unexpected error during automated capture:\n\n{str(e)}"
+            )
+            print(f"Unexpected error: {e}")
+    
+    def _attempt_eos_capture(self):
+        """Attempt to capture image from EOS Utility DSLR."""
+        try:
+            print("Initializing EOS Utility automation...")
+            
+            # Create automation instance
+            eos_utility = EOSUtilityAutomation()
+            
+            # Check if window is open
+            if not eos_utility.is_window_open():
+                QMessageBox.warning(
+                    self,
+                    "EOS Utility Not Responsive",
+                    "EOS Utility window is not open or not responding.\n\n"
+                    "Please ensure EOS Utility is properly running before attempting automated capture."
+                )
+                print("EOS Utility window not found or not responsive")
+                return
+            
+            print("Capturing image from EOS Utility...")
+            
+            # Perform capture
+            captured_file = eos_utility.capture()
+            
+            if captured_file:
+                print(f"Successfully captured: {captured_file}")
+                
+                # Create inputs dict with captured DSLR file
+                inputs = {
+                    'dslr': captured_file,
+                    'multispectral': '',
+                    'thermal': '',
+                    'audio': ''
+                }
+                
+                # Process the capture
+                self._process_manual_inputs(inputs)
+                
+                # Cleanup automation instance
+                try:
+                    eos_utility.cleanup()
+                except Exception as e:
+                    print(f"Warning during cleanup: {e}")
+            else:
+                QMessageBox.warning(
+                    self,
+                    "Capture Failed",
+                    "Failed to capture image from EOS Utility.\n\n"
+                    "Please verify:\n"
+                    "1. EOS Utility has a camera connected\n"
+                    "2. The camera is properly initialized\n"
+                    "3. File system has write permissions"
+                )
+                print("Capture failed - file not created")
+        
+        except CameraAutomationError as e:
+            QMessageBox.critical(
+                self,
+                "Camera Automation Error",
+                f"Error during automated capture:\n\n{str(e)}"
+            )
+            print(f"Automation error: {e}")
+        
+        except Exception as e:
+            QMessageBox.critical(
+                self,
+                "Unexpected Error",
+                f"Unexpected error during automated capture:\n\n{str(e)}"
+            )
+            print(f"Unexpected error: {e}")
+    
+    def _attempt_analyzir_capture(self):
+        """Attempt to capture thermal data from AnalyzIR."""
+        try:
+            print("Initializing AnalyzIR automation...")
+            
+            # Create automation instance
+            analyzir = AnalyzIRAutomation()
+            
+            # Check if window is open
+            if not analyzir.is_window_open():
+                QMessageBox.warning(
+                    self,
+                    "AnalyzIR Not Responsive",
+                    "AnalyzIR Venus window is not open or not responding.\n\n"
+                    "Please ensure AnalyzIR is properly running before attempting automated capture."
+                )
+                print("AnalyzIR window not found or not responsive")
+                return
+            
+            print("Capturing thermal data from AnalyzIR...")
+            
+            # Perform capture
+            captured_file = analyzir.capture()
+            
+            if captured_file:
+                print(f"Successfully captured: {captured_file}")
+                
+                # Create inputs dict with captured thermal file
+                inputs = {
+                    'dslr': '',
+                    'multispectral': '',
+                    'thermal': captured_file,
+                    'audio': ''
+                }
+                
+                # Process the capture
+                self._process_manual_inputs(inputs)
+                
+                # Cleanup automation instance
+                try:
+                    analyzir.cleanup()
+                except Exception as e:
+                    print(f"Warning during cleanup: {e}")
+            else:
+                QMessageBox.warning(
+                    self,
+                    "Capture Failed",
+                    "Failed to capture thermal data from AnalyzIR.\n\n"
+                    "Please verify:\n"
+                    "1. AnalyzIR has thermal image data loaded\n"
+                    "2. The IR camera (FOTRIC 323) is properly connected\n"
+                    "3. File system has write permissions"
+                )
+                print("Capture failed - file not created")
+        
+        except CameraAutomationError as e:
+            QMessageBox.critical(
+                self,
+                "Camera Automation Error",
+                f"Error during automated capture:\n\n{str(e)}"
+            )
+            print(f"Automation error: {e}")
+        
+        except Exception as e:
+            QMessageBox.critical(
+                self,
+                "Unexpected Error",
+                f"Unexpected error during automated capture:\n\n{str(e)}"
+            )
+            print(f"Unexpected error: {e}")
+    
+    def _handle_manual_input_mode(self):
+        """Handle manual input mode with file dialogs."""
+        print("Opening manual input dialog...")
+        
+        dialog = ManualInputDialog(self)
+        dialog.inputs_confirmed.connect(self._process_manual_inputs)
+        dialog.exec_()
+    
+    def _process_manual_inputs(self, inputs: dict):
+        """
+        Process manual camera inputs with RGB and multispectral models.
+        
+        Args:
+            inputs: Dictionary with keys 'dslr_side', 'dslr_top', 'multispectral', 'thermal', 'audio'
+        """
+        print("Processing manual inputs:")
+        print(f"  DSLR Side: {inputs.get('dslr_side', 'Not provided')}")
+        print(f"  DSLR Top: {inputs.get('dslr_top', 'Not provided')}")
+        print(f"  Multispectral: {inputs.get('multispectral', 'Not provided')}")
+        print(f"  Thermal: {inputs.get('thermal', 'Not provided')}")
+        print(f"  Audio: {inputs.get('audio', 'Not provided')}")
+        
+        # Create analysis record
+        report_id = f"DUR-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
+        self.current_analysis_id = self.data_manager.create_analysis(report_id, DEVICE_ID)
+        self.analysis_start_time = datetime.now()
+        
+        if not self.current_analysis_id:
+            QMessageBox.critical(self, "Error", "Failed to create analysis record. Cannot proceed.")
+            return
+        
+        print(f"Created analysis record: {report_id} (ID: {self.current_analysis_id})")
+        
+        # Save input files
+        for input_type, file_path in inputs.items():
+            if file_path:
+                self.data_manager.save_input_file(self.current_analysis_id, input_type, file_path)
+        
+        # Navigate to Reports tab
+        self.tab_widget.setCurrentIndex(5)
+        
+        # Store inputs in reports tab
+        self.reports_tab.input_data = inputs
+        self.reports_tab.current_analysis_id = self.current_analysis_id
+        self.reports_tab.current_report_id = report_id  # Store the actual report ID
+        self.reports_tab.data_manager = self.data_manager
+        
+        # Reset results and tracking
+        self.report_results = {}
+        self.models_to_run = []  # Track which models we'll run
+        
+        # Determine which models to run based on inputs
+        if inputs.get('dslr_side'):
+            print("DSLR Side (Defect Model) detected...")
+            self.models_to_run.append('defect')
+        
+        if inputs.get('dslr_top'):
+            print("DSLR Top (Locule Model) detected...")
+            self.models_to_run.append('locule')
+        
+        if inputs.get('multispectral'):
+            print("Multispectral TIFF (Maturity Model) detected...")
+            self.models_to_run.append('maturity')
+        
+        # Shape processing uses dslr_side if available AND model is loaded
+        if inputs.get('dslr_side') and self.models.get('shape') and self.models['shape'].is_loaded:
+            print("Shape Classification will be processed...")
+            self.models_to_run.append('shape')
+        
+        # Audio ripeness processing uses audio file if available AND model is loaded
+        if inputs.get('audio') and self.models.get('audio') and self.models['audio'].is_loaded:
+            print("Audio Ripeness Classification will be processed...")
+            self.models_to_run.append('audio')
+        elif inputs.get('audio'):
+            print("⚠️ Audio file provided but audio model not loaded!")
+        
+        # Start loading state if models to process
+        if len(self.models_to_run) > 0:
+            self.is_processing = True
+            self.reports_tab.set_loading(True)
+        else:
+            # No models to run, just show the inputs
+            self.reports_tab.generate_report(inputs)
+            return
+        
+        # Process DSLR Side View with Defect Model
+        if inputs.get('dslr_side'):
+            worker = DefectWorker(inputs['dslr_side'], self.models['defect'])
+            worker.signals.started.connect(lambda: self.on_worker_started("Defect Analysis"))
+            worker.signals.result_ready.connect(self.on_defect_report_result)
+            worker.signals.error.connect(lambda msg: self.on_worker_error_manual(msg, 'defect'))
+            worker.signals.finished.connect(lambda: self.on_worker_finished())
+            worker.signals.progress.connect(self.on_worker_progress)
+            self.thread_pool.start(worker)
+        
+        # Process DSLR Top View with Locule Model
+        if inputs.get('dslr_top'):
+            worker = LoculeWorker(inputs['dslr_top'], self.models['locule'])
+            worker.signals.started.connect(lambda: self.on_worker_started("Locule Analysis"))
+            worker.signals.result_ready.connect(self.on_locule_report_result)
+            worker.signals.error.connect(lambda msg: self.on_worker_error_manual(msg, 'locule'))
+            worker.signals.finished.connect(lambda: self.on_worker_finished())
+            worker.signals.progress.connect(self.on_worker_progress)
+            self.thread_pool.start(worker)
+        
+        # Process Multispectral with Maturity Model
+        if inputs.get('multispectral'):
+            worker = MaturityWorker(inputs['multispectral'], self.models['maturity'])
+            worker.signals.started.connect(lambda: self.on_worker_started("Maturity Classification"))
+            worker.signals.result_ready.connect(self.on_maturity_report_result)
+            worker.signals.error.connect(lambda msg: self.on_worker_error_manual(msg, 'maturity'))
+            worker.signals.finished.connect(lambda: self.on_worker_finished())
+            worker.signals.progress.connect(self.on_worker_progress)
+            self.thread_pool.start(worker)
+        
+        # Process DSLR Side View with Shape Model (uses same image as defect)
+        if inputs.get('dslr_side') and self.models.get('shape') and self.models['shape'].is_loaded:
+            worker = ShapeWorker(inputs['dslr_side'], self.models['shape'])
+            worker.signals.started.connect(lambda: self.on_worker_started("Shape Classification"))
+            worker.signals.result_ready.connect(self.on_shape_report_result)
+            worker.signals.error.connect(lambda msg: self.on_worker_error_manual(msg, 'shape'))
+            worker.signals.finished.connect(lambda: self.on_worker_finished())
+            worker.signals.progress.connect(self.on_worker_progress)
+            self.thread_pool.start(worker)
+        
+        # Process Audio File with Audio Ripeness Model
+        if inputs.get('audio') and self.models.get('audio') and self.models['audio'].is_loaded:
+            print(f"Starting AudioWorker for: {inputs['audio']}")
+            worker = AudioWorker(inputs['audio'], self.models['audio'])
+            worker.signals.started.connect(lambda: self.on_worker_started("Audio Ripeness Classification"))
+            worker.signals.result_ready.connect(self.on_audio_report_result)
+            worker.signals.error.connect(lambda msg: self.on_worker_error_manual(msg, 'audio'))
+            worker.signals.finished.connect(lambda: self.on_worker_finished())
+            worker.signals.progress.connect(self.on_worker_progress)
+            self.thread_pool.start(worker)
+    
+    def on_ripeness_clicked(self):
+        """Handle ripeness classifier button click - switch to Ripeness tab."""
+        # Switch to Ripeness tab (index 1)
+        self.tab_widget.setCurrentIndex(1)
+        
+        # The tab will handle file loading via its own signal
+        # Trigger file dialog immediately
+        self.on_ripeness_load_audio()
+        
+    def on_ripeness_load_audio(self):
+        """Handle audio file loading for ripeness classification."""
+        if self.is_processing:
+            QMessageBox.warning(self, "Processing", "Please wait for current processing to complete.")
+            return
+        
+        # Open file dialog for audio
+        # Use non-native dialog to avoid Windows shell freezing issues
+        file_path, _ = QFileDialog.getOpenFileName(
+            self,
+            "Select Audio File",
+            DEFAULT_DIRS['audio'],
+            FILE_FILTERS['audio'],
+            options=QFileDialog.DontUseNativeDialog
+        )
+        
+        if not file_path:
+            return
+        
+        print(f"Processing audio file: {file_path}")
+        self.is_processing = True
+        self.current_audio_file = file_path  # Store for result handler
+        
+        # Set loading state on ripeness tab
+        self.ripeness_tab.set_loading(True)
+        
+        # Create and start worker
+        worker = AudioWorker(file_path, self.models['audio'])
+        worker.signals.started.connect(lambda: self.on_worker_started("Audio Processing"))
+        worker.signals.result_ready.connect(self.on_audio_result)
+        worker.signals.error.connect(self.on_worker_error)
+        worker.signals.finished.connect(lambda: self.on_worker_finished())
+        worker.signals.progress.connect(self.on_worker_progress)
+        
+        self.thread_pool.start(worker)
+    
+    def on_quality_clicked(self):
+        """Handle quality classifier button click - switch to Quality tab."""
+        # Switch to Quality tab (index 2)
+        self.tab_widget.setCurrentIndex(2)
+        
+        # Trigger file dialog via the control panel
+        # Use QTimer to ensure the tab is fully visible before opening dialog
+        if hasattr(self.quality_tab, 'control_panel'):
+            QTimer.singleShot(100, self.quality_tab.control_panel._open_file_dialog)
+        
+    def on_quality_load_image(self):
+        """Handle image file loading for quality classification."""
+        if self.is_processing:
+            QMessageBox.warning(self, "Processing", "Please wait for current processing to complete.")
+            return
+        
+        # Open file dialog for image
+        # Use non-native dialog to avoid Windows shell freezing issues
+        file_path, _ = QFileDialog.getOpenFileName(
+            self,
+            "Select Image File",
+            DEFAULT_DIRS['image'],
+            FILE_FILTERS['image'],
+            options=QFileDialog.DontUseNativeDialog
+        )
+        
+        if not file_path:
+            return
+        
+        print(f"Processing image file: {file_path}")
+        self.is_processing = True
+        self.current_image_file = file_path  # Store for result handler
+        
+        # Set loading state on quality tab
+        self.quality_tab.set_loading(True)
+        
+        # Create and start worker
+        worker = DefectWorker(file_path, self.models['defect'])
+        worker.signals.started.connect(lambda: self.on_worker_started("Defect Detection"))
+        worker.signals.result_ready.connect(self.on_defect_result)
+        worker.signals.error.connect(self.on_worker_error)
+        worker.signals.finished.connect(lambda: self.on_worker_finished())
+        worker.signals.progress.connect(self.on_worker_progress)
+        
+        self.thread_pool.start(worker)
+    
+    def on_maturity_load_tiff(self):
+        """Handle TIFF file loading for maturity classification."""
+        if self.is_processing:
+            QMessageBox.warning(self, "Processing", "Please wait for current processing to complete.")
+            return
+        
+        # Open file dialog for TIFF
+        # Use non-native dialog to avoid Windows shell freezing issues
+        file_path, _ = QFileDialog.getOpenFileName(
+            self,
+            "Select Multispectral TIFF File",
+            DEFAULT_DIRS.get('image', str(Path.home())),
+            FILE_FILTERS.get('tiff', "TIFF Files (*.tif *.tiff);;All Files (*.*)"),
+            options=QFileDialog.DontUseNativeDialog
+        )
+        
+        if not file_path:
+            return
+        
+        print(f"Processing TIFF file: {file_path}")
+        self.is_processing = True
+        self.current_tiff_file = file_path  # Store for result handler
+        
+        # Set loading state on maturity tab
+        self.maturity_tab.set_loading(True)
+        
+        # Create and start worker
+        worker = MaturityWorker(file_path, self.models['maturity'])
+        worker.signals.started.connect(lambda: self.on_worker_started("Maturity Classification"))
+        worker.signals.result_ready.connect(self.on_maturity_result)
+        worker.signals.error.connect(self.on_worker_error)
+        worker.signals.finished.connect(lambda: self.on_worker_finished())
+        worker.signals.progress.connect(self.on_worker_progress)
+        
+        self.thread_pool.start(worker)
+    
+    def on_calibration_clicked(self):
+        """Handle calibration button click."""
+        QMessageBox.information(
+            self,
+            "System Calibration",
+            "Calibration feature coming soon!\n\n"
+            "This will allow you to:\n"
+            "- Adjust detection thresholds\n"
+            "- Fine-tune model parameters\n"
+            "- Calibrate camera settings"
+        )
+    
+    def on_batch_clicked(self):
+        """Handle batch mode button click."""
+        QMessageBox.information(
+            self,
+            "Batch Processing",
+            "Batch mode coming soon!\n\n"
+            "This will allow you to:\n"
+            "- Process multiple files at once\n"
+            "- Export results to CSV/Excel\n"
+            "- Generate batch reports"
+        )
+    
+    # ==================== Worker Signal Handlers ====================
+    
+    def on_worker_started(self, task_name: str):
+        """Handle worker started signal."""
+        print(f"{task_name} started")
+        self.status_text.setText(f"Processing: {task_name}...")
+    
+    def on_worker_progress(self, percentage: int, message: str):
+        """Handle worker progress signal."""
+        print(f"Progress: {percentage}% - {message}")
+        self.status_text.setText(f"{message} ({percentage}%)")
+    
+    def on_worker_finished(self):
+        """Handle worker finished signal."""
+        self.is_processing = False
+        self.status_text.setText("Ready")
+        print("Processing completed")
+    
+    def on_worker_error(self, error_msg: str):
+        """Handle worker error signal."""
+        self.is_processing = False
+        QMessageBox.critical(self, "Processing Error", f"An error occurred:\n\n{error_msg}")
+        print(f"Error: {error_msg}")
+    
+    def on_worker_error_manual(self, error_msg: str, model_name: str):
+        """
+        Handle worker error in manual input mode.
+        Instead of crashing, record the error and continue with other models.
+        """
+        print(f"Error in {model_name} model: {error_msg}")
+        # Mark this model as completed with no result (error case)
+        self.report_results[model_name] = {
+            'error': True,
+            'error_msg': error_msg
+        }
+        # Check if all models have answered (success or error)
+        self._check_all_reports_ready()
+    
+    def on_audio_result(self, waveform_image, spectrogram_image, class_name, confidence, probabilities, knock_count):
+        """Handle audio processing result for Ripeness tab (standalone mode)."""
+        print(f"Audio result: {class_name} ({confidence:.2%}) - {knock_count} knocks")
+        
+        # Update ripeness tab with spectrogram results
+        self.ripeness_tab.update_results(
+            spectrogram_image,
+            class_name.capitalize() if class_name else "Unknown",
+            probabilities,
+            self.current_audio_file
+        )
+        
+        # Map quality (for now, use confidence-based grading)
+        if confidence >= 0.90:
+            quality = "Grade A"
+        elif confidence >= 0.75:
+            quality = "Grade B"
+        else:
+            quality = "Grade C"
+        
+        # Add to results table
+        self.results_panel.add_result(class_name.capitalize() if class_name else "Unknown", quality, confidence)
+    
+    def on_maturity_result(self, gradcam_image, class_name, confidence, probabilities):
+        """Handle maturity processing result."""
+        print(f"Maturity result: {class_name} ({confidence:.2f}%)")
+        
+        # Update maturity tab with results
+        self.maturity_tab.update_results(
+            gradcam_image,
+            class_name,
+            probabilities,
+            self.current_tiff_file
+        )
+        
+        # Add to results table (if available)
+        # This can be extended later to add maturity results to dashboard
+    
+    def on_maturity_report_result(self, gradcam_image, class_name, confidence, probabilities):
+        """Handle maturity processing result for Reports tab."""
+        print(f"Maturity report result: {class_name} ({confidence:.2f}%)")
+        self.report_results['maturity'] = {
+            'gradcam_image': gradcam_image,
+            'class_name': class_name,
+            'confidence': confidence,
+            'probabilities': probabilities
+        }
+        
+        # Save result to database
+        if self.current_analysis_id:
+            # Convert confidence from percentage to 0-1 if needed
+            conf_value = confidence / 100.0 if confidence > 1.0 else confidence
+            self.data_manager.save_result(
+                self.current_analysis_id,
+                'maturity',
+                {
+                    'predicted_class': class_name,
+                    'confidence': conf_value,
+                    'probabilities': probabilities,
+                    'processing_time': 0.0,
+                    'metadata': {}
+                }
+            )
+            # Save Grad-CAM visualization
+            if gradcam_image:
+                self.data_manager.save_visualization(
+                    self.current_analysis_id,
+                    'maturity_gradcam',
+                    gradcam_image,
+                    'png'
+                )
+        
+        self._check_all_reports_ready()
+    
+    def on_defect_report_result(self, annotated_image, primary_class, class_counts, total_detections):
+        """Handle defect processing result for Reports tab (side view)."""
+        print(f"Defect report result: {primary_class} ({total_detections} detections)")
+        self.report_results['defect'] = {
+            'annotated_image': annotated_image,
+            'primary_class': primary_class,
+            'class_counts': class_counts,
+            'total_detections': total_detections
+        }
+        
+        # Save result to database
+        if self.current_analysis_id:
+            metadata = {
+                'total_detections': total_detections,
+                'class_counts': class_counts
+            }
+            self.data_manager.save_result(
+                self.current_analysis_id,
+                'defect',
+                {
+                    'predicted_class': primary_class,
+                    'confidence': 0.85,  # Default confidence
+                    'probabilities': {},
+                    'processing_time': 0.0,
+                    'metadata': metadata
+                }
+            )
+            # Save visualization
+            if annotated_image:
+                self.data_manager.save_visualization(
+                    self.current_analysis_id,
+                    'defect_annotated',
+                    annotated_image,
+                    'jpg'
+                )
+        
+        self._check_all_reports_ready()
+    
+    def on_locule_report_result(self, annotated_image, locule_count):
+        """Handle locule processing result for Reports tab (top view)."""
+        print(f"Locule report result: {locule_count} locules detected")
+        self.report_results['locule'] = {
+            'annotated_image': annotated_image,
+            'locule_count': locule_count
+        }
+        
+        # Save result to database
+        if self.current_analysis_id:
+            metadata = {
+                'locule_count': locule_count
+            }
+            self.data_manager.save_result(
+                self.current_analysis_id,
+                'locule',
+                {
+                    'predicted_class': f'{locule_count} locules',
+                    'confidence': 0.90,  # Default confidence
+                    'probabilities': {},
+                    'processing_time': 0.0,
+                    'metadata': metadata
+                }
+            )
+            # Save visualization
+            if annotated_image:
+                self.data_manager.save_visualization(
+                    self.current_analysis_id,
+                    'locule_annotated',
+                    annotated_image,
+                    'jpg'
+                )
+        
+        self._check_all_reports_ready()
+    
+    def on_shape_report_result(self, annotated_image, shape_class, class_id, confidence):
+        """Handle shape classification result for Reports tab."""
+        print(f"Shape report result: {shape_class} (confidence: {confidence:.3f})")
+        self.report_results['shape'] = {
+            'annotated_image': annotated_image,
+            'shape_class': shape_class,
+            'class_id': class_id,
+            'confidence': confidence
+        }
+        
+        # Save result to database
+        if self.current_analysis_id:
+            self.data_manager.save_result(
+                self.current_analysis_id,
+                'shape',
+                {
+                    'predicted_class': shape_class,
+                    'confidence': confidence,
+                    'probabilities': {},
+                    'processing_time': 0.0,
+                    'metadata': {'class_id': class_id}
+                }
+            )
+            # Save visualization
+            if annotated_image:
+                self.data_manager.save_visualization(
+                    self.current_analysis_id,
+                    'shape_annotated',
+                    annotated_image,
+                    'jpg'
+                )
+        
+        self._check_all_reports_ready()
+    
+    def on_audio_report_result(self, waveform_image, spectrogram_image, ripeness_class, confidence, probabilities, knock_count):
+        """Handle audio ripeness classification result for Reports tab."""
+        print(f"✓ Audio report result: {ripeness_class} ({confidence:.2%}) - {knock_count} knocks")
+        print(f"  Waveform image: {type(waveform_image)} size={waveform_image.size() if hasattr(waveform_image, 'size') else 'N/A'}")
+        print(f"  Spectrogram image: {type(spectrogram_image)} size={spectrogram_image.size() if hasattr(spectrogram_image, 'size') else 'N/A'}")
+        print(f"  Probabilities: {probabilities}")
+        
+        self.report_results['audio'] = {
+            'waveform_image': waveform_image,
+            'spectrogram_image': spectrogram_image,
+            'ripeness_class': ripeness_class,
+            'confidence': confidence,
+            'probabilities': probabilities,
+            'knock_count': knock_count
+        }
+        
+        # Save result to database
+        if self.current_analysis_id:
+            # Convert confidence from percentage to 0-1 if needed
+            conf_value = confidence / 100.0 if confidence > 1.0 else confidence
+            metadata = {
+                'knock_count': knock_count
+            }
+            self.data_manager.save_result(
+                self.current_analysis_id,
+                'audio',
+                {
+                    'predicted_class': ripeness_class,
+                    'confidence': conf_value,
+                    'probabilities': probabilities,
+                    'processing_time': 0.0,
+                    'metadata': metadata
+                }
+            )
+            # Save waveform visualization
+            if waveform_image:
+                self.data_manager.save_visualization(
+                    self.current_analysis_id,
+                    'audio_waveform',
+                    waveform_image,
+                    'png'
+                )
+            # Save spectrogram visualization
+            if spectrogram_image:
+                self.data_manager.save_visualization(
+                    self.current_analysis_id,
+                    'audio_spectrogram',
+                    spectrogram_image,
+                    'png'
+                )
+        
+        self._check_all_reports_ready()
+    
+    def _check_all_reports_ready(self):
+        """
+        Check if all pending reports are ready (success or error), 
+        then generate combined report with available data.
+        """
+        if not hasattr(self, 'models_to_run'):
+            return  # Safety check
+        
+        # Check if all models have reported (success or error)
+        models_answered = set(self.report_results.keys())
+        models_expected = set(self.models_to_run)
+        
+        print(f"Models to run: {models_expected}")
+        print(f"Models answered: {models_answered}")
+        
+        if models_answered >= models_expected and len(models_expected) > 0:
+            # All models have answered (success or failure)
+            print("All models answered - generating report with available data...")
+            self.reports_tab.generate_report_with_rgb_and_multispectral(
+                self.reports_tab.input_data,
+                self.report_results
+            )
+            
+            # Finalize analysis in database
+            if self.current_analysis_id and hasattr(self.reports_tab, 'current_overall_grade'):
+                grade = getattr(self.reports_tab, 'current_overall_grade', 'B')
+                description = getattr(self.reports_tab, 'current_grade_description', '')
+                
+                if self.analysis_start_time:
+                    total_time = (datetime.now() - self.analysis_start_time).total_seconds()
+                else:
+                    total_time = 0.0
+                
+                self.data_manager.finalize_analysis(
+                    self.current_analysis_id,
+                    grade,
+                    description,
+                    total_time
+                )
+                print(f"Finalized analysis with grade {grade}")
+            
+            # Always refresh recent results panel to show new analysis (after report is generated)
+            self.results_panel.refresh_from_database()
+            
+            # Update system info panel with new statistics
+            self.update_system_info_panel()
+            
+            self.reports_tab.set_loading(False)
+            self.is_processing = False
+    
+    def on_defect_result(self, annotated_image, primary_class, class_counts, total_detections):
+        """Handle defect detection result."""
+        print(f"Defect result: {primary_class} ({total_detections} detections)")
+        
+        # Update quality tab with results
+        self.quality_tab.update_results(
+            annotated_image,
+            primary_class,
+            class_counts,
+            total_detections,
+            self.current_image_file
+        )
+        
+        # Map to quality grade
+        if primary_class == "No Defects":
+            quality = "Grade A"
+            confidence = 95.0
+        elif primary_class == "Minor Defects":
+            quality = "Grade B"
+            confidence = 80.0
+        else:  # Reject
+            quality = "Grade C"
+            confidence = 70.0
+        
+        # Add to results table (use "N/A" for ripeness since it's quality-only)
+        self.results_panel.add_result("N/A", quality, confidence)
+    
+    # ==================== Menu Handlers ====================
+    
+    def show_about(self):
+        """Show the about dialog."""
+        dialog = AboutDialog(self)
+        dialog.exec_()
+    
+    def show_help(self):
+        """Show the help dialog."""
+        dialog = HelpDialog(self)
+        dialog.exec_()
+    
+    def on_go_to_dashboard(self):
+        """Handle request to go back to dashboard from reports tab."""
+        self.tab_widget.setCurrentIndex(0)  # Switch to Dashboard (index 0)
+    
+    def on_view_analysis(self, report_id: str):
+        """
+        Handle view analysis request from recent results panel.
+        
+        Args:
+            report_id: The report ID to load and display
+        """
+        # Validate report_id
+        if not report_id or report_id == 'N/A':
+            QMessageBox.warning(self, "Error", f"Invalid report ID: {report_id}")
+            return
+        
+        # Ensure data manager is set
+        if not self.data_manager:
+            QMessageBox.warning(self, "Error", "Data manager not initialized")
+            return
+        
+        self.reports_tab.data_manager = self.data_manager
+        
+        # Switch to Reports tab (index 5)
+        self.tab_widget.setCurrentIndex(5)
+        
+        # Load the analysis from database
+        success = self.reports_tab.load_analysis_from_db(report_id)
+        if not success:
+            QMessageBox.warning(self, "Error", f"Could not load analysis: {report_id}")
+    
+    def closeEvent(self, event):
+        """Handle window close event."""
+        # Wait for all threads to finish
+        if self.thread_pool.activeThreadCount() > 0:
+            reply = QMessageBox.question(
+                self,
+                "Processing in Progress",
+                "Processing is still running. Are you sure you want to exit?",
+                QMessageBox.Yes | QMessageBox.No,
+                QMessageBox.No
+            )
+            
+            if reply == QMessageBox.No:
+                event.ignore()
+                return
+        
+        print("Application closing...")
+        self.thread_pool.waitForDone(3000)  # Wait up to 3 seconds
+        event.accept()
+

+ 32 - 0
ui/panels/__init__.py

@@ -0,0 +1,32 @@
+"""
+UI Panels Package
+
+Contains all major UI panels for the dashboard.
+"""
+
+from .system_status import SystemStatusPanel
+from .quick_actions import QuickActionsPanel
+from .recent_results import RecentResultsPanel
+from .system_info import SystemInfoPanel
+from .live_feed import LiveFeedPanel
+from .rgb_preview_panel import RGBPreviewPanel
+from .multispectral_panel import MultispectralPanel
+from .audio_spectrogram_panel import AudioSpectrogramPanel
+from .ripeness_results_panel import RipenessResultsPanel
+from .ripeness_control_panel import RipenessControlPanel
+from .analysis_timeline_panel import AnalysisTimelinePanel
+
+__all__ = [
+    'SystemStatusPanel',
+    'QuickActionsPanel',
+    'RecentResultsPanel',
+    'SystemInfoPanel',
+    'LiveFeedPanel',
+    'RGBPreviewPanel',
+    'MultispectralPanel',
+    'AudioSpectrogramPanel',
+    'RipenessResultsPanel',
+    'RipenessControlPanel',
+    'AnalysisTimelinePanel',
+]
+

+ 266 - 0
ui/panels/analysis_timeline_panel.py

@@ -0,0 +1,266 @@
+"""
+Analysis Timeline Panel
+
+Panel showing history of analysis results and session statistics.
+"""
+
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, 
+                              QPushButton, QScrollArea, QFrame)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont
+from datetime import datetime
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.timeline_entry import TimelineEntry
+
+
+class AnalysisTimelinePanel(QWidget):
+    """
+    Panel for displaying analysis timeline and statistics.
+    
+    Signals:
+        save_audio_clicked: Emitted when save audio button is clicked
+        save_complete_clicked: Emitted when save complete package is clicked
+    """
+    
+    save_audio_clicked = pyqtSignal()
+    save_complete_clicked = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.test_counter = 0
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the timeline panel UI."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #34495e;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 10, 0)
+        header_layout.setSpacing(0)
+        
+        title = QLabel("Analysis Timeline")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        header_layout.addWidget(title)
+        
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: white;
+            border: none;
+        """)
+        
+        content_layout = QVBoxLayout(content)
+        content_layout.setSpacing(10)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        
+        # Timeline entries (scrollable)
+        timeline_scroll = QScrollArea()
+        timeline_scroll.setWidgetResizable(True)
+        timeline_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        timeline_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+        timeline_scroll.setMinimumHeight(220)
+        timeline_scroll.setStyleSheet("""
+            QScrollArea {
+                border: none;
+                background-color: transparent;
+            }
+        """)
+        
+        timeline_widget = QWidget()
+        self.timeline_layout = QVBoxLayout(timeline_widget)
+        self.timeline_layout.setSpacing(8)
+        self.timeline_layout.setContentsMargins(0, 0, 5, 0)
+        self.timeline_layout.addStretch()
+        
+        timeline_scroll.setWidget(timeline_widget)
+        content_layout.addWidget(timeline_scroll)
+        
+        # Separator
+        separator = QFrame()
+        separator.setFrameShape(QFrame.HLine)
+        separator.setStyleSheet("background-color: #ecf0f1;")
+        content_layout.addWidget(separator)
+        
+        # Snapshot options section
+        snapshot_label = QLabel("Current Snapshot Options:")
+        snapshot_label.setFont(QFont("Arial", 10, QFont.Bold))
+        snapshot_label.setStyleSheet("color: #2c3e50; margin-top: 8px;")
+        content_layout.addWidget(snapshot_label)
+        
+        # Snapshot buttons (first row)
+        snapshot_row1 = QHBoxLayout()
+        snapshot_row1.setSpacing(8)
+        
+        save_images_btn = self._create_snapshot_btn("Save Images", "#3498db")
+        save_images_btn.setEnabled(False)
+        save_images_btn.setToolTip("Save camera images - Coming soon")
+        snapshot_row1.addWidget(save_images_btn)
+        
+        self.save_audio_btn = self._create_snapshot_btn("Save Audio", "#16a085")
+        self.save_audio_btn.clicked.connect(self.save_audio_clicked.emit)
+        self.save_audio_btn.setEnabled(False)  # Enabled after processing
+        snapshot_row1.addWidget(self.save_audio_btn)
+        
+        save_spectral_btn = self._create_snapshot_btn("Save Spectral", "#8e44ad")
+        save_spectral_btn.setEnabled(False)
+        save_spectral_btn.setToolTip("Save spectral data - Coming soon")
+        snapshot_row1.addWidget(save_spectral_btn)
+        
+        content_layout.addLayout(snapshot_row1)
+        
+        # Complete package button (second row)
+        self.save_complete_btn = QPushButton("Save Complete Analysis Package")
+        self.save_complete_btn.setFont(QFont("Arial", 9, QFont.Bold))
+        self.save_complete_btn.setFixedHeight(28)
+        self.save_complete_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #27ae60;
+                border: 1px solid #229954;
+                color: white;
+            }
+            QPushButton:hover {
+                background-color: #229954;
+            }
+            QPushButton:disabled {
+                background-color: #95a5a6;
+                border-color: #7f8c8d;
+            }
+        """)
+        self.save_complete_btn.clicked.connect(self.save_complete_clicked.emit)
+        self.save_complete_btn.setEnabled(False)
+        self.save_complete_btn.setToolTip("Save complete analysis - Coming soon")
+        content_layout.addWidget(self.save_complete_btn)
+        
+        # Session statistics
+        stats_label = QLabel("Session Statistics:")
+        stats_label.setFont(QFont("Arial", 10, QFont.Bold))
+        stats_label.setStyleSheet("color: #2c3e50; margin-top: 10px;")
+        content_layout.addWidget(stats_label)
+        
+        # Statistics labels
+        self.stats_labels = {}
+        stats = [
+            ("tests", "• Tests Completed: 0"),
+            ("avg_time", "• Average Processing: 0.00s"),
+            ("accuracy", "• Classification Accuracy: --"),
+            ("ripe_count", "• Ripe Fruits Detected: 0 (0.0%)"),
+            ("duration", "• Session Duration: 0h 0m")
+        ]
+        
+        for key, text in stats:
+            label = QLabel(text)
+            label.setFont(QFont("Arial", 10))
+            label.setStyleSheet("color: #2c3e50; line-height: 1.4;")
+            self.stats_labels[key] = label
+            content_layout.addWidget(label)
+        
+        content_layout.addStretch()
+        layout.addWidget(header)
+        layout.addWidget(content)
+        
+    def _create_snapshot_btn(self, text: str, color: str) -> QPushButton:
+        """Create a snapshot button with specific color."""
+        btn = QPushButton(text)
+        btn.setFont(QFont("Arial", 8, QFont.Bold))
+        btn.setFixedHeight(26)
+        btn.setStyleSheet(f"""
+            QPushButton {{
+                background-color: {color};
+                border: 1px solid {color};
+                color: white;
+            }}
+            QPushButton:hover {{
+                opacity: 0.9;
+            }}
+            QPushButton:disabled {{
+                background-color: #95a5a6;
+                border-color: #7f8c8d;
+            }}
+        """)
+        return btn
+        
+    def add_test_result(self, classification: str, confidence: float, 
+                       processing_time: float):
+        """
+        Add a new test result to the timeline.
+        
+        Args:
+            classification: Classification result
+            confidence: Confidence percentage (0-100)
+            processing_time: Processing time in seconds
+        """
+        self.test_counter += 1
+        timestamp = datetime.now().strftime("%H:%M:%S")
+        
+        entry = TimelineEntry(
+            self.test_counter,
+            timestamp,
+            classification,
+            confidence,
+            processing_time
+        )
+        
+        # Insert at the top (most recent first)
+        self.timeline_layout.insertWidget(0, entry)
+        
+        # Keep only last 10 entries
+        while self.timeline_layout.count() > 11:  # 10 + stretch
+            item = self.timeline_layout.takeAt(10)
+            if item.widget():
+                item.widget().deleteLater()
+        
+        # Enable save audio button
+        self.save_audio_btn.setEnabled(True)
+        
+    def update_statistics(self, total_tests: int, avg_processing: float,
+                         ripe_count: int, session_start: datetime = None):
+        """
+        Update session statistics.
+        
+        Args:
+            total_tests: Total number of tests
+            avg_processing: Average processing time
+            ripe_count: Number of ripe classifications
+            session_start: Session start datetime
+        """
+        self.stats_labels["tests"].setText(f"• Tests Completed: {total_tests}")
+        self.stats_labels["avg_time"].setText(f"• Average Processing: {avg_processing:.2f}s")
+        
+        ripe_percentage = (ripe_count / total_tests * 100) if total_tests > 0 else 0
+        self.stats_labels["ripe_count"].setText(
+            f"• Ripe Fruits Detected: {ripe_count} ({ripe_percentage:.1f}%)"
+        )
+        
+        if session_start:
+            duration = datetime.now() - session_start
+            hours = duration.seconds // 3600
+            minutes = (duration.seconds % 3600) // 60
+            self.stats_labels["duration"].setText(f"• Session Duration: {hours}h {minutes}m")
+            
+    def clear_timeline(self):
+        """Clear all timeline entries."""
+        while self.timeline_layout.count() > 1:  # Keep stretch
+            item = self.timeline_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        
+        self.test_counter = 0
+        self.save_audio_btn.setEnabled(False)
+
+

+ 179 - 0
ui/panels/audio_spectrogram_panel.py

@@ -0,0 +1,179 @@
+"""
+Audio Spectrogram Panel
+
+Panel for displaying audio spectrogram visualization.
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QSizePolicy
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont, QPixmap, QCursor
+from ui.widgets.panel_header import PanelHeader
+
+
+class ClickableLabel(QLabel):
+    """Label that emits a signal when clicked."""
+    clicked = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.setCursor(QCursor(Qt.PointingHandCursor))
+        
+    def mousePressEvent(self, event):
+        """Emit clicked signal on mouse press."""
+        self.clicked.emit()
+        super().mousePressEvent(event)
+
+
+class AudioSpectrogramPanel(QWidget):
+    """
+    Panel for audio spectrogram display and settings.
+    
+    Signals:
+        spectrogram_clicked: Emitted when spectrogram is clicked for enlarged view
+    """
+    
+    spectrogram_clicked = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_pixmap = None
+        self.current_audio_path = None
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+        
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #16a085;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 10, 0)
+        header_layout.setSpacing(0)
+        
+        title = QLabel("Audio Spectrogram")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        status_indicator = QWidget()
+        status_indicator.setFixedSize(10, 10)
+        status_indicator.setStyleSheet("background-color: #27ae60; border-radius: 5px;")
+        
+        header_layout.addWidget(title)
+        header_layout.addStretch()
+        header_layout.addWidget(status_indicator)
+        
+        # Visualization area
+        visualization = QWidget()
+        visualization.setMinimumSize(250, 250)
+        # visualization.setMaximumSize(500, 500)
+        visualization.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+        
+        visualization_layout = QVBoxLayout(visualization)
+        visualization_layout.setContentsMargins(0, 0, 0, 0)
+        visualization_layout.setSpacing(0)
+        
+        # Frequency range label
+        range_label = QLabel("0-8kHz Range")
+        range_label.setStyleSheet("color: #bdc3c7; font-size: 12px;")
+        range_label.setAlignment(Qt.AlignCenter)
+        # range_label.setFixedHeight(15)
+        visualization_layout.addWidget(range_label)
+        
+        # Spectrogram display (clickable)
+        self.spectrogram_label = ClickableLabel()
+        self.spectrogram_label.setAlignment(Qt.AlignCenter)
+        self.spectrogram_label.setMinimumHeight(200)
+        self.spectrogram_label.setScaledContents(False)
+        self.spectrogram_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
+        self.spectrogram_label.setStyleSheet("""
+            QLabel {
+                background-color: #2c3e50;
+                border: none;
+                color: #bdc3c7;
+            }
+            QLabel:hover {
+                background-color: #34495e;
+            }
+        """)
+        self.spectrogram_label.setText("No audio loaded\n\nClick to view details")
+        self.spectrogram_label.setToolTip("Click to enlarge and view audio details")
+        self.spectrogram_label.clicked.connect(self._on_spectrogram_clicked)
+        visualization_layout.addWidget(self.spectrogram_label, 1)
+        
+        layout.addWidget(header)
+        layout.addWidget(visualization, 1)
+        
+    def update_spectrogram(self, pixmap: QPixmap, sample_rate: float = 44100, 
+                          duration: float = 0, audio_path: str = None):
+        """
+        Update the spectrogram display.
+        
+        Args:
+            pixmap: Spectrogram image as QPixmap
+            sample_rate: Audio sample rate in Hz
+            duration: Audio duration in seconds
+            audio_path: Path to the audio file for playback
+        """
+        # Store original pixmap and audio path for enlargement
+        self.current_pixmap = pixmap
+        self.current_audio_path = audio_path
+        
+        # Force widget to update geometry first
+        self.spectrogram_label.updateGeometry()
+        
+        # Get actual dimensions, with better fallbacks
+        label_width = self.spectrogram_label.width()
+        label_height = self.spectrogram_label.height()
+        
+        # Use parent widget size if label size not yet determined
+        if label_width < 100:
+            parent_width = self.width() if self.width() > 100 else 380
+            label_width = parent_width - 20
+        
+        if label_height < 100:
+            label_height = 220
+        
+        # Scale to fit width while preserving aspect ratio
+        scaled_pixmap = pixmap.scaledToWidth(
+            int(label_width),
+            Qt.SmoothTransformation
+        )
+        self.spectrogram_label.setPixmap(scaled_pixmap)
+        
+    def clear_spectrogram(self):
+        """Clear the spectrogram display."""
+        self.current_pixmap = None
+        self.current_audio_path = None
+        self.spectrogram_label.clear()
+        self.spectrogram_label.setText("No audio loaded\n\nClick to view details")
+        
+    def _on_spectrogram_clicked(self):
+        """Handle spectrogram click to show enlarged view with waveform and playback."""
+        if self.current_pixmap is not None:
+            from ui.dialogs.spectrogram_preview_dialog import SpectrogramPreviewDialog
+            dialog = SpectrogramPreviewDialog(
+                self.current_pixmap, 
+                self.current_audio_path, 
+                self
+            )
+            dialog.exec_()
+
+

+ 153 - 0
ui/panels/live_feed.py

@@ -0,0 +1,153 @@
+"""
+Live Feed Panel
+
+Displays live camera and audio feeds (placeholders for now).
+"""
+
+from PyQt5.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QLabel
+from PyQt5.QtCore import Qt
+
+from ui.widgets.status_indicator import StatusIndicator
+from resources.styles import (GROUP_BOX_STYLE, RGB_FEED_STYLE, MS_FEED_STYLE, 
+                               THERMAL_FEED_STYLE, AUDIO_FEED_STYLE)
+from utils.config import FEED_MIN_WIDTH, FEED_MIN_HEIGHT
+
+
+class LiveFeedPanel(QGroupBox):
+    """
+    Panel displaying live camera and audio feeds.
+    
+    Shows placeholders for:
+    - RGB Camera
+    - Multispectral Camera
+    - Thermal Camera
+    - Audio Spectrogram
+    
+    Note: For Phase 1, these are placeholders. Live feeds will be
+    implemented in future phases.
+    """
+    
+    def __init__(self):
+        """Initialize the live feed panel."""
+        super().__init__("Live Camera Feeds")
+        self.setStyleSheet(GROUP_BOX_STYLE)
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QHBoxLayout()
+        
+        # RGB Feed
+        rgb_layout = QVBoxLayout()
+        self.rgb_feed = QLabel()
+        self.rgb_feed.setMinimumSize(FEED_MIN_WIDTH, FEED_MIN_HEIGHT)
+        self.rgb_feed.setStyleSheet(RGB_FEED_STYLE)
+        self.rgb_feed.setAlignment(Qt.AlignCenter)
+        self.rgb_feed.setText("RGB Camera\n\n🚀 Coming Soon!\nLive feed integration")
+        self.rgb_status = StatusIndicator("offline")
+        rgb_layout.addWidget(self.rgb_feed)
+        rgb_layout.addWidget(self.rgb_status, alignment=Qt.AlignCenter)
+        layout.addLayout(rgb_layout)
+        
+        # Multispectral Feed
+        ms_layout = QVBoxLayout()
+        self.ms_feed = QLabel()
+        self.ms_feed.setMinimumSize(FEED_MIN_WIDTH, FEED_MIN_HEIGHT)
+        self.ms_feed.setStyleSheet(MS_FEED_STYLE)
+        self.ms_feed.setAlignment(Qt.AlignCenter)
+        self.ms_feed.setText("Multispectral\n\n🚀 Coming Soon!\n8-Band integration")
+        self.ms_status = StatusIndicator("offline")
+        ms_layout.addWidget(self.ms_feed)
+        ms_layout.addWidget(self.ms_status, alignment=Qt.AlignCenter)
+        layout.addLayout(ms_layout)
+        
+        # Thermal Feed
+        thermal_layout = QVBoxLayout()
+        self.thermal_feed = QLabel()
+        self.thermal_feed.setMinimumSize(FEED_MIN_WIDTH, FEED_MIN_HEIGHT)
+        self.thermal_feed.setStyleSheet(THERMAL_FEED_STYLE)
+        self.thermal_feed.setAlignment(Qt.AlignCenter)
+        self.thermal_feed.setText("Thermal Camera\n\n🚀 Coming Soon!\nThermal imaging")
+        self.thermal_status = StatusIndicator("offline")
+        thermal_layout.addWidget(self.thermal_feed)
+        thermal_layout.addWidget(self.thermal_status, alignment=Qt.AlignCenter)
+        layout.addLayout(thermal_layout)
+        
+        # Audio Visualization
+        audio_layout = QVBoxLayout()
+        self.audio_feed = QLabel()
+        self.audio_feed.setMinimumSize(FEED_MIN_WIDTH, FEED_MIN_HEIGHT)
+        self.audio_feed.setStyleSheet(AUDIO_FEED_STYLE)
+        self.audio_feed.setAlignment(Qt.AlignCenter)
+        self.audio_feed.setText("Audio Feed\n\n🚀 Coming Soon!\nLive spectrogram")
+        self.audio_status = StatusIndicator("offline")
+        audio_layout.addWidget(self.audio_feed)
+        audio_layout.addWidget(self.audio_status, alignment=Qt.AlignCenter)
+        layout.addLayout(audio_layout)
+        
+        self.setLayout(layout)
+    
+    def update_rgb_feed(self, pixmap):
+        """
+        Update RGB camera feed with new image.
+        
+        Args:
+            pixmap: QPixmap image to display
+        """
+        self.rgb_feed.setPixmap(pixmap)
+        self.rgb_status.set_status("online")
+    
+    def update_ms_feed(self, pixmap):
+        """
+        Update multispectral camera feed with new image.
+        
+        Args:
+            pixmap: QPixmap image to display
+        """
+        self.ms_feed.setPixmap(pixmap)
+        self.ms_status.set_status("online")
+    
+    def update_thermal_feed(self, pixmap):
+        """
+        Update thermal camera feed with new image.
+        
+        Args:
+            pixmap: QPixmap image to display
+        """
+        self.thermal_feed.setPixmap(pixmap)
+        self.thermal_status.set_status("online")
+    
+    def update_audio_feed(self, pixmap):
+        """
+        Update audio spectrogram display.
+        
+        Args:
+            pixmap: QPixmap spectrogram image to display
+        """
+        self.audio_feed.setPixmap(pixmap)
+        self.audio_status.set_status("online")
+    
+    def set_feed_offline(self, feed_name: str):
+        """
+        Set a specific feed to offline status.
+        
+        Args:
+            feed_name: Feed name ('rgb', 'ms', 'thermal', or 'audio')
+        """
+        if feed_name == 'rgb':
+            self.rgb_status.set_status("offline")
+            self.rgb_feed.clear()
+            self.rgb_feed.setText("RGB Camera\nOFFLINE")
+        elif feed_name == 'ms':
+            self.ms_status.set_status("offline")
+            self.ms_feed.clear()
+            self.ms_feed.setText("Multispectral\nOFFLINE")
+        elif feed_name == 'thermal':
+            self.thermal_status.set_status("offline")
+            self.thermal_feed.clear()
+            self.thermal_feed.setText("Thermal\nOFFLINE")
+        elif feed_name == 'audio':
+            self.audio_status.set_status("offline")
+            self.audio_feed.clear()
+            self.audio_feed.setText("Audio\nOFFLINE")
+

+ 274 - 0
ui/panels/maturity_control_panel.py

@@ -0,0 +1,274 @@
+"""
+Maturity Control Panel
+
+Control panel for multispectral maturity testing with device selection and parameters.
+"""
+
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, 
+                              QPushButton, QComboBox, QCheckBox)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.mode_toggle import ModeToggle
+from ui.widgets.parameter_slider import ParameterSlider
+
+
+class MaturityControlPanel(QWidget):
+    """
+    Control panel for multispectral maturity testing.
+    
+    Signals:
+        run_test_clicked: Emitted when RUN TEST button is clicked (live mode)
+        open_file_clicked: Emitted when OPEN FILE is clicked (file mode)
+        stop_clicked: Emitted when STOP button is clicked
+        reset_clicked: Emitted when RESET button is clicked
+        mode_changed: Emitted when test mode changes (str: 'live' or 'file')
+    """
+    
+    run_test_clicked = pyqtSignal()
+    open_file_clicked = pyqtSignal()
+    stop_clicked = pyqtSignal()
+    reset_clicked = pyqtSignal()
+    mode_changed = pyqtSignal(str)
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_mode = "file"
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the control panel UI."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #34495e;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 10, 0)
+        header_layout.setSpacing(0)
+        
+        title = QLabel("Control Panel")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        header_layout.addWidget(title)
+        
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: white;
+            border: none;
+        """)
+        
+        content_layout = QVBoxLayout(content)
+        content_layout.setSpacing(10)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        
+        # Camera Selection
+        camera_label = QLabel("Camera Selection:")
+        camera_label.setFont(QFont("Arial", 9, QFont.Bold))
+        camera_label.setStyleSheet("color: #2c3e50;")
+        content_layout.addWidget(camera_label)
+        
+        self.camera_combo = QComboBox()
+        self.camera_combo.addItems(["Multispectral Camera (MSC2-NIR8-1-A) ▼"])
+        self.camera_combo.setFont(QFont("Arial", 9))
+        self.camera_combo.setFixedHeight(25)
+        self.camera_combo.setEnabled(False)
+        self.camera_combo.setToolTip("Multispectral camera selection - Coming with hardware integration")
+        self.camera_combo.setStyleSheet("""
+            QComboBox { 
+                background-color: #ecf0f1; 
+                border: 1px solid #bdc3c7;
+                padding: 3px;
+                color: #7f8c8d;
+            }
+        """)
+        content_layout.addWidget(self.camera_combo)
+        
+        # Band Selection (for future use)
+        band_label = QLabel("Active Bands:")
+        band_label.setFont(QFont("Arial", 9, QFont.Bold))
+        band_label.setStyleSheet("color: #2c3e50;")
+        content_layout.addWidget(band_label)
+        
+        self.band_combo = QComboBox()
+        self.band_combo.addItems(["All 8 Bands ▼"])
+        self.band_combo.setFont(QFont("Arial", 9))
+        self.band_combo.setFixedHeight(25)
+        self.band_combo.setEnabled(False)
+        self.band_combo.setToolTip("Band selection - Coming in future update")
+        self.band_combo.setStyleSheet("""
+            QComboBox { 
+                background-color: #ecf0f1; 
+                border: 1px solid #bdc3c7;
+                padding: 3px;
+                color: #7f8c8d;
+            }
+        """)
+        content_layout.addWidget(self.band_combo)
+        
+        # Parameters Section
+        params_label = QLabel("Parameters:")
+        params_label.setFont(QFont("Arial", 9, QFont.Bold))
+        params_label.setStyleSheet("color: #2c3e50; margin-top: 5px;")
+        content_layout.addWidget(params_label)
+        
+        # Mask band slider (disabled - coming soon)
+        self.mask_band_slider = ParameterSlider("Mask Band", 0, 7, 4, "Band {}")
+        self.mask_band_slider.setEnabled(False)
+        self.mask_band_slider.setToolTip("Band index for masking (default: 4, 860nm)")
+        content_layout.addWidget(self.mask_band_slider)
+        
+        # Preprocessing Options
+        preproc_label = QLabel("Preprocessing:")
+        preproc_label.setFont(QFont("Arial", 9, QFont.Bold))
+        preproc_label.setStyleSheet("color: #2c3e50; margin-top: 5px;")
+        content_layout.addWidget(preproc_label)
+        
+        # Checkboxes
+        self.normalize_checkbox = QCheckBox("Normalize Spectral Data")
+        self.normalize_checkbox.setFont(QFont("Arial", 9))
+        self.normalize_checkbox.setChecked(True)
+        self.normalize_checkbox.setEnabled(False)
+        self.normalize_checkbox.setToolTip("Spectral data normalization (always enabled)")
+        content_layout.addWidget(self.normalize_checkbox)
+        
+        self.bg_subtract_checkbox = QCheckBox("Background Subtraction")
+        self.bg_subtract_checkbox.setFont(QFont("Arial", 9))
+        self.bg_subtract_checkbox.setEnabled(False)
+        self.bg_subtract_checkbox.setToolTip("Background subtraction - Coming soon")
+        content_layout.addWidget(self.bg_subtract_checkbox)
+        
+        self.gradcam_checkbox = QCheckBox("Generate Grad-CAM")
+        self.gradcam_checkbox.setFont(QFont("Arial", 9))
+        self.gradcam_checkbox.setChecked(True)
+        self.gradcam_checkbox.setToolTip("Generate Grad-CAM visualization overlay")
+        content_layout.addWidget(self.gradcam_checkbox)
+        
+        # Test Mode Toggle
+        mode_label = QLabel("Test Mode:")
+        mode_label.setFont(QFont("Arial", 9, QFont.Bold))
+        mode_label.setStyleSheet("color: #2c3e50; margin-top: 5px;")
+        content_layout.addWidget(mode_label)
+        
+        self.mode_toggle = ModeToggle()
+        self.mode_toggle.mode_changed.connect(self._on_mode_changed)
+        content_layout.addWidget(self.mode_toggle)
+        
+        # Control Buttons
+        # RUN TEST button
+        self.run_btn = QPushButton("OPEN FILE")
+        self.run_btn.setFont(QFont("Arial", 11, QFont.Bold))
+        self.run_btn.setFixedHeight(32)
+        self.run_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #8e44ad;
+                border: 2px solid #7d3c98;
+                color: white;
+            }
+            QPushButton:hover {
+                background-color: #7d3c98;
+            }
+            QPushButton:pressed {
+                background-color: #6c3483;
+            }
+        """)
+        self.run_btn.clicked.connect(self._on_primary_action_clicked)
+        self.run_btn.setToolTip("Select a multispectral TIFF file to analyze maturity")
+        content_layout.addWidget(self.run_btn)
+        
+        # STOP and RESET buttons
+        bottom_buttons = QHBoxLayout()
+        bottom_buttons.setSpacing(10)
+        
+        self.stop_btn = QPushButton("STOP")
+        self.stop_btn.setFont(QFont("Arial", 8, QFont.Bold))
+        self.stop_btn.setFixedHeight(22)
+        self.stop_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #e74c3c;
+                border: 1px solid #c0392b;
+                color: white;
+            }
+            QPushButton:hover {
+                background-color: #c0392b;
+            }
+        """)
+        self.stop_btn.clicked.connect(self.stop_clicked.emit)
+        self.stop_btn.setEnabled(False)
+        bottom_buttons.addWidget(self.stop_btn)
+        
+        self.reset_btn = QPushButton("RESET")
+        self.reset_btn.setFont(QFont("Arial", 8, QFont.Bold))
+        self.reset_btn.setFixedHeight(22)
+        self.reset_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #f39c12;
+                border: 1px solid #e67e22;
+                color: white;
+            }
+            QPushButton:hover {
+                background-color: #e67e22;
+            }
+        """)
+        self.reset_btn.clicked.connect(self.reset_clicked.emit)
+        bottom_buttons.addWidget(self.reset_btn)
+        
+        content_layout.addLayout(bottom_buttons)
+        content_layout.addStretch()
+        
+        layout.addWidget(header)
+        layout.addWidget(content)
+        
+        # Initialize primary action label based on default mode
+        self._update_primary_action_label()
+        
+    def set_processing(self, is_processing: bool):
+        """
+        Set the processing state.
+        
+        Args:
+            is_processing: Whether processing is active
+        """
+        self.run_btn.setEnabled(not is_processing)
+        self.stop_btn.setEnabled(is_processing)
+        
+        if is_processing:
+            self.run_btn.setText("PROCESSING...")
+        else:
+            self._update_primary_action_label()
+
+    def _on_mode_changed(self, mode: str):
+        """Handle mode change from the toggle."""
+        self.current_mode = mode
+        self.mode_changed.emit(mode)
+        self._update_primary_action_label()
+
+    def _update_primary_action_label(self):
+        """Update primary action button label and tooltip based on mode."""
+        if getattr(self, 'current_mode', 'file') == 'file':
+            self.run_btn.setText("OPEN FILE")
+            self.run_btn.setToolTip("Open a multispectral TIFF file to analyze maturity")
+        else:
+            self.run_btn.setText("RUN TEST")
+            self.run_btn.setToolTip("Run live maturity test (coming soon)")
+
+    def _on_primary_action_clicked(self):
+        """Emit the appropriate signal based on current mode."""
+        if getattr(self, 'current_mode', 'file') == 'file':
+            self.open_file_clicked.emit()
+        else:
+            self.run_test_clicked.emit()
+

+ 212 - 0
ui/panels/maturity_results_panel.py

@@ -0,0 +1,212 @@
+"""
+Maturity Results Panel
+
+Panel for displaying multispectral maturity classification results with confidence bars.
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar, QSizePolicy
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+
+class MaturityResultsPanel(QWidget):
+    """
+    Panel for displaying maturity analysis results.
+    """
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+        
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #8e44ad;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 0, 0)
+        
+        title = QLabel("Maturity Analysis Results")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        header_layout.addWidget(title)
+        
+        # Current Classification
+        classification_label = QLabel("Current Classification:")
+        classification_label.setStyleSheet("font-weight: bold; font-size: 12px; margin: 8px 10px 5px 10px; color: #2c3e50;")
+        
+        classification_frame = QWidget()
+        classification_frame.setStyleSheet("background-color: #95a5a6;")
+        classification_frame.setMinimumHeight(45)
+        classification_layout = QVBoxLayout(classification_frame)
+        classification_layout.setAlignment(Qt.AlignCenter)
+        classification_layout.setContentsMargins(5, 5, 5, 5)
+        
+        self.class_display = QLabel("—")
+        self.class_display.setAlignment(Qt.AlignCenter)
+        self.class_display.setStyleSheet("color: white; font-weight: bold; font-size: 18px;")
+        
+        classification_layout.addWidget(self.class_display)
+        
+        # Confidence Scores
+        confidence_label = QLabel("Confidence Scores:")
+        confidence_label.setStyleSheet("font-weight: bold; font-size: 11px; margin: 8px 10px 5px 10px; color: #2c3e50;")
+        
+        # Create progress bars for each category
+        categories = [
+            ("Immature", 0, "#95a5a6"),
+            ("Mature", 0, "#27ae60"),
+            ("Overmature", 0, "#e74c3c")
+        ]
+        
+        confidence_layout = QVBoxLayout()
+        confidence_layout.setSpacing(4)
+        
+        self.confidence_bars = {}
+        for name, value, color in categories:
+            category_frame = QWidget()
+            category_layout = QHBoxLayout(category_frame)
+            category_layout.setContentsMargins(10, 0, 10, 0)
+            category_layout.setSpacing(8)
+            
+            name_label = QLabel(f"{name}:")
+            name_label.setFixedWidth(80)
+            name_label.setStyleSheet("font-size: 10px; color: #2c3e50;")
+            
+            progress = QProgressBar()
+            progress.setRange(0, 100)
+            progress.setValue(int(value))
+            progress.setTextVisible(False)
+            progress.setStyleSheet(f"""
+                QProgressBar {{
+                    background-color: #ecf0f1;
+                    border: 1px solid #bdc3c7;
+                    border-radius: 0px;
+                    height: 15px;
+                }}
+                QProgressBar::chunk {{
+                    background-color: {color};
+                }}
+            """)
+            
+            value_label = QLabel(f"{value}%")
+            value_label.setFixedWidth(45)
+            value_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+            value_label.setStyleSheet("font-size: 12px; color: #2c3e50;")
+            
+            category_layout.addWidget(name_label)
+            category_layout.addWidget(progress)
+            category_layout.addWidget(value_label)
+            
+            confidence_layout.addWidget(category_frame)
+            self.confidence_bars[name] = (progress, value_label)
+        
+        # Processing time
+        self.info_label = QLabel("")
+        self.info_label.setStyleSheet("color: #7f8c8d; font-size: 12px; margin: 8px 10px 5px 10px;")
+        
+        layout.addWidget(header)
+        layout.addWidget(classification_label)
+        layout.addWidget(classification_frame)
+        layout.addWidget(confidence_label)
+        layout.addLayout(confidence_layout)
+        layout.addWidget(self.info_label)
+        layout.addStretch()
+        
+    def update_results(self, classification: str, probabilities: dict, 
+                      processing_time: float = 0, model_version: str = "MaturityNet v1.0"):
+        """
+        Update the results display.
+        
+        Args:
+            classification: Predicted class name
+            probabilities: Dictionary of class probabilities (0-1)
+            processing_time: Processing time in seconds
+            model_version: Model version string
+        """
+        # Map class names to display names
+        class_mapping = {
+            "Immature": "Immature",
+            "Mature": "Mature",
+            "Overmature": "Overmature"
+        }
+        
+        display_class = class_mapping.get(classification, classification)
+        
+        # Update classification display
+        self.class_display.setText(display_class.upper())
+        
+        # Set color based on classification
+        class_colors = {
+            "Immature": "#95a5a6",
+            "Mature": "#27ae60",
+            "Overmature": "#e74c3c"
+        }
+        bg_color = class_colors.get(classification, "#95a5a6")
+        self.class_display.parent().setStyleSheet(f"background-color: {bg_color};")
+        self.class_display.setStyleSheet("""
+            color: white;
+            font-weight: bold;
+            font-size: 18px;
+        """)
+        
+        # Update confidence bars
+        for class_name, (progress_bar, value_label) in self.confidence_bars.items():
+            # Try to find matching probability (handle case variations)
+            prob = 0
+            for key, value in probabilities.items():
+                if key.lower() == class_name.lower():
+                    prob = value
+                    break
+            
+            percentage = prob * 100
+            
+            # Update progress bar
+            progress_bar.setValue(int(percentage))
+            
+            # Update value label
+            value_label.setText(f"{percentage:.1f}%")
+            
+            # Highlight primary class
+            if class_name == display_class:
+                value_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #2c3e50;")
+            else:
+                value_label.setStyleSheet("font-size: 12px; color: #2c3e50;")
+        
+        # Update info label
+        self.info_label.setText(
+            f"Processing Time: {processing_time:.2f}s | Model: {model_version}"
+        )
+        
+    def clear_results(self):
+        """Clear all results."""
+        self.class_display.setText("—")
+        self.class_display.setStyleSheet("""
+            color: white;
+            font-weight: bold;
+            font-size: 18px;
+        """)
+        
+        for progress_bar, value_label in self.confidence_bars.values():
+            progress_bar.setValue(0)
+            value_label.setText("0%")
+            value_label.setStyleSheet("font-size: 12px; color: #2c3e50;")
+            
+        self.info_label.setText("")
+

+ 249 - 0
ui/panels/multispectral_panel.py

@@ -0,0 +1,249 @@
+"""
+Multispectral Analysis Panel
+
+Panel for displaying multispectral camera analysis (coming soon feature).
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QPixmap, QImage, QCursor
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.coming_soon_overlay import ComingSoonOverlay
+from ui.dialogs.image_preview_dialog import ImagePreviewDialog
+
+
+class MultispectralPanel(QWidget):
+    """
+    Panel for multispectral camera analysis.
+    Currently shows "COMING SOON" placeholder for future camera integration.
+    """
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+        
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #8e44ad;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 10, 0)
+        header_layout.setSpacing(0)
+        
+        title = QLabel("Multispectral Analysis")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        status_indicator = QWidget()
+        status_indicator.setFixedSize(10, 10)
+        status_indicator.setStyleSheet("background-color: #27ae60; border-radius: 5px;")
+        
+        header_layout.addWidget(title)
+        header_layout.addStretch()
+        header_layout.addWidget(status_indicator)
+        
+        # Content area
+        self.content = QWidget()
+        self.content.setMinimumSize(250, 250)
+        # content.setMaximumSize(400, 400)
+        self.content.setStyleSheet("""
+            background-color: #8e44ad;
+            border: 1px solid #9b59b6;
+            border-top: none;
+        """)
+        
+        content_layout = QVBoxLayout(self.content)
+        content_layout.setAlignment(Qt.AlignCenter)
+        content_layout.setSpacing(5)
+        
+        # Image display label (initially hidden)
+        self.image_label = QLabel()
+        self.image_label.setAlignment(Qt.AlignCenter)
+        self.image_label.setScaledContents(False)  # We'll scale manually for better control
+        self.image_label.setMinimumSize(250, 250)
+        self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+        self.image_label.setStyleSheet("""
+            QLabel {
+                background-color: #2c3e50;
+                border: 2px solid #34495e;
+            }
+        """)
+        self.image_label.hide()
+        self.image_label.setCursor(QCursor(Qt.PointingHandCursor))
+        self.image_label.mousePressEvent = self._on_image_clicked
+        self.current_pixmap = None  # Store the original pixmap for dialog
+        content_layout.addWidget(self.image_label, 1)  # Give it stretch factor
+        
+        # Main text container (shown by default)
+        self.text_container = QWidget()
+        text_layout = QVBoxLayout(self.text_container)
+        text_layout.setAlignment(Qt.AlignCenter)
+        text_layout.setSpacing(2)
+        
+        # Spectral data text
+        data_label = QLabel("8-Band Spectral Data")
+        data_label.setFont(QFont("Arial", 12))
+        data_label.setStyleSheet("color: #e8daef;")
+        data_label.setAlignment(Qt.AlignCenter)
+        text_layout.addWidget(data_label)
+        
+        # NIR enhancement text
+        enhancement_label = QLabel("NIR Enhancement Active\n(Coming soon)")
+        enhancement_label.setFont(QFont("Arial", 10))
+        enhancement_label.setStyleSheet("color: #d5a6df;")
+        enhancement_label.setAlignment(Qt.AlignCenter)
+        text_layout.addWidget(enhancement_label)
+        
+        content_layout.addWidget(self.text_container)
+        
+        # Band selection area
+        bands_frame = QWidget()
+        bands_frame.setMaximumHeight(40)
+        bands_layout = QHBoxLayout(bands_frame)
+        bands_layout.setContentsMargins(10, 5, 10, 5)
+        
+        bands_label = QLabel("Active Bands:")
+        bands_label.setStyleSheet("font-weight: bold; font-size: 12px;")
+        
+        bands_layout.addWidget(bands_label)
+        
+        bands_data = [
+            ("680nm", "#e74c3c"),
+            ("750nm", "#f39c12"),
+            ("850nm", "#27ae60"),
+            ("950nm", "#3498db")
+        ]
+        
+        for band_name, color in bands_data:
+            band_frame = QWidget()
+            band_layout = QHBoxLayout(band_frame)
+            band_layout.setContentsMargins(0, 0, 0, 0)
+            band_layout.setSpacing(5)
+            
+            color_box = QWidget()
+            color_box.setFixedSize(20, 12)
+            color_box.setStyleSheet(f"background-color: {color};")
+            
+            name_label = QLabel(band_name)
+            name_label.setStyleSheet("font-size: 12px;")
+            
+            band_layout.addWidget(color_box)
+            band_layout.addWidget(name_label)
+            bands_layout.addWidget(band_frame)
+        
+        bands_layout.addStretch()
+        
+        layout.addWidget(header)
+        layout.addWidget(self.content, 1)
+        layout.addWidget(bands_frame, 0)
+        
+        # Tooltip
+        self.setToolTip("Multispectral camera analysis - Coming with hardware integration")
+    
+    def set_image(self, pixmap: QPixmap):
+        """Display an image in the panel."""
+        if pixmap and not pixmap.isNull():
+            # Store the original pixmap for dialog display
+            self.current_pixmap = pixmap
+            
+            # Show the image label
+            self.image_label.show()
+            self.text_container.hide()
+            
+            # Update the pixmap - use a timer to ensure layout is complete
+            from PyQt5.QtCore import QTimer
+            QTimer.singleShot(50, self._update_image_display)
+            
+            # Also try immediate update in case widget is already sized
+            self._update_image_display()
+            
+            # Update tooltip to indicate clickability
+            self.image_label.setToolTip("Click to view full-size image")
+    
+    def _update_image_display(self):
+        """Update the image display with proper scaling."""
+        if self.current_pixmap and not self.current_pixmap.isNull() and self.image_label.isVisible():
+            # Get the current size of the label
+            label_size = self.image_label.size()
+            if label_size.width() <= 0 or label_size.height() <= 0:
+                # If not yet sized, use minimum size
+                label_size = self.image_label.minimumSize()
+            
+            # Calculate scaled size maintaining aspect ratio
+            scaled_pixmap = self.current_pixmap.scaled(
+                label_size.width(),
+                label_size.height(),
+                Qt.KeepAspectRatio,
+                Qt.SmoothTransformation
+            )
+            
+            self.image_label.setPixmap(scaled_pixmap)
+    
+    def resizeEvent(self, event):
+        """Handle resize events to update image scaling."""
+        super().resizeEvent(event)
+        if self.current_pixmap and not self.current_pixmap.isNull():
+            # Use QTimer to update after layout is complete
+            from PyQt5.QtCore import QTimer
+            QTimer.singleShot(10, self._update_image_display)
+    
+    def update_image(self, pixmap: QPixmap):
+        """Update the displayed image (alias for set_image)."""
+        self.set_image(pixmap)
+    
+    def clear(self):
+        """Clear the image display and show default text."""
+        self.image_label.clear()
+        self.image_label.hide()
+        self.text_container.show()
+        self.current_pixmap = None
+    
+    def _on_image_clicked(self, event):
+        """Handle click on image to show full-size dialog."""
+        if self.current_pixmap and not self.current_pixmap.isNull():
+            dialog = ImagePreviewDialog(
+                self.current_pixmap,
+                title="Multispectral Analysis - Grad-CAM Visualization",
+                parent=self
+            )
+            dialog.exec_()
+        
+    def _create_band_indicator(self, wavelength: str, color: str) -> QWidget:
+        """Create a band indicator widget."""
+        widget = QWidget()
+        layout = QHBoxLayout(widget)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(3)
+        
+        # Color box
+        color_box = QLabel()
+        color_box.setFixedSize(20, 12)
+        color_box.setStyleSheet(f"background-color: {color}; border: 1px solid #2c3e50;")
+        layout.addWidget(color_box)
+        
+        # Wavelength label
+        wavelength_label = QLabel(wavelength)
+        wavelength_label.setFont(QFont("Arial", 8))
+        wavelength_label.setStyleSheet("color: #2c3e50;")
+        layout.addWidget(wavelength_label)
+        
+        return widget
+
+

+ 498 - 0
ui/panels/quality_control_panel.py

@@ -0,0 +1,498 @@
+"""
+Quality Control Panel
+
+Panel for quality analysis controls including camera selection, parameters, and processing options.
+"""
+
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+                             QPushButton, QCheckBox, QSlider, QComboBox,
+                             QLineEdit, QSizePolicy, QGroupBox, QFrame, QFileDialog)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.mode_toggle import ModeToggle
+
+
+class QualityControlPanel(QWidget):
+    """
+    Panel for quality control settings and analysis triggering.
+    Includes camera selection, detection parameters, and processing options.
+    """
+
+    # Signals
+    analyze_requested = pyqtSignal()
+    open_file_requested = pyqtSignal(str)  # file_path
+    parameter_changed = pyqtSignal(str, float)  # parameter_name, value
+    mode_changed = pyqtSignal(str)  # 'live' or 'file'
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_mode = "file"  # Default to file mode
+        self.parameter_values = {
+            'shape_sensitivity': 70,
+            'defect_threshold': 60,
+            'locule_algorithm': 'Deep Learning v3'
+        }
+        self.init_ui()
+
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+
+        # Header using the PanelHeader widget
+        header = PanelHeader(
+            title="Control Panel",
+            color="#34495e"  # Dark gray for control
+        )
+        layout.addWidget(header)
+
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+
+        content_layout = QVBoxLayout(content)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        content_layout.setSpacing(12)
+
+        # Camera Selection Section
+        self._create_camera_section(content_layout)
+
+        # Separator
+        self._add_separator(content_layout)
+
+        # Detection Parameters Section
+        self._create_parameters_section(content_layout)
+
+        # Separator
+        self._add_separator(content_layout)
+
+        # Processing Options Section
+        self._create_processing_section(content_layout)
+
+        # Separator
+        self._add_separator(content_layout)
+
+        # Test Mode Toggle Section
+        self._create_mode_toggle_section(content_layout)
+
+        # Separator
+        self._add_separator(content_layout)
+
+        # Analyze Button
+        self._create_analyze_button(content_layout)
+
+        layout.addWidget(content, 1)
+
+    def _create_camera_section(self, parent_layout):
+        """Create camera selection section."""
+        # Section label
+        cameras_label = QLabel("Active Cameras:")
+        cameras_label.setFont(QFont("Arial", 11, QFont.Bold))
+        cameras_label.setStyleSheet("color: #ecf0f1;")
+        parent_layout.addWidget(cameras_label)
+
+        # Camera checkboxes (mark coming soon features)
+        cameras = [
+            ("RGB Top View", True, "#27ae60", "Currently showing sample data"),
+            ("RGB Side View", True, "#27ae60", "Currently showing sample data"),
+            ("Thermal Camera", False, "#e74c3c", "Coming with thermal camera hardware"),
+            ("Multispectral", True, "#27ae60", "Coming with multispectral camera integration")
+        ]
+
+        self.camera_checkboxes = {}
+
+        for camera_name, checked, color, tooltip in cameras:
+            checkbox = QCheckBox(camera_name)
+            checkbox.setChecked(checked)
+            checkbox.setFont(QFont("Arial", 10))
+            if tooltip:
+                checkbox.setToolTip(tooltip)
+
+            if checked:
+                checkbox.setStyleSheet(f"""
+                    QCheckBox {{
+                        color: #ecf0f1;
+                        spacing: 8px;
+                    }}
+                    QCheckBox::indicator:checked {{
+                        background-color: {color};
+                        border: 1px solid {color};
+                        width: 12px;
+                        height: 12px;
+                        border-radius: 2px;
+                    }}
+                    QCheckBox::indicator:unchecked {{
+                        background-color: transparent;
+                        border: 1px solid #7f8c8d;
+                        width: 12px;
+                        height: 12px;
+                        border-radius: 2px;
+                    }}
+                """)
+            else:
+                checkbox.setStyleSheet("""
+                    QCheckBox {
+                        color: #7f8c8d;
+                        spacing: 8px;
+                    }
+                    QCheckBox::indicator:checked {
+                        background-color: #e74c3c;
+                        border: 1px solid #e74c3c;
+                        width: 12px;
+                        height: 12px;
+                        border-radius: 2px;
+                    }
+                    QCheckBox::indicator:unchecked {
+                        background-color: transparent;
+                        border: 1px solid #7f8c8d;
+                        width: 12px;
+                        height: 12px;
+                        border-radius: 2px;
+                    }
+                """)
+
+            self.camera_checkboxes[camera_name] = checkbox
+            parent_layout.addWidget(checkbox)
+
+    def _create_parameters_section(self, parent_layout):
+        """Create detection parameters section."""
+        # Section label
+        params_label = QLabel("Detection Parameters:")
+        params_label.setFont(QFont("Arial", 11, QFont.Bold))
+        params_label.setStyleSheet("color: #ecf0f1;")
+        parent_layout.addWidget(params_label)
+
+        # Shape Sensitivity Slider
+        self._create_parameter_slider(
+            parent_layout, "Shape Sensitivity:", "shape_sensitivity",
+            self.parameter_values['shape_sensitivity'], 0, 100, "%"
+        )
+
+        # Defect Threshold Slider
+        self._create_parameter_slider(
+            parent_layout, "Defect Threshold:", "defect_threshold",
+            self.parameter_values['defect_threshold'], 0, 100, "%"
+        )
+
+        # Locule Algorithm Dropdown
+        locule_label = QLabel("Locule Algorithm:")
+        locule_label.setFont(QFont("Arial", 10))
+        locule_label.setStyleSheet("color: #ecf0f1;")
+        parent_layout.addWidget(locule_label)
+
+        self.locule_combo = QComboBox()
+        self.locule_combo.addItems([
+            "Watershed Seg. v2",
+            "Edge Detection v1",
+            "Deep Learning v3"
+        ])
+        self.locule_combo.setCurrentText(self.parameter_values['locule_algorithm'])
+        self.locule_combo.setFont(QFont("Arial", 9))
+        self.locule_combo.setStyleSheet("""
+            QComboBox {
+                background-color: #34495e;
+                color: #ecf0f1;
+                border: 1px solid #4a5f7a;
+                border-radius: 3px;
+                padding: 5px;
+                min-width: 120px;
+            }
+            QComboBox::drop-down {
+                border: none;
+                width: 20px;
+            }
+            QComboBox::down-arrow {
+                image: url(down_arrow.png);
+                width: 12px;
+                height: 12px;
+            }
+        """)
+        parent_layout.addWidget(self.locule_combo)
+
+    def _create_parameter_slider(self, parent_layout, label_text, param_name, value, min_val, max_val, suffix=""):
+        """Create a parameter slider with label and value display."""
+        # Container for slider row
+        slider_widget = QWidget()
+        slider_layout = QHBoxLayout(slider_widget)
+        slider_layout.setContentsMargins(0, 0, 0, 0)
+        slider_layout.setSpacing(8)
+
+        # Label
+        label = QLabel(label_text)
+        label.setFont(QFont("Arial", 10))
+        label.setStyleSheet("color: #ecf0f1;")
+        label.setFixedWidth(120)
+
+        # Slider
+        slider = QSlider(Qt.Horizontal)
+        slider.setValue(value)
+        slider.setMinimum(min_val)
+        slider.setMaximum(max_val)
+        slider.setStyleSheet("""
+            QSlider::groove:horizontal {
+                border: 1px solid #4a5f7a;
+                height: 8px;
+                background: #34495e;
+                margin: 0px;
+                border-radius: 4px;
+            }
+            QSlider::handle:horizontal {
+                background: #3498db;
+                border: 1px solid #2980b9;
+                width: 16px;
+                margin: -4px 0;
+                border-radius: 8px;
+            }
+            QSlider::handle:horizontal:hover {
+                background: #5dade2;
+            }
+        """)
+
+        # Value label
+        value_label = QLabel(f"{value}{suffix}")
+        value_label.setFont(QFont("Arial", 9))
+        value_label.setStyleSheet("color: #3498db; font-weight: bold;")
+        value_label.setFixedWidth(35)
+        value_label.setAlignment(Qt.AlignCenter)
+
+        # Store references for updates
+        slider_widget.slider = slider
+        slider_widget.value_label = value_label
+        slider_widget.param_name = param_name
+        slider_widget.suffix = suffix
+
+        # Connect slider signal
+        def update_value():
+            val = slider.value()
+            value_label.setText(f"{val}{suffix}")
+            self.parameter_values[param_name] = val
+            self.parameter_changed.emit(param_name, val)
+
+        slider.valueChanged.connect(update_value)
+
+        # Add to layout
+        slider_layout.addWidget(label)
+        slider_layout.addWidget(slider, 1)
+        slider_layout.addWidget(value_label)
+
+        parent_layout.addWidget(slider_widget)
+
+    def _create_processing_section(self, parent_layout):
+        """Create processing options section."""
+        # Section label
+        processing_label = QLabel("Processing Options:")
+        processing_label.setFont(QFont("Arial", 11, QFont.Bold))
+        processing_label.setStyleSheet("color: #ecf0f1;")
+        parent_layout.addWidget(processing_label)
+
+        # Processing checkboxes (mark coming soon features)
+        options = [
+            ("Auto Contrast", True, "#27ae60", "Currently functional"),
+            ("Edge Enhancement", True, "#27ae60", "Currently functional"),
+            ("Color Correction", False, "#7f8c8d", "Coming in future update")
+        ]
+
+        for option_name, checked, color, tooltip in options:
+            checkbox = QCheckBox(option_name)
+            checkbox.setChecked(checked)
+            checkbox.setFont(QFont("Arial", 10))
+            if tooltip:
+                checkbox.setToolTip(tooltip)
+
+            if checked:
+                checkbox.setStyleSheet(f"""
+                    QCheckBox {{
+                        color: #ecf0f1;
+                        spacing: 8px;
+                    }}
+                    QCheckBox::indicator:checked {{
+                        background-color: {color};
+                        border: 1px solid {color};
+                        width: 12px;
+                        height: 12px;
+                        border-radius: 2px;
+                    }}
+                """)
+            else:
+                checkbox.setStyleSheet("""
+                    QCheckBox {
+                        color: #7f8c8d;
+                        spacing: 8px;
+                    }
+                    QCheckBox::indicator:checked {
+                        background-color: #e74c3c;
+                        border: 1px solid #e74c3c;
+                        width: 12px;
+                        height: 12px;
+                        border-radius: 2px;
+                    }
+                    QCheckBox::indicator:unchecked {
+                        background-color: transparent;
+                        border: 1px solid #7f8c8d;
+                        width: 12px;
+                        height: 12px;
+                        border-radius: 2px;
+                    }
+                """)
+
+            parent_layout.addWidget(checkbox)
+
+    def _create_analyze_button(self, parent_layout):
+        """Create the analyze button."""
+        self.analyze_btn = QPushButton("ANALYZE")
+        self.analyze_btn.setFixedHeight(40)
+        self.analyze_btn.setFont(QFont("Arial", 14, QFont.Bold))
+        self.analyze_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #27ae60;
+                color: white;
+                border: 2px solid #229954;
+                border-radius: 5px;
+                font-size: 14px;
+                font-weight: bold;
+            }
+            QPushButton:hover {
+                background-color: #229954;
+                border-color: #1e8449;
+            }
+            QPushButton:pressed {
+                background-color: #1e8449;
+                padding-top: 2px;
+            }
+            QPushButton:disabled {
+                background-color: #7f8c8d;
+                border-color: #95a5a6;
+                color: #bdc3c7;
+            }
+        """)
+
+        # Connect signal
+        self.analyze_btn.clicked.connect(self._on_analyze_clicked)
+
+        parent_layout.addWidget(self.analyze_btn)
+
+    def _create_mode_toggle_section(self, parent_layout):
+        """Create the test mode toggle section."""
+        # Section label
+        mode_label = QLabel("Test Mode:")
+        mode_label.setFont(QFont("Arial", 11, QFont.Bold))
+        mode_label.setStyleSheet("color: #ecf0f1;")
+        parent_layout.addWidget(mode_label)
+
+        # Mode toggle widget
+        self.mode_toggle = ModeToggle()
+        self.mode_toggle.mode_changed.connect(self._on_mode_changed)
+
+        # Disable live mode (coming soon)
+        self.mode_toggle.live_btn.setEnabled(False)
+        self.mode_toggle.live_btn.setToolTip("Live camera analysis - Coming with camera hardware integration")
+
+        parent_layout.addWidget(self.mode_toggle)
+
+    def _add_separator(self, parent_layout):
+        """Add a visual separator line."""
+        separator = QFrame()
+        separator.setFrameShape(QFrame.HLine)
+        separator.setStyleSheet("color: #4a5f7a;")
+        separator.setFixedHeight(1)
+        parent_layout.addWidget(separator)
+
+    def set_analyzing(self, is_analyzing):
+        """Set analyzing state for the button."""
+        self.analyze_btn.setEnabled(not is_analyzing)
+        if is_analyzing:
+            self.analyze_btn.setText("ANALYZING...")
+            self.analyze_btn.setStyleSheet("""
+                QPushButton {
+                    background-color: #f39c12;
+                    color: white;
+                    border: 2px solid #e67e22;
+                    border-radius: 5px;
+                    font-size: 14px;
+                    font-weight: bold;
+                }
+            """)
+        else:
+            self._update_analyze_button_label()
+            self.analyze_btn.setStyleSheet("""
+                QPushButton {
+                    background-color: #27ae60;
+                    color: white;
+                    border: 2px solid #229954;
+                    border-radius: 5px;
+                    font-size: 14px;
+                    font-weight: bold;
+                }
+                QPushButton:hover {
+                    background-color: #229954;
+                    border-color: #1e8449;
+                }
+            """)
+
+    def get_parameters(self):
+        """Get current parameter values."""
+        return {
+            'shape_sensitivity': self.parameter_values['shape_sensitivity'],
+            'defect_threshold': self.parameter_values['defect_threshold'],
+            'locule_algorithm': self.locule_combo.currentText(),
+            'cameras': {name: cb.isChecked() for name, cb in self.camera_checkboxes.items()},
+            'current_mode': self.current_mode
+        }
+
+    def _on_mode_changed(self, mode: str):
+        """Handle mode change from the toggle."""
+        self.current_mode = mode
+        self.mode_changed.emit(mode)
+        self._update_analyze_button_label()
+
+    def _update_analyze_button_label(self):
+        """Update analyze button label and tooltip based on mode."""
+        if self.current_mode == 'file':
+            self.analyze_btn.setText("OPEN FILE")
+            self.analyze_btn.setToolTip("Select an image file to analyze quality")
+        else:
+            self.analyze_btn.setText("ANALYZE")
+            self.analyze_btn.setToolTip("Live camera analysis (coming soon)")
+
+    def _on_analyze_clicked(self):
+        """Handle analyze button click based on current mode."""
+        if self.current_mode == 'file':
+            self._open_file_dialog()
+        else:
+            # Live mode - coming soon
+            self.analyze_requested.emit()
+
+    def _open_file_dialog(self):
+        """Open file dialog to select image file."""
+        from utils.config import DEFAULT_DIRS, FILE_FILTERS
+        
+        file_dialog = QFileDialog()
+        file_dialog.setNameFilter(FILE_FILTERS.get("image", "Image Files (*.jpg *.jpeg *.png *.bmp *.JPG *.JPEG *.PNG *.BMP)"))
+        file_dialog.setWindowTitle("Select Image for Quality Analysis")
+        
+        # Set default directory if available
+        default_dir = DEFAULT_DIRS.get("image", ".")
+        file_dialog.setDirectory(default_dir)
+
+        if file_dialog.exec_():
+            file_paths = file_dialog.selectedFiles()
+            if file_paths and file_paths[0]:
+                self.open_file_requested.emit(file_paths[0])

+ 483 - 0
ui/panels/quality_defects_panel.py

@@ -0,0 +1,483 @@
+"""
+Defect Detection Results Panel
+
+Panel for displaying comprehensive defect detection results with analysis.
+"""
+
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+                              QSizePolicy, QScrollArea, QFrame)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont, QPixmap, QImage, QPainter, QColor, QPen, QBrush, QCursor
+
+from ui.widgets.panel_header import PanelHeader
+
+
+class QualityDefectsPanel(QWidget):
+    """
+    Panel for displaying defect detection results and analysis.
+    Shows detected defects with confidence levels and categorization.
+    """
+
+    # Signals
+    annotated_image_requested = pyqtSignal()  # Emitted when user clicks on locule count
+    defect_image_requested = pyqtSignal()    # Emitted when user clicks on defect analysis
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.defects_data = []
+        self.annotated_image_path = None
+        self.current_annotated_pixmap = None
+        self.has_results = False
+        self.has_defect_results = False
+        self.init_ui()
+
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+
+        # Header using the PanelHeader widget
+        header = PanelHeader(
+            title="Defect Detection Results",
+            color="#e74c3c"  # Red for defects
+        )
+        layout.addWidget(header)
+
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+
+        content_layout = QVBoxLayout(content)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        content_layout.setSpacing(10)
+
+        # Scroll area for defects list
+        scroll_area = QScrollArea()
+        scroll_area.setWidgetResizable(True)
+        scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+        scroll_area.setStyleSheet("""
+            QScrollArea {
+                border: none;
+                background-color: transparent;
+            }
+            QScrollBar:vertical {
+                background-color: #34495e;
+                width: 8px;
+                border-radius: 4px;
+            }
+            QScrollBar::handle:vertical {
+                background-color: #95a5a6;
+                border-radius: 4px;
+            }
+        """)
+
+        # Create main content layout
+        main_content_layout = QVBoxLayout()
+
+        # Add clickable locule count widget (initially shows placeholder)
+        self.locule_widget = self._create_locule_count_widget()
+        main_content_layout.addWidget(self.locule_widget)
+
+        # Add clickable defect analysis widget
+        self.defect_widget = self._create_defect_analysis_widget()
+        main_content_layout.addWidget(self.defect_widget)
+
+        # Set initial placeholder styling
+        self._update_locule_widget_style(False)
+
+        # Container for other defects
+        self.defects_container = QWidget()
+        self.defects_layout = QVBoxLayout(self.defects_container)
+        self.defects_layout.setContentsMargins(0, 0, 0, 0)
+        self.defects_layout.setSpacing(8)
+
+        # Add sample defects for demonstration
+        self._add_sample_defects()
+
+        scroll_area.setWidget(self.defects_container)
+        main_content_layout.addWidget(scroll_area)
+
+        content_layout.addLayout(main_content_layout, 1)
+
+        layout.addWidget(content, 1)
+
+    def _create_locule_count_widget(self):
+        """Create the clickable locule count widget."""
+        widget = QWidget()
+        widget.setFixedHeight(80)
+
+        # Make it clickable only when we have results
+        widget.mousePressEvent = self._on_locule_widget_clicked
+
+        layout = QHBoxLayout(widget)
+        layout.setContentsMargins(15, 10, 15, 10)
+
+        # Left side: Icon/Text
+        left_widget = QWidget()
+        left_layout = QVBoxLayout(left_widget)
+        left_layout.setContentsMargins(0, 0, 0, 0)
+        left_layout.setSpacing(2)
+
+        self.title_label = QLabel("📁 Open File to Start")
+        self.title_label.setFont(QFont("Arial", 10, QFont.Bold))
+        self.title_label.setStyleSheet("color: #6c757d;")
+
+        self.subtitle_label = QLabel("Select an image for locule analysis")
+        self.subtitle_label.setFont(QFont("Arial", 8))
+        self.subtitle_label.setStyleSheet("color: #6c757d;")
+
+        left_layout.addWidget(self.title_label)
+        left_layout.addWidget(self.subtitle_label)
+
+        # Right side: Count display
+        right_widget = QWidget()
+        right_layout = QVBoxLayout(right_widget)
+        right_layout.setContentsMargins(0, 0, 0, 0)
+        right_layout.setAlignment(Qt.AlignCenter)
+
+        self.locule_number_label = QLabel("--")
+        self.locule_number_label.setFont(QFont("Arial", 20, QFont.Bold))
+        self.locule_number_label.setStyleSheet("color: #6c757d;")
+
+        # self.confidence_label = QLabel("Ready for analysis")
+        # self.confidence_label.setFont(QFont("Arial", 9))
+        # self.confidence_label.setStyleSheet("color: #6c757d;")
+
+        right_layout.addWidget(self.locule_number_label)
+        # right_layout.addWidget(self.confidence_label)
+
+        # Add tooltip
+        widget.setToolTip("Open a file to begin locule analysis")
+
+        layout.addWidget(left_widget, 1)
+        layout.addWidget(right_widget, 0)
+
+        return widget
+
+    def _create_defect_analysis_widget(self):
+        """Create the clickable defect analysis widget."""
+        widget = QWidget()
+        widget.setFixedHeight(80)
+
+        # Make it clickable only when we have results
+        widget.mousePressEvent = self._on_defect_widget_clicked
+
+        layout = QHBoxLayout(widget)
+        layout.setContentsMargins(15, 10, 15, 10)
+
+        # Left side: Icon/Text
+        left_widget = QWidget()
+        left_layout = QVBoxLayout(left_widget)
+        left_layout.setContentsMargins(0, 0, 0, 0)
+        left_layout.setSpacing(2)
+
+        self.defect_title_label = QLabel("🔍 Defect Detection")
+        self.defect_title_label.setFont(QFont("Arial", 10, QFont.Bold))
+        self.defect_title_label.setStyleSheet("color: #6c757d; font-weight: bold;")
+
+        self.defect_subtitle_label = QLabel("Open file for defect analysis")
+        self.defect_subtitle_label.setFont(QFont("Arial", 8))
+        self.defect_subtitle_label.setStyleSheet("color: #6c757d;")
+
+        left_layout.addWidget(self.defect_title_label)
+        left_layout.addWidget(self.defect_subtitle_label)
+
+        # Right side: Defect count display
+        right_widget = QWidget()
+        right_layout = QVBoxLayout(right_widget)
+        right_layout.setContentsMargins(0, 0, 0, 0)
+        right_layout.setAlignment(Qt.AlignCenter)
+
+        self.defect_count_label = QLabel("--")
+        self.defect_count_label.setFont(QFont("Arial", 20, QFont.Bold))
+        self.defect_count_label.setStyleSheet("color: #6c757d;")
+
+        self.defect_status_label = QLabel("Ready")
+        self.defect_status_label.setFont(QFont("Arial", 9))
+        self.defect_status_label.setStyleSheet("color: #6c757d;")
+
+        right_layout.addWidget(self.defect_count_label)
+        right_layout.addWidget(self.defect_status_label)
+
+        # Add tooltip
+        widget.setToolTip("Open a file to begin defect analysis")
+
+        layout.addWidget(left_widget, 1)
+        layout.addWidget(right_widget, 0)
+
+        # Set initial styling (placeholder state)
+        self._update_defect_widget_style(widget, False)
+
+        return widget
+
+    def _update_defect_widget_style(self, widget, has_results: bool):
+        """Update the defect widget styling based on state."""
+        if has_results:
+            # Results state - clickable with hover effects
+            self.defect_title_label.setText("🔍 Defect Detection Result")
+            self.defect_title_label.setStyleSheet("color: #721c24; font-weight: bold;")
+
+            self.defect_subtitle_label.setText("Click to view defect analysis")
+            self.defect_subtitle_label.setStyleSheet("color: #c82333;")
+
+            widget.setStyleSheet("""
+                QWidget {
+                    background-color: #f8d7da;
+                    border: 2px solid #e74c3c;
+                    border-radius: 8px;
+                    margin: 5px;
+                }
+                QWidget:hover {
+                    background-color: #f5c6cb;
+                    border-color: #c82333;
+                }
+            """)
+
+            widget.setCursor(QCursor(Qt.PointingHandCursor))
+            widget.setToolTip("Click to view the annotated image with defect detection")
+        else:
+            # Placeholder state - not clickable
+            self.defect_title_label.setText("🔍 Defect Detection")
+            self.defect_title_label.setStyleSheet("color: #6c757d; font-weight: bold;")
+
+            self.defect_subtitle_label.setText("Open file for defect analysis")
+            self.defect_subtitle_label.setStyleSheet("color: #6c757d;")
+
+            widget.setStyleSheet("""
+                QWidget {
+                    background-color: #f8f9fa;
+                    border: 2px solid #dee2e6;
+                    border-radius: 8px;
+                    margin: 5px;
+                }
+            """)
+
+            widget.setCursor(QCursor(Qt.ArrowCursor))
+            widget.setToolTip("Open a file to begin defect analysis")
+
+    def _on_defect_widget_clicked(self, event):
+        """Handle click on defect widget."""
+        if hasattr(self, 'has_defect_results') and self.has_defect_results:
+            self.defect_image_requested.emit()
+
+    def _update_locule_widget_style(self, has_results: bool):
+        """Update the locule widget styling based on state."""
+        self.has_results = has_results
+
+        if has_results:
+            # Results state - clickable with hover effects
+            self.title_label.setText("📊 Locule Analysis Result")
+            self.title_label.setStyleSheet("color: #155724; font-weight: bold;")
+
+            self.subtitle_label.setText("Click to view annotated image")
+            self.subtitle_label.setStyleSheet("color: #218838;")
+
+            self.locule_widget.setStyleSheet("""
+                QWidget {
+                    background-color: #d4edda;
+                    border: 2px solid #27ae60;
+                    border-radius: 8px;
+                    margin: 5px;
+                }
+                QWidget:hover {
+                    background-color: #c3e6cb;
+                    border-color: #218838;
+                }
+            """)
+
+            self.locule_widget.setCursor(QCursor(Qt.PointingHandCursor))
+            self.locule_widget.setToolTip("Click to view the annotated image with locule segmentation")
+        else:
+            # Placeholder state - not clickable
+            self.title_label.setText("📁 Open File to Start")
+            self.title_label.setStyleSheet("color: #6c757d; font-weight: bold;")
+
+            self.subtitle_label.setText("Select an image for locule analysis")
+            self.subtitle_label.setStyleSheet("color: #6c757d;")
+
+            self.locule_widget.setStyleSheet("""
+                QWidget {
+                    background-color: #f8f9fa;
+                    border: 2px solid #dee2e6;
+                    border-radius: 8px;
+                    margin: 5px;
+                }
+            """)
+
+            self.locule_widget.setCursor(QCursor(Qt.ArrowCursor))
+            self.locule_widget.setToolTip("Open a file to begin locule analysis")
+
+    def _on_locule_widget_clicked(self, event):
+        """Handle click on locule widget."""
+        if self.has_results:
+            self.annotated_image_requested.emit()
+
+    def _add_sample_defects(self):
+        """Add sample defects for demonstration."""
+        sample_defects = [
+            {
+                'type': 'Mechanical Damage',
+                'location': 'Top-Left',
+                'size': '8.2mm²',
+                'confidence': 87.3,
+                'color': '#f39c12',
+                'category': 'warning'
+            },
+            {
+                'type': 'Surface Blemish',
+                'location': 'Side',
+                'size': '4.1mm²',
+                'confidence': 72.8,
+                'color': '#f39c12',
+                'category': 'warning'
+            },
+            {
+                'type': 'Shape Analysis',
+                'result': 'Regular',
+                'symmetry': '91.2%',
+                'confidence': 94.1,
+                'color': '#27ae60',
+                'category': 'success'
+            }
+        ]
+
+        for defect in sample_defects:
+            defect_widget = self._create_defect_item(defect)
+            self.defects_layout.addWidget(defect_widget)
+
+        # Add stretch to push everything to the top
+        self.defects_layout.addStretch()
+
+    def update_locule_count(self, count: int, confidence: float = 94.5):
+        """Update the locule count display with actual AI results."""
+        # Update the count display
+        self.locule_number_label.setText(str(count))
+
+        # Update the styling to show results state
+        self._update_locule_widget_style(True)
+
+    def update_defect_count(self, count: int, primary_class: str = "minor defects"):
+        """Update the defect count display with actual AI results."""
+        # Update the count display
+        self.defect_count_label.setText(str(count))
+
+        # Update the styling to show results state
+        self._update_defect_widget_style(self.defect_widget, True)
+
+        # Update status text
+        self.defect_status_label.setText(f"Primary: {primary_class}")
+        self.defect_status_label.setStyleSheet("color: #c82333; font-weight: bold;")
+
+        # Set the flag to indicate we have defect results
+        self.has_defect_results = True
+
+    def _create_defect_item(self, defect_data):
+        """Create a widget for a single defect item."""
+        widget = QWidget()
+        widget.setFixedHeight(60)
+        widget.setStyleSheet("""
+            QWidget {
+                background-color: #34495e;
+                border-radius: 5px;
+                border: 1px solid #4a5f7a;
+            }
+        """)
+
+        layout = QHBoxLayout(widget)
+        layout.setContentsMargins(10, 8, 10, 8)
+        layout.setSpacing(10)
+
+        # Status indicator (colored circle)
+        indicator = QLabel()
+        indicator.setFixedSize(12, 12)
+        indicator.setStyleSheet(f"""
+            QLabel {{
+                background-color: {defect_data['color']};
+                border-radius: 6px;
+                border: 1px solid {defect_data.get('border_color', '#fff')};
+            }}
+        """)
+
+        # Content area
+        content_widget = QWidget()
+        content_layout = QVBoxLayout(content_widget)
+        content_layout.setContentsMargins(0, 0, 0, 0)
+        content_layout.setSpacing(2)
+
+        # Main info
+        if defect_data['type'] == 'Shape Analysis':
+            main_text = f"Shape: {defect_data.get('result', 'Unknown')} | Symmetry: {defect_data.get('symmetry', 'N/A')}"
+        elif defect_data['type'] == 'Locule Count':
+            main_text = f"Detected Locules: {defect_data.get('count', '0')} | Confidence: {defect_data['confidence']:.1f}%"
+        else:
+            main_text = f"{defect_data['type']} | Location: {defect_data['location']} | Size: {defect_data['size']}"
+
+        main_label = QLabel(main_text)
+        main_label.setFont(QFont("Arial", 9, QFont.Bold))
+        main_label.setStyleSheet("color: #ecf0f1;")
+
+        content_layout.addWidget(main_label)
+
+        # Secondary info (confidence or additional details)
+        if 'confidence' in defect_data:
+            confidence_text = f"Confidence: {defect_data['confidence']:.1f}%"
+            if defect_data['type'] != 'Locule Count' and 'size' in defect_data:  # Already shown above
+                confidence_text += f" | Size: {defect_data['size']}"
+
+            secondary_label = QLabel(confidence_text)
+            secondary_label.setFont(QFont("Arial", 8))
+            secondary_label.setStyleSheet("color: #bdc3c7;")
+            content_layout.addWidget(secondary_label)
+
+        # Confidence value (right-aligned)
+        confidence_label = QLabel(f"{defect_data['confidence']:.1f}%")
+        confidence_label.setFont(QFont("Arial", 10, QFont.Bold))
+        confidence_label.setStyleSheet(f"color: {defect_data['color']};")
+        confidence_label.setFixedWidth(50)
+        confidence_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+
+        # Add widgets to layout
+        layout.addWidget(indicator)
+        layout.addWidget(content_widget, 1)
+        layout.addWidget(confidence_label)
+
+        return widget
+
+    def update_defects(self, defects_list):
+        """Update the defects list with new data."""
+        # Clear existing defects
+        for i in reversed(range(self.defects_layout.count())):
+            child = self.defects_layout.itemAt(i).widget()
+            if child is not None:
+                child.setParent(None)
+
+        # Add new defects
+        for defect in defects_list:
+            defect_widget = self._create_defect_item(defect)
+            self.defects_layout.addWidget(defect_widget)
+
+        # Add stretch to push everything to the top
+        self.defects_layout.addStretch()
+
+        self.update()
+
+    def clear_defects(self):
+        """Clear all defects."""
+        self.update_defects([])

+ 285 - 0
ui/panels/quality_history_panel.py

@@ -0,0 +1,285 @@
+"""
+Quality History Panel
+
+Panel for displaying recent quality test results in a table format.
+"""
+
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+                             QPushButton, QSizePolicy, QTableWidget,
+                             QTableWidgetItem, QHeaderView, QScrollArea)
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QColor
+
+from ui.widgets.panel_header import PanelHeader
+
+
+class QualityHistoryPanel(QWidget):
+    """
+    Panel for displaying recent quality test history.
+    Shows table with test results and export functionality.
+    """
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.test_history = []
+        self.init_ui()
+
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+
+        # Header using the PanelHeader widget
+        header = PanelHeader(
+            title="Recent Quality Tests",
+            color="#34495e"  # Dark gray for history
+        )
+        layout.addWidget(header)
+
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+
+        content_layout = QVBoxLayout(content)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        content_layout.setSpacing(10)
+
+        # Create and populate the history table
+        self._create_history_table(content_layout)
+
+        # Export button
+        self._create_export_button(content_layout)
+
+        layout.addWidget(content, 1)
+
+    def _create_history_table(self, parent_layout):
+        """Create the history table widget."""
+        # Table widget
+        self.history_table = QTableWidget()
+        self.history_table.setColumnCount(5)
+        self.history_table.setHorizontalHeaderLabels(["Time", "ID", "Grade", "Score", "Defects"])
+
+        # Configure table appearance
+        self.history_table.setAlternatingRowColors(True)
+        self.history_table.setSelectionBehavior(QTableWidget.SelectRows)
+        self.history_table.setSelectionMode(QTableWidget.SingleSelection)
+        self.history_table.setFocusPolicy(Qt.NoFocus)
+
+        # Set column widths
+        self.history_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
+        self.history_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Fixed)
+        self.history_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed)
+        self.history_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed)
+        self.history_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Fixed)
+
+        self.history_table.setColumnWidth(0, 80)  # Time
+        self.history_table.setColumnWidth(1, 70)  # ID
+        self.history_table.setColumnWidth(2, 50)  # Grade
+        self.history_table.setColumnWidth(3, 70)  # Score
+        self.history_table.setColumnWidth(4, 70)  # Defects
+
+        # Hide vertical header (row numbers)
+        self.history_table.verticalHeader().setVisible(False)
+
+        # Style the table
+        self.history_table.setStyleSheet("""
+            QTableWidget {
+                background-color: #34495e;
+                border: 1px solid #4a5f7a;
+                border-radius: 5px;
+                gridline-color: #4a5f7a;
+                selection-background-color: #3498db;
+            }
+            QHeaderView::section {
+                background-color: #2c3e50;
+                color: #ecf0f1;
+                padding: 8px;
+                border: none;
+                font-weight: bold;
+                font-size: 10px;
+            }
+            QTableWidget::item {
+                padding: 5px;
+                color: #ecf0f1;
+                border: none;
+            }
+            QTableWidget::item:selected {
+                background-color: #3498db;
+                color: white;
+            }
+        """)
+
+        # Populate with sample data
+        self._populate_sample_data()
+
+        parent_layout.addWidget(self.history_table)
+
+    def _populate_sample_data(self):
+        """Populate table with sample history data."""
+        # Sample test history data
+        sample_data = [
+            ("14:32:15", "Q-0032", "B", "78.5%", "2", "#f39c12"),
+            ("14:28:10", "Q-0031", "A", "94.2%", "0", "#27ae60"),
+            ("14:24:55", "Q-0030", "C", "52.8%", "5", "#e74c3c"),
+            ("14:20:33", "Q-0029", "A", "91.7%", "1", "#27ae60"),
+            ("14:16:12", "Q-0028", "B", "76.3%", "3", "#f39c12"),
+            ("14:12:45", "Q-0027", "A", "89.4%", "0", "#27ae60"),
+            ("14:08:22", "Q-0026", "B", "81.1%", "2", "#f39c12"),
+            ("14:04:18", "Q-0025", "C", "45.9%", "7", "#e74c3c")
+        ]
+
+        self.history_table.setRowCount(len(sample_data))
+
+        for row, (time, test_id, grade, score, defects, color) in enumerate(sample_data):
+            # Time column
+            time_item = QTableWidgetItem(time)
+            time_item.setTextAlignment(Qt.AlignCenter)
+            self.history_table.setItem(row, 0, time_item)
+
+            # ID column
+            id_item = QTableWidgetItem(test_id)
+            id_item.setTextAlignment(Qt.AlignCenter)
+            self.history_table.setItem(row, 1, id_item)
+
+            # Grade column (color-coded widget)
+            grade_item = QTableWidgetItem(grade)
+            grade_item.setTextAlignment(Qt.AlignCenter)
+            grade_item.setBackground(QColor(color))
+            grade_item.setForeground(QColor("white"))
+            self.history_table.setItem(row, 2, grade_item)
+
+            # Score column
+            score_item = QTableWidgetItem(score)
+            score_item.setTextAlignment(Qt.AlignCenter)
+            self.history_table.setItem(row, 3, score_item)
+
+            # Defects column
+            defects_item = QTableWidgetItem(defects)
+            defects_item.setTextAlignment(Qt.AlignCenter)
+            defects_item.setForeground(QColor(color))
+            self.history_table.setItem(row, 4, defects_item)
+
+        self.test_history = sample_data
+
+    def _create_export_button(self, parent_layout):
+        """Create the export results button."""
+        self.export_btn = QPushButton("EXPORT RESULTS")
+        self.export_btn.setFixedHeight(30)
+        self.export_btn.setFont(QFont("Arial", 10, QFont.Bold))
+        self.export_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #9b59b6;
+                color: white;
+                border: 1px solid #8e44ad;
+                border-radius: 3px;
+                font-size: 10px;
+                font-weight: bold;
+                padding: 5px 15px;
+            }
+            QPushButton:hover {
+                background-color: #8e44ad;
+                border-color: #7d3c98;
+            }
+            QPushButton:pressed {
+                background-color: #7d3c98;
+                padding-top: 7px;
+            }
+            QPushButton:disabled {
+                background-color: #7f8c8d;
+                border-color: #95a5a6;
+                color: #bdc3c7;
+            }
+        """)
+
+        # For now, just show placeholder functionality
+        self.export_btn.setToolTip("Export functionality - Coming soon")
+        self.export_btn.clicked.connect(self._export_results)
+
+        parent_layout.addWidget(self.export_btn)
+
+    def _export_results(self):
+        """Handle export button click (placeholder)."""
+        # Placeholder for future export functionality
+        print("Export functionality - Coming soon!")
+
+    def add_test_result(self, time, test_id, grade, score, defects):
+        """Add a new test result to the history."""
+        # Add to beginning of list (most recent first)
+        color = self._get_grade_color(grade)
+        new_result = (time, test_id, grade, score, defects, color)
+
+        self.test_history.insert(0, new_result)
+
+        # Keep only last 50 results
+        if len(self.test_history) > 50:
+            self.test_history = self.test_history[:50]
+
+        # Refresh table
+        self._refresh_table()
+
+    def _get_grade_color(self, grade):
+        """Get color code for grade."""
+        grade_colors = {
+            'A': '#27ae60',
+            'B': '#f39c12',
+            'C': '#e74c3c'
+        }
+        return grade_colors.get(grade, '#95a5a6')
+
+    def _refresh_table(self):
+        """Refresh the table display."""
+        self.history_table.setRowCount(len(self.test_history))
+
+        for row, (time, test_id, grade, score, defects, color) in enumerate(self.test_history):
+            # Time column
+            time_item = QTableWidgetItem(time)
+            time_item.setTextAlignment(Qt.AlignCenter)
+            self.history_table.setItem(row, 0, time_item)
+
+            # ID column
+            id_item = QTableWidgetItem(test_id)
+            id_item.setTextAlignment(Qt.AlignCenter)
+            self.history_table.setItem(row, 1, id_item)
+
+            # Grade column (color-coded)
+            grade_item = QTableWidgetItem(grade)
+            grade_item.setTextAlignment(Qt.AlignCenter)
+            grade_item.setBackground(QColor(color))
+            grade_item.setForeground(QColor("white"))
+            self.history_table.setItem(row, 2, grade_item)
+
+            # Score column
+            score_item = QTableWidgetItem(score)
+            score_item.setTextAlignment(Qt.AlignCenter)
+            self.history_table.setItem(row, 3, score_item)
+
+            # Defects column
+            defects_item = QTableWidgetItem(defects)
+            defects_item.setTextAlignment(Qt.AlignCenter)
+            defects_item.setForeground(QColor(color))
+            self.history_table.setItem(row, 4, defects_item)
+
+    def clear_history(self):
+        """Clear all test history."""
+        self.test_history = []
+        self.history_table.setRowCount(0)
+
+    def get_recent_tests(self, count=10):
+        """Get the most recent test results."""
+        return self.test_history[:count]

+ 395 - 0
ui/panels/quality_results_panel.py

@@ -0,0 +1,395 @@
+"""
+Quality Results Panel
+
+Panel for displaying comprehensive quality grading results with breakdown.
+"""
+
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+                             QSizePolicy, QProgressBar, QFrame)
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QColor
+
+from ui.widgets.panel_header import PanelHeader
+
+
+class QualityResultsPanel(QWidget):
+    """
+    Panel for displaying quality grading results.
+    Shows final grade, grading breakdown with progress bars, and additional metrics.
+    """
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_grade = "B"
+        self.overall_score = 78.5
+        self.grade_colors = {
+            'A': '#27ae60',
+            'B': '#f39c12',
+            'C': '#e74c3c'
+        }
+        self.init_ui()
+
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+
+        # Header using the PanelHeader widget
+        header = PanelHeader(
+            title="Quality Grading Results",
+            color="#27ae60"  # Green for results/grading
+        )
+        layout.addWidget(header)
+
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+
+        content_layout = QVBoxLayout(content)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        content_layout.setSpacing(12)
+
+        # Final Grade Display
+        self._create_grade_display(content_layout)
+
+        # Separator
+        self._add_separator(content_layout)
+
+        # Grading Breakdown Section
+        self._create_grading_breakdown(content_layout)
+
+        # Separator
+        self._add_separator(content_layout)
+
+        # Overall Score Display
+        self._create_overall_score(content_layout)
+
+        # Separator
+        self._add_separator(content_layout)
+
+        # Additional Metrics Section
+        self._create_additional_metrics(content_layout)
+
+        layout.addWidget(content, 1)
+
+    def _create_grade_display(self, parent_layout):
+        """Create the final grade display widget."""
+        # Grade widget container
+        grade_widget = QWidget()
+        grade_widget.setFixedHeight(70)
+        grade_widget.setStyleSheet(f"""
+            QWidget {{
+                background-color: {self.grade_colors.get(self.current_grade, '#95a5a6')};
+                border-radius: 8px;
+                border: 2px solid;
+            }}
+        """)
+
+        grade_layout = QVBoxLayout(grade_widget)
+        grade_layout.setContentsMargins(15, 10, 15, 10)
+        grade_layout.setAlignment(Qt.AlignCenter)
+
+        # Grade title
+        grade_title = QLabel("FINAL QUALITY GRADE")
+        grade_title.setFont(QFont("Arial", 10, QFont.Bold))
+        grade_title.setStyleSheet("color: white;")
+        grade_title.setAlignment(Qt.AlignCenter)
+
+        # Grade value (large)
+        self.grade_value_label = QLabel(self.current_grade)
+        self.grade_value_label.setFont(QFont("Arial", 32, QFont.Bold))
+        self.grade_value_label.setStyleSheet("color: white;")
+        self.grade_value_label.setAlignment(Qt.AlignCenter)
+
+        grade_layout.addWidget(grade_title)
+        grade_layout.addWidget(self.grade_value_label)
+
+        parent_layout.addWidget(grade_widget)
+
+    def _create_grading_breakdown(self, parent_layout):
+        """Create the grading breakdown section with progress bars."""
+        # Section label
+        breakdown_label = QLabel("Grading Breakdown:")
+        breakdown_label.setFont(QFont("Arial", 11, QFont.Bold))
+        breakdown_label.setStyleSheet("color: #ecf0f1;")
+        parent_layout.addWidget(breakdown_label)
+
+        # Sample grading metrics (including locule count)
+        sample_metrics = [
+            {"name": "Size & Weight", "value": 90, "color": "#27ae60"},
+            {"name": "Shape Regularity", "value": 92, "color": "#27ae60"},
+            {"name": "Locule Count", "value": 95, "color": "#3498db"},  # High score for correct locule count
+            {"name": "Surface Quality", "value": 70, "color": "#f39c12"},
+            {"name": "Color Uniformity", "value": 82, "color": "#27ae60"},
+            {"name": "Firmness (Thermal)", "value": 0, "color": "#7f8c8d"}
+        ]
+
+        self.metric_bars = {}
+
+        for metric in sample_metrics:
+            metric_widget = self._create_metric_bar(
+                metric["name"], metric["value"], metric["color"]
+            )
+            parent_layout.addWidget(metric_widget)
+
+    def _create_metric_bar(self, name, value, color):
+        """Create a single metric progress bar."""
+        # Container widget
+        widget = QWidget()
+        widget.setFixedHeight(25)
+
+        layout = QHBoxLayout(widget)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(8)
+
+        # Metric name label
+        name_label = QLabel(name)
+        name_label.setFont(QFont("Arial", 9))
+        name_label.setStyleSheet("color: #ecf0f1;")
+        name_label.setFixedWidth(130)
+
+        # Progress bar
+        progress_bar = QProgressBar()
+        progress_bar.setValue(value)
+        progress_bar.setTextVisible(False)
+        progress_bar.setFixedHeight(18)
+        progress_bar.setStyleSheet(f"""
+            QProgressBar {{
+                border: 1px solid #4a5f7a;
+                border-radius: 3px;
+                background-color: #34495e;
+                text-align: center;
+            }}
+            QProgressBar::chunk {{
+                background-color: {color};
+                border-radius: 2px;
+            }}
+        """)
+
+        # Value label (shows percentage or N/A)
+        if value > 0:
+            value_text = f"{value}%"
+            value_color = color
+        else:
+            value_text = "N/A"
+            value_color = "#7f8c8d"
+
+        value_label = QLabel(value_text)
+        value_label.setFont(QFont("Arial", 9, QFont.Bold))
+        value_label.setStyleSheet(f"color: {value_color};")
+        value_label.setFixedWidth(35)
+        value_label.setAlignment(Qt.AlignCenter)
+
+        # Store reference for updates
+        widget.progress_bar = progress_bar
+        widget.value_label = value_label
+        self.metric_bars[name] = widget
+
+        layout.addWidget(name_label)
+        layout.addWidget(progress_bar, 1)
+        layout.addWidget(value_label)
+
+        return widget
+
+    def _create_overall_score(self, parent_layout):
+        """Create the overall score display."""
+        # Overall score widget
+        overall_widget = QWidget()
+        overall_widget.setStyleSheet("""
+            QWidget {
+                background-color: #34495e;
+                border-radius: 5px;
+                border: 1px solid #4a5f7a;
+            }
+        """)
+
+        overall_layout = QHBoxLayout(overall_widget)
+        overall_layout.setContentsMargins(15, 10, 15, 10)
+        overall_layout.setSpacing(10)
+
+        # Label
+        overall_label = QLabel("Overall Quality Score:")
+        overall_label.setFont(QFont("Arial", 11, QFont.Bold))
+        overall_label.setStyleSheet("color: #ecf0f1;")
+
+        # Score value (large and prominent)
+        self.overall_score_label = QLabel(f"{self.overall_score:.1f}%")
+        self.overall_score_label.setFont(QFont("Arial", 18, QFont.Bold))
+
+        # Color code based on score ranges
+        if self.overall_score >= 90:
+            score_color = "#27ae60"  # Green for excellent
+        elif self.overall_score >= 75:
+            score_color = "#f39c12"  # Orange for good
+        else:
+            score_color = "#e74c3c"  # Red for poor
+
+        self.overall_score_label.setStyleSheet(f"color: {score_color};")
+
+        overall_layout.addWidget(overall_label)
+        overall_layout.addStretch()
+        overall_layout.addWidget(self.overall_score_label)
+
+        parent_layout.addWidget(overall_widget)
+
+    def _create_additional_metrics(self, parent_layout):
+        """Create additional metrics section."""
+        # Section label
+        metrics_label = QLabel("Additional Metrics:")
+        metrics_label.setFont(QFont("Arial", 10, QFont.Bold))
+        metrics_label.setStyleSheet("color: #ecf0f1;")
+        parent_layout.addWidget(metrics_label)
+
+        # Metrics container
+        metrics_widget = QWidget()
+        metrics_widget.setStyleSheet("""
+            QWidget {
+                background-color: #34495e;
+                border-radius: 3px;
+                border: 1px solid #4a5f7a;
+            }
+        """)
+
+        metrics_layout = QVBoxLayout(metrics_widget)
+        metrics_layout.setContentsMargins(10, 8, 10, 8)
+        metrics_layout.setSpacing(3)
+
+        # Sample additional metrics
+        sample_metrics = [
+            "• Estimated Weight: 185g",
+            "• Diameter: 72mm (equatorial)",
+            "• Aspect Ratio: 0.85 (oblate)",
+            "• Surface Defect Coverage: 3.2%",
+            "• Processing Time: 2.7s"
+        ]
+
+        for metric in sample_metrics:
+            metric_label = QLabel(metric)
+            metric_label.setFont(QFont("Arial", 8))
+            metric_label.setStyleSheet("color: #bdc3c7;")
+            metrics_layout.addWidget(metric_label)
+
+        # Model version info
+        model_label = QLabel("Model: QualityNet v2.8")
+        model_label.setFont(QFont("Arial", 8))
+        model_label.setStyleSheet("color: #7f8c8d;")
+        metrics_layout.addWidget(model_label)
+
+        parent_layout.addWidget(metrics_widget)
+
+    def _add_separator(self, parent_layout):
+        """Add a visual separator line."""
+        separator = QFrame()
+        separator.setFrameShape(QFrame.HLine)
+        separator.setStyleSheet("color: #4a5f7a;")
+        separator.setFixedHeight(1)
+        parent_layout.addWidget(separator)
+
+    def update_grade(self, grade, score):
+        """Update the grade and score display."""
+        self.current_grade = grade
+        self.overall_score = score
+
+        # Update grade display
+        self.grade_value_label.setText(grade)
+
+        # Update grade background color
+        grade_color = self.grade_colors.get(grade, '#95a5a6')
+
+        # Find the grade widget and update its color
+        for i in range(self.layout().count()):
+            child = self.layout().itemAt(i).widget()
+            if hasattr(child, 'setStyleSheet'):  # Grade display widget
+                child.setStyleSheet(f"""
+                    QWidget {{
+                        background-color: {grade_color};
+                        border-radius: 8px;
+                        border: 2px solid;
+                    }}
+                """)
+
+        # Update overall score
+        self.overall_score_label.setText(f"{score:.1f}%")
+
+        # Update score color
+        if score >= 90:
+            score_color = "#27ae60"
+        elif score >= 75:
+            score_color = "#f39c12"
+        else:
+            score_color = "#e74c3c"
+
+        self.overall_score_label.setStyleSheet(f"color: {score_color}; font-size: 18px; font-weight: bold;")
+
+    def update_locule_count(self, count: int, confidence: float = 94.5):
+        """Update the locule count metric in the grading breakdown."""
+        # Update the metric bar for locule count
+        if "Locule Count" in self.metric_bars:
+            widget = self.metric_bars["Locule Count"]
+
+            # Calculate score based on locule count (4 is ideal for durian)
+            # Perfect score (95%) for count=4, lower scores for other counts
+            if count == 4:
+                score = 95
+            elif count == 3 or count == 5:
+                score = 85
+            elif count == 2 or count == 6:
+                score = 70
+            else:
+                score = 50
+
+            # Update progress bar
+            widget.progress_bar.setValue(score)
+
+            # Update value label (show both count and score)
+            value_text = f"4/{count}"  # Shows expected vs actual
+            if count == 4:
+                value_color = "#27ae60"  # Green for correct count
+            elif count >= 3 and count <= 5:
+                value_color = "#f39c12"  # Orange for acceptable range
+            else:
+                value_color = "#e74c3c"  # Red for poor count
+
+            widget.value_label.setText(value_text)
+            widget.value_label.setStyleSheet(f"color: {value_color}; font-weight: bold;")
+
+    def update_metric(self, metric_name, value):
+        """Update a specific metric value."""
+        if metric_name in self.metric_bars:
+            widget = self.metric_bars[metric_name]
+
+            # Update progress bar
+            widget.progress_bar.setValue(value)
+
+            # Update value label
+            if value > 0:
+                value_text = f"{value}%"
+                # Update color based on value
+                if value >= 90:
+                    color = "#27ae60"
+                elif value >= 75:
+                    color = "#f39c12"
+                else:
+                    color = "#e74c3c"
+            else:
+                value_text = "N/A"
+                color = "#7f8c8d"
+
+            widget.value_label.setText(value_text)
+            widget.value_label.setStyleSheet(f"color: {color}; font-weight: bold;")

+ 252 - 0
ui/panels/quality_rgb_side_panel.py

@@ -0,0 +1,252 @@
+"""
+RGB Side View Panel
+
+Panel for displaying side-view RGB camera with shape analysis and outline detection.
+"""
+
+import os
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QPixmap, QImage, QPainter, QColor, QPen, QBrush
+
+from ui.widgets.panel_header import PanelHeader
+
+
+class QualityRGBSidePanel(QWidget):
+    """
+    Panel for RGB side view camera display with shape analysis.
+    Shows sample fruit profile with shape outline detection and analysis.
+    """
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.shape_analysis_active = True
+        self.init_ui()
+
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+
+        # Header using the PanelHeader widget
+        header = PanelHeader(
+            title="RGB Side View",
+            color="#3498db"  # Blue for RGB
+        )
+        layout.addWidget(header)
+
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+
+        content_layout = QVBoxLayout(content)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        content_layout.setAlignment(Qt.AlignCenter)
+        content_layout.setSpacing(5)
+
+        # Create sample fruit side view with shape outline
+        self.image_display = SampleFruitSideWidget()
+        self.image_display.setMinimumSize(200, 150)
+        content_layout.addWidget(self.image_display)
+
+        # Info labels
+        info_widget = QWidget()
+        info_layout = QVBoxLayout(info_widget)
+        info_layout.setContentsMargins(0, 5, 0, 0)
+        info_layout.setSpacing(2)
+
+        # Shape detection info
+        shape_label = QLabel("Shape Detection Active")
+        shape_label.setFont(QFont("Arial", 10))
+        shape_label.setStyleSheet("color: #27ae60; font-weight: bold;")
+        shape_label.setAlignment(Qt.AlignCenter)
+        info_layout.addWidget(shape_label)
+
+        # Analysis info
+        analysis_label = QLabel("Symmetry: 91.2% | Regular Shape")
+        analysis_label.setFont(QFont("Arial", 9))
+        analysis_label.setStyleSheet("color: #bdc3c7;")
+        analysis_label.setAlignment(Qt.AlignCenter)
+        info_layout.addWidget(analysis_label)
+
+        content_layout.addWidget(info_widget)
+
+        layout.addWidget(content, 1)
+
+    def update_shape_analysis(self, symmetry, shape_type):
+        """Update shape analysis results."""
+        self.image_display.update_analysis(symmetry, shape_type)
+        self.update()
+
+    def set_image(self, image_path=None):
+        """Set the image to display."""
+        if image_path and os.path.exists(image_path):
+            try:
+                pixmap = QPixmap(image_path)
+                if not pixmap.isNull():
+                    # Scale to fit display area
+                    scaled_pixmap = pixmap.scaled(
+                        240, 170, Qt.KeepAspectRatio, Qt.SmoothTransformation
+                    )
+
+                    # Update display
+                    self.image_display.update_with_image(scaled_pixmap)
+
+                    # Update status
+                    filename = os.path.basename(image_path)
+                    self.image_display.setToolTip(f"Loaded: {filename}")
+            except Exception as e:
+                print(f"Error loading image: {e}")
+        else:
+            # Show sample data
+            self.image_display.update()
+
+
+class SampleFruitSideWidget(QWidget):
+    """Widget showing sample fruit side view with shape outline."""
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.symmetry = 91.2
+        self.shape_type = "Regular"
+        self.setAttribute(Qt.WA_StyledBackground, True)
+
+    def update_analysis(self, symmetry, shape_type):
+        """Update shape analysis data."""
+        self.symmetry = symmetry
+        self.shape_type = shape_type
+        self.update()
+
+    def update_with_image(self, pixmap):
+        """Update display with external image."""
+        self.external_pixmap = pixmap
+        self.has_external_image = True
+        self.update()
+
+    def paintEvent(self, event):
+        """Custom paint event to draw fruit side view, shape outline, or external image."""
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+
+        # Get widget dimensions
+        width = self.width()
+        height = self.height()
+
+        # If we have an external image, display it
+        if hasattr(self, 'external_pixmap') and self.external_pixmap:
+            # Draw the external image scaled to fit
+            scaled_pixmap = self.external_pixmap.scaled(
+                width, height, Qt.KeepAspectRatio, Qt.SmoothTransformation
+            )
+
+            # Center the image
+            x = (width - scaled_pixmap.width()) // 2
+            y = (height - scaled_pixmap.height()) // 2
+
+            painter.drawPixmap(x, y, scaled_pixmap)
+        else:
+            # Draw default fruit visualization
+            self._draw_fruit_visualization(painter, width, height)
+
+    def _draw_fruit_visualization(self, painter, width, height):
+        """Draw the default fruit side view visualization."""
+        center_x = width // 2
+        center_y = height // 2
+
+        # Draw background
+        painter.fillRect(0, 0, width, height, QColor("#2c3e50"))
+
+        # Draw main fruit (elliptical shape for side view)
+        fruit_width = min(width, height) // 2
+        fruit_height = fruit_width * 3 // 4  # Slightly flattened
+        fruit_rect = (
+            center_x - fruit_width // 2,
+            center_y - fruit_height // 2,
+            fruit_width,
+            fruit_height
+        )
+
+        # Fruit body
+        fruit_color = QColor("#8B4513")  # Brown/orange fruit color
+        painter.setBrush(fruit_color)
+        painter.setPen(QPen(QColor("#654321"), 2))  # Darker border
+        painter.drawEllipse(*fruit_rect)
+
+        # Draw shape outline (dashed border for detected shape)
+        painter.setBrush(Qt.NoBrush)
+        painter.setPen(QPen(QColor("#27ae60"), 2, Qt.DashLine))
+        painter.drawEllipse(*fruit_rect)
+
+        # Draw symmetry indicators (small lines showing symmetry axis)
+        symmetry_y = center_y
+        symmetry_start_x = center_x - fruit_width // 4
+        symmetry_end_x = center_x + fruit_width // 4
+
+        painter.setPen(QPen(QColor("#3498db"), 1, Qt.SolidLine))
+        painter.drawLine(symmetry_start_x, symmetry_y - 5, symmetry_start_x, symmetry_y + 5)
+        painter.drawLine(symmetry_end_x, symmetry_y - 5, symmetry_end_x, symmetry_y + 5)
+
+        # Draw aspect ratio visualization
+        self._draw_aspect_ratio_info(painter, center_x, center_y, fruit_width, fruit_height)
+
+        # Draw locule counting on side view
+        self._draw_locule_side_view(painter, center_x, center_y, fruit_width, fruit_height)
+
+    def _draw_aspect_ratio_info(self, painter, center_x, center_y, fruit_width, fruit_height):
+        """Draw aspect ratio and shape information."""
+        # Calculate aspect ratio
+        aspect_ratio = fruit_width / fruit_height if fruit_height > 0 else 1.0
+
+        # Draw info text
+        info_x = center_x - 60
+        info_y = center_y + fruit_height // 2 + 20
+
+        painter.setPen(QPen(QColor("#ecf0f1"), 1))
+
+        # Shape type
+        painter.drawText(info_x, info_y, f"Shape: {self.shape_type}")
+
+        # Symmetry
+        painter.drawText(info_x, info_y + 15, f"Symmetry: {self.symmetry:.1f}%")
+
+        # Aspect ratio
+        painter.drawText(info_x, info_y + 30, f"Aspect Ratio: {aspect_ratio:.2f}")
+
+    def _draw_locule_side_view(self, painter, center_x, center_y, fruit_width, fruit_height):
+        """Draw locule counting visualization on side view."""
+        # Draw internal locule structure (cross-section view)
+        locule_radius = min(fruit_width, fruit_height) // 8
+
+        # Draw 4 locules in a circular pattern within the fruit
+        for i in range(4):
+            angle = (i * 360 / 4) * (3.14159 / 180)  # Convert to radians
+            locule_x = center_x + int((fruit_width * 0.3) * (1 if i % 2 == 0 else -1) * (1 if i < 2 else 0.7))
+            locule_y = center_y + int((fruit_height * 0.3) * (1 if i % 2 == 1 else -1) * (1 if i < 2 else 0.7))
+
+            # Draw locule as colored circle
+            locule_colors = ["#3498db", "#e74c3c", "#2ecc71", "#f39c12"]
+            painter.setBrush(QBrush(QColor(locule_colors[i])))
+            painter.setPen(QPen(QColor("#34495e"), 1))
+            painter.drawEllipse(locule_x - locule_radius, locule_y - locule_radius,
+                              locule_radius * 2, locule_radius * 2)
+
+    def update(self):
+        """Override update to ensure repaint."""
+        super().update()
+        self.repaint()

+ 277 - 0
ui/panels/quality_rgb_top_panel.py

@@ -0,0 +1,277 @@
+"""
+RGB Top View Panel
+
+Panel for displaying top-down RGB camera view with defect markers and analysis.
+"""
+
+import os
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QPixmap, QImage, QPainter, QColor, QPen, QBrush
+
+from ui.widgets.panel_header import PanelHeader
+
+
+class QualityRGBTopPanel(QWidget):
+    """
+    Panel for RGB top view camera display with defect detection overlays.
+    Shows sample fruit image with defect markers and analysis information.
+    """
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.sample_image = None
+        self.defect_markers = []
+        self.init_ui()
+
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+
+        # Header using the PanelHeader widget
+        header = PanelHeader(
+            title="RGB Top View",
+            color="#3498db"  # Blue for RGB
+        )
+        layout.addWidget(header)
+
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+
+        content_layout = QVBoxLayout(content)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        content_layout.setAlignment(Qt.AlignCenter)
+        content_layout.setSpacing(5)
+
+        # Create sample fruit image with defects
+        self.image_display = SampleFruitWidget()
+        self.image_display.setMinimumSize(200, 150)
+        content_layout.addWidget(self.image_display)
+
+        # Info labels
+        info_widget = QWidget()
+        info_layout = QVBoxLayout(info_widget)
+        info_layout.setContentsMargins(0, 5, 0, 0)
+        info_layout.setSpacing(2)
+
+        # Resolution info (grayed out)
+        res_label = QLabel("1920x1080 @ 30fps")
+        res_label.setFont(QFont("Arial", 10))
+        res_label.setStyleSheet("color: #7f8c8d;")
+        res_label.setAlignment(Qt.AlignCenter)
+        info_layout.addWidget(res_label)
+
+        # Status info
+        status_label = QLabel("🟢 ONLINE (Live Coming Soon)")
+        status_label.setFont(QFont("Arial", 9))
+        status_label.setStyleSheet("color: #27ae60; font-weight: bold;")
+        status_label.setAlignment(Qt.AlignCenter)
+        info_layout.addWidget(status_label)
+
+        content_layout.addWidget(info_widget)
+
+        layout.addWidget(content, 1)
+
+    def update_defects(self, defects):
+        """Update the defect markers on the image."""
+        self.defect_markers = defects
+        self.image_display.update_defects(defects)
+        self.update()
+
+    def set_image(self, image_path=None):
+        """Set the image to display."""
+        if image_path and os.path.exists(image_path):
+            try:
+                pixmap = QPixmap(image_path)
+                if not pixmap.isNull():
+                    # Scale to fit display area
+                    scaled_pixmap = pixmap.scaled(
+                        240, 170, Qt.KeepAspectRatio, Qt.SmoothTransformation
+                    )
+
+                    # Update display (you would need to modify SampleFruitWidget
+                    # to accept external images in a real implementation)
+                    self.image_display.update_with_image(scaled_pixmap)
+
+                    # Update status
+                    filename = os.path.basename(image_path)
+                    self.image_display.setToolTip(f"Loaded: {filename}")
+            except Exception as e:
+                print(f"Error loading image: {e}")
+        else:
+            # Show sample data
+            self.image_display.update()
+
+
+class SampleFruitWidget(QWidget):
+    """Widget showing sample fruit with defect markers."""
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.defect_markers = []
+        self.setAttribute(Qt.WA_StyledBackground, True)
+
+    def update_defects(self, defects):
+        """Update defect markers."""
+        self.defect_markers = defects
+        self.update()
+
+    def paintEvent(self, event):
+        """Custom paint event to draw fruit, defects, or external image."""
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+
+        # Get widget dimensions
+        width = self.width()
+        height = self.height()
+
+        # If we have an external image, display it
+        if hasattr(self, 'external_pixmap') and self.external_pixmap:
+            # Draw the external image scaled to fit
+            scaled_pixmap = self.external_pixmap.scaled(
+                width, height, Qt.KeepAspectRatio, Qt.SmoothTransformation
+            )
+
+            # Center the image
+            x = (width - scaled_pixmap.width()) // 2
+            y = (height - scaled_pixmap.height()) // 2
+
+            painter.drawPixmap(x, y, scaled_pixmap)
+        else:
+            # Draw default fruit visualization
+            self._draw_fruit_visualization(painter, width, height)
+
+    def _draw_fruit_visualization(self, painter, width, height):
+        """Draw the default fruit visualization."""
+        center_x = width // 2
+        center_y = height // 2
+
+        # Draw background
+        painter.fillRect(0, 0, width, height, QColor("#2c3e50"))
+
+        # Draw main fruit (circular shape)
+        fruit_radius = min(width, height) // 4
+        fruit_rect = (
+            center_x - fruit_radius,
+            center_y - fruit_radius,
+            fruit_radius * 2,
+            fruit_radius * 2
+        )
+
+        # Fruit body
+        fruit_color = QColor("#8B4513")  # Brown/orange fruit color
+        painter.setBrush(fruit_color)
+        painter.setPen(QPen(QColor("#654321"), 2))  # Darker border
+        painter.drawEllipse(*fruit_rect)
+
+        # Add some texture/pattern to fruit
+        self._add_fruit_texture(painter, center_x, center_y, fruit_radius)
+
+        # Draw defect markers if any
+        for i, defect in enumerate(self.defect_markers):
+            self._draw_defect_marker(painter, defect, center_x, center_y, fruit_radius)
+
+        # Draw some sample defects for demonstration
+        if not self.defect_markers:
+            self._draw_sample_defects(painter, center_x, center_y, fruit_radius)
+
+        # Draw locule counting visualization
+        self._draw_locule_counting(painter, center_x, center_y, fruit_radius)
+
+    def _add_fruit_texture(self, painter, center_x, center_y, radius):
+        """Add texture pattern to fruit."""
+        # Add some darker spots for texture
+        import math
+        for _ in range(5):
+            spot_x = center_x + (-radius//2 + hash(str(_)) % radius)
+            spot_y = center_y + (-radius//2 + hash(str(_*2)) % radius)
+            spot_radius = 3
+
+            # Only draw if within fruit bounds
+            distance = math.sqrt((spot_x - center_x) ** 2 + (spot_y - center_y) ** 2)
+            if distance + spot_radius <= radius:
+                painter.setBrush(QBrush(QColor("#654321")))
+                painter.setPen(Qt.NoPen)
+                painter.drawEllipse(spot_x - spot_radius, spot_y - spot_radius,
+                                  spot_radius * 2, spot_radius * 2)
+
+    def _draw_defect_marker(self, painter, defect, center_x, center_y, fruit_radius):
+        """Draw a single defect marker."""
+        # Position relative to fruit center
+        marker_x = center_x + (defect['x'] * fruit_radius // 50)
+        marker_y = center_y + (defect['y'] * fruit_radius // 50)
+
+        # Draw marker circle
+        marker_radius = max(8, defect.get('size', 8))
+        painter.setBrush(QBrush(QColor(defect.get('color', '#f39c12'))))
+        painter.setPen(QPen(QColor('#e67e22'), 2))
+        painter.drawEllipse(marker_x - marker_radius, marker_y - marker_radius,
+                          marker_radius * 2, marker_radius * 2)
+
+        # Draw confidence text if available
+        confidence = defect.get('confidence', 0)
+        if confidence > 0:
+            painter.setPen(QPen(QColor('white'), 1))
+            painter.drawText(marker_x - 20, marker_y - marker_radius - 5,
+                           f"{confidence:.1f}%")
+
+    def _draw_sample_defects(self, painter, center_x, center_y, fruit_radius):
+        """Draw sample defects for demonstration."""
+        sample_defects = [
+            {'x': -20, 'y': -15, 'color': '#f39c12', 'confidence': 87.3, 'size': 8},
+            {'x': 25, 'y': 30, 'color': '#f39c12', 'confidence': 72.8, 'size': 6},
+        ]
+
+        for defect in sample_defects:
+            self._draw_defect_marker(painter, defect, center_x, center_y, fruit_radius)
+
+    def _draw_locule_counting(self, painter, center_x, center_y, fruit_radius):
+        """Draw locule counting visualization."""
+        # Draw locule segments (colored regions within the fruit)
+        locule_colors = ["#3498db", "#e74c3c", "#2ecc71", "#f39c12", "#9b59b6"]
+
+        # Draw 4 sample locules (colored pie segments)
+        num_locules = 4
+        angle_step = 360 / num_locules
+
+        for i in range(num_locules):
+            start_angle = int(i * angle_step)
+            span_angle = int(angle_step)
+
+            # Create pie segment path
+            painter.setBrush(QBrush(QColor(locule_colors[i % len(locule_colors)])))
+            painter.setPen(QPen(QColor("#34495e"), 2))
+            painter.drawPie(
+                center_x - fruit_radius, center_y - fruit_radius,
+                fruit_radius * 2, fruit_radius * 2,
+                start_angle * 16, span_angle * 16  # Qt uses 1/16th degree units
+            )
+
+    def update(self):
+        """Override update to ensure repaint."""
+        super().update()
+        self.repaint()
+
+    def update_with_image(self, pixmap):
+        """Update display with external image."""
+        self.external_pixmap = pixmap
+        self.has_external_image = True
+        self.update()

+ 257 - 0
ui/panels/quality_thermal_panel.py

@@ -0,0 +1,257 @@
+"""
+Thermal Analysis Panel
+
+Panel for displaying thermal camera data and temperature analysis.
+Currently shows offline status with thermal gradient mockup.
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy
+from PyQt5.QtCore import Qt, QTimer
+from PyQt5.QtGui import QFont, QPixmap, QImage, QPainter, QColor, QPen, QBrush, QLinearGradient, QRadialGradient
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.coming_soon_overlay import ComingSoonOverlay
+
+
+class QualityThermalPanel(QWidget):
+    """
+    Panel for thermal analysis display.
+    Shows thermal gradient visualization with offline status.
+    """
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_temperature = 28.5
+        self.temperature_range = (22.0, 32.0)
+        self.init_ui()
+
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+
+        # Header using the PanelHeader widget
+        header = PanelHeader(
+            title="Thermal Analysis",
+            color="#e67e22"  # Orange for thermal
+        )
+        layout.addWidget(header)
+
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+
+        content_layout = QVBoxLayout(content)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        content_layout.setAlignment(Qt.AlignCenter)
+        content_layout.setSpacing(5)
+
+        # Thermal visualization area
+        self.thermal_display = ThermalVisualizationWidget()
+        self.thermal_display.setMinimumSize(200, 150)
+        content_layout.addWidget(self.thermal_display)
+
+        # Temperature info
+        temp_widget = QWidget()
+        temp_layout = QVBoxLayout(temp_widget)
+        temp_layout.setContentsMargins(0, 5, 0, 0)
+        temp_layout.setSpacing(2)
+
+        # Current temperature reading
+        self.temp_label = QLabel(f"{self.current_temperature}°C")
+        self.temp_label.setFont(QFont("Arial", 16, QFont.Bold))
+        self.temp_label.setStyleSheet("color: #f39c12;")
+        self.temp_label.setAlignment(Qt.AlignCenter)
+        temp_layout.addWidget(self.temp_label)
+
+        # Temperature scale label
+        scale_label = QLabel("Temperature Range:")
+        scale_label.setFont(QFont("Arial", 10))
+        scale_label.setStyleSheet("color: #bdc3c7;")
+        scale_label.setAlignment(Qt.AlignCenter)
+        temp_layout.addWidget(scale_label)
+
+        # Temperature scale widget
+        self.scale_widget = TemperatureScaleWidget(self.temperature_range)
+        self.scale_widget.setFixedHeight(20)
+        temp_layout.addWidget(self.scale_widget)
+
+        # Min/Max labels
+        minmax_widget = QWidget()
+        minmax_layout = QHBoxLayout(minmax_widget)
+        minmax_layout.setContentsMargins(0, 0, 0, 0)
+
+        min_label = QLabel(f"{self.temperature_range[0]}°C")
+        min_label.setFont(QFont("Arial", 9))
+        min_label.setStyleSheet("color: #7f8c8d;")
+
+        max_label = QLabel(f"{self.temperature_range[1]}°C")
+        max_label.setFont(QFont("Arial", 9))
+        max_label.setStyleSheet("color: #7f8c8d;")
+
+        minmax_layout.addWidget(min_label)
+        minmax_layout.addStretch()
+        minmax_layout.addWidget(max_label)
+
+        temp_layout.addWidget(minmax_widget)
+
+        # Offline status
+        offline_label = QLabel("🔴 OFFLINE")
+        offline_label.setFont(QFont("Arial", 9))
+        offline_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
+        offline_label.setAlignment(Qt.AlignCenter)
+        temp_layout.addWidget(offline_label)
+
+        content_layout.addWidget(temp_widget)
+
+        layout.addWidget(content, 1)
+
+    def update_temperature(self, temperature):
+        """Update current temperature reading."""
+        self.current_temperature = temperature
+        self.temp_label.setText(f"{temperature}°C")
+        self.thermal_display.update_temperature(temperature)
+        self.update()
+
+    def set_temperature_range(self, min_temp, max_temp):
+        """Set temperature range for the scale."""
+        self.temperature_range = (min_temp, max_temp)
+        self.scale_widget.update_range(min_temp, max_temp)
+
+
+class ThermalVisualizationWidget(QWidget):
+    """Widget showing thermal gradient visualization."""
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.temperature = 28.5
+        self.setAttribute(Qt.WA_StyledBackground, True)
+
+    def update_temperature(self, temperature):
+        """Update temperature for visualization."""
+        self.temperature = temperature
+        self.update()
+
+    def paintEvent(self, event):
+        """Custom paint event to draw thermal gradient."""
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+
+        # Get widget dimensions
+        width = self.width()
+        height = self.height()
+
+        # Draw background
+        painter.fillRect(0, 0, width, height, QColor("#000000"))
+
+        # Create thermal gradient (blue to red through yellow)
+        gradient = QLinearGradient(0, 0, width, height)
+
+        # Define color stops for thermal gradient
+        gradient.setColorAt(0.0, QColor("#3498db"))  # Blue (cool)
+        gradient.setColorAt(0.25, QColor("#27ae60"))  # Green
+        gradient.setColorAt(0.5, QColor("#f1c40f"))   # Yellow
+        gradient.setColorAt(0.75, QColor("#e67e22"))  # Orange
+        gradient.setColorAt(1.0, QColor("#e74c3c"))   # Red (hot)
+
+        # Draw gradient background
+        painter.fillRect(0, 0, width, height, gradient)
+
+        # Draw thermal "hot spots" or patterns
+        self._draw_thermal_patterns(painter, width, height)
+
+        # Draw temperature overlay
+        self._draw_temperature_overlay(painter, width, height)
+
+    def _draw_thermal_patterns(self, painter, width, height):
+        """Draw thermal pattern visualization."""
+        # Draw some elliptical "hot spots"
+        center_x = width // 2
+        center_y = height // 2
+
+        # Main thermal area (elliptical)
+        thermal_width = width * 3 // 4
+        thermal_height = height * 2 // 3
+
+        # Create radial gradient for thermal effect
+        radial_gradient = QRadialGradient(center_x, center_y, max(thermal_width, thermal_height) // 2)
+
+        # Center is hottest (red), edges cooler (yellow)
+        radial_gradient.setColorAt(0.0, QColor("#e74c3c"))
+        radial_gradient.setColorAt(0.7, QColor("#f39c12"))
+        radial_gradient.setColorAt(1.0, QColor("#f1c40f"))
+
+        painter.setBrush(radial_gradient)
+        painter.setPen(Qt.NoPen)
+        painter.drawEllipse(center_x - thermal_width // 2, center_y - thermal_height // 2,
+                          thermal_width, thermal_height)
+
+    def _draw_temperature_overlay(self, painter, width, height):
+        """Draw temperature overlay information."""
+        # Draw current temperature in center
+        painter.setPen(QPen(QColor("white"), 2))
+        temp_text = f"{self.temperature}°C"
+        painter.drawText(width // 2 - 30, height // 2 - 10, temp_text)
+
+    def update(self):
+        """Override update to ensure repaint."""
+        super().update()
+        self.repaint()
+
+
+class TemperatureScaleWidget(QWidget):
+    """Widget showing temperature scale with color gradient."""
+
+    def __init__(self, temp_range, parent=None):
+        super().__init__(parent)
+        self.temp_range = temp_range
+
+    def update_range(self, min_temp, max_temp):
+        """Update temperature range."""
+        self.temp_range = (min_temp, max_temp)
+        self.update()
+
+    def paintEvent(self, event):
+        """Custom paint event to draw temperature scale."""
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+
+        width = self.width()
+        height = self.height()
+
+        # Draw temperature scale gradient
+        gradient = QLinearGradient(0, 0, width, 0)
+
+        # Blue to red gradient for temperature scale
+        gradient.setColorAt(0.0, QColor("#3498db"))  # Cool
+        gradient.setColorAt(0.25, QColor("#27ae60"))
+        gradient.setColorAt(0.5, QColor("#f1c40f"))
+        gradient.setColorAt(0.75, QColor("#e67e22"))
+        gradient.setColorAt(1.0, QColor("#e74c3c"))   # Hot
+
+        painter.fillRect(0, 0, width, height, gradient)
+
+        # Draw scale border
+        painter.setPen(QPen(QColor("#34495e"), 1))
+        painter.drawRect(0, 0, width - 1, height - 1)
+
+    def update(self):
+        """Override update to ensure repaint."""
+        super().update()
+        self.repaint()

+ 150 - 0
ui/panels/quick_actions.py

@@ -0,0 +1,150 @@
+"""
+Quick Actions Panel
+
+Main action buttons for ripeness, quality, calibration, and batch processing.
+"""
+
+from PyQt5.QtWidgets import QGroupBox, QGridLayout, QPushButton
+from PyQt5.QtCore import pyqtSignal, Qt
+
+from resources.styles import (
+    GROUP_BOX_STYLE,
+    RIPENESS_BUTTON_STYLE,
+    QUALITY_BUTTON_STYLE,
+    CALIBRATION_BUTTON_STYLE,
+    BATCH_BUTTON_STYLE
+)
+
+
+class QuickActionsPanel(QGroupBox):
+    """
+    Panel with quick action buttons.
+    
+    Provides buttons for:
+    - Analyze Durian (with manual/auto mode)
+    - Ripeness Classification
+    - Quality Assessment
+    - System Calibration
+    - Batch Processing
+    
+    Signals:
+        analyze_durian_clicked: Emitted when analyze durian button clicked (bool: manual_mode)
+        ripeness_clicked: Emitted when ripeness button clicked
+        quality_clicked: Emitted when quality button clicked
+        calibration_clicked: Emitted when calibration button clicked
+        batch_clicked: Emitted when batch button clicked
+    """
+    
+    # Define signals
+    analyze_durian_clicked = pyqtSignal(bool)  # bool indicates manual mode
+    ripeness_clicked = pyqtSignal()
+    quality_clicked = pyqtSignal()
+    calibration_clicked = pyqtSignal()
+    batch_clicked = pyqtSignal()
+    
+    def __init__(self):
+        """Initialize the quick actions panel."""
+        super().__init__("Quick Actions")
+        self.setStyleSheet(GROUP_BOX_STYLE)
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QGridLayout()
+        layout.setSpacing(10)
+        
+        # ========== ANALYZE DURIAN SECTION (Row 0) ==========
+        # Auto Analyze Button (left)
+        self.auto_analyze_btn = QPushButton("Auto Analyze\nDurian")
+        self.auto_analyze_btn.setMinimumHeight(80)
+        self.auto_analyze_btn.setStyleSheet("""
+            QPushButton {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #2ecc71, stop:1 #27ae60);
+                color: white;
+                font-size: 18px;
+                font-weight: bold;
+                border-radius: 8px;
+                border: 2px solid #229954;
+            }
+            QPushButton:hover {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #27ae60, stop:1 #229954);
+            }
+            QPushButton:pressed {
+                background: #1e8449;
+            }
+        """)
+        self.auto_analyze_btn.clicked.connect(self._on_auto_analyze_clicked)
+        layout.addWidget(self.auto_analyze_btn, 0, 0)
+        
+        # Manual Entry Button (right)
+        self.manual_entry_btn = QPushButton("Manual\nEntry")
+        self.manual_entry_btn.setMinimumHeight(80)
+        self.manual_entry_btn.setStyleSheet("""
+            QPushButton {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #3498db, stop:1 #2980b9);
+                color: white;
+                font-size: 18px;
+                font-weight: bold;
+                border-radius: 8px;
+                border: 2px solid #2471a3;
+            }
+            QPushButton:hover {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #2980b9, stop:1 #2471a3);
+            }
+            QPushButton:pressed {
+                background: #1f618d;
+            }
+        """)
+        self.manual_entry_btn.clicked.connect(self._on_manual_entry_clicked)
+        layout.addWidget(self.manual_entry_btn, 0, 1)
+        
+        # ========== OTHER ACTION BUTTONS ==========
+        
+        # Ripeness Classifier Button (row 1, left) - HIDDEN FOR NOW
+        ripeness_btn = QPushButton("Ripeness\nClassifier")
+        ripeness_btn.setMinimumHeight(70)
+        ripeness_btn.setStyleSheet(RIPENESS_BUTTON_STYLE)
+        ripeness_btn.clicked.connect(self.ripeness_clicked.emit)
+        ripeness_btn.hide()
+        layout.addWidget(ripeness_btn, 1, 0)
+        
+        # Quality Classifier Button (row 1, right) - HIDDEN FOR NOW
+        quality_btn = QPushButton("Quality\nClassifier")
+        quality_btn.setMinimumHeight(70)
+        quality_btn.setStyleSheet(QUALITY_BUTTON_STYLE)
+        quality_btn.clicked.connect(self.quality_clicked.emit)
+        quality_btn.hide()
+        layout.addWidget(quality_btn, 1, 1)
+        
+        # System Calibration Button (row 2, left) - HIDDEN FOR NOW
+        calibration_btn = QPushButton("System\nCalibration")
+        calibration_btn.setMinimumHeight(70)
+        calibration_btn.setStyleSheet(CALIBRATION_BUTTON_STYLE)
+        calibration_btn.clicked.connect(self.calibration_clicked.emit)
+        calibration_btn.hide()
+        layout.addWidget(calibration_btn, 2, 0)
+        
+        # Batch Mode Button (row 2, right) - HIDDEN FOR NOW
+        batch_btn = QPushButton("Batch Mode\nAuto Processing")
+        batch_btn.setMinimumHeight(70)
+        batch_btn.setStyleSheet(BATCH_BUTTON_STYLE)
+        batch_btn.clicked.connect(self.batch_clicked.emit)
+        batch_btn.hide()
+        layout.addWidget(batch_btn, 2, 1)
+        
+        self.setLayout(layout)
+    
+    def _on_auto_analyze_clicked(self):
+        """Handle auto analyze durian button click."""
+        self.analyze_durian_clicked.emit(False)  # Auto mode
+    
+    def _on_manual_entry_clicked(self):
+        """Handle manual entry button click."""
+        self.analyze_durian_clicked.emit(True)  # Manual mode
+
+
+

+ 444 - 0
ui/panels/recent_results.py

@@ -0,0 +1,444 @@
+"""
+Recent Results Panel
+
+Displays a table of recent analysis results from the database.
+Supports sorting, scrolling, and all data display.
+"""
+
+from datetime import datetime
+from typing import List, Dict, Optional, Callable
+import time
+from PyQt5.QtWidgets import (QGroupBox, QVBoxLayout, 
+                              QTableView, QHeaderView, QPushButton)
+from PyQt5.QtCore import Qt, QAbstractTableModel, QVariant, QSortFilterProxyModel, pyqtSignal
+from PyQt5.QtGui import QFont, QColor, QBrush
+
+from resources.styles import (GROUP_BOX_STYLE, TABLE_STYLE, 
+                               get_ripeness_color, get_grade_color)
+from utils.config import TABLE_ROW_HEIGHT
+
+
+def get_local_datetime_string() -> str:
+    """
+    Get current datetime string formatted in the computer's local timezone.
+    Uses the system's local timezone for display.
+    
+    Returns:
+        str: Formatted datetime string in format "YYYY-MM-DD HH:MM:SS"
+    """
+    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+class AnalysisTableModel(QAbstractTableModel):
+    """
+    Custom table model for analysis results with sorting support.
+    
+    Supports automatic type detection for sorting:
+    - Text: alphabetical sort
+    - Numbers: numeric sort
+    - Dates: chronological sort
+    """
+    
+    def __init__(self, data: List[List[str]] = None):
+        super().__init__()
+        self.data = data if data else []
+        self.headers = ["Report ID", "Grade", "Time (s)", "Created", "View"]
+    
+    def rowCount(self, parent=None) -> int:
+        """Return number of rows."""
+        return len(self.data)
+    
+    def columnCount(self, parent=None) -> int:
+        """Return number of columns."""
+        return len(self.headers)
+    
+    def data(self, index, role=Qt.DisplayRole):
+        """Get data for a specific cell."""
+        if not index.isValid():
+            return QVariant()
+        
+        row = index.row()
+        col = index.column()
+        
+        if row < 0 or row >= len(self.data):
+            return QVariant()
+        
+        # View column (index 4) has no data in the model - it's rendered via setIndexWidget
+        if col >= len(self.data[row]):
+            return QVariant()
+        
+        cell_data = self.data[row][col]
+        
+        if role == Qt.DisplayRole:
+            return str(cell_data)
+        
+        # Color coding for Grade column
+        elif role == Qt.ForegroundRole:
+            if col == 1:  # Grade column
+                color = get_grade_color(str(cell_data))
+                return QBrush(QColor(color))
+        
+        # Font styling for Grade column
+        elif role == Qt.FontRole:
+            if col == 1:  # Grade column
+                font = QFont("Arial", 12, QFont.Bold)
+                return font
+            return QFont("Arial", 12)
+        
+        return QVariant()
+    
+    def headerData(self, section, orientation, role=Qt.DisplayRole):
+        """Get header data."""
+        if role == Qt.DisplayRole:
+            if orientation == Qt.Horizontal:
+                return self.headers[section]
+        return QVariant()
+    
+    def set_data(self, data: List[List[str]]):
+        """Update table data."""
+        self.beginResetModel()
+        self.data = data
+        self.endResetModel()
+    
+    def sort(self, column: int, order: Qt.SortOrder = Qt.AscendingOrder):
+        """Sort data by column with automatic type detection."""
+        if not self.data:
+            return
+        
+        # Detect column type
+        col_type = self._detect_column_type(column)
+        
+        # Skip sorting for non-sortable columns
+        if col_type == "none":
+            return
+        
+        # Sort based on type
+        reverse = (order == Qt.DescendingOrder)
+        
+        if col_type == "number":
+            self.data.sort(key=lambda row: self._to_float(row[column]), reverse=reverse)
+        elif col_type == "date":
+            self.data.sort(key=lambda row: self._to_datetime(row[column]), reverse=reverse)
+        else:  # text
+            self.data.sort(key=lambda row: str(row[column]).upper(), reverse=reverse)
+        
+        self.layoutChanged.emit()
+    
+    def _detect_column_type(self, column: int) -> str:
+        """Detect column data type."""
+        if not self.data:
+            return "text"
+        
+        # View column (index 4) should not be sorted
+        if column == 4:
+            return "none"
+        
+        # Check column 2 (Time)
+        if column == 2:
+            return "number"
+        
+        # Check columns 3 (Timestamps)
+        if column == 3:
+            return "date"
+        
+        # Check column 1 (Grade) - single letter or N/A
+        if column == 1:
+            return "text"
+        
+        return "text"
+    
+    def _to_float(self, value) -> float:
+        """Convert value to float for numeric sorting."""
+        try:
+            return float(str(value).replace("N/A", "0"))
+        except:
+            return 0.0
+    
+    def _to_datetime(self, value) -> datetime:
+        """Convert value to datetime for date sorting."""
+        try:
+            # Handle various datetime formats
+            value_str = str(value).strip()
+            if not value_str or value_str == "N/A":
+                return datetime.min
+            
+            # Try parsing as datetime
+            for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"]:
+                try:
+                    return datetime.strptime(value_str[:19], fmt.replace(".%f", ""))
+                except:
+                    pass
+            
+            return datetime.min
+        except:
+            return datetime.min
+
+
+class RecentResultsPanel(QGroupBox):
+    """
+    Panel displaying recent analysis results in a table.
+    
+    Features:
+    - Shows all results (not limited to 5)
+    - Sortable columns (click headers)
+    - Automatic type detection (text, number, date)
+    - Scrollable for large datasets
+    - Latest results at top by default (sorted by Created descending)
+    - Color-coded grades (A=green, B=blue, C=red)
+    - View buttons to load analyses in Reports tab
+    
+    Data is loaded from the SQLite database automatically.
+    """
+    
+    # Signal emitted when user clicks View button for an analysis
+    view_analysis_requested = pyqtSignal(str)  # Emits report_id
+    
+    def __init__(self, data_manager: Optional[object] = None):
+        """
+        Initialize the recent results panel.
+        
+        Args:
+            data_manager: DataManager instance for loading from database (optional)
+        """
+        super().__init__("Recent Analysis Results")
+        self.setStyleSheet(GROUP_BOX_STYLE)
+        self.results_data = []  # Store all results
+        self.data_manager = data_manager
+        self.model = None
+        self.proxy_model = None
+        self.table = None
+        self.init_ui()
+        self._load_initial_data()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout()
+        
+        # Create custom table model
+        self.model = AnalysisTableModel(self.results_data)
+        
+        # Create proxy model for sorting
+        self.proxy_model = QSortFilterProxyModel()
+        self.proxy_model.setSourceModel(self.model)
+        self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
+        
+        # Create table view
+        self.table = QTableView()
+        self.table.setModel(self.proxy_model)
+        self.table.setFont(QFont("Arial", 12))
+        self.table.setStyleSheet(TABLE_STYLE)
+        
+        # Table settings
+        self.table.setAlternatingRowColors(True)
+        self.table.horizontalHeader().setStretchLastSection(False)
+        self.table.verticalHeader().setVisible(False)
+        self.table.setSelectionBehavior(QTableView.SelectRows)
+        self.table.verticalHeader().setDefaultSectionSize(TABLE_ROW_HEIGHT)
+        
+        # Enable sorting
+        self.table.setSortingEnabled(True)
+        self.table.horizontalHeader().setSectionsClickable(True)
+        
+        # Resize columns to content
+        self.table.resizeColumnsToContents()
+        
+        # Configure column resize modes
+        # Make first column (Report ID) wider and stretchable
+        self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
+        # Set Created column (index 3) to fixed width to show full datetime
+        self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
+        self.table.setColumnWidth(3, 190)
+        # Set View column to fixed width
+        self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
+        self.table.setColumnWidth(4, 80)
+        
+        # Connect signal for button refresh after sorting
+        self.proxy_model.layoutChanged.connect(self._add_view_buttons)
+        
+        layout.addWidget(self.table)
+        
+        self.setLayout(layout)
+    
+    def _load_initial_data(self):
+        """Load initial data from database or use sample data as fallback."""
+        if self.data_manager:
+            self.refresh_from_database()
+        else:
+            # Fallback to sample data if data_manager not available
+            self._load_sample_data()
+    
+    def _load_sample_data(self):
+        """Load sample data for demonstration."""
+        sample_data = [
+            ["DUR-20260121-135138-2", "C", "3.50", "2026-01-21 13:51:38"],
+            ["DUR-20260121-135138-1", "B", "2.75", "2026-01-21 13:51:35"],
+            ["DUR-20260121-135138-0", "A", "1.95", "2026-01-21 13:51:32"],
+            ["DUR-20260121-135053", "B", "4.23", "2026-01-21 13:50:53"],
+            ["DUR-20260121-135052", "A", "5.50", "2026-01-21 13:50:52"]
+        ]
+        
+        self.results_data = sample_data
+        self.model.set_data(self.results_data)
+        self._sort_by_created_descending()
+        self._add_view_buttons()
+    
+    def add_result(self, report_id: str, grade: str, processing_time: float, created_at: str = None):
+        """
+        Add a new result to the table.
+        
+        Args:
+            report_id: Report ID from analysis (e.g., "DUR-20260121-135153")
+            grade: Grade (A, B, or C)
+            processing_time: Total processing time in seconds
+            created_at: Creation time (optional, defaults to now in local timezone)
+        """
+        # Use current local time if not provided
+        if not created_at:
+            created_at = get_local_datetime_string()
+        
+        # Format time string
+        time_str = f"{processing_time:.2f}"
+        
+        row_data = [report_id, grade, time_str, created_at]
+        self.results_data.append(row_data)
+        
+        # Update table display
+        self.model.set_data(self.results_data)
+        self._sort_by_created_descending()
+        self._add_view_buttons()
+    
+    def refresh_from_database(self):
+        """Refresh the table by loading recent analyses from the database."""
+        if not self.data_manager:
+            print("Data manager not initialized, cannot refresh from database")
+            return
+        
+        try:
+            # Get recent analyses from database
+            recent_analyses = self.data_manager.list_recent_analyses(limit=50)
+            
+            # Clear existing data
+            self.results_data.clear()
+            
+            # Add each analysis to results
+            for analysis in recent_analyses:
+                # Handle None processing_time
+                processing_time = analysis.get('processing_time')
+                if processing_time is None:
+                    time_str = 'N/A'
+                else:
+                    time_str = f"{processing_time:.2f}"
+                
+                grade = analysis.get('overall_grade')
+                if grade is None:
+                    grade = 'N/A'
+                
+                row_data = [
+                    analysis.get('report_id', 'N/A'),
+                    grade,
+                    time_str,
+                    analysis.get('created_at', '')
+                ]
+                self.results_data.append(row_data)
+                print(f"Added row: {row_data}")
+            
+            # Update table display
+            self.model.set_data(self.results_data)
+            self._sort_by_created_descending()
+            self._add_view_buttons()
+            
+            print(f"✓ Loaded {len(self.results_data)} analyses from database")
+            
+        except Exception as e:
+            print(f"Error refreshing from database: {e}")
+            import traceback
+            traceback.print_exc()
+            # Fallback to sample data
+            self._load_sample_data()
+    
+    def _sort_by_created_descending(self):
+        """Sort table by Created column (index 3) in descending order (latest first)."""
+        # Column 3 is "Created"
+        self.proxy_model.sort(3, Qt.DescendingOrder)
+    
+    def _add_view_buttons(self):
+        """
+        Add View buttons to the View column (index 4) for each row.
+        Buttons are placed using setIndexWidget for each row.
+        """
+        if not self.table:
+            return
+        
+        try:
+            # Get the number of rows in the proxy model (after sorting/filtering)
+            row_count = self.proxy_model.rowCount()
+            
+            # Clear existing buttons by removing widgets
+            for row in range(row_count):
+                self.table.setIndexWidget(self.proxy_model.index(row, 4), None)
+            
+            # Add new buttons
+            for row in range(row_count):
+                # Get the report_id from column 0 of the proxy model
+                report_id_index = self.proxy_model.index(row, 0)
+                report_id = self.proxy_model.data(report_id_index, Qt.DisplayRole)
+                
+                # Create the View button
+                view_btn = QPushButton("View")
+                view_btn.setStyleSheet("""
+                    QPushButton {
+                        background-color: #3498db;
+                        color: white;
+                        border: none;
+                        border-radius: 3px;
+                        padding: 5px 10px;
+                        font-size: 11px;
+                        font-weight: bold;
+                    }
+                    QPushButton:hover {
+                        background-color: #2980b9;
+                    }
+                    QPushButton:pressed {
+                        background-color: #1f618d;
+                    }
+                """)
+                
+                # Use lambda with default argument to capture report_id correctly
+                view_btn.clicked.connect(lambda checked=False, rid=report_id: self._on_view_clicked(rid))
+                
+                # Set the button in the View column
+                self.table.setIndexWidget(self.proxy_model.index(row, 4), view_btn)
+        
+        except Exception as e:
+            print(f"Error adding view buttons: {e}")
+            import traceback
+            traceback.print_exc()
+    
+    def _on_view_clicked(self, report_id: str):
+        """
+        Handle view button click.
+        
+        Args:
+            report_id: The report ID to view
+        """
+        # Validate report_id
+        if not report_id or report_id == 'N/A':
+            print(f"Invalid report_id: {report_id}")
+            return
+        
+        # Emit signal to notify main window
+        self.view_analysis_requested.emit(report_id)
+    
+    def clear_results(self):
+        """Clear all results from the table and storage."""
+        self.results_data.clear()
+        self.model.set_data([])
+    
+    def get_all_results(self) -> List[List[str]]:
+        """
+        Get all stored results.
+        
+        Returns:
+            List of result rows
+        """
+        return self.results_data.copy()

+ 132 - 0
ui/panels/rgb_preview_panel.py

@@ -0,0 +1,132 @@
+"""
+RGB Preview Panel
+
+Panel for displaying RGB camera feed (coming soon feature).
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont, QPainter, QColor, QPen
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.coming_soon_overlay import ComingSoonOverlay
+
+
+class RGBPreviewPanel(QWidget):
+    """
+    Panel for RGB camera preview.
+    Currently shows "COMING SOON" placeholder for future camera integration.
+    """
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+        
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #3498db;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 10, 0)
+        header_layout.setSpacing(0)
+        
+        title = QLabel("RGB Preview")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        status_indicator = QWidget()
+        status_indicator.setFixedSize(10, 10)
+        status_indicator.setStyleSheet("background-color: #27ae60; border-radius: 5px;")
+        
+        header_layout.addWidget(title)
+        header_layout.addStretch()
+        header_layout.addWidget(status_indicator)
+        
+        # Content area
+        content = QWidget()
+        content.setMinimumSize(250, 250)
+        # content.setMaximumSize(400, 400)
+        content.setStyleSheet("""
+            background-color: #2c3e50;
+            border: 1px solid #34495e;
+            border-top: none;
+        """)
+        
+        content_layout = QVBoxLayout(content)
+        content_layout.setAlignment(Qt.AlignCenter)
+        content_layout.setSpacing(5)
+        
+        # Main text container
+        text_container = QWidget()
+        text_layout = QVBoxLayout(text_container)
+        text_layout.setAlignment(Qt.AlignCenter)
+        text_layout.setSpacing(2)
+        
+        # Live RGB Feed text
+        feed_label = QLabel("RGB Live Feed")
+        feed_label.setFont(QFont("Arial", 14))
+        feed_label.setStyleSheet("color: #bdc3c7;")
+        feed_label.setAlignment(Qt.AlignCenter)
+        text_layout.addWidget(feed_label)
+        
+        # Resolution info
+        res_label = QLabel("1920x1080 @ 30fps\n(Coming soon)")
+        res_label.setFont(QFont("Arial", 11))
+        res_label.setStyleSheet("color: #7f8c8d;")
+        res_label.setAlignment(Qt.AlignCenter)
+        text_layout.addWidget(res_label)
+        
+        content_layout.addWidget(text_container)
+        
+        layout.addWidget(header)
+        layout.addWidget(content, 1)
+        
+        # Tooltip
+        self.setToolTip("Live RGB camera feed - Coming with hardware integration")
+
+
+class CrosshairPreview(QWidget):
+    """Widget showing crosshair placeholder for camera targeting."""
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.setAttribute(Qt.WA_TransparentForMouseEvents)
+        
+    def paintEvent(self, event):
+        """Draw crosshair overlay."""
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+        
+        # Draw crosshair
+        pen = QPen(QColor("#e74c3c"), 2)
+        painter.setPen(pen)
+        
+        center_x = self.width() // 2
+        center_y = self.height() // 2
+        
+        # Horizontal line
+        painter.drawLine(center_x - 20, center_y, center_x + 20, center_y)
+        
+        # Vertical line
+        painter.drawLine(center_x, center_y - 20, center_x, center_y + 20)
+        
+        # Center circle
+        painter.drawEllipse(center_x - 3, center_y - 3, 6, 6)
+
+

+ 281 - 0
ui/panels/ripeness_control_panel.py

@@ -0,0 +1,281 @@
+"""
+Ripeness Control Panel
+
+Control panel for ripeness testing with device selection and parameters.
+"""
+
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, 
+                              QPushButton, QComboBox, QCheckBox)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.mode_toggle import ModeToggle
+from ui.widgets.parameter_slider import ParameterSlider
+
+
+class RipenessControlPanel(QWidget):
+    """
+    Control panel for ripeness testing.
+    
+    Signals:
+        run_test_clicked: Emitted when RUN TEST button is clicked (live mode)
+        open_file_clicked: Emitted when OPEN FILE is clicked (file mode)
+        stop_clicked: Emitted when STOP button is clicked
+        reset_clicked: Emitted when RESET button is clicked
+        mode_changed: Emitted when test mode changes (str: 'live' or 'file')
+    """
+    
+    run_test_clicked = pyqtSignal()
+    open_file_clicked = pyqtSignal()
+    stop_clicked = pyqtSignal()
+    reset_clicked = pyqtSignal()
+    mode_changed = pyqtSignal(str)
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_mode = "file"
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the control panel UI."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #34495e;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 10, 0)
+        header_layout.setSpacing(0)
+        
+        title = QLabel("Control Panel")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        header_layout.addWidget(title)
+        
+        # Content area
+        content = QWidget()
+        content.setStyleSheet("""
+            background-color: white;
+            border: none;
+        """)
+        
+        content_layout = QVBoxLayout(content)
+        content_layout.setSpacing(10)
+        content_layout.setContentsMargins(10, 10, 10, 10)
+        
+        # Camera Selection
+        camera_label = QLabel("Camera Selection:")
+        camera_label.setFont(QFont("Arial", 9, QFont.Bold))
+        camera_label.setStyleSheet("color: #2c3e50;")
+        content_layout.addWidget(camera_label)
+        
+        self.camera_combo = QComboBox()
+        self.camera_combo.addItems(["Multispectral (Primary) ▼"])
+        self.camera_combo.setFont(QFont("Arial", 9))
+        self.camera_combo.setFixedHeight(25)
+        self.camera_combo.setEnabled(False)
+        self.camera_combo.setToolTip("Camera selection - Coming with hardware integration")
+        self.camera_combo.setStyleSheet("""
+            QComboBox { 
+                background-color: #ecf0f1; 
+                border: 1px solid #bdc3c7;
+                padding: 3px;
+                color: #7f8c8d;
+            }
+        """)
+        content_layout.addWidget(self.camera_combo)
+        
+        # Microphone Selection
+        mic_label = QLabel("Microphone:")
+        mic_label.setFont(QFont("Arial", 9, QFont.Bold))
+        mic_label.setStyleSheet("color: #2c3e50;")
+        content_layout.addWidget(mic_label)
+        
+        self.mic_combo = QComboBox()
+        self.mic_combo.addItems([
+            "Piezo Sensor Array ▼",
+            "Built-in Microphone",
+            "USB Audio Device"
+        ])
+        self.mic_combo.setFont(QFont("Arial", 9))
+        self.mic_combo.setFixedHeight(25)
+        self.mic_combo.setStyleSheet("""
+            QComboBox { 
+                background-color: #ecf0f1; 
+                border: 1px solid #bdc3c7;
+                padding: 3px;
+            }
+        """)
+        content_layout.addWidget(self.mic_combo)
+        
+        # Parameters Section
+        params_label = QLabel("Parameters:")
+        params_label.setFont(QFont("Arial", 9, QFont.Bold))
+        params_label.setStyleSheet("color: #2c3e50; margin-top: 5px;")
+        content_layout.addWidget(params_label)
+        
+        # Exposure slider (disabled - coming soon)
+        self.exposure_slider = ParameterSlider("Exposure", 1, 250, 125, "1/{}s")
+        self.exposure_slider.setEnabled(False)
+        self.exposure_slider.setToolTip("Exposure control - Coming in future update")
+        content_layout.addWidget(self.exposure_slider)
+        
+        # Gain slider (disabled - coming soon)
+        self.gain_slider = ParameterSlider("Gain", 100, 1600, 400, "ISO {}")
+        self.gain_slider.setEnabled(False)
+        self.gain_slider.setToolTip("Gain control - Coming in future update")
+        content_layout.addWidget(self.gain_slider)
+        
+        # Preprocessing Options
+        preproc_label = QLabel("Preprocessing:")
+        preproc_label.setFont(QFont("Arial", 9, QFont.Bold))
+        preproc_label.setStyleSheet("color: #2c3e50; margin-top: 5px;")
+        content_layout.addWidget(preproc_label)
+        
+        # Checkboxes
+        self.normalize_checkbox = QCheckBox("Normalize Spectral Data")
+        self.normalize_checkbox.setFont(QFont("Arial", 9))
+        self.normalize_checkbox.setEnabled(False)
+        self.normalize_checkbox.setToolTip("Spectral data normalization - Coming soon")
+        content_layout.addWidget(self.normalize_checkbox)
+        
+        self.denoise_checkbox = QCheckBox("Denoise Audio Signal")
+        self.denoise_checkbox.setFont(QFont("Arial", 9))
+        self.denoise_checkbox.setChecked(True)
+        self.denoise_checkbox.setToolTip("Apply noise reduction to audio signal")
+        content_layout.addWidget(self.denoise_checkbox)
+        
+        self.bg_subtract_checkbox = QCheckBox("Background Subtraction")
+        self.bg_subtract_checkbox.setFont(QFont("Arial", 9))
+        self.bg_subtract_checkbox.setEnabled(False)
+        self.bg_subtract_checkbox.setToolTip("Background subtraction - Coming soon")
+        content_layout.addWidget(self.bg_subtract_checkbox)
+        
+        # Test Mode Toggle
+        mode_label = QLabel("Test Mode:")
+        mode_label.setFont(QFont("Arial", 9, QFont.Bold))
+        mode_label.setStyleSheet("color: #2c3e50; margin-top: 5px;")
+        content_layout.addWidget(mode_label)
+        
+        self.mode_toggle = ModeToggle()
+        self.mode_toggle.mode_changed.connect(self._on_mode_changed)
+        content_layout.addWidget(self.mode_toggle)
+        
+        # Control Buttons
+        # RUN TEST button
+        self.run_btn = QPushButton("RUN TEST")
+        self.run_btn.setFont(QFont("Arial", 11, QFont.Bold))
+        self.run_btn.setFixedHeight(32)
+        self.run_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #27ae60;
+                border: 2px solid #229954;
+                color: white;
+            }
+            QPushButton:hover {
+                background-color: #229954;
+            }
+            QPushButton:pressed {
+                background-color: #1e8449;
+            }
+        """)
+        self.run_btn.clicked.connect(self._on_primary_action_clicked)
+        self.run_btn.setToolTip("Select an audio file to analyze ripeness")
+        content_layout.addWidget(self.run_btn)
+        
+        # STOP and RESET buttons
+        bottom_buttons = QHBoxLayout()
+        bottom_buttons.setSpacing(10)
+        
+        self.stop_btn = QPushButton("STOP")
+        self.stop_btn.setFont(QFont("Arial", 8, QFont.Bold))
+        self.stop_btn.setFixedHeight(22)
+        self.stop_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #e74c3c;
+                border: 1px solid #c0392b;
+                color: white;
+            }
+            QPushButton:hover {
+                background-color: #c0392b;
+            }
+        """)
+        self.stop_btn.clicked.connect(self.stop_clicked.emit)
+        self.stop_btn.setEnabled(False)
+        bottom_buttons.addWidget(self.stop_btn)
+        
+        self.reset_btn = QPushButton("RESET")
+        self.reset_btn.setFont(QFont("Arial", 8, QFont.Bold))
+        self.reset_btn.setFixedHeight(22)
+        self.reset_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #f39c12;
+                border: 1px solid #e67e22;
+                color: white;
+            }
+            QPushButton:hover {
+                background-color: #e67e22;
+            }
+        """)
+        self.reset_btn.clicked.connect(self.reset_clicked.emit)
+        bottom_buttons.addWidget(self.reset_btn)
+        
+        content_layout.addLayout(bottom_buttons)
+        content_layout.addStretch()
+        
+        layout.addWidget(header)
+        layout.addWidget(content)
+        
+        # Initialize primary action label based on default mode
+        self._update_primary_action_label()
+        
+    def set_processing(self, is_processing: bool):
+        """
+        Set the processing state.
+        
+        Args:
+            is_processing: Whether processing is active
+        """
+        self.run_btn.setEnabled(not is_processing)
+        self.stop_btn.setEnabled(is_processing)
+        
+        if is_processing:
+            self.run_btn.setText("PROCESSING...")
+        else:
+            self._update_primary_action_label()
+
+    def _on_mode_changed(self, mode: str):
+        """Handle mode change from the toggle."""
+        self.current_mode = mode
+        self.mode_changed.emit(mode)
+        self._update_primary_action_label()
+
+    def _update_primary_action_label(self):
+        """Update primary action button label and tooltip based on mode."""
+        if getattr(self, 'current_mode', 'file') == 'file':
+            self.run_btn.setText("OPEN FILE")
+            self.run_btn.setToolTip("Open an audio file to analyze ripeness")
+        else:
+            self.run_btn.setText("RUN TEST")
+            self.run_btn.setToolTip("Run live ripeness test (coming soon)")
+
+    def _on_primary_action_clicked(self):
+        """Emit the appropriate signal based on current mode."""
+        if getattr(self, 'current_mode', 'file') == 'file':
+            self.open_file_clicked.emit()
+        else:
+            self.run_test_clicked.emit()
+
+

+ 201 - 0
ui/panels/ripeness_results_panel.py

@@ -0,0 +1,201 @@
+"""
+Ripeness Results Panel
+
+Panel for displaying ripeness classification results with confidence bars.
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar, QSizePolicy
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+from ui.widgets.panel_header import PanelHeader
+from ui.widgets.confidence_bar import ConfidenceBar
+
+
+class RipenessResultsPanel(QWidget):
+    """
+    Panel for displaying ripeness analysis results.
+    """
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the panel UI."""
+        # Set size policy to expand equally
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+        
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Main panel container with card styling
+        self.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Header
+        header = QWidget()
+        header.setFixedHeight(25)
+        header.setStyleSheet("background-color: #27ae60;")
+        header_layout = QHBoxLayout(header)
+        header_layout.setContentsMargins(10, 0, 0, 0)
+        
+        title = QLabel("Ripeness Analysis Results")
+        title.setStyleSheet("color: white; font-weight: bold; font-size: 16px;")
+        
+        header_layout.addWidget(title)
+        
+        # Current Classification
+        classification_label = QLabel("Current Classification:")
+        classification_label.setStyleSheet("font-weight: bold; font-size: 12px; margin: 8px 10px 5px 10px; color: #2c3e50;")
+        
+        classification_frame = QWidget()
+        classification_frame.setStyleSheet("background-color: #95a5a6;")
+        classification_frame.setMinimumHeight(45)
+        classification_layout = QVBoxLayout(classification_frame)
+        classification_layout.setAlignment(Qt.AlignCenter)
+        classification_layout.setContentsMargins(5, 5, 5, 5)
+        
+        self.class_display = QLabel("—")
+        self.class_display.setAlignment(Qt.AlignCenter)
+        self.class_display.setStyleSheet("color: white; font-weight: bold; font-size: 18px;")
+        
+        classification_layout.addWidget(self.class_display)
+        
+        # Confidence Scores
+        confidence_label = QLabel("Confidence Scores:")
+        confidence_label.setStyleSheet("font-weight: bold; font-size: 11px; margin: 8px 10px 5px 10px; color: #2c3e50;")
+        
+        # Create progress bars for each category
+        categories = [
+            ("Unripe", 0, "#95a5a6"),
+            ("Ripe", 0, "#27ae60"),
+            ("Overripe", 0, "#e74c3c")
+        ]
+        
+        confidence_layout = QVBoxLayout()
+        confidence_layout.setSpacing(4)
+        
+        self.confidence_bars = {}
+        for name, value, color in categories:
+            category_frame = QWidget()
+            category_layout = QHBoxLayout(category_frame)
+            category_layout.setContentsMargins(10, 0, 10, 0)
+            category_layout.setSpacing(8)
+            
+            name_label = QLabel(f"{name}:")
+            name_label.setFixedWidth(60)
+            name_label.setStyleSheet("font-size: 10px; color: #2c3e50;")
+            
+            progress = QProgressBar()
+            progress.setRange(0, 100)
+            progress.setValue(int(value))
+            progress.setTextVisible(False)
+            progress.setStyleSheet(f"""
+                QProgressBar {{
+                    background-color: #ecf0f1;
+                    border: 1px solid #bdc3c7;
+                    border-radius: 0px;
+                    height: 15px;
+                }}
+                QProgressBar::chunk {{
+                    background-color: {color};
+                }}
+            """)
+            
+            value_label = QLabel(f"{value}%")
+            value_label.setFixedWidth(45)
+            value_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+            value_label.setStyleSheet("font-size: 12px; color: #2c3e50;")
+            
+            category_layout.addWidget(name_label)
+            category_layout.addWidget(progress)
+            category_layout.addWidget(value_label)
+            
+            confidence_layout.addWidget(category_frame)
+            self.confidence_bars[name] = (progress, value_label)
+        
+        # Processing time
+        self.info_label = QLabel("")
+        self.info_label.setStyleSheet("color: #7f8c8d; font-size: 12px; margin: 8px 10px 5px 10px;")
+        
+        layout.addWidget(header)
+        layout.addWidget(classification_label)
+        layout.addWidget(classification_frame)
+        layout.addWidget(confidence_label)
+        layout.addLayout(confidence_layout)
+        layout.addWidget(self.info_label)
+        layout.addStretch()
+        
+    def update_results(self, classification: str, probabilities: dict, 
+                      processing_time: float = 0, model_version: str = "RipeNet v3.2"):
+        """
+        Update the results display.
+        
+        Args:
+            classification: Predicted class name
+            probabilities: Dictionary of class probabilities (0-1)
+            processing_time: Processing time in seconds
+            model_version: Model version string
+        """
+        # Update classification display
+        self.class_display.setText(classification.upper())
+        
+        # Set color based on classification
+        class_colors = {
+            "Unripe": "#95a5a6",
+            "Ripe": "#27ae60",
+            "Overripe": "#e74c3c"
+        }
+        bg_color = class_colors.get(classification, "#95a5a6")
+        self.class_display.parent().setStyleSheet(f"background-color: {bg_color};")
+        self.class_display.setStyleSheet("""
+            color: white;
+            font-weight: bold;
+            font-size: 18px;
+        """)
+        
+        # Update confidence bars
+        for class_name, (progress_bar, value_label) in self.confidence_bars.items():
+            prob = probabilities.get(class_name, 0)
+            percentage = prob * 100
+            
+            # Update progress bar
+            progress_bar.setValue(int(percentage))
+            
+            # Update value label
+            value_label.setText(f"{percentage:.1f}%")
+            
+            # Highlight primary class
+            if class_name == classification:
+                value_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #2c3e50;")
+            else:
+                value_label.setStyleSheet("font-size: 12px; color: #2c3e50;")
+        
+        # Update info label
+        self.info_label.setText(
+            f"Processing Time: {processing_time:.2f}s | Model: {model_version}"
+        )
+        
+    def clear_results(self):
+        """Clear all results."""
+        self.class_display.setText("—")
+        self.class_display.setStyleSheet("""
+            color: white;
+            font-weight: bold;
+            font-size: 18px;
+        """)
+        
+        for progress_bar, value_label in self.confidence_bars.values():
+            progress_bar.setValue(0)
+            value_label.setText("0%")
+            value_label.setStyleSheet("font-size: 12px; color: #2c3e50;")
+            
+        self.info_label.setText("")
+
+

+ 126 - 0
ui/panels/system_info.py

@@ -0,0 +1,126 @@
+"""
+System Information Panel
+
+Displays performance metrics and model accuracy statistics.
+"""
+
+from PyQt5.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QLabel
+from PyQt5.QtGui import QFont
+
+from resources.styles import GROUP_BOX_STYLE, HEADER_LABEL_STYLE, STATUS_LABEL_STYLE
+
+
+class SystemInfoPanel(QGroupBox):
+    """
+    Panel displaying system information and statistics.
+    
+    Shows:
+    - Performance metrics (processing time, throughput, uptime)
+    - Model accuracy statistics (calculated from confidence scores)
+    
+    Note: Memory usage is displayed in SystemStatusPanel, not here.
+    """
+    
+    def __init__(self):
+        """Initialize the system information panel."""
+        super().__init__("System Information")
+        self.setStyleSheet(GROUP_BOX_STYLE)
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QHBoxLayout()
+        
+        # Performance Metrics (left side)
+        perf_layout = QVBoxLayout()
+        perf_label = QLabel("Performance Metrics:")
+        perf_label.setFont(QFont("Arial", 16, QFont.Bold))
+        perf_label.setStyleSheet(HEADER_LABEL_STYLE)
+        perf_layout.addWidget(perf_label)
+        
+        # Performance metrics
+        self.metrics = {
+            'processing_time': QLabel("• Average Processing Time: --"),
+            'throughput': QLabel("• Daily Throughput: 0 fruits analyzed"),
+            'uptime': QLabel("• System Uptime: 0h 0m")
+        }
+        
+        for metric_label in self.metrics.values():
+            metric_label.setStyleSheet(STATUS_LABEL_STYLE)
+            perf_layout.addWidget(metric_label)
+        
+        layout.addLayout(perf_layout)
+        
+        # Model Accuracy (right side)
+        accuracy_layout = QVBoxLayout()
+        accuracy_header = QLabel("Model Accuracy (Last 100 Tests):")
+        accuracy_header.setFont(QFont("Arial", 16, QFont.Bold))
+        accuracy_header.setStyleSheet(HEADER_LABEL_STYLE)
+        accuracy_layout.addWidget(accuracy_header)
+        
+        # Accuracy metrics (mapped from model_type returned by DataManager)
+        # Model types: 'audio', 'defect', 'locule', 'maturity', 'shape'
+        self.accuracies = {
+            'audio': QLabel("• Ripeness Classification: --"),
+            'defect': QLabel("• Defect Detection: --"),
+            'locule': QLabel("• Locule Detection: --"),
+            'maturity': QLabel("• Maturity Assessment: --"),
+            'shape': QLabel("• Shape Analysis: --")
+        }
+        
+        for accuracy_label in self.accuracies.values():
+            accuracy_label.setStyleSheet(STATUS_LABEL_STYLE)
+            accuracy_layout.addWidget(accuracy_label)
+        
+        layout.addLayout(accuracy_layout)
+        self.setLayout(layout)
+    
+    def update_processing_time(self, avg_time: float):
+        """
+        Update average processing time.
+        
+        Args:
+            avg_time: Average processing time in seconds
+        """
+        self.metrics['processing_time'].setText(f"• Average Processing Time: {avg_time:.1f}s per fruit")
+    
+    def update_throughput(self, count: int):
+        """
+        Update daily throughput.
+        
+        Args:
+            count: Number of fruits analyzed today
+        """
+        self.metrics['throughput'].setText(f"• Daily Throughput: {count:,} fruits analyzed")
+    
+    def update_uptime(self, hours: int, minutes: int):
+        """
+        Update system uptime.
+        
+        Args:
+            hours: Uptime hours
+            minutes: Uptime minutes
+        """
+        self.metrics['uptime'].setText(f"• System Uptime: {hours}h {minutes}m")
+    
+    
+    def update_accuracy(self, model: str, accuracy: float):
+        """
+        Update model accuracy.
+        
+        Args:
+            model: Model name from DataManager ('audio', 'defect', 'locule', 'maturity', 'shape')
+            accuracy: Accuracy percentage (0-100)
+        """
+        if model in self.accuracies:
+            label_map = {
+                'audio': f"• Ripeness Classification: {accuracy:.1f}%",
+                'defect': f"• Defect Detection: {accuracy:.1f}%",
+                'locule': f"• Locule Detection: {accuracy:.1f}%",
+                'maturity': f"• Maturity Assessment: {accuracy:.1f}%",
+                'shape': f"• Shape Analysis: {accuracy:.1f}%"
+            }
+            self.accuracies[model].setText(label_map[model])
+
+
+

+ 265 - 0
ui/panels/system_status.py

@@ -0,0 +1,265 @@
+"""
+System Status Panel
+
+Displays the status of cameras, AI models, and hardware.
+Allows manual refresh of status information.
+"""
+
+from typing import Optional, Dict, Any, Callable
+from PyQt5.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
+from PyQt5.QtGui import QFont
+from PyQt5.QtCore import Qt
+
+from ui.widgets.status_indicator import StatusIndicator
+from resources.styles import GROUP_BOX_STYLE, HEADER_LABEL_STYLE, INFO_LABEL_STYLE, STANDARD_BUTTON_STYLE
+from utils.config import MODEL_VERSIONS
+from utils.system_monitor import get_full_system_status
+
+
+class SystemStatusPanel(QGroupBox):
+    """
+    Panel displaying real-time system status information.
+    
+    Shows:
+    - Camera systems (RGB, Multispectral, Thermal, Audio)
+    - AI models (Ripeness, Quality, Defect Detection, Maturity, Shape)
+    - Hardware information (GPU, RAM)
+    
+    Includes manual refresh button to update all statuses on demand.
+    """
+    
+    def __init__(self):
+        """Initialize the system status panel."""
+        super().__init__("System Status")
+        self.setStyleSheet(GROUP_BOX_STYLE)
+        
+        # Store status update callback
+        self.models = None
+        
+        # Store references to all status widgets for updates
+        self.camera_status_indicators = {}
+        self.camera_info_labels = {}
+        
+        self.model_status_indicators = {}
+        self.model_info_labels = {}
+        
+        self.gpu_info_label = None
+        self.ram_info_label = None
+        
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout()
+        
+        # Camera Systems Section
+        self._add_camera_systems(layout)
+        
+        # AI Models Section
+        self._add_ai_models(layout)
+        
+        # Hardware Section
+        self._add_hardware_info(layout)
+        
+        # Refresh Button
+        refresh_btn = QPushButton("🔄 Refresh Status")
+        refresh_btn.setStyleSheet(STANDARD_BUTTON_STYLE)
+        refresh_btn.clicked.connect(self.refresh_status)
+        layout.addWidget(refresh_btn)
+        
+        layout.addStretch()
+        self.setLayout(layout)
+    
+    def _add_camera_systems(self, layout: QVBoxLayout):
+        """Add camera systems section to layout."""
+        # Section header
+        camera_label = QLabel("Camera Systems:")
+        camera_label.setFont(QFont("Arial", 16, QFont.Bold))
+        camera_label.setStyleSheet(HEADER_LABEL_STYLE + " margin-top: 10px;")
+        layout.addWidget(camera_label)
+        
+        camera_names = ["EOS Utility", "2nd Look", "AnalyzIR", "Audio System"]
+        
+        for camera_name in camera_names:
+            camera_layout = QHBoxLayout()
+            
+            # Status indicator
+            status_indicator = StatusIndicator("offline")
+            camera_layout.addWidget(status_indicator)
+            self.camera_status_indicators[camera_name] = status_indicator
+            
+            # Camera name
+            name_label = QLabel(camera_name)
+            name_label.setFont(QFont("Arial", 12))
+            camera_layout.addWidget(name_label)
+            
+            camera_layout.addStretch()
+            
+            # Info label (specs or DISCONNECTED)
+            info_label = QLabel("Initializing...")
+            info_label.setStyleSheet(INFO_LABEL_STYLE)
+            camera_layout.addWidget(info_label)
+            self.camera_info_labels[camera_name] = info_label
+            
+            layout.addLayout(camera_layout)
+    
+    def _add_ai_models(self, layout: QVBoxLayout):
+        """Add AI models section to layout."""
+        # Section header
+        ai_label = QLabel("AI Models:")
+        ai_label.setFont(QFont("Arial", 16, QFont.Bold))
+        ai_label.setStyleSheet(HEADER_LABEL_STYLE + " margin-top: 15px;")
+        layout.addWidget(ai_label)
+        
+        model_info = [
+            ('Ripeness', MODEL_VERSIONS.get('ripeness', '')),
+            ('Quality', MODEL_VERSIONS.get('quality', '')),
+            ('Defect', MODEL_VERSIONS.get('defect', '')),
+            ('Maturity', MODEL_VERSIONS.get('maturity', '')),
+            ('Shape', MODEL_VERSIONS.get('shape', '')),
+        ]
+        
+        for model_display_name, version in model_info:
+            model_layout = QHBoxLayout()
+            
+            # Status indicator
+            status_indicator = StatusIndicator("offline")
+            model_layout.addWidget(status_indicator)
+            self.model_status_indicators[model_display_name] = status_indicator
+            
+            # Model name with version
+            model_text = QLabel(f"{model_display_name} Model {version}")
+            model_text.setFont(QFont("Arial", 12))
+            model_layout.addWidget(model_text)
+            
+            model_layout.addStretch()
+            
+            # Info label
+            info_label = QLabel("Initializing...")
+            info_label.setStyleSheet(INFO_LABEL_STYLE)
+            model_layout.addWidget(info_label)
+            self.model_info_labels[model_display_name] = info_label
+            
+            layout.addLayout(model_layout)
+    
+    def _add_hardware_info(self, layout: QVBoxLayout):
+        """Add hardware information section to layout."""
+        # Section header
+        hardware_label = QLabel("Hardware:")
+        hardware_label.setFont(QFont("Arial", 16, QFont.Bold))
+        hardware_label.setStyleSheet(HEADER_LABEL_STYLE + " margin-top: 15px;")
+        layout.addWidget(hardware_label)
+        
+        # GPU info
+        self.gpu_info_label = QLabel("GPU: Initializing...")
+        self.gpu_info_label.setStyleSheet("color: #2c3e50; font-size: 12px;")
+        layout.addWidget(self.gpu_info_label)
+        
+        # RAM info
+        self.ram_info_label = QLabel("RAM: Initializing...")
+        self.ram_info_label.setStyleSheet("color: #2c3e50; font-size: 12px;")
+        layout.addWidget(self.ram_info_label)
+    
+    def set_models_reference(self, models: Dict[str, Any]):
+        """
+        Set reference to loaded models for status checking.
+        
+        Args:
+            models: Dictionary of model instances from main_window
+        """
+        self.models = models
+    
+    def refresh_status(self):
+        """Refresh all system status information."""
+        try:
+            print("\n🔄 ============= REFRESH STATUS =============")
+            print("🔄 Refreshing system status...")
+            print(f"   Available camera_status_indicators: {list(self.camera_status_indicators.keys())}")
+            print(f"   Available model_status_indicators: {list(self.model_status_indicators.keys())}")
+            
+            # Get complete system status
+            system_status = get_full_system_status(self.models)
+            
+            # Update camera status
+            cameras_status = system_status['cameras']
+            print(f"\n📷 Camera status returned: {cameras_status}")
+            for camera_name, camera_info in cameras_status.items():
+                print(f"   Processing camera: {camera_name}")
+                if camera_name in self.camera_status_indicators:
+                    # Update indicator
+                    status = 'online' if camera_info['running'] else 'offline'
+                    self.camera_status_indicators[camera_name].set_status(status)
+                    print(f"      ✓ Updated status to: {status}")
+                    
+                    # Update info label
+                    info_text = camera_info['spec']
+                    self.camera_info_labels[camera_name].setText(info_text)
+                    print(f"      ✓ Updated info to: {info_text}")
+                    
+                    # Update color for disconnected
+                    if not camera_info['running']:
+                        self.camera_info_labels[camera_name].setStyleSheet(
+                            "color: #e74c3c; font-size: 12px; font-weight: bold;"
+                        )
+                    else:
+                        self.camera_info_labels[camera_name].setStyleSheet(INFO_LABEL_STYLE)
+                else:
+                    print(f"      ✗ WARNING: Not found in camera_status_indicators!")
+            
+            # Update model status
+            models_status = system_status['models']
+            print(f"\n🤖 Model status returned: {models_status}")
+            for model_name, model_info in models_status.items():
+                print(f"   Processing model: {model_name}")
+                if model_name in self.model_status_indicators:
+                    # Update indicator
+                    self.model_status_indicators[model_name].set_status(model_info['status'])
+                    print(f"      ✓ Updated status to: {model_info['status']}")
+                    
+                    # Update info label
+                    self.model_info_labels[model_name].setText(model_info['info'])
+                    print(f"      ✓ Updated info to: {model_info['info']}")
+                else:
+                    print(f"      ✗ WARNING: Not found in model_status_indicators!")
+            
+            # Update GPU info
+            gpu_info = system_status['gpu']
+            print(f"\n💾 GPU info: {gpu_info['display']}")
+            self.gpu_info_label.setText(gpu_info['display'])
+            
+            # Update RAM info
+            ram_info = system_status['ram']
+            print(f"💾 RAM info: {ram_info['display']}")
+            self.ram_info_label.setText(ram_info['display'])
+            
+            print("✓ System status refresh complete\n")
+            
+        except Exception as e:
+            print(f"❌ Error refreshing system status: {e}")
+            import traceback
+            traceback.print_exc()
+    
+    def update_model_status(self, model_name: str, status: str, info_text: str = ""):
+        """
+        Update the status of a specific model (for backward compatibility).
+        
+        Args:
+            model_name: Name of the model ('ripeness', 'quality', or 'defect')
+            status: Status ('online', 'offline', or 'updating')
+            info_text: Optional info text to display
+        """
+        # Map old model names to new display names
+        model_map = {
+            'ripeness': 'Ripeness',
+            'quality': 'Quality',
+            'defect': 'Defect',
+            'maturity': 'Maturity',
+            'shape': 'Shape'
+        }
+        
+        display_name = model_map.get(model_name, model_name)
+        
+        if display_name in self.model_status_indicators:
+            self.model_status_indicators[display_name].set_status(status)
+            if info_text:
+                self.model_info_labels[display_name].setText(info_text)

+ 22 - 0
ui/tabs/__init__.py

@@ -0,0 +1,22 @@
+"""
+DuDONG UI Tabs Package
+
+This package contains all tab widgets for the main application.
+"""
+
+from .ripeness_tab import RipenessTab
+from .quality_tab import QualityTab
+from .maturity_tab import MaturityTab
+from .parameters_tab import ParametersTab
+from .reports_tab import ReportsTab
+
+__all__ = [
+    'RipenessTab',
+    'QualityTab',
+    'MaturityTab',
+    'ParametersTab',
+    'ReportsTab'
+]
+
+
+

+ 227 - 0
ui/tabs/maturity_tab.py

@@ -0,0 +1,227 @@
+"""
+Maturity Classification Tab
+
+This tab handles multispectral TIFF file processing for maturity classification.
+"""
+
+from PyQt5.QtWidgets import QWidget, QGridLayout, QHBoxLayout, QMessageBox
+from PyQt5.QtCore import pyqtSignal
+from PyQt5.QtGui import QPixmap, QImage
+import time
+
+from ui.panels.rgb_preview_panel import RGBPreviewPanel
+from ui.panels.multispectral_panel import MultispectralPanel
+from ui.panels.maturity_results_panel import MaturityResultsPanel
+from ui.panels.maturity_control_panel import MaturityControlPanel
+from ui.panels.analysis_timeline_panel import AnalysisTimelinePanel
+from utils.session_manager import SessionManager
+
+
+class MaturityTab(QWidget):
+    """
+    Tab for maturity classification using multispectral analysis.
+    
+    Features:
+    - RGB Preview (Coming Soon)
+    - Multispectral Analysis with Grad-CAM
+    - Maturity Results with Confidence Bars
+    - Control Panel
+    - Analysis Timeline & Statistics
+    
+    Signals:
+        load_tiff_requested: Emitted when user wants to load a TIFF file
+    """
+    
+    load_tiff_requested = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.session_manager = SessionManager()
+        self.current_file_path = None
+        self.processing_start_time = None
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the UI components with 2x2+2 grid layout matching mockup."""
+        # Main layout with content area styling to match mockup
+        main_layout = QHBoxLayout(self)
+        main_layout.setContentsMargins(20, 20, 20, 0)
+        main_layout.setSpacing(20)
+        
+        # Left panels grid (2x2)
+        left_grid = QGridLayout()
+        left_grid.setSpacing(20)
+        left_grid.setContentsMargins(0, 0, 0, 0)
+        
+        # Create left grid panels
+        self.rgb_panel = RGBPreviewPanel()
+        self.multispectral_panel = MultispectralPanel()
+        self.results_panel = MaturityResultsPanel()
+        
+        # Create a placeholder panel for the bottom-left (can be used for band visualization)
+        self.band_panel = QWidget()
+        self.band_panel.setStyleSheet("""
+            QWidget {
+                background-color: white;
+                border: 1px solid #ddd;
+            }
+        """)
+        
+        # Add panels to grid
+        left_grid.addWidget(self.rgb_panel, 0, 0, 1, 1)
+        left_grid.addWidget(self.multispectral_panel, 0, 1, 1, 1)
+        left_grid.addWidget(self.band_panel, 1, 0, 1, 1)
+        left_grid.addWidget(self.results_panel, 1, 1, 1, 1)
+        
+        # Set column and row stretch factors for equal sizing
+        left_grid.setColumnStretch(0, 1)
+        left_grid.setColumnStretch(1, 1)
+        left_grid.setRowStretch(0, 1)
+        left_grid.setRowStretch(1, 1)
+        
+        # Right panels
+        self.control_panel = MaturityControlPanel()
+        self.timeline_panel = AnalysisTimelinePanel()
+        
+        # Add panels to main content layout with adjusted proportions
+        # Increased left grid to make previews more square, reduced timeline
+        main_layout.addLayout(left_grid, 3)  # Stretch factor 3 for 2x2 grid (larger previews)
+        main_layout.addWidget(self.control_panel, 0)  # No stretch (fixed width ~200px)
+        main_layout.addWidget(self.timeline_panel, 1)  # Stretch factor 1 (reduced from 2)
+        
+        # Connect signals
+        self.control_panel.run_test_clicked.connect(self._on_run_test)
+        self.control_panel.open_file_clicked.connect(self._on_open_file)
+        self.control_panel.stop_clicked.connect(self._on_stop)
+        self.control_panel.reset_clicked.connect(self._on_reset)
+        self.timeline_panel.save_audio_clicked.connect(self._on_save_tiff)
+        
+    def _on_run_test(self):
+        """Handle RUN TEST button click."""
+        self.processing_start_time = time.time()
+        self.control_panel.set_processing(True)
+        # TODO: Implement live mode
+        QMessageBox.information(
+            self,
+            "Live Mode",
+            "Live multispectral camera mode coming soon!"
+        )
+        self.control_panel.set_processing(False)
+        
+    def _on_open_file(self):
+        """Handle OPEN FILE button click."""
+        self.processing_start_time = time.time()
+        self.control_panel.set_processing(True)
+        self.load_tiff_requested.emit()
+        
+    def _on_stop(self):
+        """Handle STOP button click."""
+        self.control_panel.set_processing(False)
+        # TODO: Implement worker cancellation
+        
+    def _on_reset(self):
+        """Handle RESET button click."""
+        # Clear all displays
+        self.multispectral_panel.clear() if hasattr(self.multispectral_panel, 'clear') else None
+        self.results_panel.clear_results()
+        self.current_file_path = None
+        self.control_panel.set_processing(False)
+        
+    def _on_save_tiff(self):
+        """Handle save TIFF button click."""
+        if self.current_file_path:
+            QMessageBox.information(
+                self,
+                "Save TIFF",
+                "TIFF save functionality coming in future update.\n\n"
+                f"Would save: {self.current_file_path}"
+            )
+        
+    def set_loading(self, is_loading: bool):
+        """
+        Set loading state.
+        
+        Args:
+            is_loading: Whether processing is active
+        """
+        self.control_panel.set_processing(is_loading)
+        
+    def update_results(self, gradcam_image: QImage, predicted_class: str, 
+                      probabilities: dict, file_path: str):
+        """
+        Update the tab with processing results.
+        
+        Args:
+            gradcam_image: QImage of the Grad-CAM visualization
+            predicted_class: Predicted maturity class
+            probabilities: Dictionary of class probabilities (0-1 scale)
+            file_path: Path to the TIFF file
+        """
+        # Calculate processing time
+        processing_time = 0
+        if self.processing_start_time:
+            processing_time = time.time() - self.processing_start_time
+            self.processing_start_time = None
+        
+        # Store current file path
+        self.current_file_path = file_path
+        
+        # Update Multispectral Panel with Grad-CAM
+        if gradcam_image:
+            pixmap = QPixmap.fromImage(gradcam_image)
+            # Update the multispectral panel
+            self.multispectral_panel.set_image(pixmap)
+        
+        # Update Maturity Results Panel
+        self.results_panel.update_results(
+            predicted_class,
+            probabilities,
+            processing_time,
+            model_version="MaturityNet v1.0"
+        )
+        
+        # Convert probability to percentage for display
+        confidence = probabilities.get(predicted_class, 0) * 100
+        if not confidence:
+            # Try case-insensitive match
+            for key, value in probabilities.items():
+                if key.lower() == predicted_class.lower():
+                    confidence = value * 100
+                    break
+        
+        self.timeline_panel.add_test_result(
+            predicted_class,
+            confidence,
+            processing_time
+        )
+        
+        # Add to session manager
+        self.session_manager.add_result(
+            classification=predicted_class,
+            confidence=confidence,
+            probabilities=probabilities,
+            processing_time=processing_time,
+            file_path=file_path
+        )
+        
+        # Update statistics
+        stats = self.session_manager.get_statistics_summary()
+        self.timeline_panel.update_statistics(
+            total_tests=stats["total_tests"],
+            avg_processing=stats["avg_processing_time"],
+            ripe_count=stats["ripe_count"],  # Reuse ripe_count for maturity count
+            session_start=stats["session_start"]
+        )
+        
+        # Update processing state
+        self.control_panel.set_processing(False)
+        
+    def clear_results(self):
+        """Clear all displayed results and reset session."""
+        self.multispectral_panel.clear() if hasattr(self.multispectral_panel, 'clear') else None
+        self.results_panel.clear_results()
+        self.timeline_panel.clear_timeline()
+        self.session_manager.clear_session()
+        self.current_file_path = None
+        self.processing_start_time = None
+

+ 51 - 0
ui/tabs/parameters_tab.py

@@ -0,0 +1,51 @@
+"""
+Parameters & Settings Tab
+
+Placeholder for future configuration and settings functionality.
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+from resources.styles import COLORS
+
+
+class ParametersTab(QWidget):
+    """Placeholder tab for system parameters and settings."""
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the UI components."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(40, 40, 40, 40)
+        
+        # Icon
+        icon_label = QLabel("⚙️")
+        icon_label.setFont(QFont("Arial", 80))
+        icon_label.setAlignment(Qt.AlignCenter)
+        layout.addWidget(icon_label)
+        
+        # Title
+        title = QLabel("Parameters & Settings")
+        title.setFont(QFont("Arial", 24, QFont.Bold))
+        title.setAlignment(Qt.AlignCenter)
+        layout.addWidget(title)
+        
+        # Message
+        message = QLabel("This feature is coming in a future update!\n\n"
+                        "Here you'll be able to:\n"
+                        "• Adjust AI model thresholds\n"
+                        "• Configure camera settings\n"
+                        "• Manage system preferences\n"
+                        "• Calibrate detection parameters")
+        message.setFont(QFont("Arial", 14))
+        message.setAlignment(Qt.AlignCenter)
+        message.setStyleSheet(f"color: {COLORS['text_secondary']}; line-height: 1.6;")
+        layout.addWidget(message)
+        
+        layout.addStretch()
+

+ 859 - 0
ui/tabs/quality_tab.py

@@ -0,0 +1,859 @@
+"""
+Quality Classification Tab
+
+This tab handles comprehensive quality assessment with multiple camera views and analysis.
+"""
+
+import os
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
+                              QLabel, QPushButton, QFrame, QGroupBox, QTableWidget,
+                              QTableWidgetItem, QHeaderView)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont, QPixmap, QImage
+
+from resources.styles import COLORS, STYLES
+from ui.panels.quality_rgb_top_panel import QualityRGBTopPanel
+from ui.panels.quality_rgb_side_panel import QualityRGBSidePanel
+from ui.panels.quality_thermal_panel import QualityThermalPanel
+from ui.panels.quality_defects_panel import QualityDefectsPanel
+from ui.panels.quality_control_panel import QualityControlPanel
+from ui.panels.quality_results_panel import QualityResultsPanel
+from ui.panels.quality_history_panel import QualityHistoryPanel
+from ui.dialogs.image_preview_dialog import ImagePreviewDialog
+from models.locule_model import LoculeModel
+from models.defect_model import DefectModel
+from utils.config import get_device
+
+
+class QualityTab(QWidget):
+    """
+    Comprehensive quality assessment tab with multiple camera views and analysis.
+
+    Signals:
+        load_image_requested: Emitted when user wants to load an image file
+    """
+
+    load_image_requested = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.locule_model = None
+        self.defect_model = None
+        self.last_processed_file = None
+        self.init_ui()
+        self._initialize_models()
+        
+    def init_ui(self):
+        """Initialize the UI components with new 2x2+2 grid layout."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(10, 10, 10, 10)
+        layout.setSpacing(10)
+
+        # # Title
+        # title = QLabel("🔍 Quality Assessment")
+        # title.setFont(QFont("Arial", 16, QFont.Bold))
+        # title.setAlignment(Qt.AlignCenter)
+        # title.setStyleSheet(f"color: {COLORS['text_primary']}; margin: 10px;")
+        # layout.addWidget(title)
+
+        # Main content area with 2x2+2 grid layout
+        content_widget = QWidget()
+        content_layout = QGridLayout(content_widget)
+        content_layout.setContentsMargins(0, 0, 0, 0)
+        content_layout.setSpacing(8)
+
+        # Create all panels
+        self.rgb_top_panel = QualityRGBTopPanel()
+        self.rgb_side_panel = QualityRGBSidePanel()
+        self.thermal_panel = QualityThermalPanel()
+        self.defects_panel = QualityDefectsPanel()
+        self.quality_results_panel = QualityResultsPanel()
+        self.control_panel = QualityControlPanel()
+        self.history_panel = QualityHistoryPanel()
+
+        # Add panels to grid layout
+        # Row 0, Col 0: RGB Top View Panel
+        content_layout.addWidget(self.rgb_top_panel, 0, 0)
+
+        # Row 0, Col 1: RGB Side View Panel
+        content_layout.addWidget(self.rgb_side_panel, 0, 1)
+
+        # Row 1, Col 0: Thermal Analysis Panel
+        content_layout.addWidget(self.thermal_panel, 1, 0)
+
+        # Row 1, Col 1: Defect Detection Panel
+        content_layout.addWidget(self.defects_panel, 1, 1)
+
+        # Row 0-1, Col 2: Quality Control Panel (span 2 rows)
+        content_layout.addWidget(self.control_panel, 0, 2, 2, 1)
+
+        # Row 0-1, Col 3: Quality Results Panel (span 1 row)
+        content_layout.addWidget(self.quality_results_panel, 0, 3)
+
+        # Row 1, Col 3: Quality History Panel (span 1 row)
+        content_layout.addWidget(self.history_panel, 1, 3)
+
+        # Set column stretches: [2, 2, 1, 2] (control panel gets less space)
+        content_layout.setColumnStretch(0, 2)
+        content_layout.setColumnStretch(1, 2)
+        content_layout.setColumnStretch(2, 1)
+        content_layout.setColumnStretch(3, 2)
+
+        layout.addWidget(content_widget, 1)
+
+        # Status bar at bottom
+        self.status_label = QLabel("Ready for quality assessment. Use Control Panel to analyze samples.")
+        self.status_label.setAlignment(Qt.AlignCenter)
+        self.status_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 11px; padding: 5px;")
+        layout.addWidget(self.status_label)
+
+        # Connect control panel signals
+        self._connect_signals()
+
+    def _initialize_models(self):
+        """Initialize the locule and defect models."""
+        try:
+            # Get device configuration
+            device = get_device()
+            print(f"Initializing quality models on device: {device}")
+
+            # Initialize locule model
+            try:
+                self.locule_model = LoculeModel(device=device)
+                if not self.locule_model.load():
+                    print("⚠ Warning: Could not load locule model. Check if 'locule.pt' exists in project root.")
+                    self.status_label.setText("Warning: Locule model not loaded. Check model file: locule.pt")
+                    self.locule_model = None
+                else:
+                    print(f"✓ Locule model loaded successfully on {device}")
+            except Exception as e:
+                print(f"✗ Error initializing locule model: {e}")
+                import traceback
+                traceback.print_exc()
+                self.locule_model = None
+
+            # Initialize defect model
+            try:
+                self.defect_model = DefectModel(device=device)
+                if not self.defect_model.load():
+                    print("⚠ Warning: Could not load defect model. Check if 'best.pt' exists in project root.")
+                    self.status_label.setText("Warning: Defect model not loaded. Check model file: best.pt")
+                    self.defect_model = None
+                else:
+                    print(f"✓ Defect model loaded successfully on {device}")
+            except Exception as e:
+                print(f"✗ Error initializing defect model: {e}")
+                import traceback
+                traceback.print_exc()
+                self.defect_model = None
+
+            # Update status if both models loaded
+            if self.locule_model and self.locule_model.is_loaded and self.defect_model and self.defect_model.is_loaded:
+                self.status_label.setText("Ready for quality assessment. Click 'OPEN FILE' to analyze an image.")
+                self.status_label.setStyleSheet(f"color: {COLORS['success']}; font-size: 11px;")
+
+        except Exception as e:
+            print(f"✗ Error initializing models: {e}")
+            import traceback
+            traceback.print_exc()
+            self.locule_model = None
+            self.defect_model = None
+            self.status_label.setText(f"Error initializing models: {str(e)}")
+            self.status_label.setStyleSheet(f"color: {COLORS.get('error', '#e74c3c')}; font-size: 11px;")
+        
+    def _connect_signals(self):
+        """Connect signals between panels."""
+        # Connect control panel signals
+        self.control_panel.analyze_requested.connect(self._on_analyze_requested)
+        self.control_panel.open_file_requested.connect(self._on_open_file_requested)
+        self.control_panel.parameter_changed.connect(self._on_parameter_changed)
+        self.control_panel.mode_changed.connect(self._on_mode_changed)
+
+        # Connect defect panel signals
+        self.defects_panel.annotated_image_requested.connect(self._on_annotated_image_requested)
+        self.defects_panel.defect_image_requested.connect(self._on_defect_image_requested)
+
+    def _on_analyze_requested(self):
+        """Handle analyze button click from control panel."""
+        # For now, simulate processing by updating all panels with sample data
+        self._simulate_processing()
+
+    def _on_parameter_changed(self, parameter_name, value):
+        """Handle parameter changes from control panel."""
+        # Update status to show parameter change
+        self.status_label.setText(f"Parameter '{parameter_name}' changed to {value}")
+
+    def _on_mode_changed(self, mode: str):
+        """Handle mode change from control panel."""
+        if mode == 'file':
+            self.status_label.setText("File mode selected. Click 'OPEN FILE' to select an image.")
+        else:
+            self.status_label.setText("Live mode selected (Coming Soon).")
+
+    def _on_open_file_requested(self, file_path: str):
+        """Handle file selection from control panel."""
+        if file_path and file_path.strip():
+            self._process_image_file(file_path)
+        else:
+            self.status_label.setText("No file selected. Please select an image file.")
+            self.status_label.setStyleSheet(f"color: {COLORS['warning']}; font-size: 11px;")
+
+    def _on_annotated_image_requested(self):
+        """Handle annotated image request from clicking on locule count."""
+        # Show the most recent annotated image if available
+        if hasattr(self, 'last_processed_file') and self.last_processed_file:
+            self._show_annotated_image_dialog(self.last_processed_file)
+        else:
+            self.status_label.setText("No processed image available to display.")
+
+    def _on_defect_image_requested(self):
+        """Handle defect image request from clicking on defect analysis."""
+        # Show the defect-annotated image if available
+        if hasattr(self, 'last_processed_file') and self.last_processed_file:
+            self._show_defect_annotated_image_dialog(self.last_processed_file)
+        else:
+            self.status_label.setText("No processed image available to display.")
+
+    def _simulate_processing(self):
+        """Simulate processing for demonstration purposes."""
+        # Set control panel to analyzing state
+        self.control_panel.set_analyzing(True)
+
+        # Update status
+        self.status_label.setText("Processing sample...")
+
+        # Simulate processing delay (in real app, this would be handled by workers)
+        from PyQt5.QtCore import QTimer
+        QTimer.singleShot(2000, self._complete_processing)
+
+    def _complete_processing(self):
+        """Complete the processing simulation."""
+        # Reset control panel
+        self.control_panel.set_analyzing(False)
+
+        # Add result to history
+        from datetime import datetime
+        current_time = datetime.now().strftime("%H:%M:%S")
+        test_id = f"Q-{len(self.history_panel.test_history) + 1:04d}"
+
+        # Sample result based on current parameters
+        params = self.control_panel.get_parameters()
+        grade = "B"  # Default grade
+        score = 78.5  # Default score
+        defects = "2"  # Default defect count
+
+        self.history_panel.add_test_result(current_time, test_id, grade, f"{score:.1f}%", defects)
+
+        # Update status
+        self.status_label.setText("Processing complete. Results updated.")
+
+    def _process_image_file(self, file_path: str):
+        """Process the selected image file with quality models."""
+        import os
+        from pathlib import Path
+        
+        try:
+            # Validate file exists
+            if not os.path.exists(file_path):
+                raise FileNotFoundError(f"File not found: {file_path}")
+            
+            # Validate it's an image file
+            valid_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.JPG', '.JPEG', '.PNG', '.BMP']
+            file_ext = Path(file_path).suffix
+            if file_ext not in valid_extensions:
+                raise ValueError(f"Invalid image file format: {file_ext}. Supported formats: {', '.join(valid_extensions)}")
+            
+            # Set loading state
+            self.control_panel.set_analyzing(True)
+            filename = os.path.basename(file_path)
+            self.status_label.setText(f"Processing: {filename}...")
+            self.status_label.setStyleSheet(f"color: {COLORS['warning']}; font-size: 11px;")
+
+            # Load and display the image in RGB panels
+            self._display_image_in_panels(file_path)
+
+            # Process with models immediately (no artificial delay)
+            self._analyze_image_with_models(file_path)
+
+        except Exception as e:
+            error_msg = f"Error processing file: {str(e)}"
+            self.status_label.setText(error_msg)
+            self.status_label.setStyleSheet(f"color: {COLORS.get('error', '#e74c3c')}; font-size: 11px;")
+            self.control_panel.set_analyzing(False)
+            print(f"Error in _process_image_file: {e}")
+            import traceback
+            traceback.print_exc()
+
+    def _display_image_in_panels(self, file_path: str):
+        """Display the loaded image in the RGB panels."""
+        from PyQt5.QtGui import QPixmap
+
+        # Load image as pixmap
+        pixmap = QPixmap(file_path)
+        if pixmap.isNull():
+            raise ValueError(f"Could not load image: {file_path}")
+
+        # Scale to fit panels
+        scaled_pixmap = pixmap.scaled(
+            250, 180, Qt.KeepAspectRatio, Qt.SmoothTransformation
+        )
+
+        # Update RGB panels with the loaded image
+        # Note: In a real implementation, you would process the image with models first
+        # and then display the annotated results
+        self.rgb_top_panel.set_image(file_path)
+        self.rgb_side_panel.set_image(file_path)
+
+    def _analyze_image_with_models(self, file_path: str):
+        """Analyze the image using defect and locule models."""
+        # Process immediately without artificial delay
+        # The models will handle their own processing time
+        self._complete_image_analysis(file_path)
+
+    def _complete_image_analysis(self, file_path: str):
+        """Complete the image analysis with both locule and defect model results."""
+        # Store the file path for later use
+        self.last_processed_file = file_path
+
+        try:
+            # Initialize results
+            locule_result = None
+            defect_result = None
+
+            # Check if models are loaded
+            if not self.locule_model or not self.locule_model.is_loaded:
+                print("Warning: Locule model not loaded. Check model path: locule.pt")
+            if not self.defect_model or not self.defect_model.is_loaded:
+                print("Warning: Defect model not loaded. Check model path: best.pt")
+
+            # Use actual locule model if available
+            if self.locule_model and self.locule_model.is_loaded:
+                try:
+                    print(f"Running locule analysis on: {file_path}")
+                    locule_result = self.locule_model.predict(file_path)
+                    if locule_result['success']:
+                        print(f"✓ Locule analysis complete: {locule_result['locule_count']} locules detected")
+                    else:
+                        print(f"✗ Locule model failed: {locule_result.get('error', 'Unknown error')}")
+                except Exception as e:
+                    print(f"Exception during locule prediction: {e}")
+                    import traceback
+                    traceback.print_exc()
+            else:
+                print("Locule model not available - skipping locule analysis")
+
+            # Use actual defect model if available
+            if self.defect_model and self.defect_model.is_loaded:
+                try:
+                    print(f"Running defect analysis on: {file_path}")
+                    defect_result = self.defect_model.predict(file_path)
+                    if defect_result['success']:
+                        print(f"✓ Defect analysis complete: {defect_result['total_detections']} detections, primary class: {defect_result.get('primary_class', 'N/A')}")
+                    else:
+                        print(f"✗ Defect model failed: {defect_result.get('error', 'Unknown error')}")
+                except Exception as e:
+                    print(f"Exception during defect prediction: {e}")
+                    import traceback
+                    traceback.print_exc()
+            else:
+                print("Defect model not available - skipping defect analysis")
+
+            # Update panels with results
+            self._update_panels_with_results(locule_result, defect_result, file_path)
+
+        except Exception as e:
+            print(f"Error in image analysis: {e}")
+            import traceback
+            traceback.print_exc()
+            self._fallback_analysis(file_path)
+
+        # Reset control panel
+        self.control_panel.set_analyzing(False)
+
+        # Update status
+        import os
+        filename = os.path.basename(file_path)
+        self.status_label.setText(f"Analysis complete: {filename}")
+        self.status_label.setStyleSheet(f"color: {COLORS['success']}; font-size: 11px;")
+
+        # Show both dialogs - locule and defect analysis (only if we have results)
+        if (locule_result and locule_result.get('success')) or (defect_result and defect_result.get('success')):
+            self._show_dual_analysis_dialogs(file_path)
+
+    def _calculate_quality_grade(self, locule_count: int, has_defects: bool) -> tuple:
+        """
+        Calculate quality grade based on locule count and defect status.
+        
+        Rules:
+        - 5 locules + no defects = Class A
+        - Less than 5 locules + no defects = Class B
+        - Less than 5 locules + defects = Class C
+        
+        Args:
+            locule_count: Number of locules detected
+            has_defects: True if defects are present, False otherwise
+        
+        Returns:
+            tuple: (grade_letter, score_percentage)
+        """
+        if locule_count == 5 and not has_defects:
+            return ("A", 95.0)
+        elif locule_count < 5 and not has_defects:
+            return ("B", 85.0)
+        elif locule_count < 5 and has_defects:
+            return ("C", 70.0)
+        else:
+            # Edge cases: 5 locules with defects, or more than 5 locules
+            # Default to B for 5 locules with defects, C for more than 5 with defects
+            if locule_count >= 5 and has_defects:
+                return ("B", 80.0)  # Still good locule count but has defects
+            elif locule_count > 5 and not has_defects:
+                return ("B", 85.0)  # More than ideal but no defects
+            else:
+                # Fallback
+                return ("B", 75.0)
+
+    def _update_panels_with_results(self, locule_result, defect_result, file_path: str):
+        """Update all panels with locule and defect model results."""
+        # Initialize values
+        locule_count = 0
+        has_defects = False
+        total_detections = 0
+        primary_class = "No Defects"
+        
+        # Handle locule results
+        if locule_result and locule_result['success']:
+            locule_count = locule_result['locule_count']
+            locule_detections = locule_result['detections']
+
+            # Update the clickable locule count widget with actual AI results
+            self.defects_panel.update_locule_count(locule_count)
+
+            # Update the locule count metric in quality results
+            self.quality_results_panel.update_locule_count(locule_count, 94.5)
+        else:
+            # Fallback locule count
+            locule_count = 4
+            self.defects_panel.update_locule_count(locule_count)
+            self.quality_results_panel.update_locule_count(locule_count)
+
+        # Handle defect results
+        if defect_result and defect_result['success']:
+            total_detections = defect_result['total_detections']
+            primary_class = defect_result['primary_class']
+            class_counts = defect_result['class_counts']
+            defect_detections = defect_result['detections']
+
+            # Determine if defects are present
+            # "No Defects" class means no defects, anything else means defects exist
+            has_defects = (primary_class != "No Defects")
+
+            # Update defect detection panel with actual defect results
+            self.defects_panel.update_defect_count(total_detections, primary_class)
+
+            # Create defect analysis results for the panel
+            defect_analysis_results = []
+
+            # Add individual defect detections
+            for detection in defect_detections:
+                defect_analysis_results.append({
+                    'type': detection['class_name'],
+                    'confidence': detection['confidence'],
+                    'color': '#f39c12',
+                    'category': 'warning'
+                })
+
+            # Add shape analysis (placeholder for now)
+            defect_analysis_results.append({
+                'type': 'Shape Analysis',
+                'result': 'Regular',
+                'symmetry': '91.2%',
+                'confidence': 94.1,
+                'color': '#27ae60',
+                'category': 'success'
+            })
+
+            # Update defect detection panel with all results
+            self.defects_panel.update_defects(defect_analysis_results)
+        else:
+            # Fallback defect analysis - assume no defects if model failed
+            has_defects = False
+            total_detections = 0
+            primary_class = "No Defects"
+            self.defects_panel.update_defect_count(0, "No defects")
+            self.defects_panel.update_defects([
+                {
+                    'type': 'Shape Analysis',
+                    'result': 'Regular',
+                    'symmetry': '91.2%',
+                    'confidence': 94.1,
+                    'color': '#27ae60',
+                    'category': 'success'
+                }
+            ])
+
+        # Calculate quality grade based on locule count and defect status
+        grade, score = self._calculate_quality_grade(locule_count, has_defects)
+        
+        # Update quality results panel with calculated grade
+        self.quality_results_panel.update_grade(grade, score)
+        
+        # Update history with calculated grade
+        from datetime import datetime
+        current_time = datetime.now().strftime("%H:%M:%S")
+        self.history_panel.add_test_result(
+            time=current_time,
+            test_id=f"Q-{len(self.history_panel.test_history) + 1:04d}",
+            grade=grade,
+            score=f"{score:.1f}%",
+            defects=str(total_detections)
+        )
+        
+        print(f"Quality Grade Calculated: Grade {grade} (Score: {score:.1f}%) - Locules: {locule_count}, Defects: {'Yes' if has_defects else 'No'}")
+
+    def _fallback_analysis(self, file_path: str):
+        """Fallback analysis when model is not available."""
+        # Update locule count widget with fallback data
+        self.defects_panel.update_locule_count(4)
+
+        # Update locule count in quality results panel
+        self.quality_results_panel.update_locule_count(4)
+
+        # Generate sample analysis results
+        defects_list = [
+            {
+                'type': 'Shape Analysis',
+                'result': 'Regular',
+                'symmetry': '91.2%',
+                'confidence': 94.1,
+                'color': '#27ae60',
+                'category': 'success'
+            }
+        ]
+
+        # Update defect detection panel
+        self.defects_panel.update_defects(defects_list)
+
+        # Calculate grade for fallback (assume 4 locules, no defects = Grade B)
+        grade, score = self._calculate_quality_grade(4, False)
+        
+        # Update quality results panel with calculated grade
+        self.quality_results_panel.update_grade(grade, score)
+        
+        # Update history
+        from datetime import datetime
+        current_time = datetime.now().strftime("%H:%M:%S")
+        self.history_panel.add_test_result(
+            time=current_time,
+            test_id=f"Q-{len(self.history_panel.test_history) + 1:04d}",
+            grade=grade,
+            score=f"{score:.1f}%",
+            defects="0"
+        )
+
+    def _generate_annotated_image(self, original_path: str):
+        """Generate an annotated image using the actual LoculeModel."""
+        try:
+            # Check if locule model is available
+            if self.locule_model is None or not self.locule_model.is_loaded:
+                print("Locule model not available, using fallback annotation")
+                return self._generate_fallback_annotated_image(original_path)
+
+            # Use actual locule model for prediction
+            result = self.locule_model.predict(original_path)
+
+            if result['success']:
+                # Return the actual annotated QImage from the model
+                return self._qimage_to_pixmap(result['annotated_image'])
+            else:
+                print(f"Locule model prediction failed: {result['error']}")
+                return self._generate_fallback_annotated_image(original_path)
+
+        except Exception as e:
+            print(f"Error in locule model prediction: {e}")
+            return self._generate_fallback_annotated_image(original_path)
+
+    def _generate_fallback_annotated_image(self, original_path: str):
+        """Fallback annotation method when model is not available."""
+        try:
+            from PyQt5.QtGui import QPixmap, QPainter, QColor, QPen, QBrush
+            import math
+
+            # Load original image
+            original_pixmap = QPixmap(original_path)
+            if original_pixmap.isNull():
+                return None
+
+            # Create a copy for annotation
+            annotated_pixmap = original_pixmap.copy()
+
+            # Create painter for annotations
+            painter = QPainter(annotated_pixmap)
+            painter.setRenderHint(QPainter.Antialiasing)
+
+            # Get image dimensions
+            width = annotated_pixmap.width()
+            height = annotated_pixmap.height()
+
+            # Draw defect markers (sample data for now)
+            defect_positions = [
+                (width * 0.3, height * 0.4, "Mechanical Damage"),
+                (width * 0.7, height * 0.6, "Surface Blemish")
+            ]
+
+            for x, y, defect_type in defect_positions:
+                # Draw defect circle
+                painter.setBrush(QBrush(QColor("#f39c12")))
+                painter.setPen(QPen(QColor("#e67e22"), 3))
+                painter.drawEllipse(int(x - 15), int(y - 15), 30, 30)
+
+                # Draw confidence text
+                painter.setPen(QPen(QColor("white"), 2))
+                painter.drawText(int(x - 20), int(y - 20), f"87%")
+
+            # Draw locule counting visualization
+            center_x, center_y = width // 2, height // 2
+            fruit_radius = min(width, height) // 4
+
+            # Draw locule segments
+            locule_colors = ["#3498db", "#e74c3c", "#2ecc71", "#f39c12"]
+            num_locules = 4
+            angle_step = 360 / num_locules
+
+            for i in range(num_locules):
+                start_angle = int(i * angle_step)
+                span_angle = int(angle_step)
+
+                # Draw locule segment
+                painter.setBrush(QBrush(QColor(locule_colors[i])))
+                painter.setPen(QPen(QColor("#34495e"), 2))
+                painter.drawPie(
+                    center_x - fruit_radius, center_y - fruit_radius,
+                    fruit_radius * 2, fruit_radius * 2,
+                    start_angle * 16, span_angle * 16
+                )
+
+            painter.end()
+            return annotated_pixmap
+
+        except Exception as e:
+            print(f"Error generating fallback annotated image: {e}")
+            return None
+
+    def _qimage_to_pixmap(self, qimage):
+        """Convert QImage to QPixmap."""
+        from PyQt5.QtGui import QPixmap
+        return QPixmap.fromImage(qimage)
+
+    def _show_annotated_image_dialog(self, file_path: str):
+        """Show dialog with annotated image results."""
+        try:
+            # Generate annotated image
+            annotated_pixmap = self._generate_annotated_image(file_path)
+
+            if annotated_pixmap:
+                # Show in preview dialog
+                dialog = ImagePreviewDialog(
+                    annotated_pixmap,
+                    title=f"Quality Analysis Results - {os.path.basename(file_path)}",
+                    parent=self
+                )
+                dialog.exec_()
+            else:
+                # Fallback to original image
+                original_pixmap = QPixmap(file_path)
+                if not original_pixmap.isNull():
+                    dialog = ImagePreviewDialog(
+                        original_pixmap,
+                        title=f"Original Image - {os.path.basename(file_path)}",
+                        parent=self
+                    )
+                    dialog.exec_()
+
+        except Exception as e:
+            print(f"Error showing image dialog: {e}")
+
+    def _show_defect_annotated_image_dialog(self, file_path: str):
+        """Show dialog with defect-annotated image results."""
+        try:
+            # Generate defect-annotated image
+            annotated_pixmap = self._generate_defect_annotated_image(file_path)
+
+            if annotated_pixmap:
+                # Show in preview dialog
+                dialog = ImagePreviewDialog(
+                    annotated_pixmap,
+                    title=f"Defect Detection Results - {os.path.basename(file_path)}",
+                    parent=self
+                )
+                dialog.exec_()
+            else:
+                # Fallback to original image
+                original_pixmap = QPixmap(file_path)
+                if not original_pixmap.isNull():
+                    dialog = ImagePreviewDialog(
+                        original_pixmap,
+                        title=f"Original Image - {os.path.basename(file_path)}",
+                        parent=self
+                    )
+                    dialog.exec_()
+
+        except Exception as e:
+            print(f"Error showing defect image dialog: {e}")
+
+    def _generate_defect_annotated_image(self, original_path: str):
+        """Generate a defect-annotated image using the actual DefectModel."""
+        try:
+            # Check if defect model is available
+            if self.defect_model is None or not self.defect_model.is_loaded:
+                print("Defect model not available, using fallback annotation")
+                return self._generate_fallback_defect_annotated_image(original_path)
+
+            # Use actual defect model for prediction
+            result = self.defect_model.predict(original_path)
+
+            if result['success']:
+                # Return the actual annotated QImage from the model
+                return self._qimage_to_pixmap(result['annotated_image'])
+            else:
+                print(f"Defect model prediction failed: {result['error']}")
+                return self._generate_fallback_defect_annotated_image(original_path)
+
+        except Exception as e:
+            print(f"Error in defect model prediction: {e}")
+            return self._generate_fallback_defect_annotated_image(original_path)
+
+    def _generate_fallback_defect_annotated_image(self, original_path: str):
+        """Fallback defect annotation method when model is not available."""
+        try:
+            from PyQt5.QtGui import QPixmap, QPainter, QColor, QPen, QBrush
+
+            # Load original image
+            original_pixmap = QPixmap(original_path)
+            if original_pixmap.isNull():
+                return None
+
+            # Create a copy for annotation
+            annotated_pixmap = original_pixmap.copy()
+
+            # Create painter for annotations
+            painter = QPainter(annotated_pixmap)
+            painter.setRenderHint(QPainter.Antialiasing)
+
+            # Get image dimensions
+            width = annotated_pixmap.width()
+            height = annotated_pixmap.height()
+
+            # Draw sample defect markers
+            defect_positions = [
+                (width * 0.3, height * 0.4, "Minor Defect", "#f39c12"),
+                (width * 0.7, height * 0.6, "Surface Blemish", "#e67e22")
+            ]
+
+            for x, y, defect_type, color_hex in defect_positions:
+                # Draw defect bounding box
+                color = QColor(color_hex)
+                painter.setPen(QPen(color, 3))
+                painter.setBrush(QBrush(QColor(0, 0, 0, 0)))  # Transparent fill
+
+                # Draw rectangle
+                box_size = 40
+                painter.drawRect(int(x - box_size/2), int(y - box_size/2), box_size, box_size)
+
+                # Draw confidence text
+                painter.setPen(QPen(QColor("white"), 2))
+                painter.drawText(int(x - 20), int(y - 25), f"87%")
+
+                # Draw label
+                painter.drawText(int(x - 30), int(y + 30), defect_type)
+
+            painter.end()
+            return annotated_pixmap
+
+        except Exception as e:
+            print(f"Error generating fallback defect annotated image: {e}")
+            return None
+
+    def _show_dual_analysis_dialogs(self, file_path: str):
+        """Show both locule and defect analysis dialogs."""
+        try:
+            # Show locule analysis dialog first
+            self._show_annotated_image_dialog(file_path)
+
+            # Then show defect analysis dialog
+            self._show_defect_annotated_image_dialog(file_path)
+
+        except Exception as e:
+            print(f"Error showing dual analysis dialogs: {e}")
+
+    def set_loading(self, is_loading: bool):
+        """Set loading state for all panels."""
+        # Update control panel analyzing state
+        self.control_panel.set_analyzing(is_loading)
+
+        if is_loading:
+            self.status_label.setText("Processing sample...")
+            self.status_label.setStyleSheet(f"color: {COLORS['warning']}; font-size: 11px;")
+        else:
+            self.status_label.setText("Ready for quality assessment.")
+            self.status_label.setStyleSheet(f"color: {COLORS['success']}; font-size: 11px;")
+
+    def update_results(self, annotated_image: QImage, primary_class: str,
+                      class_counts: dict, total_detections: int, file_path: str):
+        """
+        Update the tab with processing results.
+        This method is kept for compatibility with existing workers.
+
+        Args:
+            annotated_image: QImage with bounding boxes drawn
+            primary_class: Primary defect class detected
+            class_counts: Dictionary of class counts
+            total_detections: Total number of detections
+            file_path: Path to the image file
+        """
+        # For now, use the new panel structure
+        # In a real implementation, this would update the appropriate panels
+        # with the processed image data
+
+        # Update defect detection results
+        defects_list = []
+        for class_name, count in class_counts.items():
+            defects_list.append({
+                'type': class_name,
+                'confidence': 85.0,  # Placeholder confidence
+                'color': '#f39c12',
+                'category': 'warning'
+            })
+
+        self.defects_panel.update_defects(defects_list)
+
+        # Update file path in status
+        import os
+        filename = os.path.basename(file_path)
+        self.status_label.setText(f"Processed: {filename}")
+
+        self.set_loading(False)
+
+    def clear_results(self):
+        """Clear all displayed results."""
+        # Clear defect detection results
+        self.defects_panel.clear_defects()
+
+        # Clear history selection
+        self.history_panel.history_table.clearSelection()
+
+        # Reset status
+        self.status_label.setText("Ready for quality assessment. Use Control Panel to analyze samples.")
+        self.status_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 11px;")
+
+    def get_panel_status(self):
+        """Get status information from all panels."""
+        return {
+            'rgb_top': 'ONLINE',
+            'rgb_side': 'ONLINE',
+            'thermal': 'OFFLINE',
+            'defects': 'ONLINE',
+            'control': 'READY',
+            'history': 'READY'
+        }
+

+ 641 - 0
ui/tabs/reports_tab.py

@@ -0,0 +1,641 @@
+"""
+Reports & Export Tab
+
+Display analysis results and export functionality.
+
+This module provides the main Reports Tab UI, delegating report generation,
+visualization, export, and printing to specialized modules.
+"""
+
+from datetime import datetime
+from typing import Optional, Dict, List, Tuple
+
+from PyQt5.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+    QFrame, QMessageBox, QStackedWidget, QScrollArea
+)
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont, QPixmap, QImage
+
+from resources.styles import COLORS
+from ui.widgets.loading_screen import LoadingScreen
+from ui.dialogs import PrintOptionsDialog
+
+# Import refactored modules
+from ui.components.report_generator import (
+    generate_basic_report,
+    generate_model_report,
+    generate_comprehensive_report,
+    extract_report_content
+)
+from ui.components.pdf_exporter import PDFExporter
+from ui.components.report_printer import ReportPrinter
+
+
+class ReportsTab(QWidget):
+    """Tab for displaying analysis reports and exporting data."""
+    
+    # Signal to notify when user wants to go back to dashboard
+    go_to_dashboard = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_data = None
+        self.input_data = None
+        self.maturity_result = None
+        self.current_analysis_id = None  # Set by main_window for data persistence
+        self.current_report_id = None  # Set by main_window - the actual report ID
+        self.data_manager = None  # Set by main_window for data persistence
+        self.current_overall_grade = 'B'  # Track current grade for finalization
+        self.current_grade_description = ''  # Track grade description
+        self.has_result = False  # Track if we have a report result
+        self.current_results = None  # Store results dictionary for print/export
+        
+        # Initialize export/print modules
+        self.pdf_exporter = PDFExporter()
+        self.report_printer = ReportPrinter()
+        
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize the UI components."""
+        main_layout = QVBoxLayout(self)
+        main_layout.setContentsMargins(10, 10, 10, 10)
+        
+        # Header with title and action buttons
+        header_layout = QHBoxLayout()
+        
+        title = QLabel("📊 Analysis Report")
+        title.setFont(QFont("Arial", 18, QFont.Bold))
+        header_layout.addWidget(title)
+        
+        header_layout.addStretch()
+        
+        # Export PDF button
+        self.export_btn = QPushButton("📄 Export PDF")
+        self.export_btn.setStyleSheet("""
+            QPushButton {
+                background-color: #3498db;
+                color: white;
+                font-weight: bold;
+                padding: 8px 16px;
+                border-radius: 4px;
+                font-size: 12px;
+            }
+            QPushButton:hover {
+                background-color: #2980b9;
+            }
+            QPushButton:disabled {
+                background-color: #95a5a6;
+            }
+        """)
+        self.export_btn.clicked.connect(self.export_pdf)
+        self.export_btn.setEnabled(False)
+        header_layout.addWidget(self.export_btn)
+        
+        main_layout.addLayout(header_layout)
+        
+        # Separator
+        separator = QFrame()
+        separator.setFrameShape(QFrame.HLine)
+        separator.setStyleSheet("background-color: #bdc3c7;")
+        main_layout.addWidget(separator)
+        
+        # Stacked widget for content and loading screen
+        self.stacked_widget = QStackedWidget()
+        
+        # Loading screen (index 0) - NOT started yet, will be started when needed
+        self.loading_screen = LoadingScreen("Processing analysis...")
+        self.loading_screen.stop_animation()  # Stop by default, will start when processing begins
+        self.stacked_widget.addWidget(self.loading_screen)
+        
+        # Report content (index 1)
+        scroll = QScrollArea()
+        scroll.setWidgetResizable(True)
+        scroll.setStyleSheet("QScrollArea { border: none; }")
+        
+        # Report content widget
+        self.report_widget = QWidget()
+        self.report_layout = QVBoxLayout(self.report_widget)
+        self.report_layout.setContentsMargins(10, 10, 10, 10)
+        
+        # Create empty state with button
+        self._create_empty_state()
+        
+        scroll.setWidget(self.report_widget)
+        self.stacked_widget.addWidget(scroll)
+        
+        main_layout.addWidget(self.stacked_widget)
+        
+        # Show content view by default (not loading)
+        self.stacked_widget.setCurrentIndex(1)
+    
+    def _create_empty_state(self):
+        """Create the empty state UI with button to go to dashboard."""
+        # Clear any existing widgets
+        while self.report_layout.count() > 0:
+            item = self.report_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        
+        self.report_layout.addStretch()
+        
+        # Icon/Title
+        icon_label = QLabel("📊")
+        icon_label.setFont(QFont("Arial", 48))
+        icon_label.setAlignment(Qt.AlignCenter)
+        self.report_layout.addWidget(icon_label)
+        
+        # Message
+        message_label = QLabel("No Analysis Yet")
+        message_label.setFont(QFont("Arial", 18, QFont.Bold))
+        message_label.setAlignment(Qt.AlignCenter)
+        message_label.setStyleSheet(f"color: {COLORS['text_primary']};")
+        self.report_layout.addWidget(message_label)
+        
+        # Description
+        desc_label = QLabel("Click 'Analyze Durian' on the dashboard to start an analysis")
+        desc_label.setFont(QFont("Arial", 12))
+        desc_label.setAlignment(Qt.AlignCenter)
+        desc_label.setStyleSheet(f"color: {COLORS['text_secondary']}; margin: 10px;")
+        desc_label.setWordWrap(True)
+        self.report_layout.addWidget(desc_label)
+        
+        # Button container
+        button_layout = QHBoxLayout()
+        button_layout.addStretch()
+        
+        # Go to Dashboard button
+        go_dashboard_btn = QPushButton("🏠 Go to Dashboard")
+        go_dashboard_btn.setFont(QFont("Arial", 12, QFont.Bold))
+        go_dashboard_btn.setStyleSheet("""
+            QPushButton {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #3498db, stop:1 #2980b9);
+                color: white;
+                padding: 12px 24px;
+                border-radius: 6px;
+                border: none;
+            }
+            QPushButton:hover {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #2980b9, stop:1 #1f618d);
+            }
+            QPushButton:pressed {
+                background: #1f618d;
+            }
+        """)
+        go_dashboard_btn.clicked.connect(self.go_to_dashboard.emit)
+        button_layout.addWidget(go_dashboard_btn)
+        
+        button_layout.addStretch()
+        self.report_layout.addLayout(button_layout)
+        
+        self.report_layout.addStretch()
+        
+        self.has_result = False
+    
+    def _add_result_action_button(self):
+        """Add action button to go to dashboard for a new analysis."""
+        # Find and remove existing action button if any
+        for i in range(self.report_layout.count() - 1, -1, -1):
+            item = self.report_layout.itemAt(i)
+            if item and item.widget() and hasattr(item.widget(), 'objectName'):
+                if item.widget().objectName() == "result_action_button":
+                    item.widget().deleteLater()
+                    break
+        
+        # Add button container at the end
+        action_layout = QHBoxLayout()
+        action_layout.addStretch()
+        
+        # New Analysis button
+        new_analysis_btn = QPushButton("🔄 New Analysis")
+        new_analysis_btn.setObjectName("result_action_button")
+        new_analysis_btn.setFont(QFont("Arial", 11, QFont.Bold))
+        new_analysis_btn.setStyleSheet("""
+            QPushButton {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #2ecc71, stop:1 #27ae60);
+                color: white;
+                padding: 10px 20px;
+                border-radius: 5px;
+                border: none;
+            }
+            QPushButton:hover {
+                background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+                    stop:0 #27ae60, stop:1 #1e8449);
+            }
+            QPushButton:pressed {
+                background: #1e8449;
+            }
+        """)
+        new_analysis_btn.clicked.connect(self.go_to_dashboard.emit)
+        action_layout.addWidget(new_analysis_btn)
+        
+        action_layout.addStretch()
+        self.report_layout.addLayout(action_layout)
+        self.has_result = True
+    
+    def clear_report(self):
+        """Clear the current report and show empty state."""
+        # Remove all widgets
+        while self.report_layout.count() > 0:
+            item = self.report_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        
+        # Show empty state
+        self._create_empty_state()
+        
+        self.export_btn.setEnabled(False)
+        self.current_data = None
+        self.has_result = False
+    
+    def set_loading(self, is_loading: bool, message: str = "Processing analysis..."):
+        """
+        Set loading state for report generation.
+        
+        Args:
+            is_loading: True to show loading screen, False to show content
+            message: Loading message to display
+        """
+        self.export_btn.setEnabled(not is_loading)
+        
+        if is_loading:
+            # Show loading screen with animation
+            self.loading_screen.set_message(message)
+            self.loading_screen.set_status("")
+            self.loading_screen.spinner_index = 0  # Reset spinner
+            self.stacked_widget.setCurrentIndex(0)
+            self.loading_screen.start_animation()
+        else:
+            # Hide loading screen and show content
+            self.loading_screen.stop_animation()
+            self.stacked_widget.setCurrentIndex(1)
+    
+    def generate_report(self, input_data: Dict[str, str]):
+        """
+        Generate analysis report from input data.
+        
+        Args:
+            input_data: Dictionary with keys 'dslr', 'multispectral', 'thermal', 'audio'
+        """
+        self.current_data = input_data
+        self.input_data = input_data
+        
+        # Clear existing report
+        while self.report_layout.count() > 0:
+            item = self.report_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        
+        # Generate report using report generator
+        result = generate_basic_report(self.report_layout, input_data)
+        self.current_overall_grade = result['grade']
+        self.current_grade_description = result['description']
+        
+        self.report_layout.addStretch()
+        
+        # Add action button for new analysis
+        self._add_result_action_button()
+        
+        # Stop loading screen and show content
+        self.loading_screen.stop_animation()
+        self.stacked_widget.setCurrentIndex(1)
+        
+        # Enable export/print buttons
+        self.export_btn.setEnabled(True)
+    
+    def generate_report_with_model(
+        self,
+        input_data: Dict[str, str],
+        gradcam_image: QImage,
+        predicted_class: str,
+        confidence: float,
+        probabilities: Dict[str, float]
+    ):
+        """
+        Generate analysis report with actual multispectral model results.
+        
+        Args:
+            input_data: Dictionary with keys 'dslr', 'multispectral', 'thermal', 'audio'
+            gradcam_image: QImage of the Grad-CAM visualization
+            predicted_class: Predicted maturity class from model
+            confidence: Model confidence (0-1 scale)
+            probabilities: Dictionary of class probabilities
+        """
+        self.current_data = input_data
+        self.input_data = input_data
+        self.maturity_result = {
+            'class': predicted_class,
+            'confidence': confidence,
+            'probabilities': probabilities,
+            'gradcam_image': gradcam_image
+        }
+        
+        # Clear existing report
+        while self.report_layout.count() > 0:
+            item = self.report_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        
+        # Generate report using report generator
+        result = generate_model_report(
+            self.report_layout,
+            input_data,
+            gradcam_image,
+            predicted_class,
+            confidence,
+            probabilities
+        )
+        self.current_overall_grade = result['grade']
+        self.current_grade_description = result['description']
+        
+        self.report_layout.addStretch()
+        
+        # Add action button for new analysis
+        self._add_result_action_button()
+        
+        # Stop loading screen and show content
+        self.loading_screen.stop_animation()
+        self.stacked_widget.setCurrentIndex(1)
+        
+        # Enable export/print buttons
+        self.export_btn.setEnabled(True)
+    
+    def generate_report_with_rgb_and_multispectral(self, input_data: Dict[str, str], results: Dict):
+        """
+        Generate comprehensive report with RGB models and multispectral analysis.
+        
+        Args:
+            input_data: Dictionary with input file paths
+            results: Dictionary with processing results from all models
+                     Keys: 'defect', 'locule', 'maturity', 'shape', 'audio'
+        """
+        self.current_data = input_data
+        self.input_data = input_data
+        self.current_results = results  # Store results for print/export
+        
+        # Clear existing report
+        while self.report_layout.count() > 0:
+            item = self.report_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        
+        # Generate report using report generator
+        result = generate_comprehensive_report(
+            self.report_layout,
+            input_data,
+            results,
+            self.current_report_id
+        )
+        self.current_overall_grade = result['grade']
+        self.current_grade_description = result['description']
+        
+        self.report_layout.addStretch()
+        
+        # Add action button for new analysis
+        self._add_result_action_button()
+        
+        # Stop loading screen and show content
+        self.loading_screen.stop_animation()
+        self.stacked_widget.setCurrentIndex(1)
+        
+        # Enable export/print buttons
+        self.export_btn.setEnabled(True)
+    
+    def _get_report_visualizations(self) -> List[Tuple[str, QPixmap]]:
+        """
+        Extract all visualizations from the current report.
+        
+        Returns:
+            List of tuples (title, QPixmap)
+        """
+        visualizations = []
+        
+        if not self.current_results:
+            return visualizations
+        
+        results = self.current_results
+        
+        # Grad-CAM visualization
+        if 'maturity' in results and results['maturity'].get('gradcam_image'):
+            gradcam_img = results['maturity']['gradcam_image']
+            if isinstance(gradcam_img, QImage):
+                gradcam_pixmap = QPixmap.fromImage(gradcam_img)
+            else:
+                gradcam_pixmap = gradcam_img
+            if not gradcam_pixmap.isNull():
+                visualizations.append(("Grad-CAM Visualization (Maturity)", gradcam_pixmap))
+        
+        # Locule analysis visualization
+        if 'locule' in results and results['locule'].get('annotated_image'):
+            locule_pixmap = results['locule']['annotated_image']
+            if not locule_pixmap.isNull():
+                visualizations.append(("Locule Analysis (Top View)", locule_pixmap))
+        
+        # Defect analysis visualization
+        if 'defect' in results and results['defect'].get('annotated_image'):
+            defect_pixmap = results['defect']['annotated_image']
+            if not defect_pixmap.isNull():
+                visualizations.append(("Defect Analysis (Side View)", defect_pixmap))
+        
+        # Shape analysis visualization
+        if 'shape' in results and results['shape'].get('annotated_image'):
+            shape_pixmap = results['shape']['annotated_image']
+            if not shape_pixmap.isNull():
+                visualizations.append(("Shape Classification (Side View)", shape_pixmap))
+        
+        # Audio waveform visualization
+        if 'audio' in results and results['audio'].get('waveform_image'):
+            waveform_pixmap = results['audio']['waveform_image']
+            if not waveform_pixmap.isNull():
+                visualizations.append(("Waveform with Detected Knocks", waveform_pixmap))
+        
+        # Audio spectrogram visualization
+        if 'audio' in results and results['audio'].get('spectrogram_image'):
+            spectrogram_pixmap = results['audio']['spectrogram_image']
+            if not spectrogram_pixmap.isNull():
+                visualizations.append(("Mel Spectrogram", spectrogram_pixmap))
+        
+        return visualizations
+    
+    def export_pdf(self):
+        """Export report as PDF using reportlab."""
+        # Check if report data is available
+        if not self.has_result:
+            QMessageBox.warning(
+                self,
+                "No Report",
+                "No analysis report available to export. Please complete an analysis first."
+            )
+            return
+        
+        # Ask about visualizations BEFORE file dialog
+        options_dialog = PrintOptionsDialog(self)
+        if options_dialog.exec_() != PrintOptionsDialog.Accepted:
+            return
+        
+        include_visualizations = options_dialog.get_include_visualizations()
+        
+        # Extract report content
+        report_content = extract_report_content(
+            self.current_report_id or f"DUR-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
+            self.current_overall_grade,
+            self.current_grade_description,
+            self.current_results
+        )
+        
+        # Get visualizations
+        visualizations = self._get_report_visualizations()
+        
+        # Export using PDF exporter
+        self.pdf_exporter.export_report(
+            self,
+            self.current_report_id or datetime.now().strftime('%Y%m%d_%H%M%S'),
+            report_content,
+            visualizations,
+            include_visualizations
+        )
+    
+    def print_report(self):
+        """Print report with options dialog."""
+        # Check if report data is available
+        if not self.has_result:
+            QMessageBox.warning(
+                self,
+                "No Report",
+                "No analysis report available to print. Please complete an analysis first."
+            )
+            return
+        
+        # Show print options dialog
+        options_dialog = PrintOptionsDialog(self)
+        if options_dialog.exec_() != PrintOptionsDialog.Accepted:
+            return
+        
+        include_visualizations = options_dialog.get_include_visualizations()
+        
+        # Extract report content
+        report_content = extract_report_content(
+            self.current_report_id or f"DUR-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
+            self.current_overall_grade,
+            self.current_grade_description,
+            self.current_results
+        )
+        
+        # Get visualizations
+        visualizations = self._get_report_visualizations()
+        
+        # Print using report printer
+        self.report_printer.print_report(
+            self,
+            report_content,
+            visualizations,
+            include_visualizations
+        )
+    
+    def load_analysis_from_db(self, report_id: str) -> bool:
+        """
+        Load a saved analysis from the database and display it.
+        
+        Args:
+            report_id: Report ID to load
+            
+        Returns:
+            bool: True if successfully loaded, False otherwise
+        """
+        if not self.data_manager:
+            QMessageBox.warning(self, "Error", "Data manager not initialized")
+            return False
+        
+        # Export analysis data with full file paths
+        analysis_data = self.data_manager.export_analysis_to_dict(report_id)
+        if not analysis_data:
+            QMessageBox.warning(self, "Error", f"Analysis not found: {report_id}")
+            return False
+        
+        # Reconstruct input_data dictionary for report generation
+        input_data = {}
+        for input_item in analysis_data['inputs']:
+            input_type = input_item['input_type']
+            full_path = input_item.get('full_path')
+            if full_path:
+                input_data[input_type] = full_path
+        
+        # Reconstruct results dictionary with visualizations
+        results = {}
+        for result in analysis_data['results']:
+            model_type = result['model_type']
+            
+            # Find visualizations for this result
+            result_data = {
+                'predicted_class': result['predicted_class'],
+                'confidence': result['confidence'],
+                'probabilities': result['probabilities'],
+                'metadata': result['metadata']
+            }
+            
+            # Load annotated images
+            if model_type == 'defect':
+                # Find defect_annotated visualization
+                viz = next((v for v in analysis_data['visualizations'] 
+                           if v['visualization_type'] == 'defect_annotated'), None)
+                if viz and viz.get('full_path'):
+                    result_data['annotated_image'] = QPixmap(viz['full_path'])
+                result_data['primary_class'] = result['predicted_class']
+                result_data['total_detections'] = result['metadata'].get('total_detections', 0)
+                result_data['class_counts'] = result['metadata'].get('class_counts', {})
+            
+            elif model_type == 'locule':
+                # Find locule_annotated visualization
+                viz = next((v for v in analysis_data['visualizations'] 
+                           if v['visualization_type'] == 'locule_annotated'), None)
+                if viz and viz.get('full_path'):
+                    result_data['annotated_image'] = QPixmap(viz['full_path'])
+                result_data['locule_count'] = result['metadata'].get('locule_count', 0)
+            
+            elif model_type == 'maturity':
+                # Find maturity_gradcam visualization
+                viz = next((v for v in analysis_data['visualizations'] 
+                           if v['visualization_type'] == 'maturity_gradcam'), None)
+                if viz and viz.get('full_path'):
+                    result_data['gradcam_image'] = QImage(viz['full_path'])
+                result_data['class_name'] = result['predicted_class']
+            
+            elif model_type == 'shape':
+                # Find shape_annotated visualization
+                viz = next((v for v in analysis_data['visualizations'] 
+                           if v['visualization_type'] == 'shape_annotated'), None)
+                if viz and viz.get('full_path'):
+                    result_data['annotated_image'] = QPixmap(viz['full_path'])
+                result_data['shape_class'] = result['predicted_class']
+                result_data['class_id'] = result['metadata'].get('class_id', 0)
+            
+            elif model_type == 'audio':
+                # Find audio visualizations
+                waveform_viz = next((v for v in analysis_data['visualizations'] 
+                                    if v['visualization_type'] == 'audio_waveform'), None)
+                spectrogram_viz = next((v for v in analysis_data['visualizations'] 
+                                       if v['visualization_type'] == 'audio_spectrogram'), None)
+                
+                if waveform_viz and waveform_viz.get('full_path'):
+                    result_data['waveform_image'] = QPixmap(waveform_viz['full_path'])
+                if spectrogram_viz and spectrogram_viz.get('full_path'):
+                    result_data['spectrogram_image'] = QPixmap(spectrogram_viz['full_path'])
+                
+                result_data['ripeness_class'] = result['predicted_class']
+                result_data['knock_count'] = result['metadata'].get('knock_count', 0)
+            
+            results[model_type] = result_data
+        
+        # Store for reference
+        self.current_data = analysis_data
+        self.current_report_id = report_id  # Store the report ID for display
+        self.input_data = input_data
+        
+        # Display the report
+        self.generate_report_with_rgb_and_multispectral(input_data, results)
+        
+        return True

+ 214 - 0
ui/tabs/ripeness_tab.py

@@ -0,0 +1,214 @@
+"""
+Ripeness Classification Tab
+
+This tab handles audio file processing for ripeness classification.
+Enhanced with Phase 8: Professional grid layout with multiple data visualization panels.
+"""
+
+from PyQt5.QtWidgets import QWidget, QGridLayout, QHBoxLayout, QMessageBox
+from PyQt5.QtCore import pyqtSignal
+from PyQt5.QtGui import QPixmap
+import time
+
+from ui.panels.rgb_preview_panel import RGBPreviewPanel
+from ui.panels.multispectral_panel import MultispectralPanel
+from ui.panels.audio_spectrogram_panel import AudioSpectrogramPanel
+from ui.panels.ripeness_results_panel import RipenessResultsPanel
+from ui.panels.ripeness_control_panel import RipenessControlPanel
+from ui.panels.analysis_timeline_panel import AnalysisTimelinePanel
+from utils.session_manager import SessionManager
+
+
+class RipenessTab(QWidget):
+    """
+    Tab for ripeness classification using audio analysis.
+    
+    Features:
+    - RGB Preview (Coming Soon)
+    - Multispectral Analysis (Coming Soon)
+    - Audio Spectrogram Display
+    - Ripeness Results with Confidence Bars
+    - Control Panel
+    - Analysis Timeline & Statistics
+    
+    Signals:
+        load_audio_requested: Emitted when user wants to load an audio file
+    """
+    
+    load_audio_requested = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.session_manager = SessionManager()
+        self.current_file_path = None
+        self.processing_start_time = None
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the UI components with 2x2+2 grid layout matching mockup."""
+        # Main layout with content area styling to match mockup
+        main_layout = QHBoxLayout(self)
+        main_layout.setContentsMargins(20, 20, 20, 0)
+        main_layout.setSpacing(20)
+        
+        # Left panels grid (2x2)
+        left_grid = QGridLayout()
+        left_grid.setSpacing(20)
+        left_grid.setContentsMargins(0, 0, 0, 0)
+        
+        # Create left grid panels
+        self.rgb_panel = RGBPreviewPanel()
+        self.multispectral_panel = MultispectralPanel()
+        self.audio_panel = AudioSpectrogramPanel()
+        self.results_panel = RipenessResultsPanel()
+        
+        # Add panels to grid
+        left_grid.addWidget(self.rgb_panel, 0, 0, 1, 1)
+        left_grid.addWidget(self.multispectral_panel, 0, 1, 1, 1)
+        left_grid.addWidget(self.audio_panel, 1, 0, 1, 1)
+        left_grid.addWidget(self.results_panel, 1, 1, 1, 1)
+        
+        # Set column and row stretch factors for equal sizing
+        left_grid.setColumnStretch(0, 1)
+        left_grid.setColumnStretch(1, 1)
+        left_grid.setRowStretch(0, 1)
+        left_grid.setRowStretch(1, 1)
+        
+        # Right panels
+        self.control_panel = RipenessControlPanel()
+        self.timeline_panel = AnalysisTimelinePanel()
+        
+        # Add panels to main content layout with adjusted proportions
+        # Increased left grid to make previews more square, reduced timeline
+        main_layout.addLayout(left_grid, 3)  # Stretch factor 3 for 2x2 grid (larger previews)
+        main_layout.addWidget(self.control_panel, 0)  # No stretch (fixed width ~200px)
+        main_layout.addWidget(self.timeline_panel, 1)  # Stretch factor 1 (reduced from 2)
+        
+        # Connect signals
+        self.control_panel.run_test_clicked.connect(self._on_run_test)
+        self.control_panel.open_file_clicked.connect(self._on_run_test)
+        self.control_panel.stop_clicked.connect(self._on_stop)
+        self.control_panel.reset_clicked.connect(self._on_reset)
+        self.timeline_panel.save_audio_clicked.connect(self._on_save_audio)
+        
+    def _on_run_test(self):
+        """Handle RUN TEST button click."""
+        self.processing_start_time = time.time()
+        self.control_panel.set_processing(True)
+        self.load_audio_requested.emit()
+        
+    def _on_stop(self):
+        """Handle STOP button click."""
+        self.control_panel.set_processing(False)
+        # TODO: Implement worker cancellation
+        
+    def _on_reset(self):
+        """Handle RESET button click."""
+        # Clear all displays
+        self.audio_panel.clear_spectrogram()
+        self.results_panel.clear_results()
+        self.current_file_path = None
+        self.control_panel.set_processing(False)
+        
+    def _on_save_audio(self):
+        """Handle save audio button click."""
+        if self.current_file_path:
+            QMessageBox.information(
+                self,
+                "Save Audio",
+                "Audio save functionality coming in future update.\n\n"
+                f"Would save: {self.current_file_path}"
+            )
+        
+    def set_loading(self, is_loading: bool):
+        """
+        Set loading state.
+        
+        Args:
+            is_loading: Whether processing is active
+        """
+        self.control_panel.set_processing(is_loading)
+        
+    def update_results(self, spectrogram: QPixmap, predicted_class: str, 
+                      probabilities: dict, file_path: str):
+        """
+        Update the tab with processing results.
+        
+        Args:
+            spectrogram: QPixmap of the spectrogram
+            predicted_class: Predicted ripeness class
+            probabilities: Dictionary of class probabilities (0-1 scale)
+            file_path: Path to the audio file
+        """
+        # Calculate processing time
+        processing_time = 0
+        if self.processing_start_time:
+            processing_time = time.time() - self.processing_start_time
+            self.processing_start_time = None
+        
+        # Store current file path
+        self.current_file_path = file_path
+        
+        # Update Audio Spectrogram Panel
+        # TODO: Extract actual sample rate and duration from audio
+        self.audio_panel.update_spectrogram(
+            spectrogram, 
+            sample_rate=44100, 
+            duration=3.2, 
+            audio_path=file_path
+        )
+        
+        # Update Ripeness Results Panel
+        self.results_panel.update_results(
+            predicted_class,
+            probabilities,
+            processing_time,
+            model_version="RipeNet v3.2"
+        )
+        
+        # Convert probability to percentage for display (probabilities are now in decimal form)
+        confidence = probabilities.get(predicted_class, 0) * 100
+        self.timeline_panel.add_test_result(
+            predicted_class,
+            confidence,
+            processing_time
+        )
+        
+        # Add to session manager (probabilities are already in percentage form)
+        self.session_manager.add_result(
+            classification=predicted_class,
+            confidence=confidence,
+            probabilities=probabilities,
+            processing_time=processing_time,
+            file_path=file_path
+        )
+        
+        # Update statistics
+        stats = self.session_manager.get_statistics_summary()
+        self.timeline_panel.update_statistics(
+            total_tests=stats["total_tests"],
+            avg_processing=stats["avg_processing_time"],
+            ripe_count=stats["ripe_count"],
+            session_start=stats["session_start"]
+        )
+        
+        # Update processing state
+        self.control_panel.set_processing(False)
+        
+    def clear_results(self):
+        """Clear all displayed results and reset session."""
+        self.audio_panel.clear_spectrogram()
+        self.results_panel.clear_results()
+        self.timeline_panel.clear_timeline()
+        self.session_manager.clear_session()
+        self.current_file_path = None
+        self.processing_start_time = None
+        
+    def get_denoise_enabled(self) -> bool:
+        """
+        Check if audio denoising is enabled.
+        
+        Returns:
+            bool: True if denoise checkbox is checked
+        """
+        return self.control_panel.denoise_checkbox.isChecked()

+ 29 - 0
ui/widgets/__init__.py

@@ -0,0 +1,29 @@
+"""
+UI Widgets Package
+
+Contains reusable custom widgets.
+"""
+
+from .status_indicator import StatusIndicator
+from .panel_header import PanelHeader
+from .confidence_bar import ConfidenceBar
+from .coming_soon_overlay import ComingSoonOverlay
+from .mode_toggle import ModeToggle
+from .parameter_slider import ParameterSlider
+from .timeline_entry import TimelineEntry
+from .spinner import Spinner, DotsSpinner
+from .loading_screen import LoadingScreen
+
+__all__ = [
+    'StatusIndicator',
+    'PanelHeader',
+    'ConfidenceBar',
+    'ComingSoonOverlay',
+    'ModeToggle',
+    'ParameterSlider',
+    'TimelineEntry',
+    'Spinner',
+    'DotsSpinner',
+    'LoadingScreen',
+]
+

+ 55 - 0
ui/widgets/coming_soon_overlay.py

@@ -0,0 +1,55 @@
+"""
+Coming Soon Overlay Widget
+
+Semi-transparent overlay for features not yet implemented.
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+
+class ComingSoonOverlay(QWidget):
+    """
+    Overlay widget that displays "COMING SOON" message over disabled content.
+    
+    Args:
+        message: Main message text
+        description: Optional detailed description
+        parent: Parent widget
+    """
+    
+    def __init__(self, message: str = "COMING SOON", 
+                 description: str = "", parent=None):
+        super().__init__(parent)
+        self.init_ui(message, description)
+        
+    def init_ui(self, message: str, description: str):
+        """Initialize the overlay UI."""
+        # Make overlay fill parent
+        self.setAttribute(Qt.WA_StyledBackground, True)
+        self.setStyleSheet("""
+            background-color: rgba(44, 62, 80, 0.85);
+            border-radius: 4px;
+        """)
+        
+        layout = QVBoxLayout(self)
+        layout.setAlignment(Qt.AlignCenter)
+        
+        # Main message
+        main_label = QLabel(message)
+        main_label.setFont(QFont("Arial", 14, QFont.Bold))
+        main_label.setStyleSheet("color: #ecf0f1;")
+        main_label.setAlignment(Qt.AlignCenter)
+        layout.addWidget(main_label)
+        
+        # Description (if provided)
+        if description:
+            desc_label = QLabel(description)
+            desc_label.setFont(QFont("Arial", 10))
+            desc_label.setStyleSheet("color: #bdc3c7;")
+            desc_label.setAlignment(Qt.AlignCenter)
+            desc_label.setWordWrap(True)
+            layout.addWidget(desc_label)
+
+

+ 84 - 0
ui/widgets/confidence_bar.py

@@ -0,0 +1,84 @@
+"""
+Confidence Bar Widget
+
+Horizontal progress bar for displaying confidence scores.
+"""
+
+from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLabel, QProgressBar
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+
+class ConfidenceBar(QWidget):
+    """
+    Horizontal confidence bar with label and percentage.
+    
+    Args:
+        label: Label text (e.g., "Ripe")
+        color: Bar color hex code
+        parent: Parent widget
+    """
+    
+    def __init__(self, label: str, color: str = "#27ae60", parent=None):
+        super().__init__(parent)
+        self.color = color
+        self.init_ui(label)
+        
+    def init_ui(self, label: str):
+        """Initialize the confidence bar UI."""
+        layout = QHBoxLayout(self)
+        layout.setContentsMargins(0, 5, 0, 5)
+        layout.setSpacing(10)
+        
+        # Label
+        self.label = QLabel(label + ":")
+        self.label.setFont(QFont("Arial", 9))
+        self.label.setMinimumWidth(70)
+        layout.addWidget(self.label)
+        
+        # Progress bar
+        self.progress_bar = QProgressBar()
+        self.progress_bar.setFixedHeight(12)
+        self.progress_bar.setMaximum(100)
+        self.progress_bar.setValue(0)
+        self.progress_bar.setTextVisible(False)
+        self.progress_bar.setStyleSheet(f"""
+            QProgressBar {{
+                background-color: #ecf0f1;
+                border: 1px solid #bdc3c7;
+                border-radius: 2px;
+            }}
+            QProgressBar::chunk {{
+                background-color: {self.color};
+                border-radius: 2px;
+            }}
+        """)
+        layout.addWidget(self.progress_bar, 1)
+        
+        # Percentage label
+        self.percent_label = QLabel("0.0%")
+        self.percent_label.setFont(QFont("Arial", 9))
+        self.percent_label.setMinimumWidth(50)
+        self.percent_label.setStyleSheet("color: #2c3e50;")
+        layout.addWidget(self.percent_label)
+        
+    def set_value(self, value: float, is_primary: bool = False):
+        """
+        Set the confidence value.
+        
+        Args:
+            value: Confidence value (0-100)
+            is_primary: Whether this is the primary/selected classification
+        """
+        self.progress_bar.setValue(int(value))
+        self.percent_label.setText(f"{value:.1f}%")
+        
+        # Bold text for primary classification
+        if is_primary:
+            self.label.setStyleSheet("font-weight: bold; color: #2c3e50;")
+            self.percent_label.setStyleSheet("font-weight: bold; color: #2c3e50;")
+        else:
+            self.label.setStyleSheet("color: #2c3e50;")
+            self.percent_label.setStyleSheet("color: #2c3e50;")
+
+

+ 185 - 0
ui/widgets/loading_screen.py

@@ -0,0 +1,185 @@
+"""
+Loading Screen Widget
+
+Displays an animated loading screen with animated GIF and progress indication.
+"""
+
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
+from PyQt5.QtCore import Qt, QSize
+from PyQt5.QtGui import QFont, QMovie, QPixmap
+from pathlib import Path
+
+
+class LoadingScreen(QWidget):
+    """
+    A professional loading screen widget with animated GIF and message.
+    
+    Features:
+    - Animated loading GIF
+    - Centered layout
+    - Custom messages
+    - Clean visual design
+    """
+    
+    def __init__(self, message: str = "Processing analysis...", parent=None):
+        """
+        Initialize the loading screen.
+        
+        Args:
+            message: Initial loading message to display
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.message = message
+        self.movie = None
+        self.init_ui()
+    
+    def init_ui(self):
+        """Initialize UI components."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(20)
+        
+        # Add stretch at top for centering
+        layout.addStretch()
+        
+        # Animated GIF label
+        self.gif_label = QLabel()
+        self.gif_label.setAlignment(Qt.AlignCenter)
+        
+        # Load the animated GIF
+        gif_path = Path(__file__).parent.parent.parent / "assets" / "loading-gif.gif"
+        if gif_path.exists():
+            self.movie = QMovie(str(gif_path))
+            # Set a reasonable size for the GIF
+            scaled_size = self.movie.scaledSize()
+            if scaled_size.width() <= 0 or scaled_size.height() <= 0:
+                self.movie.setScaledSize(QSize(120, 120))
+            self.gif_label.setMovie(self.movie)
+            self.movie.start()
+        else:
+            # Fallback if GIF not found
+            self.gif_label.setText("⏳")
+            self.gif_label.setFont(QFont("Arial", 48))
+        
+        layout.addWidget(self.gif_label, alignment=Qt.AlignCenter)
+        
+        # Loading message
+        self.message_label = QLabel(self.message)
+        self.message_label.setFont(QFont("Arial", 16, QFont.Bold))
+        self.message_label.setAlignment(Qt.AlignCenter)
+        self.message_label.setStyleSheet("color: #2c3e50;")
+        layout.addWidget(self.message_label, alignment=Qt.AlignCenter)
+        
+        # Add stretch at bottom for centering
+        layout.addStretch()
+        
+        # Set background
+        self.setStyleSheet("background-color: #ffffff;")
+    
+    def start_animation(self):
+        """Start the animation."""
+        if self.movie:
+            self.movie.start()
+    
+    def stop_animation(self):
+        """Stop the animation."""
+        if self.movie:
+            self.movie.stop()
+    
+    def set_message(self, message: str):
+        """
+        Update the loading message.
+        
+        Args:
+            message: New loading message
+        """
+        self.message_label.setText(message)
+    
+    def set_status(self, status: str):
+        """
+        Update the status text (not used in this simplified version).
+        
+        Args:
+            status: Status message (ignored)
+        """
+        pass
+    
+    def cleanup(self):
+        """Cleanup animation resources."""
+        self.stop_animation()
+        if self.movie:
+            self.movie.deleteLater()
+
+
+class LoadingOverlay(QWidget):
+    """
+    A full-screen loading overlay that can be placed on top of other widgets.
+    Uses the same simplified loading design as LoadingScreen.
+    """
+    
+    def __init__(self, message: str = "Processing...", parent=None):
+        """
+        Initialize the loading overlay.
+        
+        Args:
+            message: Loading message
+            parent: Parent widget
+        """
+        super().__init__(parent)
+        self.setWindowFlags(Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)
+        self.setAttribute(Qt.WA_TranslucentBackground)
+        
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        
+        # Semi-transparent background
+        self.setStyleSheet("background-color: rgba(0, 0, 0, 0.3);")
+        
+        # Create centered loading widget
+        self.loading_widget = QWidget()
+        self.loading_layout = QVBoxLayout(self.loading_widget)
+        self.loading_layout.setContentsMargins(40, 40, 40, 40)
+        self.loading_layout.setSpacing(20)
+        
+        # Background container
+        self.loading_widget.setStyleSheet("""
+            QWidget {
+                background-color: #ffffff;
+                border-radius: 12px;
+                border: 1px solid #ecf0f1;
+            }
+        """)
+        
+        # Animated GIF
+        self.gif_label = QLabel()
+        self.gif_label.setAlignment(Qt.AlignCenter)
+        
+        gif_path = Path(__file__).parent.parent.parent / "assets" / "loading-gif.gif"
+        self.movie = None
+        if gif_path.exists():
+            self.movie = QMovie(str(gif_path))
+            self.gif_label.setMovie(self.movie)
+            self.movie.start()
+        else:
+            self.gif_label.setText("⏳")
+            self.gif_label.setFont(QFont("Arial", 48))
+        
+        self.loading_layout.addWidget(self.gif_label, alignment=Qt.AlignCenter)
+        
+        # Message
+        message_label = QLabel(message)
+        message_label.setFont(QFont("Arial", 14, QFont.Bold))
+        message_label.setAlignment(Qt.AlignCenter)
+        message_label.setStyleSheet("color: #2c3e50;")
+        self.loading_layout.addWidget(message_label)
+        
+        layout.addStretch()
+        layout.addWidget(self.loading_widget, alignment=Qt.AlignCenter)
+        layout.addStretch()
+    
+    def cleanup(self):
+        """Stop animation and cleanup."""
+        if self.movie:
+            self.movie.stop()
+            self.movie.deleteLater()

+ 120 - 0
ui/widgets/mode_toggle.py

@@ -0,0 +1,120 @@
+"""
+Mode Toggle Widget
+
+Toggle button widget for switching between modes (LIVE/FILE).
+"""
+
+from PyQt5.QtWidgets import QWidget, QHBoxLayout, QPushButton
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont
+
+
+class ModeToggle(QWidget):
+    """
+    Toggle widget for mode selection.
+    
+    Signals:
+        mode_changed: Emitted when mode is changed (str: 'live' or 'file')
+    """
+    
+    mode_changed = pyqtSignal(str)
+    
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.current_mode = "file"
+        self.init_ui()
+        
+    def init_ui(self):
+        """Initialize the toggle UI."""
+        layout = QHBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        
+        # Container style to resemble a switch/segment control
+        self.setStyleSheet("""
+            QWidget#ModeToggleRoot {
+                background-color: #ecf0f1;
+                border: 1px solid #bdc3c7;
+                border-radius: 14px;
+            }
+        """)
+        self.setObjectName("ModeToggleRoot")
+        
+        # LIVE button
+        self.live_btn = QPushButton("LIVE")
+        self.live_btn.setFont(QFont("Arial", 9))
+        self.live_btn.setFixedSize(80, 28)
+        self.live_btn.setCheckable(True)
+        self.live_btn.clicked.connect(lambda: self.set_mode("live"))
+        layout.addWidget(self.live_btn)
+        
+        # FILE button
+        self.file_btn = QPushButton("FILE")
+        self.file_btn.setFont(QFont("Arial", 9))
+        self.file_btn.setFixedSize(80, 28)
+        self.file_btn.setCheckable(True)
+        self.file_btn.setChecked(True)
+        self.file_btn.clicked.connect(lambda: self.set_mode("file"))
+        layout.addWidget(self.file_btn)
+        
+        self._update_styles()
+        
+        # Disable LIVE mode initially (coming soon)
+        self.live_btn.setEnabled(False)
+        self.live_btn.setToolTip("Live audio streaming - Coming in future update")
+        
+    def set_mode(self, mode: str):
+        """
+        Set the current mode.
+        
+        Args:
+            mode: 'live' or 'file'
+        """
+        self.current_mode = mode
+        self.live_btn.setChecked(mode == "live")
+        self.file_btn.setChecked(mode == "file")
+        self._update_styles()
+        self.mode_changed.emit(mode)
+        
+    def _update_styles(self):
+        """Update button styles based on selection."""
+        active_style = """
+            QPushButton {{
+                background-color: #3498db;
+                border: none;
+                border-radius: 14px;
+                color: white;
+                font-weight: bold;
+                padding: 4px 10px;
+            }}
+        """
+        
+        inactive_style = """
+            QPushButton {{
+                background-color: transparent;
+                border: none;
+                border-radius: 14px;
+                color: #2c3e50;
+                padding: 4px 10px;
+            }}
+            QPushButton:hover {{
+                background-color: #d5dbdb;
+            }}
+        """
+        
+        disabled_style = """
+            QPushButton:disabled {{
+                background-color: #95a5a6;
+                border: 1px solid #7f8c8d;
+                color: #ecf0f1;
+            }}
+        """
+        
+        self.live_btn.setStyleSheet(
+            (active_style if self.current_mode == "live" else inactive_style) + disabled_style
+        )
+        self.file_btn.setStyleSheet(
+            active_style if self.current_mode == "file" else inactive_style
+        )
+
+

+ 75 - 0
ui/widgets/panel_header.py

@@ -0,0 +1,75 @@
+"""
+Panel Header Widget
+
+Reusable header component for panels with title and optional status indicator.
+"""
+
+from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLabel
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QFont
+
+
+class PanelHeader(QWidget):
+    """
+    Header widget for panels with colored background and status indicator.
+    
+    Args:
+        title: Header title text
+        color: Background color hex code
+        show_status: Whether to show status indicator
+        status: Status type ('online', 'offline', 'processing')
+    """
+    
+    def __init__(self, title: str, color: str = "#34495e", 
+                 show_status: bool = False, status: str = "offline", parent=None):
+        super().__init__(parent)
+        self.status_indicator = None
+        self.init_ui(title, color, show_status, status)
+        
+    def init_ui(self, title: str, color: str, show_status: bool, status: str):
+        """Initialize the header UI."""
+        self.setFixedHeight(25)
+        self.setStyleSheet(f"""
+            QWidget {{
+                background-color: {color};
+                border-top-left-radius: 5px;
+                border-top-right-radius: 5px;
+            }}
+        """)
+        
+        layout = QHBoxLayout(self)
+        layout.setContentsMargins(10, 0, 10, 0)
+        layout.setSpacing(5)
+        
+        # Title label
+        title_label = QLabel(title)
+        title_label.setFont(QFont("Arial", 10, QFont.Bold))
+        title_label.setStyleSheet("color: white;")
+        layout.addWidget(title_label)
+        
+        layout.addStretch()
+        
+        # Status indicator
+        if show_status:
+            self.status_indicator = QLabel("●")
+            self.status_indicator.setFont(QFont("Arial", 12))
+            self.set_status(status)
+            layout.addWidget(self.status_indicator)
+            
+    def set_status(self, status: str):
+        """
+        Update the status indicator.
+        
+        Args:
+            status: Status type ('online', 'offline', 'processing')
+        """
+        if self.status_indicator:
+            status_colors = {
+                "online": "#27ae60",
+                "offline": "#e74c3c",
+                "processing": "#f39c12"
+            }
+            color = status_colors.get(status, "#95a5a6")
+            self.status_indicator.setStyleSheet(f"color: {color};")
+
+

+ 87 - 0
ui/widgets/parameter_slider.py

@@ -0,0 +1,87 @@
+"""
+Parameter Slider Widget
+
+Slider widget with label and value display for parameter adjustment.
+"""
+
+from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLabel, QSlider
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont
+
+
+class ParameterSlider(QWidget):
+    """
+    Slider widget for parameter adjustment.
+    
+    Signals:
+        value_changed: Emitted when slider value changes (int)
+    """
+    
+    value_changed = pyqtSignal(int)
+    
+    def __init__(self, label: str, min_val: int = 0, max_val: int = 100,
+                 default_val: int = 50, value_format: str = "{}",
+                 parent=None):
+        super().__init__(parent)
+        self.value_format = value_format
+        self.init_ui(label, min_val, max_val, default_val)
+        
+    def init_ui(self, label: str, min_val: int, max_val: int, default_val: int):
+        """Initialize the slider UI."""
+        layout = QHBoxLayout(self)
+        layout.setContentsMargins(0, 5, 0, 5)
+        layout.setSpacing(10)
+        
+        # Label
+        label_widget = QLabel(label + ":")
+        label_widget.setFont(QFont("Arial", 9))
+        label_widget.setMinimumWidth(70)
+        layout.addWidget(label_widget)
+        
+        # Slider
+        self.slider = QSlider(Qt.Horizontal)
+        self.slider.setMinimum(min_val)
+        self.slider.setMaximum(max_val)
+        self.slider.setValue(default_val)
+        self.slider.valueChanged.connect(self._on_value_changed)
+        self.slider.setStyleSheet("""
+            QSlider::groove:horizontal {
+                background: #ecf0f1;
+                height: 15px;
+                border: 1px solid #bdc3c7;
+                border-radius: 2px;
+            }
+            QSlider::handle:horizontal {
+                background: #3498db;
+                width: 20px;
+                margin: -3px 0;
+                border-radius: 3px;
+            }
+            QSlider::sub-page:horizontal {
+                background: #3498db;
+                border-radius: 2px;
+            }
+        """)
+        layout.addWidget(self.slider, 1)
+        
+        # Value label
+        self.value_label = QLabel(self.value_format.format(default_val))
+        self.value_label.setFont(QFont("Arial", 9))
+        self.value_label.setMinimumWidth(60)
+        self.value_label.setStyleSheet("color: #2c3e50;")
+        layout.addWidget(self.value_label)
+        
+    def _on_value_changed(self, value: int):
+        """Handle slider value changes."""
+        self.value_label.setText(self.value_format.format(value))
+        self.value_changed.emit(value)
+        
+    def get_value(self) -> int:
+        """Get current slider value."""
+        return self.slider.value()
+        
+    def set_value(self, value: int):
+        """Set slider value."""
+        self.slider.setValue(value)
+
+

+ 177 - 0
ui/widgets/spinner.py

@@ -0,0 +1,177 @@
+"""
+Custom Spinner Widget
+
+A professionally designed animated spinner with rotating arc design.
+"""
+
+from PyQt5.QtWidgets import QWidget
+from PyQt5.QtCore import Qt, QTimer, QRect, QSize
+from PyQt5.QtGui import QPainter, QPen, QColor, QBrush
+
+
+class Spinner(QWidget):
+    """
+    A custom rotating spinner widget with smooth arc animation.
+    
+    Features:
+    - Smooth rotating arc design
+    - Customizable colors and sizes
+    - Professional appearance
+    """
+    
+    def __init__(self, parent=None, size: int = 60, color: str = "#3498db"):
+        """
+        Initialize the spinner.
+        
+        Args:
+            parent: Parent widget
+            size: Size of the spinner in pixels
+            color: Color of the spinner (hex or color name)
+        """
+        super().__init__(parent)
+        self.size = size
+        self.color = QColor(color)
+        self.angle = 0
+        self.setMinimumSize(size + 20, size + 20)
+        self.setMaximumSize(size + 20, size + 20)
+        
+        # Animation timer
+        self.timer = QTimer()
+        self.timer.timeout.connect(self._rotate)
+        self.timer.start(30)  # ~33 FPS for smooth animation
+    
+    def _rotate(self):
+        """Update rotation angle."""
+        self.angle = (self.angle + 6) % 360  # Rotate 6 degrees each frame
+        self.update()
+    
+    def paintEvent(self, event):
+        """Paint the spinner."""
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+        
+        # Center the spinner
+        center_x = self.width() // 2
+        center_y = self.height() // 2
+        
+        # Draw rotating arc
+        pen = QPen(self.color)
+        pen.setWidth(4)
+        pen.setCapStyle(Qt.RoundCap)
+        painter.setPen(pen)
+        
+        # Draw the spinning arc
+        rect = QRect(center_x - self.size // 2, center_y - self.size // 2, self.size, self.size)
+        start_angle = self.angle * 16  # Qt uses 1/16 degree units
+        span_angle = 120 * 16  # 120 degree arc
+        
+        painter.drawArc(rect, start_angle, span_angle)
+        
+        # Draw subtle background circle
+        bg_pen = QPen(QColor("#ecf0f1"))
+        bg_pen.setWidth(3)
+        painter.setPen(bg_pen)
+        painter.drawEllipse(rect)
+    
+    def stop(self):
+        """Stop the spinner animation."""
+        self.timer.stop()
+    
+    def start(self):
+        """Start the spinner animation."""
+        self.timer.start(30)
+    
+    def set_color(self, color: str):
+        """
+        Change the spinner color.
+        
+        Args:
+            color: Color in hex or color name
+        """
+        self.color = QColor(color)
+        self.update()
+
+
+class DotsSpinner(QWidget):
+    """
+    An alternative spinner with rotating dots around a circle.
+    """
+    
+    def __init__(self, parent=None, size: int = 60, color: str = "#3498db", dot_count: int = 8):
+        """
+        Initialize the dots spinner.
+        
+        Args:
+            parent: Parent widget
+            size: Size of the spinner in pixels
+            color: Color of the spinner dots
+            dot_count: Number of rotating dots
+        """
+        super().__init__(parent)
+        self.size = size
+        self.color = QColor(color)
+        self.angle = 0
+        self.dot_count = dot_count
+        self.setMinimumSize(size + 20, size + 20)
+        self.setMaximumSize(size + 20, size + 20)
+        
+        # Animation timer
+        self.timer = QTimer()
+        self.timer.timeout.connect(self._rotate)
+        self.timer.start(30)
+    
+    def _rotate(self):
+        """Update rotation angle."""
+        self.angle = (self.angle + 360 / self.dot_count / 8) % 360
+        self.update()
+    
+    def paintEvent(self, event):
+        """Paint the spinner with rotating dots."""
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+        
+        # Center the spinner
+        center_x = self.width() // 2
+        center_y = self.height() // 2
+        
+        # Draw rotating dots
+        import math
+        radius = self.size // 2
+        dot_radius = 3
+        
+        for i in range(self.dot_count):
+            # Calculate angle for this dot
+            dot_angle = (self.angle + (360 / self.dot_count) * i) * math.pi / 180
+            
+            # Calculate position
+            x = center_x + radius * math.cos(dot_angle)
+            y = center_y + radius * math.sin(dot_angle)
+            
+            # Fade effect - dots fade out as they come to the back
+            opacity = (i / self.dot_count + 0.5) % 1.0
+            color = QColor(self.color)
+            color.setAlpha(int(255 * opacity))
+            
+            # Draw dot
+            painter.setPen(Qt.NoPen)
+            painter.setBrush(color)
+            painter.drawEllipse(int(x - dot_radius), int(y - dot_radius), 
+                              dot_radius * 2, dot_radius * 2)
+    
+    def stop(self):
+        """Stop the spinner animation."""
+        self.timer.stop()
+    
+    def start(self):
+        """Start the spinner animation."""
+        self.timer.start(30)
+    
+    def set_color(self, color: str):
+        """
+        Change the spinner color.
+        
+        Args:
+            color: Color in hex or color name
+        """
+        self.color = QColor(color)
+        self.update()

+ 63 - 0
ui/widgets/status_indicator.py

@@ -0,0 +1,63 @@
+"""
+Status Indicator Widget
+
+A simple colored dot widget to show online/offline/updating status.
+"""
+
+from PyQt5.QtWidgets import QLabel
+
+from resources.styles import get_status_indicator_style
+from utils.config import STATUS_INDICATOR_SIZE
+
+
+class StatusIndicator(QLabel):
+    """
+    A colored status indicator dot.
+    
+    Shows different colors based on status:
+    - online: Green
+    - offline: Red
+    - updating: Orange
+    
+    Attributes:
+        status: Current status ('online', 'offline', or 'updating')
+    """
+    
+    def __init__(self, status: str = "online"):
+        """
+        Initialize the status indicator.
+        
+        Args:
+            status: Initial status ('online', 'offline', or 'updating')
+        """
+        super().__init__()
+        self.status = status
+        self.setFixedSize(STATUS_INDICATOR_SIZE, STATUS_INDICATOR_SIZE)
+        self.update_status()
+    
+    def update_status(self):
+        """Update the visual appearance based on current status."""
+        style = get_status_indicator_style(self.status)
+        self.setStyleSheet(style)
+    
+    def set_status(self, status: str):
+        """
+        Set a new status and update appearance.
+        
+        Args:
+            status: New status ('online', 'offline', or 'updating')
+        """
+        self.status = status
+        self.update_status()
+    
+    def get_status(self) -> str:
+        """
+        Get the current status.
+        
+        Returns:
+            str: Current status
+        """
+        return self.status
+
+
+

+ 108 - 0
ui/widgets/timeline_entry.py

@@ -0,0 +1,108 @@
+"""
+Timeline Entry Widget
+
+Widget for displaying a single test result entry in the analysis timeline.
+"""
+
+from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtGui import QFont
+
+
+class TimelineEntry(QWidget):
+    """
+    Timeline entry showing a test result.
+    
+    Signals:
+        save_clicked: Emitted when save button is clicked
+        view_clicked: Emitted when view button is clicked
+    """
+    
+    save_clicked = pyqtSignal(int)  # test_id
+    view_clicked = pyqtSignal(int)  # test_id
+    
+    def __init__(self, test_id: int, timestamp: str, classification: str,
+                 confidence: float, processing_time: float, parent=None):
+        super().__init__(parent)
+        self.test_id = test_id
+        self.init_ui(timestamp, classification, confidence, processing_time)
+        
+    def init_ui(self, timestamp: str, classification: str, 
+                confidence: float, processing_time: float):
+        """Initialize the timeline entry UI."""
+        self.setFixedHeight(48)
+        
+        # Set background color (alternating)
+        bg_color = "#f8f9fa" if self.test_id % 2 == 0 else "#ffffff"
+        self.setStyleSheet(f"""
+            QWidget {{
+                background-color: {bg_color};
+                border: 1px solid #ecf0f1;
+                border-radius: 3px;
+            }}
+        """)
+        
+        layout = QHBoxLayout(self)
+        layout.setContentsMargins(12, 8, 12, 8)
+        layout.setSpacing(12)
+        
+        # Status indicator dot
+        status_colors = {
+            "Ripe": "#27ae60",
+            "Overripe": "#f39c12",
+            "Unripe": "#95a5a6",
+            "Overripe": "#e74c3c"
+        }
+        color = status_colors.get(classification, "#95a5a6")
+        
+        status_dot = QLabel("●")
+        status_dot.setFont(QFont("Arial", 12))
+        status_dot.setStyleSheet(f"color: {color};")
+        status_dot.setFixedWidth(15)
+        layout.addWidget(status_dot)
+        
+        # Info section
+        info_layout = QVBoxLayout()
+        info_layout.setSpacing(2)
+        
+        # Timestamp and test ID
+        header_label = QLabel(f"{timestamp} - Test #{self.test_id:04d}")
+        header_label.setFont(QFont("Arial", 10, QFont.Bold))
+        header_label.setStyleSheet("color: #2c3e50;")
+        info_layout.addWidget(header_label)
+        
+        # Classification and details
+        details = f"{classification.upper()} ({confidence:.1f}%) - Processing: {processing_time:.2f}s"
+        details_label = QLabel(details)
+        details_label.setFont(QFont("Arial", 9))
+        details_label.setStyleSheet(f"color: {color};")
+        info_layout.addWidget(details_label)
+        
+        layout.addLayout(info_layout, 1)
+        
+        # Action button
+        if self.test_id == 1:  # Most recent - show SAVE button
+            action_btn = QPushButton("SAVE")
+            action_btn.clicked.connect(lambda: self.save_clicked.emit(self.test_id))
+            btn_color = "#27ae60"
+        else:  # Older entries - show VIEW button
+            action_btn = QPushButton("VIEW")
+            action_btn.clicked.connect(lambda: self.view_clicked.emit(self.test_id))
+            btn_color = "#3498db"
+            
+        action_btn.setFont(QFont("Arial", 8, QFont.Bold))
+        action_btn.setFixedSize(50, 24)
+        action_btn.setStyleSheet(f"""
+            QPushButton {{
+                background-color: {btn_color};
+                border: none;
+                border-radius: 2px;
+                color: white;
+            }}
+            QPushButton:hover {{
+                opacity: 0.9;
+            }}
+        """)
+        layout.addWidget(action_btn)
+
+

+ 37 - 0
utils/__init__.py

@@ -0,0 +1,37 @@
+"""
+Utils Package
+
+Contains utility functions for image processing, audio processing, configuration, and camera automation.
+"""
+
+from .process_utils import (
+    is_process_running,
+    check_camera_applications,
+    get_running_camera_apps,
+    get_missing_camera_apps
+)
+
+from .camera_automation import (
+    CameraAutomation,
+    SecondLookAutomation,
+    EOSUtilityAutomation,
+    AnalyzIRAutomation,
+    CameraAutomationError,
+    create_camera_automation
+)
+
+__all__ = [
+    'is_process_running',
+    'check_camera_applications',
+    'get_running_camera_apps',
+    'get_missing_camera_apps',
+    'CameraAutomation',
+    'SecondLookAutomation',
+    'EOSUtilityAutomation',
+    'AnalyzIRAutomation',
+    'CameraAutomationError',
+    'create_camera_automation'
+]
+
+
+

+ 1495 - 0
utils/camera_automation.py

@@ -0,0 +1,1495 @@
+"""
+Camera Automation Module
+
+Provides GUI automation for controlling external camera applications.
+Uses pywinauto for Windows-based window automation and keyboard/mouse control.
+
+Supports:
+- 2nd Look (Multispectral Camera)
+- EOS Utility (DSLR Camera)
+- AnalyzIR (Thermal Camera) - planned
+"""
+
+import os
+import sys
+import time
+import tempfile
+import shutil
+from pathlib import Path
+from abc import ABC, abstractmethod
+from typing import Optional, Tuple
+from datetime import datetime
+import logging
+
+# Configure logging
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+try:
+    from pywinauto import Application, findwindows
+    from pywinauto.keyboard import send_keys
+    PYWINAUTO_AVAILABLE = True
+except ImportError:
+    PYWINAUTO_AVAILABLE = False
+    logger.warning("pywinauto not available - GUI automation will be limited")
+
+try:
+    import pyautogui
+    PYAUTOGUI_AVAILABLE = True
+except ImportError:
+    PYAUTOGUI_AVAILABLE = False
+    logger.warning("pyautogui not available - fallback automation unavailable")
+
+
+class CameraAutomationError(Exception):
+    """Raised when camera automation fails."""
+    pass
+
+
+class CameraAutomation(ABC):
+    """
+    Abstract base class for camera application automation.
+    
+    All camera automation implementations should inherit from this class
+    and implement the required methods.
+    """
+    
+    def __init__(self, app_name: str, window_title: str = None):
+        """
+        Initialize camera automation.
+        
+        Args:
+            app_name: Name of the application (e.g., "2nd Look", "EOS Utility")
+            window_title: Optional window title to search for
+        """
+        self.app_name = app_name
+        self.window_title = window_title or app_name
+        self.app = None
+        self.window = None
+        self.last_capture_time = None
+        logger.info(f"Initialized {self.__class__.__name__} for {app_name}")
+    
+    @abstractmethod
+    def find_window(self) -> bool:
+        """
+        Find and connect to the application window.
+        
+        Returns:
+            bool: True if window found and connected, False otherwise
+        """
+        pass
+    
+    @abstractmethod
+    def is_window_open(self) -> bool:
+        """
+        Check if the application window is currently open and responsive.
+        
+        Returns:
+            bool: True if window is open and responsive, False otherwise
+        """
+        pass
+    
+    @abstractmethod
+    def capture(self, output_dir: str = None) -> Optional[str]:
+        """
+        Perform automated capture from the application.
+        
+        Args:
+            output_dir: Directory to save captured file (optional)
+            
+        Returns:
+            str: Path to captured file if successful, None otherwise
+        """
+        pass
+    
+    def get_last_image(self) -> Optional[str]:
+        """
+        Retrieve the path to the last captured image.
+        
+        Returns:
+            str: Path to last image if available, None otherwise
+        """
+        # Default implementation - should be overridden by subclasses
+        return None
+
+
+class SecondLookAutomation(CameraAutomation):
+    """
+    Automation for 2nd Look multispectral camera application.
+    
+    Handles:
+    - Finding and connecting to 2nd Look window
+    - Clicking Record button and Trigger Software
+    - Monitoring for TIFF file creation in Recordings directory
+    - Stopping the recording
+    - Cleanup of temporary files
+    
+    Recording directory: C:\\Users\\[USERNAME]\\Documents\\IO Industries\\2ndLook\\Recordings\\
+    File pattern: Recording_YYYY-MM-DD_HH_MM_SS\\TIFF\\Camera\\Image_XXXXXX.tif
+    """
+    
+    # Configuration constants
+    RECORDING_BASE_DIR = Path.home() / "Documents" / "IO Industries" / "2ndLook" / "Recordings"
+    TRIGGER_WAIT_TIME = 2  # seconds to wait after trigger
+    FILE_WATCH_TIMEOUT = 15  # seconds to wait for file creation
+    STOP_WAIT_TIME = 1  # seconds to wait after clicking stop
+    
+    # Button coordinates (calibrate with calibrate_2ndlook_buttons.py if needed)
+    RECORD_BUTTON_POS = (64, 1422)  # Red circle at bottom left
+    TRIGGER_SOFTWARE_POS = (125, 357)  # Toolbar button
+    STOP_BUTTON_POS = (319, 1422)  # Bottom stop button
+    
+    def __init__(self, default_save_dir: str = None):
+        """
+        Initialize 2nd Look automation.
+        
+        Args:
+            default_save_dir: Default directory where 2nd Look saves files
+        """
+        super().__init__("2nd Look", "2ndLook - Control")
+        self.default_save_dir = Path(default_save_dir) if default_save_dir else None
+        self.last_image_path = None
+        self.temp_dir = Path(tempfile.gettempdir()) / "dudong_2ndlook"
+        self.temp_dir.mkdir(exist_ok=True)
+        logger.info(f"2nd Look temp directory: {self.temp_dir}")
+        logger.debug(f"Button coordinates - Record: {self.RECORD_BUTTON_POS}, Trigger: {self.TRIGGER_SOFTWARE_POS}, Stop: {self.STOP_BUTTON_POS}")
+    
+    def find_window(self) -> bool:
+        """
+        Find and connect to 2nd Look window.
+        
+        Returns:
+            bool: True if found, False otherwise
+        """
+        if not PYWINAUTO_AVAILABLE:
+            logger.error("pywinauto not available - cannot find window")
+            return False
+        
+        try:
+            logger.info(f"Searching for '{self.window_title}' window...")
+            
+            # Try to find window by title
+            windows = findwindows.find_windows(title_re=f".*{self.window_title}.*")
+            
+            if windows:
+                logger.info(f"Found {len(windows)} window(s) matching '{self.window_title}'")
+                window_handle = windows[0]
+                
+                # Try to connect to application using the window handle
+                try:
+                    # Use the window handle directly with pywinauto
+                    self.app = Application(backend="uia").connect(handle=window_handle)
+                    self.window = self.app.window(handle=window_handle)
+                    logger.info(f"Connected to 2nd Look (handle: {window_handle})")
+                    return True
+                except Exception as e:
+                    logger.warning(f"Could not connect via UIA backend: {e}")
+                    try:
+                        # Fallback: try using top_level_only
+                        self.app = Application(backend="uia").connect(top_level_only=False)
+                        logger.info("Connected to application using fallback method")
+                        return True
+                    except Exception as e2:
+                        logger.warning(f"Fallback connection also failed: {e2}")
+                        return False
+            else:
+                logger.warning(f"No window found matching '{self.window_title}'")
+                return False
+        
+        except Exception as e:
+            logger.error(f"Error finding 2nd Look window: {e}")
+            return False
+    
+    def is_window_open(self) -> bool:
+        """
+        Check if 2nd Look is open and responsive.
+        
+        Returns:
+            bool: True if open and responsive, False otherwise
+        """
+        try:
+            if not PYWINAUTO_AVAILABLE:
+                logger.debug("pywinauto not available - cannot check window state")
+                return False
+            
+            # Check for window existence
+            windows = findwindows.find_windows(title_re=f".*{self.window_title}.*")
+            
+            if windows:
+                logger.debug("2nd Look window is open")
+                return True
+            else:
+                logger.debug("2nd Look window not found")
+                return False
+        
+        except Exception as e:
+            logger.warning(f"Error checking window state: {e}")
+            return False
+    
+    def capture(self, output_dir: str = None) -> Optional[str]:
+        """
+        Capture multispectral image from 2nd Look using Record and Trigger Software.
+        
+        Workflow:
+        1. Click Record button (red circle)
+        2. Click Trigger Software button
+        3. Wait for TIFF file in Recordings directory
+        4. Click Stop button
+        5. Copy captured file to output directory
+        
+        Args:
+            output_dir: Directory to save TIFF (uses temp dir if not specified)
+            
+        Returns:
+            str: Path to captured TIFF file, or None if capture failed
+        """
+        output_dir = Path(output_dir) if output_dir else self.temp_dir
+        output_dir.mkdir(parents=True, exist_ok=True)
+        
+        try:
+            logger.info("Starting 2nd Look capture workflow...")
+            
+            # Step 1: Verify window is open
+            if not self.is_window_open():
+                logger.error("2nd Look window is not open")
+                raise CameraAutomationError("2nd Look application not found or not running")
+            
+            # Step 2: Reconnect to window if needed
+            if self.app is None or self.window is None:
+                if not self.find_window():
+                    raise CameraAutomationError("Could not connect to 2nd Look window")
+            
+            # Step 3: Bring window to focus
+            try:
+                logger.debug("Bringing 2nd Look to focus...")
+                self.window.set_focus()
+                time.sleep(0.5)
+            except Exception as e:
+                logger.warning(f"Could not set focus: {e}")
+            
+            # Step 4: Click Record button (red circle at bottom left)
+            logger.info("Clicking Record button...")
+            self._click_record_button()
+            time.sleep(1)
+            
+            # Step 5: Click Trigger Software button
+            logger.info("Clicking Trigger Software button...")
+            self._click_trigger_software_button()
+            time.sleep(self.TRIGGER_WAIT_TIME)
+            
+            # Step 6: Wait for file creation in Recordings directory
+            logger.info(f"Waiting for TIFF file in {self.RECORDING_BASE_DIR}...")
+            captured_file = self._wait_for_recording_file()
+            
+            if not captured_file:
+                logger.error("Timeout waiting for TIFF file creation")
+                raise CameraAutomationError("Timeout waiting for file - capture may have failed")
+            
+            logger.info(f"File detected: {captured_file}")
+            
+            # Step 7: Click Stop button to end recording
+            logger.info("Clicking Stop button...")
+            self._click_stop_button()
+            time.sleep(self.STOP_WAIT_TIME)
+            
+            # Step 8: Copy captured file to output directory
+            logger.info(f"Copying file to output directory: {output_dir}")
+            output_file = self._copy_captured_file(captured_file, output_dir)
+            
+            if output_file:
+                logger.info(f"Successfully captured: {output_file}")
+                self.last_image_path = str(output_file)
+                self.last_capture_time = datetime.now()
+                return str(output_file)
+            else:
+                logger.error("Failed to copy captured file")
+                raise CameraAutomationError("Failed to copy captured file to output directory")
+        
+        except Exception as e:
+            logger.error(f"Capture failed: {e}")
+            return None
+    
+    def get_last_image(self) -> Optional[str]:
+        """
+        Get the path to the last captured image.
+        
+        Returns:
+            str: Path to last image if available, None otherwise
+        """
+        if self.last_image_path and Path(self.last_image_path).exists():
+            logger.debug(f"Returning last image: {self.last_image_path}")
+            return self.last_image_path
+        
+        logger.debug("No valid last image path found")
+        return None
+    
+    def set_button_coordinates(self, record_pos: Tuple[int, int] = None, 
+                               trigger_pos: Tuple[int, int] = None, 
+                               stop_pos: Tuple[int, int] = None) -> None:
+        """
+        Set button coordinates (for calibration).
+        
+        Args:
+            record_pos: (x, y) coordinates for Record button
+            trigger_pos: (x, y) coordinates for Trigger Software button
+            stop_pos: (x, y) coordinates for Stop button
+        """
+        if record_pos:
+            self.RECORD_BUTTON_POS = record_pos
+            logger.info(f"Record button coordinates set to {record_pos}")
+        if trigger_pos:
+            self.TRIGGER_SOFTWARE_POS = trigger_pos
+            logger.info(f"Trigger Software button coordinates set to {trigger_pos}")
+        if stop_pos:
+            self.STOP_BUTTON_POS = stop_pos
+            logger.info(f"Stop button coordinates set to {stop_pos}")
+    
+    def _click_record_button(self) -> bool:
+        """
+        Click the Record button (red circle at bottom left).
+        
+        Uses coordinates from RECORD_BUTTON_POS (can be calibrated).
+        
+        Returns:
+            bool: True if click was attempted, False otherwise
+        """
+        try:
+            if not PYAUTOGUI_AVAILABLE:
+                logger.warning("pyautogui not available - cannot click buttons")
+                return False
+            
+            record_x, record_y = self.RECORD_BUTTON_POS
+            logger.debug(f"Clicking Record button at ({record_x}, {record_y})")
+            pyautogui.click(record_x, record_y)
+            return True
+        
+        except Exception as e:
+            logger.error(f"Error clicking Record button: {e}")
+            return False
+    
+    def _click_trigger_software_button(self) -> bool:
+        """
+        Click the Trigger Software button in the toolbar.
+        
+        Uses coordinates from TRIGGER_SOFTWARE_POS (can be calibrated).
+        
+        Returns:
+            bool: True if click was attempted, False otherwise
+        """
+        try:
+            if not PYAUTOGUI_AVAILABLE:
+                logger.warning("pyautogui not available - cannot click buttons")
+                return False
+            
+            trigger_x, trigger_y = self.TRIGGER_SOFTWARE_POS
+            logger.debug(f"Clicking Trigger Software button at ({trigger_x}, {trigger_y})")
+            pyautogui.click(trigger_x, trigger_y)
+            return True
+        
+        except Exception as e:
+            logger.error(f"Error clicking Trigger Software button: {e}")
+            return False
+    
+    def _click_stop_button(self) -> bool:
+        """
+        Click the Stop button (next to pause at bottom).
+        
+        Uses coordinates from STOP_BUTTON_POS (can be calibrated).
+        
+        Returns:
+            bool: True if click was attempted, False otherwise
+        """
+        try:
+            if not PYAUTOGUI_AVAILABLE:
+                logger.warning("pyautogui not available - cannot click buttons")
+                return False
+            
+            stop_x, stop_y = self.STOP_BUTTON_POS
+            logger.debug(f"Clicking Stop button at ({stop_x}, {stop_y})")
+            pyautogui.click(stop_x, stop_y)
+            return True
+        
+        except Exception as e:
+            logger.error(f"Error clicking Stop button: {e}")
+            return False
+    
+    def _wait_for_recording_file(self, timeout: int = None) -> Optional[Path]:
+        """
+        Wait for a TIFF file to be created in the 2nd Look Recordings directory.
+        
+        File pattern: Recording_YYYY-MM-DD_HH_MM_SS/TIFF/Camera/Image_XXXXXX.tif
+        
+        Args:
+            timeout: Maximum time to wait in seconds
+            
+        Returns:
+            Path: Path to created TIFF file, or None if timeout
+        """
+        timeout = timeout or self.FILE_WATCH_TIMEOUT
+        start_time = time.time()
+        
+        if not self.RECORDING_BASE_DIR.exists():
+            logger.error(f"Recording directory not found: {self.RECORDING_BASE_DIR}")
+            return None
+        
+        logger.debug(f"Monitoring for TIFF files in: {self.RECORDING_BASE_DIR}")
+        
+        while time.time() - start_time < timeout:
+            try:
+                # Search for TIFF files in subdirectories
+                # Pattern: Recording_*/TIFF/Camera/*.tif
+                tiff_files = list(self.RECORDING_BASE_DIR.glob("*/TIFF/Camera/*.tif"))
+                
+                if tiff_files:
+                    # Return the most recently created file
+                    newest_file = max(tiff_files, key=lambda p: p.stat().st_mtime)
+                    logger.info(f"Found TIFF file: {newest_file}")
+                    return newest_file
+                
+                time.sleep(0.5)
+            
+            except Exception as e:
+                logger.warning(f"Error monitoring recording directory: {e}")
+                time.sleep(1)
+        
+        logger.warning(f"No TIFF file created in {self.RECORDING_BASE_DIR} within {timeout} seconds")
+        return None
+    
+    def _copy_captured_file(self, source_file: Path, output_dir: Path) -> Optional[Path]:
+        """
+        Copy captured TIFF file to output directory.
+        
+        Args:
+            source_file: Path to source TIFF file
+            output_dir: Directory to copy file to
+            
+        Returns:
+            Path: Path to copied file, or None if copy failed
+        """
+        try:
+            if not source_file.exists():
+                logger.error(f"Source file not found: {source_file}")
+                return None
+            
+            # Create output filename with timestamp
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+            output_filename = f"2ndlook_capture_{timestamp}.tif"
+            output_file = output_dir / output_filename
+            
+            logger.debug(f"Copying {source_file} to {output_file}")
+            shutil.copy2(source_file, output_file)
+            
+            logger.info(f"File copied successfully: {output_file}")
+            return output_file
+        
+        except Exception as e:
+            logger.error(f"Error copying file: {e}")
+            return None
+    
+    def set_default_save_directory(self, directory: str) -> None:
+        """
+        Set the default save directory for 2nd Look exports.
+        
+        Args:
+            directory: Path to save directory
+        """
+        self.default_save_dir = Path(directory)
+        logger.info(f"Default save directory set to: {self.default_save_dir}")
+    
+    def cleanup(self) -> None:
+        """Clean up resources and temporary files."""
+        try:
+            if self.window:
+                try:
+                    # Try to close the app gracefully
+                    logger.debug("Closing 2nd Look window...")
+                    self.window.close()
+                except Exception as e:
+                    logger.debug(f"Could not close window: {e}")
+            
+            # Clean up old temp files (older than 1 hour)
+            if self.temp_dir.exists():
+                now = time.time()
+                for tiff_file in self.temp_dir.glob("*.tif*"):
+                    if now - tiff_file.stat().st_mtime > 3600:  # 1 hour
+                        try:
+                            tiff_file.unlink()
+                            logger.debug(f"Cleaned up old file: {tiff_file.name}")
+                        except Exception as e:
+                            logger.warning(f"Could not delete {tiff_file}: {e}")
+            
+            logger.info("Cleanup completed")
+        
+        except Exception as e:
+            logger.warning(f"Error during cleanup: {e}")
+
+
+class EOSUtilityAutomation(CameraAutomation):
+    """
+    Automation for EOS Utility DSLR camera application.
+    
+    Handles:
+    - Finding and connecting to EOS Utility window
+    - Clicking the capture button (X: 318, Y: 134)
+    - Monitoring for JPG file creation in Pictures\\EOS-Utility directory
+    - Copying captured file to output directory
+    
+    Pictures directory structure: C:\\Users\\[USERNAME]\\Pictures\\EOS-Utility\\[DATE]\\IMG_[SEQUENCE].JPG
+    Example: C:\\Users\\AIDurian\\Pictures\\EOS-Utility\\2025_12_04\\IMG_0001.JPG
+    """
+    
+    # Configuration constants
+    PICTURES_BASE_DIR = Path.home() / "Pictures" / "EOS-Utility"
+    CAPTURE_BUTTON_POS = (318, 134)  # Click position for capture button
+    FILE_WATCH_TIMEOUT = 10  # seconds to wait for file creation
+    
+    def __init__(self, default_save_dir: str = None):
+        """
+        Initialize EOS Utility automation.
+        
+        Args:
+            default_save_dir: Default directory where captures should be saved
+        """
+        super().__init__("EOS Utility", "EOS M50m2")
+        self.default_save_dir = Path(default_save_dir) if default_save_dir else None
+        self.last_image_path = None
+        self.temp_dir = Path(tempfile.gettempdir()) / "dudong_eos"
+        self.temp_dir.mkdir(exist_ok=True)
+        logger.info(f"EOS Utility temp directory: {self.temp_dir}")
+        logger.debug(f"Capture button coordinates: {self.CAPTURE_BUTTON_POS}")
+    
+    def find_window(self) -> bool:
+        """
+        Find and connect to EOS Utility window.
+        
+        Returns:
+            bool: True if found, False otherwise
+        """
+        if not PYWINAUTO_AVAILABLE:
+            logger.error("pywinauto not available - cannot find window")
+            return False
+        
+        try:
+            logger.info(f"Searching for '{self.window_title}' window...")
+            
+            # Try to find window by title
+            windows = findwindows.find_windows(title_re=f".*{self.window_title}.*")
+            
+            if windows:
+                logger.info(f"Found {len(windows)} window(s) matching '{self.window_title}'")
+                window_handle = windows[0]
+                
+                try:
+                    self.app = Application(backend="uia").connect(handle=window_handle)
+                    self.window = self.app.window(handle=window_handle)
+                    logger.info(f"Connected to EOS Utility (handle: {window_handle})")
+                    return True
+                except Exception as e:
+                    logger.warning(f"Could not connect via UIA backend: {e}")
+                    try:
+                        self.app = Application(backend="uia").connect(top_level_only=False)
+                        logger.info("Connected to application using fallback method")
+                        return True
+                    except Exception as e2:
+                        logger.warning(f"Fallback connection also failed: {e2}")
+                        return False
+            else:
+                logger.warning(f"No window found matching '{self.window_title}'")
+                return False
+        
+        except Exception as e:
+            logger.error(f"Error finding EOS Utility window: {e}")
+            return False
+    
+    def is_window_open(self) -> bool:
+        """
+        Check if EOS Utility is open and responsive.
+        
+        Returns:
+            bool: True if open and responsive, False otherwise
+        """
+        try:
+            if not PYWINAUTO_AVAILABLE:
+                logger.debug("pywinauto not available - cannot check window state")
+                return False
+            
+            windows = findwindows.find_windows(title_re=f".*{self.window_title}.*")
+            
+            if windows:
+                logger.debug("EOS Utility window is open")
+                return True
+            else:
+                logger.debug("EOS Utility window not found")
+                return False
+        
+        except Exception as e:
+            logger.warning(f"Error checking window state: {e}")
+            return False
+    
+    def capture(self, output_dir: str = None) -> Optional[str]:
+        """
+        Capture image from EOS Utility DSLR camera.
+        
+        Workflow:
+        1. Bring window to focus
+        2. Click capture button (X: 318, Y: 134)
+        3. Wait for JPG file in Pictures\\EOS-Utility\\[DATE] directory
+        4. Copy captured file to output directory
+        
+        Args:
+            output_dir: Directory to save JPG (uses temp dir if not specified)
+            
+        Returns:
+            str: Path to captured JPG file, or None if capture failed
+        """
+        output_dir = Path(output_dir) if output_dir else self.temp_dir
+        output_dir.mkdir(parents=True, exist_ok=True)
+        
+        try:
+            logger.info("Starting EOS Utility capture workflow...")
+            
+            # Step 1: Verify window is open
+            if not self.is_window_open():
+                logger.error("EOS Utility window is not open")
+                raise CameraAutomationError("EOS Utility application not found or not running")
+            
+            # Step 2: Reconnect to window if needed
+            if self.app is None or self.window is None:
+                if not self.find_window():
+                    raise CameraAutomationError("Could not connect to EOS Utility window")
+            
+            # Step 3: Bring window to focus
+            try:
+                logger.debug("Bringing EOS Utility to focus...")
+                self.window.set_focus()
+                time.sleep(0.5)
+            except Exception as e:
+                logger.warning(f"Could not set focus: {e}")
+            
+            # Step 4: Get the latest file timestamp before capture
+            latest_file_before = self._get_latest_capture_file()
+            logger.debug(f"Latest file before capture: {latest_file_before}")
+            
+            # Step 5: Click capture button
+            logger.info("Clicking capture button...")
+            self._click_capture_button()
+            time.sleep(1)
+            
+            # Step 6: Wait for new file creation
+            logger.info(f"Waiting for JPG file in {self.PICTURES_BASE_DIR}...")
+            captured_file = self._wait_for_capture_file(latest_file_before)
+            
+            if not captured_file:
+                logger.error("Timeout waiting for JPG file creation")
+                raise CameraAutomationError("Timeout waiting for file - capture may have failed")
+            
+            logger.info(f"File detected: {captured_file}")
+            
+            # Step 7: Copy captured file to output directory
+            logger.info(f"Copying file to output directory: {output_dir}")
+            output_file = self._copy_captured_file(captured_file, output_dir)
+            
+            if output_file:
+                logger.info(f"Successfully captured: {output_file}")
+                self.last_image_path = str(output_file)
+                self.last_capture_time = datetime.now()
+                return str(output_file)
+            else:
+                logger.error("Failed to copy captured file")
+                raise CameraAutomationError("Failed to copy captured file to output directory")
+        
+        except Exception as e:
+            logger.error(f"Capture failed: {e}")
+            return None
+    
+    def get_last_image(self) -> Optional[str]:
+        """
+        Get the path to the last captured image.
+        
+        Returns:
+            str: Path to last image if available, None otherwise
+        """
+        if self.last_image_path and Path(self.last_image_path).exists():
+            logger.debug(f"Returning last image: {self.last_image_path}")
+            return self.last_image_path
+        
+        logger.debug("No valid last image path found")
+        return None
+    
+    def set_button_coordinates(self, capture_pos: Tuple[int, int] = None) -> None:
+        """
+        Set capture button coordinates (for calibration).
+        
+        Args:
+            capture_pos: (x, y) coordinates for capture button
+        """
+        if capture_pos:
+            self.CAPTURE_BUTTON_POS = capture_pos
+            logger.info(f"Capture button coordinates set to {capture_pos}")
+    
+    def _click_capture_button(self) -> bool:
+        """
+        Click the capture button in EOS Utility.
+        
+        Uses coordinates from CAPTURE_BUTTON_POS (can be calibrated).
+        
+        Returns:
+            bool: True if click was attempted, False otherwise
+        """
+        try:
+            if not PYAUTOGUI_AVAILABLE:
+                logger.warning("pyautogui not available - cannot click buttons")
+                return False
+            
+            capture_x, capture_y = self.CAPTURE_BUTTON_POS
+            logger.debug(f"Clicking capture button at ({capture_x}, {capture_y})")
+            pyautogui.click(capture_x, capture_y)
+            return True
+        
+        except Exception as e:
+            logger.error(f"Error clicking capture button: {e}")
+            return False
+    
+    def _get_latest_capture_file(self) -> Optional[Path]:
+        """
+        Get the latest JPG file in the EOS Utility Pictures directory.
+        
+        Returns:
+            Path: Path to latest JPG file, or None if no files exist
+        """
+        try:
+            if not self.PICTURES_BASE_DIR.exists():
+                logger.debug(f"EOS Utility Pictures directory not found yet: {self.PICTURES_BASE_DIR}")
+                return None
+            
+            # Search for JPG files recursively
+            jpg_files = list(self.PICTURES_BASE_DIR.glob("**/IMG_*.JPG"))
+            
+            if jpg_files:
+                latest = max(jpg_files, key=lambda p: p.stat().st_mtime)
+                logger.debug(f"Found latest capture file: {latest}")
+                return latest
+            
+            logger.debug("No capture files found in Pictures directory")
+            return None
+        
+        except Exception as e:
+            logger.warning(f"Error getting latest capture file: {e}")
+            return None
+    
+    def _wait_for_capture_file(self, file_before: Optional[Path] = None, timeout: int = None) -> Optional[Path]:
+        """
+        Wait for a new JPG file to be created in the EOS Utility Pictures directory.
+        
+        File pattern: Pictures\\EOS-Utility\\[DATE]\\IMG_[SEQUENCE].JPG
+        
+        Args:
+            file_before: Previous file to compare against (detect new file)
+            timeout: Maximum time to wait in seconds
+            
+        Returns:
+            Path: Path to newly created JPG file, or None if timeout
+        """
+        timeout = timeout or self.FILE_WATCH_TIMEOUT
+        start_time = time.time()
+        
+        if not self.PICTURES_BASE_DIR.exists():
+            logger.debug(f"Creating Pictures directory: {self.PICTURES_BASE_DIR}")
+            return None
+        
+        logger.debug(f"Monitoring for JPG files in: {self.PICTURES_BASE_DIR}")
+        
+        while time.time() - start_time < timeout:
+            try:
+                # Search for JPG files
+                jpg_files = list(self.PICTURES_BASE_DIR.glob("**/IMG_*.JPG"))
+                
+                if jpg_files:
+                    # Get the most recently created file
+                    newest_file = max(jpg_files, key=lambda p: p.stat().st_mtime)
+                    
+                    # If we had a previous file, ensure this is a new one
+                    if file_before is None or newest_file != file_before:
+                        logger.info(f"Found new JPG file: {newest_file}")
+                        return newest_file
+                
+                time.sleep(0.5)
+            
+            except Exception as e:
+                logger.warning(f"Error monitoring Pictures directory: {e}")
+                time.sleep(1)
+        
+        logger.warning(f"No new JPG file created in {self.PICTURES_BASE_DIR} within {timeout} seconds")
+        return None
+    
+    def _copy_captured_file(self, source_file: Path, output_dir: Path) -> Optional[Path]:
+        """
+        Copy captured JPG file to output directory.
+        
+        Args:
+            source_file: Path to source JPG file
+            output_dir: Directory to copy file to
+            
+        Returns:
+            Path: Path to copied file, or None if copy failed
+        """
+        try:
+            if not source_file.exists():
+                logger.error(f"Source file not found: {source_file}")
+                return None
+            
+            # Create output filename with timestamp
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+            output_filename = f"eos_capture_{timestamp}.jpg"
+            output_file = output_dir / output_filename
+            
+            logger.debug(f"Copying {source_file} to {output_file}")
+            shutil.copy2(source_file, output_file)
+            
+            logger.info(f"File copied successfully: {output_file}")
+            return output_file
+        
+        except Exception as e:
+            logger.error(f"Error copying file: {e}")
+            return None
+    
+    def set_default_save_directory(self, directory: str) -> None:
+        """
+        Set the default save directory for EOS Utility exports.
+        
+        Args:
+            directory: Path to save directory
+        """
+        self.default_save_dir = Path(directory)
+        logger.info(f"Default save directory set to: {self.default_save_dir}")
+    
+    def cleanup(self) -> None:
+        """Clean up resources and temporary files."""
+        try:
+            if self.window:
+                try:
+                    logger.debug("Closing EOS Utility window...")
+                    self.window.close()
+                except Exception as e:
+                    logger.debug(f"Could not close window: {e}")
+            
+            # Clean up old temp files (older than 1 hour)
+            if self.temp_dir.exists():
+                now = time.time()
+                for jpg_file in self.temp_dir.glob("*.jpg"):
+                    if now - jpg_file.stat().st_mtime > 3600:  # 1 hour
+                        try:
+                            jpg_file.unlink()
+                            logger.debug(f"Cleaned up old file: {jpg_file.name}")
+                        except Exception as e:
+                            logger.warning(f"Could not delete {jpg_file}: {e}")
+            
+            logger.info("Cleanup completed")
+        
+        except Exception as e:
+            logger.warning(f"Error during cleanup: {e}")
+
+
+class AnalyzIRAutomation(CameraAutomation):
+    """
+    Automation for AnalyzIR thermal camera application (FOTRIC 323).
+    
+    Handles:
+    - Finding and connecting to IR Camera and AnalyzIR Venus windows
+    - Taking snapshot in IR Camera window
+    - Performing automated click sequence to export thermal data
+    - Monitoring for CSV file creation in Pictures\\AnalyzIR directory
+    - Copying captured CSV file to output directory
+    
+    Workflow:
+    1. Check IR Camera window (SN:0803009169)
+    2. Left-click (X: 2515, Y: 898) to take snapshot in IR Camera
+    3. Switch to AnalyzIR Venus window
+    4. Right-click (X: 567, Y: 311) on latest data (from snapshot)
+    5. Left-click menu option (X: 694, Y: 450)
+    6. Left-click (X: 2370, Y: 109) - export/menu option
+    7. Left-click (X: 963, Y: 396) - confirmation/export
+    8. Left-click (X: 1713, Y: 1296) - button
+    9. Left-click (X: 1424, Y: 896) - save location
+    10. Left-click (X: 2522, Y: 26) - confirm
+    11. Left-click (X: 1497, Y: 892) - close/finalize
+    
+    File structure: C:\\Users\\[USERNAME]\\Pictures\\AnalyzIR\\YYYY-MM-DD_HHMMSS_CSV\\YYYY-MM-DD_HHMMSS.csv
+    Example: C:\\Users\\AIDurian\\Pictures\\AnalyzIR\\2025-12-04_155903_CSV\\2025-12-04_155903.csv
+    """
+    
+    # Configuration constants
+    PICTURES_BASE_DIR = Path.home() / "Pictures" / "AnalyzIR"
+    FILE_WATCH_TIMEOUT = 20  # seconds to wait for file creation
+    CLICK_DELAY = 0.3  # delay between clicks
+    
+    # IR Camera window detection
+    IR_CAMERA_WINDOW_TITLE = "IR Camera(SN:0803009169)"
+    ANALYZIR_VENUS_WINDOW_TITLE = "AnalyzIR Venus"
+    
+    # Click coordinates for IR Camera snapshot
+    IR_CAMERA_SNAPSHOT_POS = (2515, 898)  # Take snapshot button in IR Camera window
+    
+    # AnalyzIR Venus click sequence
+    ANALYZIR_VENUS_CLICKS = [
+        (567, 311, "right"),    # Right-click on latest data
+        (694, 450, "left"),     # Click menu option
+        (2370, 109, "left"),    # Export/menu option
+        (963, 396, "left"),     # Confirmation/export button
+        (1713, 1296, "left"),   # Button
+        (1424, 896, "left"),    # Save location
+        (2522, 26, "left"),     # Confirm
+        (1497, 892, "left"),    # Close/finalize
+    ]
+    
+    def __init__(self, default_save_dir: str = None):
+        """
+        Initialize AnalyzIR automation.
+        
+        Args:
+            default_save_dir: Default directory where captures should be saved
+        """
+        super().__init__("AnalyzIR", self.ANALYZIR_VENUS_WINDOW_TITLE)
+        self.default_save_dir = Path(default_save_dir) if default_save_dir else None
+        self.last_csv_path = None
+        self.temp_dir = Path(tempfile.gettempdir()) / "dudong_analyzir"
+        self.temp_dir.mkdir(exist_ok=True)
+        self.ir_camera_window = None
+        logger.info(f"AnalyzIR temp directory: {self.temp_dir}")
+        logger.debug(f"IR Camera window title: {self.IR_CAMERA_WINDOW_TITLE}")
+        logger.debug(f"AnalyzIR Venus window title: {self.ANALYZIR_VENUS_WINDOW_TITLE}")
+    
+    def find_window(self) -> bool:
+        """
+        Find and connect to AnalyzIR Venus window.
+        
+        Returns:
+            bool: True if found, False otherwise
+        """
+        if not PYWINAUTO_AVAILABLE:
+            logger.error("pywinauto not available - cannot find window")
+            return False
+        
+        try:
+            logger.info(f"Searching for '{self.ANALYZIR_VENUS_WINDOW_TITLE}' window...")
+            
+            # Try to find AnalyzIR Venus window with multiple patterns
+            windows = []
+            try:
+                # Try exact match first
+                windows = findwindows.find_windows(title_re=f".*{self.ANALYZIR_VENUS_WINDOW_TITLE}.*")
+                if not windows:
+                    # Try partial match
+                    windows = findwindows.find_windows(title_re=".*AnalyzIR.*")
+                    logger.debug("Using partial 'AnalyzIR' pattern match")
+            except Exception as e:
+                logger.warning(f"Error searching for windows: {e}")
+            
+            if windows:
+                logger.info(f"Found {len(windows)} window(s) matching search criteria")
+                
+                # Find the best match - prioritize exact title match
+                window_handle = None
+                for i, win in enumerate(windows):
+                    try:
+                        app_temp = Application(backend="uia").connect(handle=win, timeout=2)
+                        window_temp = app_temp.window(handle=win)
+                        title = window_temp.window_text() if hasattr(window_temp, 'window_text') else "Unknown"
+                        logger.debug(f"  Window {i}: Handle={win}, Title='{title}'")
+                        
+                        # Prioritize "AnalyzIR Venus" without extra suffixes/paths
+                        if "AnalyzIR Venus" in title and "File" not in title:
+                            window_handle = win
+                            logger.debug(f"  -> Selected as best match (exact title)")
+                            break
+                        elif window_handle is None:
+                            # Keep as fallback if no exact match found
+                            window_handle = win
+                    except Exception as e:
+                        logger.debug(f"  Window {i}: Handle={win}, (could not get title: {e})")
+                        if window_handle is None:
+                            window_handle = win  # Use as fallback
+                
+                if window_handle is None:
+                    window_handle = windows[0]  # Final fallback
+                
+                try:
+                    self.app = Application(backend="uia").connect(handle=window_handle)
+                    self.window = self.app.window(handle=window_handle)
+                    logger.info(f"Connected to AnalyzIR Venus (handle: {window_handle})")
+                except Exception as e:
+                    logger.warning(f"Could not connect via UIA backend: {e}")
+                    try:
+                        self.app = Application(backend="uia").connect(top_level_only=False)
+                        logger.info("Connected to application using fallback method")
+                    except Exception as e2:
+                        logger.warning(f"Fallback connection also failed: {e2}")
+                        return False
+                
+                # Also try to find and store IR Camera window with multiple patterns
+                logger.debug(f"Searching for IR Camera window...")
+                ir_windows = []
+                try:
+                    # Try exact match
+                    ir_windows = findwindows.find_windows(title_re=f".*{self.IR_CAMERA_WINDOW_TITLE}.*")
+                    if not ir_windows:
+                        # Try partial match - just "IR Camera"
+                        ir_windows = findwindows.find_windows(title_re=".*IR Camera.*")
+                        logger.debug("Using partial 'IR Camera' pattern match")
+                except Exception as e:
+                    logger.debug(f"Error searching for IR Camera window: {e}")
+                
+                if ir_windows:
+                    self.ir_camera_window = ir_windows[0]
+                    try:
+                        app_temp = Application(backend="uia").connect(handle=self.ir_camera_window, timeout=2)
+                        window_temp = app_temp.window(handle=self.ir_camera_window)
+                        title = window_temp.window_text() if hasattr(window_temp, 'window_text') else "Unknown"
+                        logger.info(f"Found IR Camera window (handle: {self.ir_camera_window}, title: '{title}')")
+                    except Exception as e:
+                        logger.info(f"Found IR Camera window (handle: {self.ir_camera_window})")
+                else:
+                    logger.debug("IR Camera window not found (it may not be open yet)")
+                
+                return True
+            else:
+                logger.warning(f"No window found matching AnalyzIR criteria")
+                logger.debug("Make sure AnalyzIR Venus is open and visible")
+                return False
+        
+        except Exception as e:
+            logger.error(f"Error finding AnalyzIR window: {e}")
+            import traceback
+            logger.debug(traceback.format_exc())
+            return False
+    
+    def is_window_open(self) -> bool:
+        """
+        Check if AnalyzIR Venus window is open and responsive.
+        
+        Returns:
+            bool: True if open and responsive, False otherwise
+        """
+        try:
+            if not PYWINAUTO_AVAILABLE:
+                logger.debug("pywinauto not available - cannot check window state")
+                return False
+            
+            windows = findwindows.find_windows(title_re=f".*{self.ANALYZIR_VENUS_WINDOW_TITLE}.*")
+            
+            if windows:
+                logger.debug("AnalyzIR Venus window is open")
+                return True
+            else:
+                logger.debug("AnalyzIR Venus window not found")
+                return False
+        
+        except Exception as e:
+            logger.warning(f"Error checking window state: {e}")
+            return False
+    
+    def capture(self, output_dir: str = None) -> Optional[str]:
+        """
+        Capture thermal data from AnalyzIR and export as CSV.
+        
+        Workflow:
+        1. Find and focus IR Camera window
+        2. Click close button in IR Camera window
+        3. Find and focus AnalyzIR Venus window
+        4. Perform automated click sequence to export data
+        5. Wait for CSV file creation in Pictures\\AnalyzIR directory
+        6. Copy captured CSV file to output directory
+        
+        Args:
+            output_dir: Directory to save CSV (uses temp dir if not specified)
+            
+        Returns:
+            str: Path to captured CSV file, or None if capture failed
+        """
+        output_dir = Path(output_dir) if output_dir else self.temp_dir
+        output_dir.mkdir(parents=True, exist_ok=True)
+        
+        try:
+            logger.info("Starting AnalyzIR capture workflow...")
+            
+            # Step 1: Verify windows are open
+            if not self.is_window_open():
+                logger.error("AnalyzIR Venus window is not open")
+                raise CameraAutomationError("AnalyzIR Venus application not found or not running")
+            
+            # Step 2: Reconnect to windows if needed
+            if self.app is None or self.window is None:
+                if not self.find_window():
+                    raise CameraAutomationError("Could not connect to AnalyzIR window")
+            
+            # Step 3: Take snapshot in IR Camera window
+            self._take_ir_snapshot()
+            time.sleep(2)  # Wait for snapshot to be processed and data to reach AnalyzIR
+            
+            # Step 4: Bring AnalyzIR Venus to focus
+            try:
+                logger.debug("Bringing AnalyzIR Venus to focus...")
+                self.window.set_focus()
+                time.sleep(0.5)
+                
+                # Move mouse to the AnalyzIR window to ensure it's active
+                if PYAUTOGUI_AVAILABLE:
+                    # Move to center of expected window area
+                    pyautogui.moveTo(1000, 400)
+                    time.sleep(0.2)
+            except Exception as e:
+                logger.warning(f"Could not set focus: {e}")
+            
+            # Step 5: Get the latest file timestamp before capture
+            latest_file_before = self._get_latest_csv_file()
+            logger.debug(f"Latest CSV file before capture: {latest_file_before}")
+            
+            # Step 6: Perform automated click sequence to export data
+            logger.info("Performing automated click sequence to export thermal data...")
+            self._perform_export_sequence()
+            
+            # Step 7: Wait for new CSV file creation
+            logger.info(f"Waiting for CSV file in {self.PICTURES_BASE_DIR}...")
+            captured_file = self._wait_for_csv_file(latest_file_before)
+            
+            if not captured_file:
+                logger.error("Timeout waiting for CSV file creation")
+                raise CameraAutomationError("Timeout waiting for CSV file - capture may have failed")
+            
+            logger.info(f"CSV file detected: {captured_file}")
+            
+            # Step 8: Copy captured file to output directory
+            logger.info(f"Copying file to output directory: {output_dir}")
+            output_file = self._copy_captured_file(captured_file, output_dir)
+            
+            if output_file:
+                logger.info(f"Successfully captured: {output_file}")
+                self.last_csv_path = str(output_file)
+                self.last_capture_time = datetime.now()
+                return str(output_file)
+            else:
+                logger.error("Failed to copy captured file")
+                raise CameraAutomationError("Failed to copy captured file to output directory")
+        
+        except Exception as e:
+            logger.error(f"Capture failed: {e}")
+            return None
+    
+    def get_last_image(self) -> Optional[str]:
+        """
+        Get the path to the last captured CSV file.
+        
+        Returns:
+            str: Path to last CSV file if available, None otherwise
+        """
+        if self.last_csv_path and Path(self.last_csv_path).exists():
+            logger.debug(f"Returning last CSV: {self.last_csv_path}")
+            return self.last_csv_path
+        
+        logger.debug("No valid last CSV path found")
+        return None
+    
+    def _take_ir_snapshot(self) -> bool:
+        """
+        Take a snapshot in the IR Camera window.
+        
+        Steps:
+        1. Focus/activate IR Camera window
+        2. Click the snapshot button at coordinates (2515, 898)
+        3. Wait for snapshot to be processed
+        
+        Returns:
+            bool: True if click was attempted, False otherwise
+        """
+        try:
+            if not PYAUTOGUI_AVAILABLE:
+                logger.warning("pyautogui not available - cannot click buttons")
+                return False
+            
+            # Step 1: Try to focus IR Camera window if found
+            if self.ir_camera_window:
+                try:
+                    logger.debug(f"Focusing IR Camera window (handle: {self.ir_camera_window})...")
+                    ir_app = Application(backend="uia").connect(handle=self.ir_camera_window, timeout=2)
+                    ir_window = ir_app.window(handle=self.ir_camera_window)
+                    
+                    # Bring window to foreground and focus
+                    ir_window.set_focus()
+                    time.sleep(0.3)
+                    
+                    logger.info("IR Camera window focused")
+                except Exception as e:
+                    logger.debug(f"Could not focus IR Camera window: {e}")
+                    # Continue anyway - click might still work
+            else:
+                logger.debug("IR Camera window handle not available - clicking anyway")
+            
+            # Step 2: Click snapshot button
+            snapshot_x, snapshot_y = self.IR_CAMERA_SNAPSHOT_POS
+            logger.debug(f"Clicking IR Camera snapshot button at ({snapshot_x}, {snapshot_y})")
+            pyautogui.click(snapshot_x, snapshot_y)
+            time.sleep(0.5)  # Increased wait
+            
+            logger.info("Snapshot taken in IR Camera")
+            return True
+        
+        except Exception as e:
+            logger.warning(f"Could not take IR Camera snapshot (non-critical): {e}")
+            return False
+    
+    def _perform_export_sequence(self) -> bool:
+        """
+        Perform the automated click sequence to export thermal data from AnalyzIR Venus.
+        
+        Sequence:
+        1. Right-click at (567, 311) on latest snapshot data
+        2. Left-click menu option at (694, 450)
+        3. Left-click export/menu at (2370, 109)
+        4. Left-click confirmation at (963, 396)
+        5. Left-click button at (1713, 1296)
+        6. Left-click save location at (1424, 896)
+        7. Left-click confirm at (2522, 26)
+        8. Left-click close/finalize at (1497, 892)
+        
+        Returns:
+            bool: True if sequence completed, False otherwise
+        """
+        try:
+            if not PYAUTOGUI_AVAILABLE:
+                logger.warning("pyautogui not available - cannot perform click sequence")
+                return False
+            
+            for i, (x, y, click_type) in enumerate(self.ANALYZIR_VENUS_CLICKS, 1):
+                logger.debug(f"Click {i}/{len(self.ANALYZIR_VENUS_CLICKS)}: {click_type}-click at ({x}, {y})")
+                
+                if click_type == "right":
+                    pyautogui.click(x, y, button='right')
+                else:  # left
+                    pyautogui.click(x, y)
+                
+                time.sleep(self.CLICK_DELAY)
+            
+            logger.info("Export sequence completed successfully")
+            return True
+        
+        except Exception as e:
+            logger.error(f"Error performing export sequence: {e}")
+            return False
+    
+    def _get_latest_csv_file(self) -> Optional[Path]:
+        """
+        Get the latest CSV file in the AnalyzIR Pictures directory.
+        
+        Returns:
+            Path: Path to latest CSV file, or None if no files exist
+        """
+        try:
+            if not self.PICTURES_BASE_DIR.exists():
+                logger.debug(f"AnalyzIR Pictures directory not found yet: {self.PICTURES_BASE_DIR}")
+                return None
+            
+            # Search for CSV files in subdirectories (pattern: YYYY-MM-DD_HHMMSS_CSV/*.csv)
+            csv_files = list(self.PICTURES_BASE_DIR.glob("*_CSV/*.csv"))
+            
+            if csv_files:
+                latest = max(csv_files, key=lambda p: p.stat().st_mtime)
+                logger.debug(f"Found latest CSV file: {latest}")
+                return latest
+            
+            logger.debug("No CSV files found in Pictures directory")
+            return None
+        
+        except Exception as e:
+            logger.warning(f"Error getting latest CSV file: {e}")
+            return None
+    
+    def _wait_for_csv_file(self, file_before: Optional[Path] = None, timeout: int = None) -> Optional[Path]:
+        """
+        Wait for a new CSV file to be created in the AnalyzIR Pictures directory.
+        
+        File pattern: Pictures\\AnalyzIR\\YYYY-MM-DD_HHMMSS_CSV\\YYYY-MM-DD_HHMMSS.csv
+        
+        Args:
+            file_before: Previous file to compare against (detect new file)
+            timeout: Maximum time to wait in seconds
+            
+        Returns:
+            Path: Path to newly created CSV file, or None if timeout
+        """
+        timeout = timeout or self.FILE_WATCH_TIMEOUT
+        start_time = time.time()
+        
+        if not self.PICTURES_BASE_DIR.exists():
+            logger.debug(f"Creating Pictures/AnalyzIR directory: {self.PICTURES_BASE_DIR}")
+            self.PICTURES_BASE_DIR.mkdir(parents=True, exist_ok=True)
+        
+        logger.debug(f"Monitoring for CSV files in: {self.PICTURES_BASE_DIR}")
+        
+        while time.time() - start_time < timeout:
+            try:
+                # Search for CSV files in subdirectories
+                csv_files = list(self.PICTURES_BASE_DIR.glob("*_CSV/*.csv"))
+                
+                if csv_files:
+                    # Get the most recently created file
+                    newest_file = max(csv_files, key=lambda p: p.stat().st_mtime)
+                    
+                    # If we had a previous file, ensure this is a new one
+                    if file_before is None or newest_file != file_before:
+                        logger.info(f"Found new CSV file: {newest_file}")
+                        return newest_file
+                
+                time.sleep(0.5)
+            
+            except Exception as e:
+                logger.warning(f"Error monitoring AnalyzIR directory: {e}")
+                time.sleep(1)
+        
+        logger.warning(f"No new CSV file created in {self.PICTURES_BASE_DIR} within {timeout} seconds")
+        return None
+    
+    def _copy_captured_file(self, source_file: Path, output_dir: Path) -> Optional[Path]:
+        """
+        Copy captured CSV file to output directory.
+        
+        Args:
+            source_file: Path to source CSV file
+            output_dir: Directory to copy file to
+            
+        Returns:
+            Path: Path to copied file, or None if copy failed
+        """
+        try:
+            if not source_file.exists():
+                logger.error(f"Source file not found: {source_file}")
+                return None
+            
+            # Create output filename with timestamp
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+            output_filename = f"analyzir_thermal_{timestamp}.csv"
+            output_file = output_dir / output_filename
+            
+            logger.debug(f"Copying {source_file} to {output_file}")
+            shutil.copy2(source_file, output_file)
+            
+            logger.info(f"File copied successfully: {output_file}")
+            return output_file
+        
+        except Exception as e:
+            logger.error(f"Error copying file: {e}")
+            return None
+    
+    def set_default_save_directory(self, directory: str) -> None:
+        """
+        Set the default save directory for AnalyzIR exports.
+        
+        Args:
+            directory: Path to save directory
+        """
+        self.default_save_dir = Path(directory)
+        logger.info(f"Default save directory set to: {self.default_save_dir}")
+    
+    def cleanup(self) -> None:
+        """Clean up resources and temporary files."""
+        try:
+            if self.window:
+                try:
+                    logger.debug("Closing AnalyzIR window...")
+                    self.window.close()
+                except Exception as e:
+                    logger.debug(f"Could not close window: {e}")
+            
+            # Clean up old temp files (older than 1 hour)
+            if self.temp_dir.exists():
+                now = time.time()
+                for csv_file in self.temp_dir.glob("*.csv"):
+                    if now - csv_file.stat().st_mtime > 3600:  # 1 hour
+                        try:
+                            csv_file.unlink()
+                            logger.debug(f"Cleaned up old file: {csv_file.name}")
+                        except Exception as e:
+                            logger.warning(f"Could not delete {csv_file}: {e}")
+            
+            logger.info("Cleanup completed")
+        
+        except Exception as e:
+            logger.warning(f"Error during cleanup: {e}")
+
+
+# Debug utility function
+def debug_analyzir_windows():
+    """
+    Debug function to find and display all open windows that match AnalyzIR patterns.
+    Useful for troubleshooting window detection issues.
+    """
+    if not PYWINAUTO_AVAILABLE:
+        print("pywinauto not available")
+        return
+    
+    print("\n" + "=" * 70)
+    print("AnalyzIR Window Detection Debug")
+    print("=" * 70)
+    
+    try:
+        # Find all windows
+        print("\nSearching for AnalyzIR-related windows...")
+        
+        patterns = [
+            ("AnalyzIR Venus", ".*AnalyzIR Venus.*"),
+            ("AnalyzIR (any)", ".*AnalyzIR.*"),
+            ("IR Camera (exact)", ".*IR Camera\\(SN:0803009169\\).*"),
+            ("IR Camera (partial)", ".*IR Camera.*"),
+        ]
+        
+        for pattern_name, pattern in patterns:
+            print(f"\nPattern: {pattern_name}")
+            print(f"Regex: {pattern}")
+            try:
+                windows = findwindows.find_windows(title_re=pattern)
+                if windows:
+                    print(f"  Found {len(windows)} window(s):")
+                    for i, win_handle in enumerate(windows):
+                        try:
+                            app = Application(backend="uia").connect(handle=win_handle, timeout=1)
+                            window = app.window(handle=win_handle)
+                            title = window.window_text() if hasattr(window, 'window_text') else "Unknown"
+                            print(f"    {i+1}. Handle: {win_handle}, Title: '{title}'")
+                        except Exception as e:
+                            print(f"    {i+1}. Handle: {win_handle}, (could not get title)")
+                else:
+                    print("  No windows found")
+            except Exception as e:
+                print(f"  Error: {e}")
+        
+        print("\n" + "=" * 70)
+    
+    except Exception as e:
+        print(f"Error during debug: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+# Factory function for creating camera automation instances
+def create_camera_automation(camera_type: str, **kwargs) -> Optional[CameraAutomation]:
+    """
+    Factory function to create camera automation instances.
+    
+    Args:
+        camera_type: Type of camera ("2nd_look", "eos_utility", "analyzir")
+        **kwargs: Additional arguments passed to the automation class
+        
+    Returns:
+        CameraAutomation: Automation instance, or None if type not recognized
+    """
+    camera_type = camera_type.lower().strip()
+    
+    if camera_type in ("2nd_look", "2ndlook", "multispectral"):
+        return SecondLookAutomation(**kwargs)
+    elif camera_type in ("eos_utility", "eosutility", "dslr"):
+        return EOSUtilityAutomation(**kwargs)
+    elif camera_type in ("analyzir", "thermal", "fotric"):
+        return AnalyzIRAutomation(**kwargs)
+    else:
+        logger.error(f"Unknown camera type: {camera_type}")
+        return None
+

+ 251 - 0
utils/config.py

@@ -0,0 +1,251 @@
+"""
+Configuration Module
+
+Centralized configuration for paths, constants, and settings.
+"""
+
+import os
+from pathlib import Path
+from typing import Dict, Tuple
+
+# ==================== PATHS ====================
+
+# Base paths
+PROJECT_ROOT = Path(__file__).parent.parent  # Points to dudong-v2/
+
+# Model paths
+MODELS_DIR = PROJECT_ROOT / "model_files"
+# Use the directory containing all the model files
+AUDIO_MODEL_PATH = PROJECT_ROOT / "model_files" / "audio"
+DEFECT_MODEL_PATH = PROJECT_ROOT / "model_files" / "best.pt"
+LOCULE_MODEL_PATH = PROJECT_ROOT / "model_files" / "locule.pt"
+MATURITY_MODEL_PATH = PROJECT_ROOT / "model_files" / "multispectral" / "maturity" / "final_model.pt"
+
+# Image paths
+IMAGES_DIR = PROJECT_ROOT / "assets" / "logos"
+
+# Test data paths (optional - can be removed if not needed)
+UNSEEN_DIR = PROJECT_ROOT / "unseen"
+UNSEEN_AUDIO_UNRIPE = UNSEEN_DIR / "unripe"
+UNSEEN_AUDIO_MIDRIPE = UNSEEN_DIR / "midripe"
+UNSEEN_QUALITY = UNSEEN_DIR / "quality"
+
+# Data storage paths
+DATA_DIR = PROJECT_ROOT / "data"
+DATABASE_PATH = DATA_DIR / "database.db"
+ANALYSES_DIR = DATA_DIR / "analyses"
+
+# ==================== MODEL SETTINGS ====================
+
+# Device configuration
+DEVICE_PRIORITY = ["cuda", "cpu"]  # Try CUDA first, fallback to CPU
+
+# Model versions (for display)
+MODEL_VERSIONS = {
+    "ripeness": "",
+    "quality": "",
+    "defect": "",
+    "maturity": "",
+}
+
+# Audio model settings
+AUDIO_SAMPLE_RATE = 16000
+AUDIO_DESIRED_SAMPLES = 16000
+AUDIO_FRAME_LENGTH = 255
+AUDIO_FRAME_STEP = 128
+
+# YOLO model settings
+YOLO_CONFIDENCE_THRESHOLD = 0.2
+YOLO_IMAGE_SIZE = 640
+
+# Maturity model settings
+MATURITY_MASK_BAND_INDEX = 4  # Band index for masking (860nm)
+MATURITY_IMG_SIZE = 256  # Target image size after preprocessing
+MATURITY_IMG_PAD = 8  # Padding for cropping
+
+# ==================== CLASS DEFINITIONS ====================
+
+# Ripeness classes (matching the three-class model from notebook)
+RIPENESS_CLASSES = ["unripe", "ripe", "overripe"]
+
+# Defect detection classes and colors (BGR format for OpenCV)
+DEFECT_CLASS_COLORS: Dict[int, Tuple[int, int, int]] = {
+    0: (255, 34, 134),   # Minor defects - Pink/Magenta
+    1: (0, 252, 199),    # No defects - Cyan/Turquoise
+    2: (86, 0, 254),     # Reject - Purple
+}
+
+DEFECT_CLASS_NAMES = {
+    0: "Minor Defects",
+    1: "No Defects",
+    2: "Reject",
+}
+
+# Locule segmentation colors (BGR format) - ROYGBIV
+LOCULE_COLORS: list[Tuple[int, int, int]] = [
+    (0, 0, 255),      # Red
+    (0, 165, 255),    # Orange
+    (0, 255, 255),    # Yellow
+    (0, 255, 0),      # Green
+    (255, 0, 0),      # Blue
+    (130, 0, 75),     # Indigo
+    (211, 0, 148),    # Violet
+]
+
+# ==================== UI SETTINGS ====================
+
+# Window settings
+WINDOW_TITLE = "DuDONG Grading System"
+WINDOW_WIDTH = 1920
+WINDOW_HEIGHT = 1080
+DEVICE_ID = "MAIN-001"
+
+# UI Colors (for PyQt styling)
+UI_COLORS = {
+    # Primary colors
+    "primary_dark": "#2c3e50",
+    "primary_light": "#34495e",
+    "accent_blue": "#3498db",
+    "accent_green": "#27ae60",
+    
+    # Status colors
+    "online": "#27ae60",
+    "offline": "#e74c3c",
+    "updating": "#f39c12",
+    
+    # Background colors
+    "bg_light": "#f8f9fa",
+    "bg_white": "#ffffff",
+    "bg_panel": "#ecf0f1",
+    
+    # Text colors
+    "text_dark": "#2c3e50",
+    "text_medium": "#7f8c8d",
+    "text_light": "#bdc3c7",
+    
+    # Button colors
+    "btn_green": "#27ae60",
+    "btn_green_hover": "#229954",
+    "btn_blue": "#3498db",
+    "btn_blue_hover": "#2980b9",
+    "btn_orange": "#f39c12",
+    "btn_orange_hover": "#e67e22",
+    "btn_purple": "#9b59b6",
+    "btn_purple_hover": "#8e44ad",
+    "btn_red": "#e74c3c",
+    "btn_red_hover": "#c0392b",
+    
+    # Grade colors
+    "grade_a": "#27ae60",
+    "grade_b": "#3498db",
+    "grade_c": "#e74c3c",
+}
+
+# Status indicator sizes
+STATUS_INDICATOR_SIZE = 12
+STATUS_INDICATOR_RADIUS = 6
+
+# Table settings
+TABLE_ROW_HEIGHT = 45
+TABLE_RECENT_RESULTS_COUNT = 5
+TABLE_MAX_RESULTS_MEMORY = 100
+
+# Feed display sizes
+FEED_MIN_WIDTH = 150
+FEED_MIN_HEIGHT = 110
+
+# Image display sizes
+RESULT_IMAGE_WIDTH = 621
+RESULT_IMAGE_HEIGHT = 441
+
+# ==================== THREADING SETTINGS ====================
+
+# Thread pool settings
+MAX_THREAD_COUNT = None  # None = use default (CPU count)
+
+# ==================== PERFORMANCE SETTINGS ====================
+
+# Spectrogram figure size
+SPECTROGRAM_FIG_SIZE = (8, 1.9)
+SPECTROGRAM_DPI = 100
+
+# ==================== LOGGING SETTINGS ====================
+
+# Log level
+LOG_LEVEL = "INFO"
+LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+
+# ==================== FILE DIALOG FILTERS ====================
+
+FILE_FILTERS = {
+    "audio": "Audio Files (*.wav *.mp3 *.flac *.ogg *.m4a *.aac *.wma);;WAV Files (*.wav);;MP3 Files (*.mp3);;FLAC Files (*.flac);;OGG Files (*.ogg);;M4A Files (*.m4a);;AAC Files (*.aac);;WMA Files (*.wma);;All Files (*.*)",
+    "image": "Image Files (*.jpg *.jpeg *.png *.JPG *.JPEG *.PNG)",
+    "tiff": "TIFF Files (*.tif *.tiff *.TIF *.TIFF);;All Files (*.*)",
+    "all_media": "All Files (*.*)",
+}
+
+# Default directories for file dialogs
+DEFAULT_DIRS = {
+    "audio": str(UNSEEN_DIR),
+    "image": str(PROJECT_ROOT),
+}
+
+# ==================== HELPER FUNCTIONS ====================
+
+def get_device() -> str:
+    """
+    Get the best available device for model inference.
+    
+    Returns:
+        str: Device name ('cuda' or 'cpu')
+    """
+    import torch
+    
+    for device in DEVICE_PRIORITY:
+        if device == "cuda" and torch.cuda.is_available():
+            return "cuda"
+    return "cpu"
+
+
+def get_gpu_info() -> str:
+    """
+    Get GPU information for display.
+    
+    Returns:
+        str: GPU information string
+    """
+    import torch
+    
+    if torch.cuda.is_available():
+        gpu_name = torch.cuda.get_device_name(0)
+        return f"GPU: {gpu_name}"
+    return "GPU: Not Available (Using CPU)"
+
+
+def ensure_paths_exist() -> None:
+    """Create necessary directories if they don't exist."""
+    # Create necessary directories
+    for directory in [IMAGES_DIR, UNSEEN_DIR, DATA_DIR, ANALYSES_DIR]:
+        directory.mkdir(parents=True, exist_ok=True)
+
+
+def validate_model_paths() -> Dict[str, bool]:
+    """
+    Check if all required model files exist.
+    
+    Returns:
+        Dict[str, bool]: Dictionary of model availability
+    """
+    return {
+        "audio": AUDIO_MODEL_PATH.exists(),
+        "defect": DEFECT_MODEL_PATH.exists(),
+        "locule": LOCULE_MODEL_PATH.exists(),
+        "maturity": MATURITY_MODEL_PATH.exists(),
+    }
+
+
+# ==================== INITIALIZATION ====================
+
+# Ensure paths exist when module is imported
+ensure_paths_exist()
+

+ 69 - 0
utils/data_export.py

@@ -0,0 +1,69 @@
+"""
+Data Export Utilities
+
+Functions for exporting test results and session data (placeholder for future implementation).
+"""
+
+import csv
+from typing import List, Dict
+from datetime import datetime
+
+
+def export_session_to_csv(results: List[Dict], file_path: str) -> bool:
+    """
+    Export session results to CSV file.
+    
+    Args:
+        results: List of result dictionaries
+        file_path: Path to save CSV file
+        
+    Returns:
+        bool: True if successful, False otherwise
+        
+    Note:
+        This is a placeholder implementation for future development.
+    """
+    # TODO: Implement CSV export functionality
+    print(f"[COMING SOON] Export to CSV: {file_path}")
+    return False
+
+
+def export_complete_package(result: Dict, output_dir: str) -> bool:
+    """
+    Export complete analysis package including audio, images, and results.
+    
+    Args:
+        result: Result dictionary
+        output_dir: Directory to save package
+        
+    Returns:
+        bool: True if successful, False otherwise
+        
+    Note:
+        This is a placeholder implementation for future development.
+    """
+    # TODO: Implement complete package export
+    print(f"[COMING SOON] Export complete package to: {output_dir}")
+    return False
+
+
+def save_audio_with_metadata(audio_path: str, result: Dict, output_path: str) -> bool:
+    """
+    Save audio file with metadata.
+    
+    Args:
+        audio_path: Path to source audio file
+        result: Result dictionary with metadata
+        output_path: Path to save audio file
+        
+    Returns:
+        bool: True if successful, False otherwise
+        
+    Note:
+        This is a placeholder implementation for future development.
+    """
+    # TODO: Implement audio save with metadata
+    print(f"[COMING SOON] Save audio: {audio_path} -> {output_path}")
+    return False
+
+

+ 661 - 0
utils/data_manager.py

@@ -0,0 +1,661 @@
+"""
+Data Manager Module
+
+Handles all data persistence operations for analysis results.
+Manages database operations and file storage for analysis data.
+"""
+
+import json
+import logging
+import sqlite3
+import shutil
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Optional, Dict, List, Any
+
+from .db_schema import get_database_connection, close_database_connection, init_database
+from .config import DATABASE_PATH, ANALYSES_DIR
+
+logger = logging.getLogger(__name__)
+
+
+class DataManager:
+    """
+    Manages persistent storage of analysis data.
+    
+    Handles:
+    - Database operations for metadata
+    - File system storage for binary files
+    - Analysis record lifecycle
+    """
+    
+    def __init__(self):
+        """Initialize the data manager."""
+        self.db_path = DATABASE_PATH
+        self.analyses_dir = ANALYSES_DIR
+        
+        # Initialize database
+        init_database(self.db_path)
+        
+        logger.info(f"DataManager initialized with database: {self.db_path}")
+    
+    def create_analysis(self, report_id: str, device_id: str) -> Optional[int]:
+        """
+        Create a new analysis record.
+        
+        Args:
+            report_id: Unique report identifier (e.g., "DUR-20250115-143022")
+            device_id: Device identifier
+            
+        Returns:
+            int: Analysis ID or None if failed
+        """
+        try:
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return None
+            
+            cursor = conn.cursor()
+            cursor.execute("""
+                INSERT INTO analyses (report_id, timestamp, device_id)
+                VALUES (?, ?, ?)
+            """, (report_id, datetime.now(), device_id))
+            
+            analysis_id = cursor.lastrowid
+            conn.commit()
+            close_database_connection(conn)
+            
+            # Create directory structure for this analysis
+            analysis_dir = self.analyses_dir / report_id
+            (analysis_dir / "inputs").mkdir(parents=True, exist_ok=True)
+            (analysis_dir / "results").mkdir(parents=True, exist_ok=True)
+            (analysis_dir / "reports").mkdir(parents=True, exist_ok=True)
+            
+            logger.info(f"Created analysis record: {report_id} (ID: {analysis_id})")
+            return analysis_id
+            
+        except Exception as e:
+            logger.error(f"Error creating analysis: {e}")
+            return None
+    
+    def save_input_file(self, analysis_id: int, input_type: str, original_path: str) -> bool:
+        """
+        Save an input file and record it in the database.
+        
+        Args:
+            analysis_id: Analysis ID
+            input_type: Type of input ('dslr_side', 'dslr_top', 'multispectral', 'thermal', 'audio')
+            original_path: Path to the original file
+            
+        Returns:
+            bool: True if successful
+        """
+        try:
+            original_file = Path(original_path)
+            if not original_file.exists():
+                logger.error(f"Input file not found: {original_path}")
+                return False
+            
+            # Get analysis record to find report_id
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return False
+            
+            cursor = conn.cursor()
+            cursor.execute("SELECT report_id FROM analyses WHERE id = ?", (analysis_id,))
+            result = cursor.fetchone()
+            close_database_connection(conn)
+            
+            if not result:
+                logger.error(f"Analysis not found: {analysis_id}")
+                return False
+            
+            report_id = result[0]
+            
+            # Determine destination filename
+            # Map input_type to file extension if needed
+            filename = original_file.name
+            dest_dir = self.analyses_dir / report_id / "inputs"
+            dest_path = dest_dir / filename
+            
+            # Copy file
+            shutil.copy2(original_file, dest_path)
+            file_size = dest_path.stat().st_size
+            
+            # Record in database
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return False
+            
+            cursor = conn.cursor()
+            relative_path = f"inputs/{filename}"
+            cursor.execute("""
+                INSERT INTO analysis_inputs (analysis_id, input_type, original_path, saved_path, file_size)
+                VALUES (?, ?, ?, ?, ?)
+            """, (analysis_id, input_type, original_path, relative_path, file_size))
+            
+            conn.commit()
+            close_database_connection(conn)
+            
+            logger.info(f"Saved input file: {input_type} -> {relative_path}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Error saving input file: {e}")
+            return False
+    
+    def save_result(self, analysis_id: int, model_type: str, result_dict: Dict[str, Any]) -> bool:
+        """
+        Save model result to database.
+        
+        Args:
+            analysis_id: Analysis ID
+            model_type: Type of model ('defect', 'locule', 'maturity', 'shape', 'audio')
+            result_dict: Dictionary containing result data:
+                - predicted_class: Predicted class name
+                - confidence: Confidence value (0-1)
+                - probabilities: Dict of class probabilities
+                - processing_time: Time in seconds
+                - metadata: Additional model-specific data (optional)
+                
+        Returns:
+            bool: True if successful
+        """
+        try:
+            predicted_class = result_dict.get('predicted_class', 'Unknown')
+            confidence = result_dict.get('confidence', 0.0)
+            probabilities = result_dict.get('probabilities', {})
+            processing_time = result_dict.get('processing_time', 0.0)
+            metadata = result_dict.get('metadata', {})
+            
+            # Ensure confidence is 0-1 range
+            if confidence > 1.0:
+                confidence = confidence / 100.0
+            
+            # Convert to JSON strings
+            probabilities_json = json.dumps(probabilities)
+            metadata_json = json.dumps(metadata)
+            
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return False
+            
+            cursor = conn.cursor()
+            cursor.execute("""
+                INSERT INTO analysis_results 
+                (analysis_id, model_type, predicted_class, confidence, probabilities, processing_time, metadata)
+                VALUES (?, ?, ?, ?, ?, ?, ?)
+            """, (analysis_id, model_type, predicted_class, confidence, probabilities_json, processing_time, metadata_json))
+            
+            conn.commit()
+            close_database_connection(conn)
+            
+            logger.info(f"Saved result: {model_type} -> {predicted_class} ({confidence*100:.1f}%)")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Error saving result: {e}")
+            return False
+    
+    def save_visualization(self, analysis_id: int, viz_type: str, image_data: Any, format_ext: str = 'png') -> bool:
+        """
+        Save visualization image to file system.
+        
+        Args:
+            analysis_id: Analysis ID
+            viz_type: Type of visualization ('defect_annotated', 'locule_annotated', etc.)
+            image_data: Image data (QImage, numpy array, or file path)
+            format_ext: File extension for saved image
+            
+        Returns:
+            bool: True if successful
+        """
+        try:
+            from PyQt5.QtGui import QImage, QPixmap
+            import numpy as np
+            
+            # Get report_id
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return False
+            
+            cursor = conn.cursor()
+            cursor.execute("SELECT report_id FROM analyses WHERE id = ?", (analysis_id,))
+            result = cursor.fetchone()
+            close_database_connection(conn)
+            
+            if not result:
+                logger.error(f"Analysis not found: {analysis_id}")
+                return False
+            
+            report_id = result[0]
+            results_dir = self.analyses_dir / report_id / "results"
+            
+            # Generate filename
+            filename = f"{viz_type}.{format_ext}"
+            file_path = results_dir / filename
+            
+            # Save based on image_data type
+            if isinstance(image_data, str):
+                # It's a file path, copy it
+                shutil.copy2(image_data, file_path)
+            elif isinstance(image_data, QImage):
+                # PyQt QImage
+                pixmap = QPixmap.fromImage(image_data)
+                pixmap.save(str(file_path))
+            elif isinstance(image_data, QPixmap):
+                # PyQt QPixmap
+                image_data.save(str(file_path))
+            elif isinstance(image_data, np.ndarray):
+                # Numpy array - save as PNG using OpenCV
+                import cv2
+                # Convert RGB to BGR if needed
+                if len(image_data.shape) == 3 and image_data.shape[2] == 3:
+                    image_data = cv2.cvtColor(image_data, cv2.COLOR_RGB2BGR)
+                cv2.imwrite(str(file_path), image_data)
+            else:
+                logger.error(f"Unsupported image data type: {type(image_data)}")
+                return False
+            
+            # Record in database
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return False
+            
+            cursor = conn.cursor()
+            relative_path = f"results/{filename}"
+            cursor.execute("""
+                INSERT INTO analysis_visualizations (analysis_id, visualization_type, file_path)
+                VALUES (?, ?, ?)
+            """, (analysis_id, viz_type, relative_path))
+            
+            conn.commit()
+            close_database_connection(conn)
+            
+            logger.info(f"Saved visualization: {viz_type} -> {relative_path}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Error saving visualization: {e}")
+            return False
+    
+    def finalize_analysis(self, analysis_id: int, overall_grade: str, grade_description: str, total_time: float) -> bool:
+        """
+        Finalize an analysis record with results.
+        
+        Args:
+            analysis_id: Analysis ID
+            overall_grade: Grade ('A', 'B', or 'C')
+            grade_description: Description of the grade
+            total_time: Total processing time in seconds
+            
+        Returns:
+            bool: True if successful
+        """
+        try:
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return False
+            
+            cursor = conn.cursor()
+            cursor.execute("""
+                UPDATE analyses 
+                SET overall_grade = ?, grade_description = ?, processing_time = ?
+                WHERE id = ?
+            """, (overall_grade, grade_description, total_time, analysis_id))
+            
+            conn.commit()
+            close_database_connection(conn)
+            
+            logger.info(f"Finalized analysis: ID={analysis_id}, Grade={overall_grade}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"Error finalizing analysis: {e}")
+            return False
+    
+    def get_analysis(self, report_id: str) -> Optional[Dict[str, Any]]:
+        """
+        Retrieve complete analysis data.
+        
+        Args:
+            report_id: Report ID to retrieve
+            
+        Returns:
+            Dict with analysis data or None if not found
+        """
+        try:
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return None
+            
+            cursor = conn.cursor()
+            
+            # Get analysis record
+            cursor.execute("""
+                SELECT id, report_id, timestamp, device_id, overall_grade, grade_description, processing_time, created_at
+                FROM analyses
+                WHERE report_id = ?
+            """, (report_id,))
+            
+            analysis_row = cursor.fetchone()
+            if not analysis_row:
+                logger.warning(f"Analysis not found: {report_id}")
+                close_database_connection(conn)
+                return None
+            
+            analysis_id = analysis_row[0]
+            
+            # Get inputs
+            cursor.execute("""
+                SELECT input_type, original_path, saved_path, file_size
+                FROM analysis_inputs
+                WHERE analysis_id = ?
+                ORDER BY created_at
+            """, (analysis_id,))
+            
+            inputs = [dict(zip(['input_type', 'original_path', 'saved_path', 'file_size'], row)) 
+                     for row in cursor.fetchall()]
+            
+            # Get results
+            cursor.execute("""
+                SELECT model_type, predicted_class, confidence, probabilities, processing_time, metadata
+                FROM analysis_results
+                WHERE analysis_id = ?
+                ORDER BY created_at
+            """, (analysis_id,))
+            
+            results = []
+            for row in cursor.fetchall():
+                result_dict = {
+                    'model_type': row[0],
+                    'predicted_class': row[1],
+                    'confidence': row[2],
+                    'probabilities': json.loads(row[3] or '{}'),
+                    'processing_time': row[4],
+                    'metadata': json.loads(row[5] or '{}'),
+                }
+                results.append(result_dict)
+            
+            # Get visualizations
+            cursor.execute("""
+                SELECT visualization_type, file_path
+                FROM analysis_visualizations
+                WHERE analysis_id = ?
+                ORDER BY created_at
+            """, (analysis_id,))
+            
+            visualizations = [dict(zip(['visualization_type', 'file_path'], row)) 
+                             for row in cursor.fetchall()]
+            
+            close_database_connection(conn)
+            
+            return {
+                'id': analysis_id,
+                'report_id': analysis_row[1],
+                'timestamp': analysis_row[2],
+                'device_id': analysis_row[3],
+                'overall_grade': analysis_row[4],
+                'grade_description': analysis_row[5],
+                'processing_time': analysis_row[6],
+                'created_at': analysis_row[7],
+                'inputs': inputs,
+                'results': results,
+                'visualizations': visualizations,
+            }
+            
+        except Exception as e:
+            logger.error(f"Error retrieving analysis: {e}")
+            return None
+    
+    def list_recent_analyses(self, limit: int = 50) -> List[Dict[str, Any]]:
+        """
+        Get list of recent analyses.
+        
+        Args:
+            limit: Maximum number of analyses to return
+            
+        Returns:
+            List of analysis records with created_at formatted in local timezone
+        """
+        try:
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return []
+            
+            cursor = conn.cursor()
+            cursor.execute("""
+                SELECT id, report_id, timestamp, device_id, overall_grade, processing_time, created_at
+                FROM analyses
+                ORDER BY created_at DESC
+                LIMIT ?
+            """, (limit,))
+            
+            analyses = []
+            for row in cursor.fetchall():
+                # Convert created_at from UTC to local timezone
+                created_at = row[6]
+                if created_at:
+                    try:
+                        # Parse the UTC datetime string from database
+                        utc_datetime = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S")
+                        # Mark it as UTC
+                        utc_datetime = utc_datetime.replace(tzinfo=timezone.utc)
+                        # Convert to local timezone
+                        local_datetime = utc_datetime.astimezone()
+                        # Format for display
+                        created_at = local_datetime.strftime("%Y-%m-%d %H:%M:%S")
+                    except Exception as e:
+                        # If parsing fails, keep original value
+                        logger.debug(f"Failed to parse datetime: {e}")
+                        pass
+                
+                analyses.append({
+                    'id': row[0],
+                    'report_id': row[1],
+                    'timestamp': row[2],
+                    'device_id': row[3],
+                    'overall_grade': row[4],
+                    'processing_time': row[5],
+                    'created_at': created_at,
+                })
+            
+            close_database_connection(conn)
+            return analyses
+            
+        except Exception as e:
+            logger.error(f"Error listing analyses: {e}")
+            return []
+    
+    def get_analysis_file_path(self, report_id: str, relative_path: str) -> Optional[Path]:
+        """
+        Get full path to a file stored in analysis folder.
+        
+        Args:
+            report_id: Report ID
+            relative_path: Relative path (e.g., 'inputs/image.jpg' or 'results/defect_annotated.png')
+            
+        Returns:
+            Full file path or None if not found
+        """
+        file_path = self.analyses_dir / report_id / relative_path
+        if file_path.exists():
+            return file_path
+        return None
+    
+    def export_analysis_to_dict(self, report_id: str) -> Optional[Dict[str, Any]]:
+        """
+        Export analysis data for reports display.
+        Converts file paths to full paths for use in UI.
+        
+        Args:
+            report_id: Report ID to export
+            
+        Returns:
+            Analysis data with full file paths or None if not found
+        """
+        analysis = self.get_analysis(report_id)
+        if not analysis:
+            return None
+        
+        # Convert relative paths to full paths
+        for input_item in analysis['inputs']:
+            if input_item['saved_path']:
+                full_path = self.get_analysis_file_path(report_id, input_item['saved_path'])
+                input_item['full_path'] = str(full_path) if full_path else None
+        
+        for viz_item in analysis['visualizations']:
+            if viz_item['file_path']:
+                full_path = self.get_analysis_file_path(report_id, viz_item['file_path'])
+                viz_item['full_path'] = str(full_path) if full_path else None
+        
+        return analysis
+    
+    def get_daily_analysis_count(self) -> int:
+        """
+        Get count of analyses created today.
+        
+        Returns:
+            int: Number of analyses created today (UTC date)
+        """
+        try:
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return 0
+            
+            cursor = conn.cursor()
+            
+            # Get today's date in UTC
+            today = datetime.now(timezone.utc).date()
+            
+            # Query analyses created today
+            cursor.execute("""
+                SELECT COUNT(*)
+                FROM analyses
+                WHERE DATE(created_at) = ?
+            """, (str(today),))
+            
+            result = cursor.fetchone()
+            close_database_connection(conn)
+            
+            count = result[0] if result else 0
+            logger.info(f"Daily analysis count: {count}")
+            return count
+            
+        except Exception as e:
+            logger.error(f"Error getting daily analysis count: {e}")
+            return 0
+    
+    def get_average_processing_time(self, limit: int = 100) -> float:
+        """
+        Get average processing time from recent analyses.
+        
+        Args:
+            limit: Maximum number of recent analyses to consider
+            
+        Returns:
+            float: Average processing time in seconds (0.0 if no data)
+        """
+        try:
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return 0.0
+            
+            cursor = conn.cursor()
+            
+            # Query average processing time from recent analyses
+            cursor.execute("""
+                SELECT AVG(processing_time)
+                FROM (
+                    SELECT processing_time
+                    FROM analyses
+                    WHERE processing_time IS NOT NULL AND processing_time > 0
+                    ORDER BY created_at DESC
+                    LIMIT ?
+                )
+            """, (limit,))
+            
+            result = cursor.fetchone()
+            close_database_connection(conn)
+            
+            avg_time = float(result[0]) if result and result[0] else 0.0
+            logger.info(f"Average processing time: {avg_time:.2f}s")
+            return avg_time
+            
+        except Exception as e:
+            logger.error(f"Error getting average processing time: {e}")
+            return 0.0
+    
+    def get_model_accuracy_stats(self, limit: int = 100) -> Dict[str, float]:
+        """
+        Get average confidence scores per model type from recent results.
+        
+        This calculates the average confidence (as a proxy for accuracy) for each model
+        from the most recent analyses.
+        
+        Args:
+            limit: Maximum number of recent analyses to consider
+            
+        Returns:
+            Dict mapping model type to average confidence percentage (0-100)
+            Example: {'audio': 94.2, 'defect': 87.5, 'locule': 91.8, 'maturity': 88.3, 'shape': 89.1}
+        """
+        try:
+            conn = get_database_connection(self.db_path)
+            if not conn:
+                logger.error("Failed to connect to database")
+                return {}
+            
+            cursor = conn.cursor()
+            
+            # Get most recent analysis IDs
+            cursor.execute("""
+                SELECT id FROM analyses
+                ORDER BY created_at DESC
+                LIMIT ?
+            """, (limit,))
+            
+            recent_analysis_ids = [row[0] for row in cursor.fetchall()]
+            
+            if not recent_analysis_ids:
+                close_database_connection(conn)
+                logger.info("No analyses found for accuracy calculation")
+                return {}
+            
+            # Format IDs for SQL IN clause
+            ids_placeholder = ','.join('?' * len(recent_analysis_ids))
+            
+            # Query average confidence per model type
+            cursor.execute(f"""
+                SELECT model_type, AVG(confidence * 100)
+                FROM analysis_results
+                WHERE analysis_id IN ({ids_placeholder})
+                GROUP BY model_type
+            """, recent_analysis_ids)
+            
+            results = cursor.fetchall()
+            close_database_connection(conn)
+            
+            # Build result dictionary
+            accuracy_stats = {}
+            for model_type, avg_confidence in results:
+                if model_type and avg_confidence is not None:
+                    accuracy_stats[model_type] = round(avg_confidence, 1)
+            
+            logger.info(f"Model accuracy stats: {accuracy_stats}")
+            return accuracy_stats
+            
+        except Exception as e:
+            logger.error(f"Error getting model accuracy stats: {e}")
+            return {}

+ 174 - 0
utils/db_schema.py

@@ -0,0 +1,174 @@
+"""
+Database Schema and Initialization
+
+Defines SQLite database schema for storing analysis data.
+Handles database creation and migrations.
+"""
+
+import sqlite3
+import logging
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+def get_schema_version() -> int:
+    """Get the current schema version."""
+    return 1
+
+
+def get_database_schema() -> str:
+    """
+    Get SQL schema for database initialization.
+    
+    Returns:
+        str: SQL schema definition
+    """
+    return """
+-- Analyses table: stores basic analysis information
+CREATE TABLE IF NOT EXISTS analyses (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    report_id TEXT UNIQUE NOT NULL,
+    timestamp DATETIME NOT NULL,
+    device_id TEXT NOT NULL,
+    overall_grade TEXT,
+    grade_description TEXT,
+    processing_time REAL,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Analysis inputs table: stores input files information
+CREATE TABLE IF NOT EXISTS analysis_inputs (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    analysis_id INTEGER NOT NULL,
+    input_type TEXT NOT NULL,
+    original_path TEXT NOT NULL,
+    saved_path TEXT NOT NULL,
+    file_size INTEGER,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (analysis_id) REFERENCES analyses(id) ON DELETE CASCADE
+);
+
+-- Analysis results table: stores model predictions and results
+CREATE TABLE IF NOT EXISTS analysis_results (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    analysis_id INTEGER NOT NULL,
+    model_type TEXT NOT NULL,
+    predicted_class TEXT,
+    confidence REAL,
+    probabilities TEXT,
+    processing_time REAL,
+    metadata TEXT,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (analysis_id) REFERENCES analyses(id) ON DELETE CASCADE
+);
+
+-- Analysis visualizations table: stores visualization image metadata
+CREATE TABLE IF NOT EXISTS analysis_visualizations (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    analysis_id INTEGER NOT NULL,
+    visualization_type TEXT NOT NULL,
+    file_path TEXT NOT NULL,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (analysis_id) REFERENCES analyses(id) ON DELETE CASCADE
+);
+
+-- Create indexes for common queries
+CREATE INDEX IF NOT EXISTS idx_analyses_report_id ON analyses(report_id);
+CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON analyses(created_at);
+CREATE INDEX IF NOT EXISTS idx_analysis_inputs_analysis_id ON analysis_inputs(analysis_id);
+CREATE INDEX IF NOT EXISTS idx_analysis_results_analysis_id ON analysis_results(analysis_id);
+CREATE INDEX IF NOT EXISTS idx_analysis_visualizations_analysis_id ON analysis_visualizations(analysis_id);
+"""
+
+
+def init_database(db_path: Path) -> bool:
+    """
+    Initialize the database with schema.
+    
+    Args:
+        db_path: Path to the database file
+        
+    Returns:
+        bool: True if initialization successful, False otherwise
+    """
+    try:
+        # Ensure parent directory exists
+        db_path.parent.mkdir(parents=True, exist_ok=True)
+        
+        # Connect to database
+        conn = sqlite3.connect(str(db_path))
+        cursor = conn.cursor()
+        
+        # Execute schema
+        cursor.executescript(get_database_schema())
+        
+        # Create metadata table to track schema version
+        cursor.execute("""
+            CREATE TABLE IF NOT EXISTS schema_version (
+                version INTEGER PRIMARY KEY,
+                updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        
+        # Check if version exists
+        cursor.execute("SELECT version FROM schema_version LIMIT 1")
+        result = cursor.fetchone()
+        
+        if not result:
+            # Insert initial version
+            cursor.execute("INSERT INTO schema_version (version) VALUES (?)", (get_schema_version(),))
+        
+        conn.commit()
+        conn.close()
+        
+        logger.info(f"Database initialized successfully: {db_path}")
+        return True
+        
+    except Exception as e:
+        logger.error(f"Error initializing database: {e}")
+        return False
+
+
+def get_database_connection(db_path: Path) -> Optional[sqlite3.Connection]:
+    """
+    Get a connection to the database.
+    
+    Args:
+        db_path: Path to the database file
+        
+    Returns:
+        sqlite3.Connection or None if connection failed
+    """
+    try:
+        # Initialize database if doesn't exist
+        if not db_path.exists():
+            init_database(db_path)
+        
+        # Enable foreign keys
+        conn = sqlite3.connect(str(db_path))
+        conn.execute("PRAGMA foreign_keys = ON")
+        
+        # Use Row factory for easier dict-like access
+        conn.row_factory = sqlite3.Row
+        
+        return conn
+        
+    except Exception as e:
+        logger.error(f"Error connecting to database: {e}")
+        return None
+
+
+def close_database_connection(conn: sqlite3.Connection) -> None:
+    """
+    Close a database connection.
+    
+    Args:
+        conn: Connection to close
+    """
+    try:
+        if conn:
+            conn.close()
+    except Exception as e:
+        logger.error(f"Error closing database connection: {e}")

+ 93 - 0
utils/grade_calculator.py

@@ -0,0 +1,93 @@
+"""
+Grade Calculator Utility
+
+Pure functions for calculating durian grades and retrieving color representations
+for different classifications.
+"""
+
+from typing import Optional, Tuple
+
+
+def calculate_durian_grade(
+    locule_count: int,
+    has_defects: bool,
+    shape_class: Optional[str] = None,
+    maturity_class: Optional[str] = None
+) -> Tuple[str, str]:
+    """
+    Calculate durian grade based on locule count, defect status, shape, and maturity.
+    
+    Rules:
+    - Has defects (any locule count) = Class C
+    - Class A: 5 locules + Regular shape + no defects + Mature
+    - Class B: Good quality but missing one or more Class A requirements (irregular shape, <5 locules, immature/overmature)
+    - Class C: Has defects or doesn't meet Grade I requirements
+    
+    Args:
+        locule_count: Number of locules detected
+        has_defects: True if defects are present, False otherwise
+        shape_class: Shape classification ('Regular' or 'Irregular')
+        maturity_class: Maturity classification ('Immature', 'Mature', or 'Overmature')
+    
+    Returns:
+        tuple: (grade_letter, grade_description)
+    """
+    if has_defects:
+        return ('C', 'Durian with defects detected - Grade C (Grade II)')
+    
+    # Check if mature (Class A requires Mature status)
+    is_mature = maturity_class == 'Mature' if maturity_class else True  # Assume mature if not provided
+    
+    # No defects, now check locules, shape, and maturity
+    if locule_count == 5 and shape_class == 'Regular' and is_mature:
+        # Perfect conditions for Class A
+        return ('A', 'Premium Grade: 5 locules, regular shape, mature, no defects - Class A')
+    elif has_defects or maturity_class == 'Overmature':
+        # Defects or overmature = Class C
+        return ('C', f'Grade II: Has defects or is overmature - Class C')
+    elif maturity_class == 'Immature':
+        # Immature durian = Class B
+        return ('B', f'Grade I: Immature durian with {locule_count} locules - Class B')
+    elif locule_count == 5:
+        # 5 locules but might have irregular shape or not mature
+        if shape_class == 'Irregular':
+            return ('B', f'Grade I: 5 locules with irregular shape - Class B')
+        elif is_mature:
+            return ('A', 'Premium Grade: 5 locules, regular shape, mature, no defects - Class A')
+        else:
+            return ('B', f'Grade I: 5 locules but not mature - Class B')
+    elif locule_count < 5:
+        return ('B', f'Grade I: Good quality with {locule_count} locules, no defects - Class B')
+    else:
+        # More than 5 locules but no defects
+        return ('B', f'Grade I: Good quality with {locule_count} locules, no defects - Class B')
+
+
+def get_ripeness_color(ripeness: str) -> str:
+    """Get color for ripeness status."""
+    colors = {
+        'Unripe': '#3498db',
+        'Ripe': '#27ae60',
+        'Overripe': '#e74c3c'
+    }
+    return colors.get(ripeness, '#555')
+
+
+def get_maturity_color(maturity: str) -> str:
+    """Get color for maturity status."""
+    colors = {
+        'Immature': '#3498db',
+        'Mature': '#27ae60',
+        'Overmature': '#e74c3c'
+    }
+    return colors.get(maturity, '#555')
+
+
+def get_grade_color(grade: str) -> str:
+    """Get color for overall grade."""
+    colors = {
+        'A': '#27ae60',
+        'B': '#f39c12',
+        'C': '#e74c3c'
+    }
+    return colors.get(grade, '#555')

+ 83 - 0
utils/process_utils.py

@@ -0,0 +1,83 @@
+"""
+Process Utilities
+
+Utilities for detecting and interacting with running processes/applications.
+"""
+
+import psutil
+from typing import List, Dict
+
+
+def is_process_running(process_name: str) -> bool:
+    """
+    Check if a process is currently running.
+    
+    Args:
+        process_name: Name of the process (e.g., 'EOS Utility.exe')
+        
+    Returns:
+        bool: True if process is running, False otherwise
+    """
+    try:
+        for proc in psutil.process_iter(['name']):
+            try:
+                if proc.info['name'] and process_name.lower() in proc.info['name'].lower():
+                    return True
+            except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
+                pass
+    except Exception as e:
+        print(f"Error checking process {process_name}: {e}")
+    
+    return False
+
+
+def check_camera_applications() -> Dict[str, bool]:
+    """
+    Check if required camera applications are running.
+    
+    Returns:
+        Dict[str, bool]: Dictionary mapping application name to running status
+    """
+    # Common process names for camera applications
+    camera_apps = {
+        'EOS Utility': ['EOS Utility.exe', 'EOSUtility.exe', 'eu.exe'],
+        '2nd Look': ['2ndLook.exe', 'secondlook.exe', '2nd Look.exe'],
+        'AnalyzIR': ['AnalyzIR.exe', 'analyzir.exe']
+    }
+    
+    results = {}
+    for app_name, process_names in camera_apps.items():
+        running = False
+        for proc_name in process_names:
+            if is_process_running(proc_name):
+                running = True
+                break
+        results[app_name] = running
+    
+    return results
+
+
+def get_running_camera_apps() -> List[str]:
+    """
+    Get list of camera applications that are currently running.
+    
+    Returns:
+        List[str]: List of running camera application names
+    """
+    status = check_camera_applications()
+    return [app for app, running in status.items() if running]
+
+
+def get_missing_camera_apps() -> List[str]:
+    """
+    Get list of camera applications that are not running.
+    
+    Returns:
+        List[str]: List of camera application names that are not running
+    """
+    status = check_camera_applications()
+    return [app for app, running in status.items() if not running]
+
+
+
+

+ 364 - 0
utils/quality_sample_data.py

@@ -0,0 +1,364 @@
+"""
+Quality Sample Data Generator
+
+Utility for generating sample data and images for quality tab demonstration.
+"""
+
+import numpy as np
+from PyQt5.QtGui import QPixmap, QImage, QColor, QPainter, QPen, QBrush
+from PyQt5.QtCore import Qt, QPoint, QRect
+import random
+import math
+
+
+class QualitySampleDataGenerator:
+    """Generator for sample quality assessment data."""
+
+    def __init__(self):
+        self.fruit_colors = [
+            QColor("#8B4513"),  # Brown/orange
+            QColor("#CD853F"),  # Peru
+            QColor("#DEB887"),  # Burlywood
+            QColor("#F4A460"),  # Sandy brown
+        ]
+
+        self.defect_colors = [
+            QColor("#F39C12"),  # Orange for mechanical damage
+            QColor("#E67E22"),  # Carrot for surface blemish
+            QColor("#E74C3C"),  # Red for severe defects
+        ]
+
+    def generate_sample_fruit_top_view(self, width=300, height=250):
+        """Generate a sample top view fruit image with defects."""
+        image = QImage(width, height, QImage.Format_RGB32)
+        image.fill(QColor("#2C3E50"))
+
+        painter = QPainter(image)
+        painter.setRenderHint(QPainter.Antialiasing)
+
+        # Draw main fruit
+        fruit_radius = min(width, height) // 4
+        center_x = width // 2
+        center_y = height // 2
+
+        # Fruit body with gradient
+        fruit_color = random.choice(self.fruit_colors)
+        painter.setBrush(QBrush(fruit_color))
+        painter.setPen(QPen(QColor("#654321"), 2))
+        painter.drawEllipse(center_x - fruit_radius, center_y - fruit_radius,
+                          fruit_radius * 2, fruit_radius * 2)
+
+        # Add some texture/pattern to fruit
+        self._add_fruit_texture(painter, center_x, center_y, fruit_radius)
+
+        # Add defect markers
+        defects = self._generate_sample_defects()
+        for defect in defects:
+            self._draw_defect_on_fruit(painter, defect, center_x, center_y, fruit_radius)
+
+        painter.end()
+        return image, defects
+
+    def generate_sample_fruit_side_view(self, width=300, height=200):
+        """Generate a sample side view fruit image with shape outline."""
+        image = QImage(width, height, QImage.Format_RGB32)
+        image.fill(QColor("#2C3E50"))
+
+        painter = QPainter(image)
+        painter.setRenderHint(QPainter.Antialiasing)
+
+        # Draw main fruit (elliptical for side view)
+        fruit_width = width * 3 // 5
+        fruit_height = fruit_width * 3 // 4
+        center_x = width // 2
+        center_y = height // 2
+
+        # Fruit body
+        fruit_color = random.choice(self.fruit_colors)
+        painter.setBrush(QBrush(fruit_color))
+        painter.setPen(QPen(QColor("#654321"), 2))
+        painter.drawEllipse(center_x - fruit_width // 2, center_y - fruit_height // 2,
+                          fruit_width, fruit_height)
+
+        # Draw shape outline (dashed)
+        painter.setBrush(Qt.NoBrush)
+        painter.setPen(QPen(QColor("#27AE60"), 2, Qt.DashLine))
+        painter.drawEllipse(center_x - fruit_width // 2, center_y - fruit_height // 2,
+                          fruit_width, fruit_height)
+
+        # Draw symmetry indicators
+        self._draw_symmetry_indicators(painter, center_x, center_y, fruit_width)
+
+        painter.end()
+
+        # Generate shape analysis data
+        symmetry = random.uniform(85, 95)
+        aspect_ratio = fruit_width / fruit_height
+
+        return image, symmetry, aspect_ratio
+
+    def generate_thermal_gradient(self, width=300, height=200, base_temp=28.5):
+        """Generate thermal gradient visualization."""
+        image = QImage(width, height, QImage.Format_RGB32)
+        image.fill(QColor("#000000"))
+
+        painter = QPainter(image)
+        painter.setRenderHint(QPainter.Antialiasing)
+
+        # Create thermal gradient background
+        center_x = width // 2
+        center_y = height // 2
+
+        # Main thermal area (elliptical)
+        thermal_width = width * 3 // 4
+        thermal_height = height * 2 // 3
+
+        # Create radial gradient for thermal effect
+        radial_gradient = QRadialGradient(center_x, center_y, max(thermal_width, thermal_height) // 2)
+
+        # Center is hottest (red), edges cooler
+        radial_gradient.setColorAt(0.0, QColor("#E74C3C"))  # Hot center
+        radial_gradient.setColorAt(0.7, QColor("#F39C12"))  # Medium
+        radial_gradient.setColorAt(1.0, QColor("#F1C40F"))  # Cool edges
+
+        painter.setBrush(QBrush(radial_gradient))
+        painter.setPen(Qt.NoPen)
+        painter.drawEllipse(center_x - thermal_width // 2, center_y - thermal_height // 2,
+                          thermal_width, thermal_height)
+
+        # Add temperature scale overlay
+        self._add_temperature_scale(painter, width, height, base_temp)
+
+        painter.end()
+        return image, base_temp
+
+    def generate_quality_grading_data(self):
+        """Generate sample quality grading data."""
+        # Simulate quality metrics
+        metrics = {
+            "size_weight": random.uniform(85, 95),
+            "shape_regularity": random.uniform(87, 94),
+            "surface_quality": random.uniform(65, 80),
+            "color_uniformity": random.uniform(78, 88),
+            "firmness_thermal": 0  # Not available (thermal offline)
+        }
+
+        # Calculate overall score
+        available_metrics = [v for k, v in metrics.items() if v > 0]
+        overall_score = sum(available_metrics) / len(available_metrics)
+
+        # Determine grade
+        if overall_score >= 90:
+            grade = "A"
+        elif overall_score >= 75:
+            grade = "B"
+        else:
+            grade = "C"
+
+        # Generate additional metrics
+        additional_metrics = {
+            "estimated_weight": round(random.uniform(150, 220), 1),
+            "diameter": random.randint(65, 80),
+            "aspect_ratio": round(random.uniform(0.8, 0.9), 2),
+            "surface_defect_coverage": round(random.uniform(2, 8), 1),
+            "processing_time": round(random.uniform(2.0, 3.5), 1)
+        }
+
+        return {
+            "grade": grade,
+            "overall_score": round(overall_score, 1),
+            "metrics": metrics,
+            "additional_metrics": additional_metrics,
+            "model_version": "QualityNet v2.8"
+        }
+
+    def _generate_sample_defects(self):
+        """Generate sample defect data."""
+        defects = []
+
+        # Mechanical damage
+        defects.append({
+            'type': 'Mechanical Damage',
+            'x': random.randint(-20, 20),
+            'y': random.randint(-20, 20),
+            'size': random.randint(6, 12),
+            'confidence': round(random.uniform(80, 95), 1),
+            'color': '#F39C12',
+            'location': 'Top-Left',
+            'size_mm2': round(random.uniform(6, 12), 1)
+        })
+
+        # Surface blemish (sometimes)
+        if random.random() > 0.5:
+            defects.append({
+                'type': 'Surface Blemish',
+                'x': random.randint(-15, 25),
+                'y': random.randint(-10, 25),
+                'size': random.randint(4, 8),
+                'confidence': round(random.uniform(70, 85), 1),
+                'color': '#E67E22',
+                'location': 'Side',
+                'size_mm2': round(random.uniform(3, 7), 1)
+            })
+
+        return defects
+
+    def _add_fruit_texture(self, painter, center_x, center_y, radius):
+        """Add texture pattern to fruit."""
+        # Add some darker spots for texture
+        for _ in range(5):
+            spot_x = center_x + random.randint(-radius // 2, radius // 2)
+            spot_y = center_y + random.randint(-radius // 2, radius // 2)
+            spot_radius = random.randint(3, 8)
+
+            # Only draw if within fruit bounds
+            distance = math.sqrt((spot_x - center_x) ** 2 + (spot_y - center_y) ** 2)
+            if distance + spot_radius <= radius:
+                painter.setBrush(QBrush(QColor("#654321")))
+                painter.setPen(Qt.NoPen)
+                painter.drawEllipse(spot_x - spot_radius, spot_y - spot_radius,
+                                  spot_radius * 2, spot_radius * 2)
+
+    def _draw_defect_on_fruit(self, painter, defect, center_x, center_y, fruit_radius):
+        """Draw a defect marker on the fruit."""
+        # Calculate position relative to fruit center
+        marker_x = center_x + (defect['x'] * fruit_radius // 50)
+        marker_y = center_y + (defect['y'] * fruit_radius // 50)
+
+        # Draw marker circle
+        marker_radius = max(6, defect['size'])
+        painter.setBrush(QBrush(QColor(defect['color'])))
+        painter.setPen(QPen(QColor('#D35400'), 2))
+        painter.drawEllipse(marker_x - marker_radius, marker_y - marker_radius,
+                          marker_radius * 2, marker_radius * 2)
+
+        # Draw confidence text
+        painter.setPen(QPen(QColor('white'), 1))
+        painter.drawText(marker_x - 15, marker_y - marker_radius - 3,
+                        f"{defect['confidence']}%")
+
+    def _draw_symmetry_indicators(self, painter, center_x, center_y, fruit_width):
+        """Draw symmetry indicator lines."""
+        # Draw symmetry axis lines
+        symmetry_y = center_y
+        line_length = fruit_width // 6
+
+        painter.setPen(QPen(QColor("#3498DB"), 2, Qt.SolidLine))
+
+        # Left indicator
+        left_x = center_x - fruit_width // 4
+        painter.drawLine(left_x, symmetry_y - line_length, left_x, symmetry_y + line_length)
+
+        # Right indicator
+        right_x = center_x + fruit_width // 4
+        painter.drawLine(right_x, symmetry_y - line_length, right_x, symmetry_y + line_length)
+
+    def _add_temperature_scale(self, painter, width, height, base_temp):
+        """Add temperature scale overlay."""
+        # Temperature scale at bottom
+        scale_width = width * 3 // 4
+        scale_height = 15
+        scale_x = (width - scale_width) // 2
+        scale_y = height - 30
+
+        # Draw scale background
+        painter.setBrush(QBrush(QColor("#34495E")))
+        painter.setPen(Qt.NoPen)
+        painter.drawRect(scale_x, scale_y, scale_width, scale_height)
+
+        # Draw temperature gradient in scale
+        temp_gradient = QBrush()
+        temp_gradient = QLinearGradient(scale_x, 0, scale_x + scale_width, 0)
+
+        temp_gradient.setColorAt(0.0, QColor("#3498DB"))  # Cool (22°C)
+        temp_gradient.setColorAt(0.25, QColor("#27AE60"))
+        temp_gradient.setColorAt(0.5, QColor("#F1C40F"))
+        temp_gradient.setColorAt(0.75, QColor("#E67E22"))
+        temp_gradient.setColorAt(1.0, QColor("#E74C3C"))   # Hot (32°C)
+
+        painter.setBrush(temp_gradient)
+        painter.drawRect(scale_x, scale_y, scale_width, scale_height)
+
+        # Draw scale border
+        painter.setPen(QPen(QColor("#ECF0F1"), 1))
+        painter.drawRect(scale_x, scale_y, scale_width, scale_height)
+
+        # Draw temperature labels
+        painter.setPen(QPen(QColor("white"), 1))
+        min_temp = "22°C"
+        max_temp = "32°C"
+        current_temp = f"{base_temp}°C"
+
+        # Min temperature
+        painter.drawText(scale_x + 5, scale_y - 2, min_temp)
+
+        # Current temperature (centered)
+        current_rect = QRect(scale_x + scale_width // 2 - 20, scale_y - 18, 40, 15)
+        painter.drawText(current_rect, Qt.AlignCenter, current_temp)
+
+        # Max temperature
+        max_temp_rect = QRect(scale_x + scale_width - 35, scale_y - 2, 30, 12)
+        painter.drawText(max_temp_rect, Qt.AlignRight, max_temp)
+
+
+# Global instance for easy access
+sample_data_generator = QualitySampleDataGenerator()
+
+
+def get_sample_fruit_top_view():
+    """Get a sample top view image and defects data."""
+    return sample_data_generator.generate_sample_fruit_top_view()
+
+
+def get_sample_fruit_side_view():
+    """Get a sample side view image and analysis data."""
+    return sample_data_generator.generate_sample_fruit_side_view()
+
+
+def get_thermal_gradient():
+    """Get a thermal gradient visualization."""
+    return sample_data_generator.generate_thermal_gradient()
+
+
+def get_quality_grading_data():
+    """Get sample quality grading data."""
+    return sample_data_generator.generate_quality_grading_data()
+
+
+def generate_defect_detection_results():
+    """Generate sample defect detection results."""
+    defects = sample_data_generator._generate_sample_defects()
+
+    # Convert to format expected by panels
+    formatted_defects = []
+
+    for defect in defects:
+        formatted_defects.append({
+            'type': defect['type'],
+            'location': defect['location'],
+            'size': f"{defect['size_mm2']}mm²",
+            'confidence': defect['confidence'],
+            'color': defect['color'],
+            'category': 'warning'
+        })
+
+    # Add shape analysis
+    formatted_defects.append({
+        'type': 'Shape Analysis',
+        'result': 'Regular',
+        'symmetry': '91.2%',
+        'confidence': 94.1,
+        'color': '#27ae60',
+        'category': 'success'
+    })
+
+    # Add locule count
+    formatted_defects.append({
+        'type': 'Locule Count',
+        'count': '4',
+        'confidence': 94.5,
+        'color': '#3498db',
+        'category': 'info'
+    })
+
+    return formatted_defects

+ 159 - 0
utils/session_manager.py

@@ -0,0 +1,159 @@
+"""
+Session Manager
+
+Tracks and manages analysis session data.
+"""
+
+from datetime import datetime
+from typing import List, Dict, Optional
+from dataclasses import dataclass, field
+
+
+@dataclass
+class TestResult:
+    """Data class for a single test result."""
+    test_id: int
+    timestamp: datetime
+    classification: str
+    confidence: float  # 0-100
+    probabilities: Dict[str, float]  # class_name -> probability
+    processing_time: float
+    file_path: Optional[str] = None
+    
+    
+class SessionManager:
+    """
+    Manager for tracking ripeness testing session data.
+    
+    Tracks test results, calculates statistics, and manages session state.
+    """
+    
+    def __init__(self, max_history: int = 100):
+        """
+        Initialize session manager.
+        
+        Args:
+            max_history: Maximum number of results to keep in history
+        """
+        self.max_history = max_history
+        self.session_start = datetime.now()
+        self.results: List[TestResult] = []
+        self.test_counter = 0
+        
+    def add_result(self, classification: str, confidence: float,
+                   probabilities: Dict[str, float], processing_time: float,
+                   file_path: Optional[str] = None) -> TestResult:
+        """
+        Add a new test result to the session.
+        
+        Args:
+            classification: Predicted class name
+            confidence: Confidence percentage (0-100)
+            probabilities: Dictionary of class probabilities
+            processing_time: Processing time in seconds
+            file_path: Optional path to the processed file
+            
+        Returns:
+            TestResult object
+        """
+        self.test_counter += 1
+        
+        result = TestResult(
+            test_id=self.test_counter,
+            timestamp=datetime.now(),
+            classification=classification,
+            confidence=confidence,
+            probabilities=probabilities,
+            processing_time=processing_time,
+            file_path=file_path
+        )
+        
+        self.results.insert(0, result)  # Insert at beginning (most recent first)
+        
+        # Trim history if needed
+        if len(self.results) > self.max_history:
+            self.results = self.results[:self.max_history]
+            
+        return result
+        
+    def get_total_tests(self) -> int:
+        """Get total number of tests in session."""
+        return len(self.results)
+        
+    def get_average_processing_time(self) -> float:
+        """Get average processing time across all tests."""
+        if not self.results:
+            return 0.0
+        return sum(r.processing_time for r in self.results) / len(self.results)
+        
+    def get_classification_counts(self) -> Dict[str, int]:
+        """
+        Get counts for each classification type.
+        
+        Returns:
+            Dictionary mapping classification names to counts
+        """
+        counts = {}
+        for result in self.results:
+            classification = result.classification
+            counts[classification] = counts.get(classification, 0) + 1
+        return counts
+        
+    def get_ripe_count(self) -> int:
+        """Get number of 'Ripe' classifications."""
+        return sum(1 for r in self.results if r.classification == "Ripe")
+        
+    def get_session_duration(self) -> Dict[str, int]:
+        """
+        Get session duration.
+        
+        Returns:
+            Dictionary with 'hours' and 'minutes' keys
+        """
+        duration = datetime.now() - self.session_start
+        hours = duration.seconds // 3600
+        minutes = (duration.seconds % 3600) // 60
+        return {"hours": hours, "minutes": minutes}
+        
+    def get_recent_results(self, count: int = 10) -> List[TestResult]:
+        """
+        Get most recent test results.
+        
+        Args:
+            count: Number of results to return
+            
+        Returns:
+            List of TestResult objects
+        """
+        return self.results[:count]
+        
+    def get_last_result(self) -> Optional[TestResult]:
+        """Get the most recent test result."""
+        return self.results[0] if self.results else None
+        
+    def clear_session(self):
+        """Clear all session data and reset."""
+        self.results.clear()
+        self.test_counter = 0
+        self.session_start = datetime.now()
+        
+    def get_statistics_summary(self) -> Dict[str, any]:
+        """
+        Get complete statistics summary.
+        
+        Returns:
+            Dictionary containing all session statistics
+        """
+        counts = self.get_classification_counts()
+        duration = self.get_session_duration()
+        
+        return {
+            "total_tests": self.get_total_tests(),
+            "avg_processing_time": self.get_average_processing_time(),
+            "ripe_count": self.get_ripe_count(),
+            "classification_counts": counts,
+            "session_duration": duration,
+            "session_start": self.session_start
+        }
+
+

+ 242 - 0
utils/system_monitor.py

@@ -0,0 +1,242 @@
+"""
+System Monitor Module
+
+Monitors system status including:
+- Camera applications (running/disconnected)
+- AI model load state
+- GPU usage and information
+- RAM usage
+"""
+
+import psutil
+import logging
+from typing import Dict, Optional, Any
+from utils.process_utils import check_camera_applications
+
+logger = logging.getLogger(__name__)
+
+
+def get_camera_status() -> Dict[str, Dict[str, Any]]:
+    """
+    Get status of all camera applications.
+    
+    Returns:
+        Dict mapping camera app name to dict with 'running' status and spec info
+        Example: {
+            'EOS Utility': {'running': True, 'spec': '1920x1080 @ 30fps'},
+            '2nd Look': {'running': False, 'spec': 'DISCONNECTED'},
+            'AnalyzIR': {'running': True, 'spec': 'Thermal @ 60fps'}
+        }
+    """
+    try:
+        # Check which apps are running
+        app_status = check_camera_applications()
+        print(f"[system_monitor] check_camera_applications returned: {app_status}")
+        logger.info(f"Camera app status: {app_status}")
+        
+        # Define specs for each camera app
+        camera_specs = {
+            'EOS Utility': '1920x1080 @ 30fps',
+            '2nd Look': '8-band Near Infrared Multispectral Camera',
+            'AnalyzIR': 'FOTRIC 323F 264*198 Thermal Imaging Camera'
+        }
+        
+        audio_spec = '44.1kHz, 16-bit'
+        
+        result = {}
+        
+        # Build status dict for each camera app
+        for app_name, running in app_status.items():
+            spec = camera_specs.get(app_name, 'Unknown')
+            result[app_name] = {
+                'running': running,
+                'spec': spec if running else 'DISCONNECTED'
+            }
+        
+        # Note: Audio system is checked via EOS Utility presence
+        # Audio is considered connected if EOS Utility is running
+        result['Audio System'] = {
+            'running': app_status.get('EOS Utility', False),
+            'spec': audio_spec if app_status.get('EOS Utility', False) else 'DISCONNECTED'
+        }
+        
+        print(f"[system_monitor] Returning camera status: {result}")
+        return result
+    
+    except Exception as e:
+        print(f"[system_monitor] ERROR in get_camera_status: {e}")
+        logger.error(f"Error getting camera status: {e}", exc_info=True)
+        return {}
+
+
+def get_model_load_status(models: Optional[Dict[str, Any]] = None) -> Dict[str, Dict[str, str]]:
+    """
+    Get load status of all AI models.
+    
+    Args:
+        models: Dictionary of model instances from main_window
+               If None, returns all models as 'Not Available'
+    
+    Returns:
+        Dict mapping model name to dict with 'status' and 'info'
+        Example: {
+            'Ripeness': {'status': 'online', 'info': 'Loaded'},
+            'Quality': {'status': 'offline', 'info': 'Failed'},
+            'Defect': {'status': 'online', 'info': 'Loaded'},
+            'Maturity': {'status': 'online', 'info': 'Loaded'},
+            'Shape': {'status': 'offline', 'info': 'Not Loaded'}
+        }
+    """
+    try:
+        model_names = {
+            'audio': 'Ripeness',
+            'defect': 'Quality',
+            'locule': 'Defect',
+            'maturity': 'Maturity',
+            'shape': 'Shape'
+        }
+        
+        result = {}
+        
+        for model_key, display_name in model_names.items():
+            if not models or models.get(model_key) is None:
+                result[display_name] = {
+                    'status': 'offline',
+                    'info': 'Not Loaded'
+                }
+            elif hasattr(models[model_key], 'is_loaded') and models[model_key].is_loaded:
+                result[display_name] = {
+                    'status': 'online',
+                    'info': 'Loaded'
+                }
+            else:
+                result[display_name] = {
+                    'status': 'offline',
+                    'info': 'Failed'
+                }
+        
+        return result
+    
+    except Exception as e:
+        logger.error(f"Error getting model load status: {e}", exc_info=True)
+        return {}
+
+
+def get_gpu_info() -> Dict[str, Any]:
+    """
+    Get GPU information and usage.
+    
+    Returns:
+        Dict with GPU info:
+        {
+            'available': True/False,
+            'name': 'NVIDIA RTX 3080',
+            'usage_percent': 45.2,
+            'vram_used_gb': 6.2,
+            'vram_total_gb': 12.0,
+            'display': 'NVIDIA RTX 3080 | Usage: 45% (6.2GB/12GB)'
+        }
+    """
+    try:
+        import torch
+        
+        if not torch.cuda.is_available():
+            return {
+                'available': False,
+                'name': 'Not Available',
+                'usage_percent': 0,
+                'vram_used_gb': 0,
+                'vram_total_gb': 0,
+                'display': 'GPU: Not Available (Using CPU)'
+            }
+        
+        # Get GPU name
+        gpu_name = torch.cuda.get_device_name(0)
+        
+        # Get VRAM info
+        total_vram = torch.cuda.get_device_properties(0).total_memory / (1024**3)  # Convert to GB
+        reserved_vram = torch.cuda.memory_reserved(0) / (1024**3)
+        allocated_vram = torch.cuda.memory_allocated(0) / (1024**3)
+        used_vram = max(reserved_vram, allocated_vram)
+        
+        # Calculate percentage
+        vram_percent = (used_vram / total_vram * 100) if total_vram > 0 else 0
+        
+        display_str = f"GPU: {gpu_name} | Usage: {vram_percent:.0f}% ({used_vram:.1f}GB/{total_vram:.1f}GB)"
+        
+        return {
+            'available': True,
+            'name': gpu_name,
+            'usage_percent': vram_percent,
+            'vram_used_gb': used_vram,
+            'vram_total_gb': total_vram,
+            'display': display_str
+        }
+    
+    except Exception as e:
+        print(f"Error getting GPU info: {e}")
+        return {
+            'available': False,
+            'name': 'Error',
+            'usage_percent': 0,
+            'vram_used_gb': 0,
+            'vram_total_gb': 0,
+            'display': 'GPU: Error retrieving info'
+        }
+
+
+def get_ram_info() -> Dict[str, Any]:
+    """
+    Get system RAM usage information.
+    
+    Returns:
+        Dict with RAM info:
+        {
+            'usage_percent': 62.5,
+            'used_gb': 8.0,
+            'total_gb': 16.0,
+            'display': 'RAM: 62% (8.0GB/16.0GB)'
+        }
+    """
+    try:
+        virtual_memory = psutil.virtual_memory()
+        
+        usage_percent = virtual_memory.percent
+        used_gb = virtual_memory.used / (1024**3)  # Convert bytes to GB
+        total_gb = virtual_memory.total / (1024**3)
+        
+        display_str = f"RAM: {usage_percent:.0f}% ({used_gb:.1f}GB/{total_gb:.1f}GB)"
+        
+        return {
+            'usage_percent': usage_percent,
+            'used_gb': used_gb,
+            'total_gb': total_gb,
+            'display': display_str
+        }
+    
+    except Exception as e:
+        print(f"Error getting RAM info: {e}")
+        return {
+            'usage_percent': 0,
+            'used_gb': 0,
+            'total_gb': 0,
+            'display': 'RAM: Error retrieving info'
+        }
+
+
+def get_full_system_status(models: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+    """
+    Get complete system status snapshot.
+    
+    Args:
+        models: Dictionary of model instances from main_window
+    
+    Returns:
+        Dict with all system status information
+    """
+    return {
+        'cameras': get_camera_status(),
+        'models': get_model_load_status(models),
+        'gpu': get_gpu_info(),
+        'ram': get_ram_info()
+    }

+ 28 - 0
workers/__init__.py

@@ -0,0 +1,28 @@
+"""
+Workers Package
+
+Contains QRunnable worker classes for async processing.
+"""
+
+from .base_worker import BaseWorker, WorkerSignals
+from .audio_worker import AudioWorker, AudioWorkerSignals
+from .defect_worker import DefectWorker, DefectWorkerSignals
+from .locule_worker import LoculeWorker, LoculeWorkerSignals
+from .maturity_worker import MaturityWorker, MaturityWorkerSignals
+from .shape_worker import ShapeWorker, ShapeWorkerSignals
+
+__all__ = [
+    'BaseWorker',
+    'WorkerSignals',
+    'AudioWorker',
+    'AudioWorkerSignals',
+    'DefectWorker',
+    'DefectWorkerSignals',
+    'LoculeWorker',
+    'LoculeWorkerSignals',
+    'MaturityWorker',
+    'MaturityWorkerSignals',
+    'ShapeWorker',
+    'ShapeWorkerSignals',
+]
+

+ 136 - 0
workers/audio_worker.py

@@ -0,0 +1,136 @@
+"""
+Audio Worker Module
+
+Worker thread for async audio processing and ripeness classification.
+"""
+
+from typing import Optional
+import logging
+
+from PyQt5.QtCore import pyqtSignal
+from PyQt5.QtGui import QPixmap
+
+from workers.base_worker import BaseWorker, WorkerSignals
+from models.audio_model import AudioModel
+
+logger = logging.getLogger(__name__)
+
+
+class AudioWorkerSignals(WorkerSignals):
+    """
+    Signals specific to audio processing.
+    
+    Signals:
+        result_ready: Emitted when prediction is complete
+            (waveform: QPixmap, spectrogram: QPixmap, class_name: str, 
+             confidence: float, probabilities: dict, knock_count: int)
+    """
+    result_ready = pyqtSignal(QPixmap, QPixmap, str, float, dict, int)
+
+
+class AudioWorker(BaseWorker):
+    """
+    Worker for processing audio files and predicting ripeness.
+
+    Runs AudioModel inference in a background thread without blocking UI.
+    Supports multiple audio formats (WAV, MP3, FLAC, OGG, M4A, AAC, WMA).
+
+    Attributes:
+        audio_path: Path to audio file (multiple formats supported)
+        model: AudioModel instance
+        signals: AudioWorkerSignals for emitting results
+    """
+    
+    def __init__(self, audio_path: str, model: Optional[AudioModel] = None):
+        """
+        Initialize the audio worker.
+
+        Args:
+            audio_path: Path to audio file to process (supports multiple formats)
+            model: AudioModel instance (if None, creates new one)
+        """
+        super().__init__()
+        self.audio_path = audio_path
+        self.model = model
+        
+        # Replace base signals with audio-specific signals
+        self.signals = AudioWorkerSignals()
+        
+        # If no model provided, create and load one
+        if self.model is None:
+            self.model = AudioModel(device='cpu')  # TensorFlow works best on CPU
+            self.model.load()
+        
+        logger.info(f"AudioWorker created for: {audio_path}")
+    
+    def process(self):
+        """
+        Process the audio file and predict ripeness.
+        
+        Emits result_ready signal with prediction results.
+        """
+        if self.is_cancelled():
+            logger.info("AudioWorker cancelled before processing")
+            return
+        
+        # Update progress
+        self.emit_progress(10, "Loading audio file...")
+        
+        if not self.model.is_loaded:
+            logger.warning("⚠️  Model not loaded, loading now...")
+            self.emit_progress(30, "Loading model...")
+            if not self.model.load():
+                logger.error("❌ Failed to load audio model")
+                raise RuntimeError("Failed to load audio model")
+            logger.info("✓ Model loaded successfully")
+        
+        # Process audio
+        self.emit_progress(50, "Detecting knocks and generating visualizations...")
+        
+        logger.info(f"Processing audio: {self.audio_path}")
+        result = self.model.predict(self.audio_path)
+        logger.info(f"Prediction result success: {result.get('success')}")
+        
+        if self.is_cancelled():
+            logger.info("AudioWorker cancelled during processing")
+            return
+        
+        if not result['success']:
+            logger.error(f"❌ Prediction failed: {result.get('error')}")
+            raise RuntimeError(result['error'])
+        
+        logger.info(f"✓ Prediction successful: {result.get('class_name')} ({result.get('confidence'):.2%})")
+        
+        self.emit_progress(90, "Finalizing results...")
+        
+        # Emit results (handle None images gracefully with placeholders)
+        waveform_image = result.get('waveform_image')
+        spectrogram_image = result.get('spectrogram_image')
+        
+        if waveform_image is None:
+            # Create a placeholder pixmap for missing waveform
+            from PyQt5.QtGui import QPixmap, QColor
+            waveform_image = QPixmap(400, 200)
+            waveform_image.fill(QColor("#2c3e50"))
+        
+        if spectrogram_image is None:
+            # Create a placeholder pixmap for missing spectrogram
+            from PyQt5.QtGui import QPixmap, QColor
+            spectrogram_image = QPixmap(400, 200)
+            spectrogram_image.fill(QColor("#2c3e50"))
+
+        self.signals.result_ready.emit(
+            waveform_image,
+            spectrogram_image,
+            result['class_name'],
+            result['confidence'],
+            result['probabilities'],
+            result.get('knock_count', 0)
+        )
+        
+        self.emit_progress(100, "Complete!")
+        
+        logger.info(f"AudioWorker completed: {result['class_name']} ({result['confidence']:.2%}) - {result.get('knock_count', 0)} knocks")
+
+
+

+ 124 - 0
workers/base_worker.py

@@ -0,0 +1,124 @@
+"""
+Base Worker Module
+
+Base class for all worker threads in the DuDONG system.
+Provides common functionality for async processing with Qt signals.
+"""
+
+from typing import Any
+import logging
+
+from PyQt5.QtCore import QRunnable, QObject, pyqtSignal, pyqtSlot
+
+logger = logging.getLogger(__name__)
+
+
+class WorkerSignals(QObject):
+    """
+    Base signals for worker threads.
+    
+    All workers emit these common signals for UI updates.
+    
+    Signals:
+        started: Emitted when worker starts processing
+        finished: Emitted when worker completes (success or failure)
+        error: Emitted when an error occurs (error_message: str)
+        progress: Emitted to report progress (percentage: int, message: str)
+    """
+    
+    started = pyqtSignal()
+    finished = pyqtSignal()
+    error = pyqtSignal(str)  # error message
+    progress = pyqtSignal(int, str)  # percentage, message
+
+
+class BaseWorker(QRunnable):
+    """
+    Base class for worker threads.
+    
+    All worker classes should inherit from this and implement the process() method.
+    Workers run in QThreadPool for non-blocking UI updates.
+    
+    Attributes:
+        signals: WorkerSignals instance for emitting signals
+    """
+    
+    def __init__(self):
+        """Initialize the base worker."""
+        super().__init__()
+        self.signals = WorkerSignals()
+        self._is_cancelled = False
+        
+    def process(self) -> Any:
+        """
+        Process the worker's task.
+        
+        This method must be implemented by all subclasses.
+        Should perform the actual work and return results.
+        
+        Returns:
+            Any: Processing results (format depends on worker type)
+        
+        Raises:
+            NotImplementedError: If not overridden by subclass
+            Exception: Any errors during processing
+        """
+        raise NotImplementedError("Subclasses must implement process() method")
+    
+    @pyqtSlot()
+    def run(self):
+        """
+        Run the worker.
+        
+        This is called by QThreadPool. It handles the workflow:
+        1. Emit started signal
+        2. Call process() method
+        3. Emit finished signal
+        4. Catch and emit any errors
+        """
+        try:
+            logger.info(f"{self.__class__.__name__} started")
+            self.signals.started.emit()
+            
+            # Call the subclass's process method
+            self.process()
+            
+            logger.info(f"{self.__class__.__name__} finished")
+            
+        except Exception as e:
+            error_msg = f"{self.__class__.__name__} error: {str(e)}"
+            logger.error(error_msg)
+            self.signals.error.emit(error_msg)
+            
+        finally:
+            self.signals.finished.emit()
+    
+    def cancel(self):
+        """
+        Request cancellation of the worker.
+        
+        Note: This sets a flag - the process() method must check
+        self._is_cancelled periodically to actually stop.
+        """
+        logger.info(f"{self.__class__.__name__} cancellation requested")
+        self._is_cancelled = True
+    
+    def is_cancelled(self) -> bool:
+        """
+        Check if worker has been cancelled.
+        
+        Returns:
+            bool: True if cancelled, False otherwise
+        """
+        return self._is_cancelled
+    
+    def emit_progress(self, percentage: int, message: str = ""):
+        """
+        Emit a progress update.
+        
+        Args:
+            percentage: Progress percentage (0-100)
+            message: Optional progress message
+        """
+        self.signals.progress.emit(percentage, message)
+

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů