reports_tab.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. """
  2. Reports & Export Tab
  3. Display analysis results and export functionality.
  4. This module provides the main Reports Tab UI, delegating report generation,
  5. visualization, export, and printing to specialized modules.
  6. """
  7. from datetime import datetime
  8. from typing import Optional, Dict, List, Tuple
  9. from PyQt5.QtWidgets import (
  10. QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
  11. QFrame, QMessageBox, QStackedWidget, QScrollArea
  12. )
  13. from PyQt5.QtCore import Qt, pyqtSignal
  14. from PyQt5.QtGui import QFont, QPixmap, QImage
  15. from resources.styles import COLORS
  16. from ui.widgets.loading_screen import LoadingScreen
  17. from ui.dialogs import PrintOptionsDialog
  18. # Import refactored modules
  19. from ui.components.report_generator import (
  20. generate_basic_report,
  21. generate_model_report,
  22. generate_comprehensive_report,
  23. extract_report_content
  24. )
  25. from ui.components.pdf_exporter import PDFExporter
  26. from ui.components.report_printer import ReportPrinter
  27. class ReportsTab(QWidget):
  28. """Tab for displaying analysis reports and exporting data."""
  29. # Signal to notify when user wants to go back to dashboard
  30. go_to_dashboard = pyqtSignal()
  31. def __init__(self, parent=None):
  32. super().__init__(parent)
  33. self.current_data = None
  34. self.input_data = None
  35. self.maturity_result = None
  36. self.current_analysis_id = None # Set by main_window for data persistence
  37. self.current_report_id = None # Set by main_window - the actual report ID
  38. self.data_manager = None # Set by main_window for data persistence
  39. self.current_overall_grade = 'B' # Track current grade for finalization
  40. self.current_grade_description = '' # Track grade description
  41. self.has_result = False # Track if we have a report result
  42. self.current_results = None # Store results dictionary for print/export
  43. # Initialize export/print modules
  44. self.pdf_exporter = PDFExporter()
  45. self.report_printer = ReportPrinter()
  46. self.init_ui()
  47. def init_ui(self):
  48. """Initialize the UI components."""
  49. main_layout = QVBoxLayout(self)
  50. main_layout.setContentsMargins(10, 10, 10, 10)
  51. # Header with title and action buttons
  52. header_layout = QHBoxLayout()
  53. title = QLabel("📊 Analysis Report")
  54. title.setFont(QFont("Arial", 18, QFont.Bold))
  55. header_layout.addWidget(title)
  56. header_layout.addStretch()
  57. # Export PDF button
  58. self.export_btn = QPushButton("📄 Export PDF")
  59. self.export_btn.setStyleSheet("""
  60. QPushButton {
  61. background-color: #3498db;
  62. color: white;
  63. font-weight: bold;
  64. padding: 8px 16px;
  65. border-radius: 4px;
  66. font-size: 12px;
  67. }
  68. QPushButton:hover {
  69. background-color: #2980b9;
  70. }
  71. QPushButton:disabled {
  72. background-color: #95a5a6;
  73. }
  74. """)
  75. self.export_btn.clicked.connect(self.export_pdf)
  76. self.export_btn.setEnabled(False)
  77. header_layout.addWidget(self.export_btn)
  78. main_layout.addLayout(header_layout)
  79. # Separator
  80. separator = QFrame()
  81. separator.setFrameShape(QFrame.HLine)
  82. separator.setStyleSheet("background-color: #bdc3c7;")
  83. main_layout.addWidget(separator)
  84. # Stacked widget for content and loading screen
  85. self.stacked_widget = QStackedWidget()
  86. # Loading screen (index 0) - NOT started yet, will be started when needed
  87. self.loading_screen = LoadingScreen("Processing analysis...")
  88. self.loading_screen.stop_animation() # Stop by default, will start when processing begins
  89. self.stacked_widget.addWidget(self.loading_screen)
  90. # Report content (index 1)
  91. scroll = QScrollArea()
  92. scroll.setWidgetResizable(True)
  93. scroll.setStyleSheet("QScrollArea { border: none; }")
  94. # Report content widget
  95. self.report_widget = QWidget()
  96. self.report_layout = QVBoxLayout(self.report_widget)
  97. self.report_layout.setContentsMargins(10, 10, 10, 10)
  98. # Create empty state with button
  99. self._create_empty_state()
  100. scroll.setWidget(self.report_widget)
  101. self.stacked_widget.addWidget(scroll)
  102. main_layout.addWidget(self.stacked_widget)
  103. # Show content view by default (not loading)
  104. self.stacked_widget.setCurrentIndex(1)
  105. def _create_empty_state(self):
  106. """Create the empty state UI with button to go to dashboard."""
  107. # Clear any existing widgets
  108. while self.report_layout.count() > 0:
  109. item = self.report_layout.takeAt(0)
  110. if item.widget():
  111. item.widget().deleteLater()
  112. self.report_layout.addStretch()
  113. # Icon/Title
  114. icon_label = QLabel("📊")
  115. icon_label.setFont(QFont("Arial", 48))
  116. icon_label.setAlignment(Qt.AlignCenter)
  117. self.report_layout.addWidget(icon_label)
  118. # Message
  119. message_label = QLabel("No Analysis Yet")
  120. message_label.setFont(QFont("Arial", 18, QFont.Bold))
  121. message_label.setAlignment(Qt.AlignCenter)
  122. message_label.setStyleSheet(f"color: {COLORS['text_primary']};")
  123. self.report_layout.addWidget(message_label)
  124. # Description
  125. desc_label = QLabel("Click 'Analyze Durian' on the dashboard to start an analysis")
  126. desc_label.setFont(QFont("Arial", 12))
  127. desc_label.setAlignment(Qt.AlignCenter)
  128. desc_label.setStyleSheet(f"color: {COLORS['text_secondary']}; margin: 10px;")
  129. desc_label.setWordWrap(True)
  130. self.report_layout.addWidget(desc_label)
  131. # Button container
  132. button_layout = QHBoxLayout()
  133. button_layout.addStretch()
  134. # Go to Dashboard button
  135. go_dashboard_btn = QPushButton("🏠 Go to Dashboard")
  136. go_dashboard_btn.setFont(QFont("Arial", 12, QFont.Bold))
  137. go_dashboard_btn.setStyleSheet("""
  138. QPushButton {
  139. background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
  140. stop:0 #3498db, stop:1 #2980b9);
  141. color: white;
  142. padding: 12px 24px;
  143. border-radius: 6px;
  144. border: none;
  145. }
  146. QPushButton:hover {
  147. background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
  148. stop:0 #2980b9, stop:1 #1f618d);
  149. }
  150. QPushButton:pressed {
  151. background: #1f618d;
  152. }
  153. """)
  154. go_dashboard_btn.clicked.connect(self.go_to_dashboard.emit)
  155. button_layout.addWidget(go_dashboard_btn)
  156. button_layout.addStretch()
  157. self.report_layout.addLayout(button_layout)
  158. self.report_layout.addStretch()
  159. self.has_result = False
  160. def _add_result_action_button(self):
  161. """Add action button to go to dashboard for a new analysis."""
  162. # Find and remove existing action button if any
  163. for i in range(self.report_layout.count() - 1, -1, -1):
  164. item = self.report_layout.itemAt(i)
  165. if item and item.widget() and hasattr(item.widget(), 'objectName'):
  166. if item.widget().objectName() == "result_action_button":
  167. item.widget().deleteLater()
  168. break
  169. # Add button container at the end
  170. action_layout = QHBoxLayout()
  171. action_layout.addStretch()
  172. # New Analysis button
  173. new_analysis_btn = QPushButton("🔄 New Analysis")
  174. new_analysis_btn.setObjectName("result_action_button")
  175. new_analysis_btn.setFont(QFont("Arial", 11, QFont.Bold))
  176. new_analysis_btn.setStyleSheet("""
  177. QPushButton {
  178. background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
  179. stop:0 #2ecc71, stop:1 #27ae60);
  180. color: white;
  181. padding: 10px 20px;
  182. border-radius: 5px;
  183. border: none;
  184. }
  185. QPushButton:hover {
  186. background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
  187. stop:0 #27ae60, stop:1 #1e8449);
  188. }
  189. QPushButton:pressed {
  190. background: #1e8449;
  191. }
  192. """)
  193. new_analysis_btn.clicked.connect(self.go_to_dashboard.emit)
  194. action_layout.addWidget(new_analysis_btn)
  195. action_layout.addStretch()
  196. self.report_layout.addLayout(action_layout)
  197. self.has_result = True
  198. def clear_report(self):
  199. """Clear the current report and show empty state."""
  200. # Remove all widgets
  201. while self.report_layout.count() > 0:
  202. item = self.report_layout.takeAt(0)
  203. if item.widget():
  204. item.widget().deleteLater()
  205. # Show empty state
  206. self._create_empty_state()
  207. self.export_btn.setEnabled(False)
  208. self.current_data = None
  209. self.has_result = False
  210. def set_loading(self, is_loading: bool, message: str = "Processing analysis..."):
  211. """
  212. Set loading state for report generation.
  213. Args:
  214. is_loading: True to show loading screen, False to show content
  215. message: Loading message to display
  216. """
  217. self.export_btn.setEnabled(not is_loading)
  218. if is_loading:
  219. # Show loading screen with animation
  220. self.loading_screen.set_message(message)
  221. self.loading_screen.set_status("")
  222. self.loading_screen.spinner_index = 0 # Reset spinner
  223. self.stacked_widget.setCurrentIndex(0)
  224. self.loading_screen.start_animation()
  225. else:
  226. # Hide loading screen and show content
  227. self.loading_screen.stop_animation()
  228. self.stacked_widget.setCurrentIndex(1)
  229. def generate_report(self, input_data: Dict[str, str]):
  230. """
  231. Generate analysis report from input data.
  232. Args:
  233. input_data: Dictionary with keys 'dslr', 'multispectral', 'thermal', 'audio'
  234. """
  235. self.current_data = input_data
  236. self.input_data = input_data
  237. # Clear existing report
  238. while self.report_layout.count() > 0:
  239. item = self.report_layout.takeAt(0)
  240. if item.widget():
  241. item.widget().deleteLater()
  242. # Generate report using report generator
  243. result = generate_basic_report(self.report_layout, input_data)
  244. self.current_overall_grade = result['grade']
  245. self.current_grade_description = result['description']
  246. self.report_layout.addStretch()
  247. # Add action button for new analysis
  248. self._add_result_action_button()
  249. # Stop loading screen and show content
  250. self.loading_screen.stop_animation()
  251. self.stacked_widget.setCurrentIndex(1)
  252. # Enable export/print buttons
  253. self.export_btn.setEnabled(True)
  254. def generate_report_with_model(
  255. self,
  256. input_data: Dict[str, str],
  257. gradcam_image: QImage,
  258. predicted_class: str,
  259. confidence: float,
  260. probabilities: Dict[str, float]
  261. ):
  262. """
  263. Generate analysis report with actual multispectral model results.
  264. Args:
  265. input_data: Dictionary with keys 'dslr', 'multispectral', 'thermal', 'audio'
  266. gradcam_image: QImage of the Grad-CAM visualization
  267. predicted_class: Predicted maturity class from model
  268. confidence: Model confidence (0-1 scale)
  269. probabilities: Dictionary of class probabilities
  270. """
  271. self.current_data = input_data
  272. self.input_data = input_data
  273. self.maturity_result = {
  274. 'class': predicted_class,
  275. 'confidence': confidence,
  276. 'probabilities': probabilities,
  277. 'gradcam_image': gradcam_image
  278. }
  279. # Clear existing report
  280. while self.report_layout.count() > 0:
  281. item = self.report_layout.takeAt(0)
  282. if item.widget():
  283. item.widget().deleteLater()
  284. # Generate report using report generator
  285. result = generate_model_report(
  286. self.report_layout,
  287. input_data,
  288. gradcam_image,
  289. predicted_class,
  290. confidence,
  291. probabilities
  292. )
  293. self.current_overall_grade = result['grade']
  294. self.current_grade_description = result['description']
  295. self.report_layout.addStretch()
  296. # Add action button for new analysis
  297. self._add_result_action_button()
  298. # Stop loading screen and show content
  299. self.loading_screen.stop_animation()
  300. self.stacked_widget.setCurrentIndex(1)
  301. # Enable export/print buttons
  302. self.export_btn.setEnabled(True)
  303. def generate_report_with_rgb_and_multispectral(self, input_data: Dict[str, str], results: Dict):
  304. """
  305. Generate comprehensive report with RGB models and multispectral analysis.
  306. Args:
  307. input_data: Dictionary with input file paths
  308. results: Dictionary with processing results from all models
  309. Keys: 'defect', 'locule', 'maturity', 'shape', 'audio'
  310. """
  311. self.current_data = input_data
  312. self.input_data = input_data
  313. self.current_results = results # Store results for print/export
  314. # Clear existing report
  315. while self.report_layout.count() > 0:
  316. item = self.report_layout.takeAt(0)
  317. if item.widget():
  318. item.widget().deleteLater()
  319. # Generate report using report generator
  320. result = generate_comprehensive_report(
  321. self.report_layout,
  322. input_data,
  323. results,
  324. self.current_report_id
  325. )
  326. self.current_overall_grade = result['grade']
  327. self.current_grade_description = result['description']
  328. self.report_layout.addStretch()
  329. # Add action button for new analysis
  330. self._add_result_action_button()
  331. # Stop loading screen and show content
  332. self.loading_screen.stop_animation()
  333. self.stacked_widget.setCurrentIndex(1)
  334. # Enable export/print buttons
  335. self.export_btn.setEnabled(True)
  336. def _get_report_visualizations(self) -> List[Tuple[str, QPixmap]]:
  337. """
  338. Extract all visualizations from the current report.
  339. Returns:
  340. List of tuples (title, QPixmap)
  341. """
  342. visualizations = []
  343. if not self.current_results:
  344. return visualizations
  345. results = self.current_results
  346. # Grad-CAM visualization
  347. if 'maturity' in results and results['maturity'].get('gradcam_image'):
  348. gradcam_img = results['maturity']['gradcam_image']
  349. if isinstance(gradcam_img, QImage):
  350. gradcam_pixmap = QPixmap.fromImage(gradcam_img)
  351. else:
  352. gradcam_pixmap = gradcam_img
  353. if not gradcam_pixmap.isNull():
  354. visualizations.append(("Grad-CAM Visualization (Maturity)", gradcam_pixmap))
  355. # Locule analysis visualization
  356. if 'locule' in results and results['locule'].get('annotated_image'):
  357. locule_pixmap = results['locule']['annotated_image']
  358. if not locule_pixmap.isNull():
  359. visualizations.append(("Locule Analysis (Top View)", locule_pixmap))
  360. # Defect analysis visualization
  361. if 'defect' in results and results['defect'].get('annotated_image'):
  362. defect_pixmap = results['defect']['annotated_image']
  363. if not defect_pixmap.isNull():
  364. visualizations.append(("Defect Analysis (Side View)", defect_pixmap))
  365. # Shape analysis visualization
  366. if 'shape' in results and results['shape'].get('annotated_image'):
  367. shape_pixmap = results['shape']['annotated_image']
  368. if not shape_pixmap.isNull():
  369. visualizations.append(("Shape Classification (Side View)", shape_pixmap))
  370. # Audio waveform visualization
  371. if 'audio' in results and results['audio'].get('waveform_image'):
  372. waveform_pixmap = results['audio']['waveform_image']
  373. if not waveform_pixmap.isNull():
  374. visualizations.append(("Waveform with Detected Knocks", waveform_pixmap))
  375. # Audio spectrogram visualization
  376. if 'audio' in results and results['audio'].get('spectrogram_image'):
  377. spectrogram_pixmap = results['audio']['spectrogram_image']
  378. if not spectrogram_pixmap.isNull():
  379. visualizations.append(("Mel Spectrogram", spectrogram_pixmap))
  380. return visualizations
  381. def export_pdf(self):
  382. """Export report as PDF using reportlab."""
  383. # Check if report data is available
  384. if not self.has_result:
  385. QMessageBox.warning(
  386. self,
  387. "No Report",
  388. "No analysis report available to export. Please complete an analysis first."
  389. )
  390. return
  391. # Ask about visualizations BEFORE file dialog
  392. options_dialog = PrintOptionsDialog(self)
  393. if options_dialog.exec_() != PrintOptionsDialog.Accepted:
  394. return
  395. include_visualizations = options_dialog.get_include_visualizations()
  396. # Extract report content
  397. report_content = extract_report_content(
  398. self.current_report_id or f"DUR-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
  399. self.current_overall_grade,
  400. self.current_grade_description,
  401. self.current_results
  402. )
  403. # Get visualizations
  404. visualizations = self._get_report_visualizations()
  405. # Export using PDF exporter
  406. self.pdf_exporter.export_report(
  407. self,
  408. self.current_report_id or datetime.now().strftime('%Y%m%d_%H%M%S'),
  409. report_content,
  410. visualizations,
  411. include_visualizations
  412. )
  413. def print_report(self):
  414. """Print report with options dialog."""
  415. # Check if report data is available
  416. if not self.has_result:
  417. QMessageBox.warning(
  418. self,
  419. "No Report",
  420. "No analysis report available to print. Please complete an analysis first."
  421. )
  422. return
  423. # Show print options dialog
  424. options_dialog = PrintOptionsDialog(self)
  425. if options_dialog.exec_() != PrintOptionsDialog.Accepted:
  426. return
  427. include_visualizations = options_dialog.get_include_visualizations()
  428. # Extract report content
  429. report_content = extract_report_content(
  430. self.current_report_id or f"DUR-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
  431. self.current_overall_grade,
  432. self.current_grade_description,
  433. self.current_results
  434. )
  435. # Get visualizations
  436. visualizations = self._get_report_visualizations()
  437. # Print using report printer
  438. self.report_printer.print_report(
  439. self,
  440. report_content,
  441. visualizations,
  442. include_visualizations
  443. )
  444. def load_analysis_from_db(self, report_id: str) -> bool:
  445. """
  446. Load a saved analysis from the database and display it.
  447. Args:
  448. report_id: Report ID to load
  449. Returns:
  450. bool: True if successfully loaded, False otherwise
  451. """
  452. if not self.data_manager:
  453. QMessageBox.warning(self, "Error", "Data manager not initialized")
  454. return False
  455. # Export analysis data with full file paths
  456. analysis_data = self.data_manager.export_analysis_to_dict(report_id)
  457. if not analysis_data:
  458. QMessageBox.warning(self, "Error", f"Analysis not found: {report_id}")
  459. return False
  460. # Reconstruct input_data dictionary for report generation
  461. input_data = {}
  462. for input_item in analysis_data['inputs']:
  463. input_type = input_item['input_type']
  464. full_path = input_item.get('full_path')
  465. if full_path:
  466. input_data[input_type] = full_path
  467. # Reconstruct results dictionary with visualizations
  468. results = {}
  469. for result in analysis_data['results']:
  470. model_type = result['model_type']
  471. # Find visualizations for this result
  472. result_data = {
  473. 'predicted_class': result['predicted_class'],
  474. 'confidence': result['confidence'],
  475. 'probabilities': result['probabilities'],
  476. 'metadata': result['metadata']
  477. }
  478. # Load annotated images
  479. if model_type == 'defect':
  480. # Find defect_annotated visualization
  481. viz = next((v for v in analysis_data['visualizations']
  482. if v['visualization_type'] == 'defect_annotated'), None)
  483. if viz and viz.get('full_path'):
  484. result_data['annotated_image'] = QPixmap(viz['full_path'])
  485. result_data['primary_class'] = result['predicted_class']
  486. result_data['total_detections'] = result['metadata'].get('total_detections', 0)
  487. result_data['class_counts'] = result['metadata'].get('class_counts', {})
  488. elif model_type == 'locule':
  489. # Find locule_annotated visualization
  490. viz = next((v for v in analysis_data['visualizations']
  491. if v['visualization_type'] == 'locule_annotated'), None)
  492. if viz and viz.get('full_path'):
  493. result_data['annotated_image'] = QPixmap(viz['full_path'])
  494. result_data['locule_count'] = result['metadata'].get('locule_count', 0)
  495. elif model_type == 'maturity':
  496. # Find maturity_gradcam visualization
  497. viz = next((v for v in analysis_data['visualizations']
  498. if v['visualization_type'] == 'maturity_gradcam'), None)
  499. if viz and viz.get('full_path'):
  500. result_data['gradcam_image'] = QImage(viz['full_path'])
  501. result_data['class_name'] = result['predicted_class']
  502. elif model_type == 'shape':
  503. # Find shape_annotated visualization
  504. viz = next((v for v in analysis_data['visualizations']
  505. if v['visualization_type'] == 'shape_annotated'), None)
  506. if viz and viz.get('full_path'):
  507. result_data['annotated_image'] = QPixmap(viz['full_path'])
  508. result_data['shape_class'] = result['predicted_class']
  509. result_data['class_id'] = result['metadata'].get('class_id', 0)
  510. elif model_type == 'audio':
  511. # Find audio visualizations
  512. waveform_viz = next((v for v in analysis_data['visualizations']
  513. if v['visualization_type'] == 'audio_waveform'), None)
  514. spectrogram_viz = next((v for v in analysis_data['visualizations']
  515. if v['visualization_type'] == 'audio_spectrogram'), None)
  516. if waveform_viz and waveform_viz.get('full_path'):
  517. result_data['waveform_image'] = QPixmap(waveform_viz['full_path'])
  518. if spectrogram_viz and spectrogram_viz.get('full_path'):
  519. result_data['spectrogram_image'] = QPixmap(spectrogram_viz['full_path'])
  520. result_data['ripeness_class'] = result['predicted_class']
  521. result_data['knock_count'] = result['metadata'].get('knock_count', 0)
  522. results[model_type] = result_data
  523. # Store for reference
  524. self.current_data = analysis_data
  525. self.current_report_id = report_id # Store the report ID for display
  526. self.input_data = input_data
  527. # Display the report
  528. self.generate_report_with_rgb_and_multispectral(input_data, results)
  529. return True