cluster4npu/ui/dialogs/deployment.py
HuangMason320 ccd7cdd6b9 feat: Reorganize test scripts and improve YOLOv5 postprocessing
- Move test scripts to tests/ directory for better organization
- Add improved YOLOv5 postprocessing with reference implementation
- Update gitignore to exclude *.mflow files and include main.spec
- Add debug capabilities and coordinate scaling improvements
- Enhance multi-series support with proper validation
- Add AGENTS.md documentation and example utilities

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 19:23:59 +08:00

1283 lines
58 KiB
Python

"""
Pipeline Deployment Dialog
This dialog handles the conversion of .mflow pipeline data to executable format
and deployment to Kneron dongles using the InferencePipeline system.
Main Components:
- Pipeline conversion using MFlowConverter
- Topology analysis and optimization
- Dongle status monitoring
- Real-time deployment progress
- Error handling and troubleshooting
Usage:
from ui.dialogs.deployment import DeploymentDialog
dialog = DeploymentDialog(pipeline_data, parent=self)
dialog.exec_()
"""
import os
import sys
import json
import threading
import traceback
import io
import contextlib
from typing import Dict, Any, List, Optional
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton,
QProgressBar, QTabWidget, QWidget, QFormLayout, QLineEdit, QSpinBox,
QCheckBox, QGroupBox, QScrollArea, QTableWidget, QTableWidgetItem,
QHeaderView, QMessageBox, QSplitter, QFrame
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt5.QtGui import QFont, QColor, QPalette, QImage, QPixmap
# Ensure project root is on sys.path so that 'core.functions' package imports work
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
try:
from core.functions.mflow_converter import MFlowConverter, PipelineConfig
CONVERTER_AVAILABLE = True
except ImportError as e:
print(f"Warning: MFlow converter not available: {e}")
CONVERTER_AVAILABLE = False
try:
from core.functions.Multidongle import MultiDongle
from core.functions.InferencePipeline import InferencePipeline
from core.functions.workflow_orchestrator import WorkflowOrchestrator
# from workflow_orchestrator import WorkflowOrchestrator
PIPELINE_AVAILABLE = True
except ImportError as e:
print(f"Warning: Pipeline system not available: {e}")
PIPELINE_AVAILABLE = False
class StdoutCapture:
"""Context manager to capture stdout/stderr and emit to signal."""
def __init__(self, signal_emitter):
self.signal_emitter = signal_emitter
self.original_stdout = None
self.original_stderr = None
self.captured_output = io.StringIO()
def __enter__(self):
self.original_stdout = sys.stdout
self.original_stderr = sys.stderr
# Create a custom write function that both prints to original and captures
class TeeWriter:
def __init__(self, original, captured, emitter):
self.original = original
self.captured = captured
self.emitter = emitter
self._emitting = False # Prevent recursion
def write(self, text):
# Write to original stdout/stderr (so it still appears in terminal)
# Check if original exists (it might be None in PyInstaller builds)
if self.original is not None:
self.original.write(text)
self.original.flush()
# Capture for GUI if it's a substantial message and not already emitting
if text.strip() and not self._emitting:
self._emitting = True
try:
self.emitter(text)
finally:
self._emitting = False
def flush(self):
# Check if original exists before calling flush
if self.original is not None:
self.original.flush()
# Replace stdout and stderr with our tee writers
sys.stdout = TeeWriter(self.original_stdout, self.captured_output, self.signal_emitter)
sys.stderr = TeeWriter(self.original_stderr, self.captured_output, self.signal_emitter)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Restore original stdout/stderr
sys.stdout = self.original_stdout
sys.stderr = self.original_stderr
class DeploymentWorker(QThread):
"""Worker thread for pipeline deployment to avoid blocking UI."""
# Signals
progress_updated = pyqtSignal(int, str) # progress, message
topology_analyzed = pyqtSignal(dict) # topology analysis results
conversion_completed = pyqtSignal(object) # PipelineConfig object
deployment_started = pyqtSignal()
deployment_completed = pyqtSignal(bool, str) # success, message
error_occurred = pyqtSignal(str)
frame_updated = pyqtSignal('PyQt_PyObject') # For live view
result_updated = pyqtSignal(dict) # For inference results
terminal_output = pyqtSignal(str) # For terminal output in GUI
stdout_captured = pyqtSignal(str) # For captured stdout/stderr
def __init__(self, pipeline_data: Dict[str, Any]):
super().__init__()
self.pipeline_data = pipeline_data
self.should_stop = False
self.orchestrator = None
def run(self):
"""Main deployment workflow."""
try:
# Step 1: Convert .mflow to pipeline config
self.progress_updated.emit(10, "Converting pipeline configuration...")
if not CONVERTER_AVAILABLE:
self.error_occurred.emit("MFlow converter not available. Please check installation.")
return
converter = MFlowConverter()
config = converter._convert_mflow_to_config(self.pipeline_data)
# Emit topology analysis results
self.topology_analyzed.emit({
'total_stages': len(config.stage_configs),
'pipeline_name': config.pipeline_name,
'input_config': config.input_config,
'output_config': config.output_config
})
self.progress_updated.emit(30, "Pipeline conversion completed")
self.conversion_completed.emit(config)
if self.should_stop:
return
# Step 2: Validate configuration
self.progress_updated.emit(40, "Validating pipeline configuration...")
is_valid, errors = converter.validate_config(config)
if not is_valid:
error_msg = "Configuration validation failed:\n" + "\n".join(errors)
self.error_occurred.emit(error_msg)
return
self.progress_updated.emit(60, "Configuration validation passed")
if self.should_stop:
return
# Step 3: Initialize pipeline (if dongle system available)
self.progress_updated.emit(70, "Initializing inference pipeline...")
if not PIPELINE_AVAILABLE:
self.progress_updated.emit(100, "Pipeline configuration ready (dongle system not available)")
self.deployment_completed.emit(True, "Pipeline configuration prepared successfully. Dongle system not available for actual deployment.")
return
# Create InferencePipeline instance with stdout capture
try:
# Capture all stdout/stderr during pipeline operations
with StdoutCapture(self.stdout_captured.emit):
pipeline = converter.create_inference_pipeline(config)
self.progress_updated.emit(80, "Initializing workflow orchestrator...")
self.deployment_started.emit()
# Create and start the orchestrator
self.orchestrator = WorkflowOrchestrator(pipeline, config.input_config, config.output_config)
self.orchestrator.set_frame_callback(self.frame_updated.emit)
# Set up both GUI and terminal result callbacks
def combined_result_callback(result_dict):
# Check if this is a valid result (not async/processing status)
stage_results = result_dict.get('stage_results', {})
has_valid_result = False
for stage_id, result in stage_results.items():
if isinstance(result, dict):
status = result.get('status', '')
if status not in ['async', 'processing']:
has_valid_result = True
break
elif isinstance(result, tuple) and len(result) == 2:
prob, result_str = result
if prob is not None and result_str not in ['Processing']:
has_valid_result = True
break
# Only display and process if we have valid results
if has_valid_result:
# Add current FPS from pipeline to result_dict
current_fps = pipeline.get_current_fps()
result_dict['current_pipeline_fps'] = current_fps
if os.getenv('C4NPU_DEBUG', '0') == '1':
print(f"DEBUG: Pipeline FPS = {current_fps:.2f}") # Debug info
# Send to GUI terminal and results display
terminal_output = self._format_terminal_results(result_dict)
self.terminal_output.emit(terminal_output)
# Emit for GUI
self.result_updated.emit(result_dict)
self.orchestrator.set_result_callback(combined_result_callback)
self.orchestrator.start()
self.progress_updated.emit(100, "Pipeline deployed successfully!")
self.deployment_completed.emit(True, f"Pipeline '{config.pipeline_name}' deployed with {len(config.stage_configs)} stages")
# Keep running until stop is requested with continued stdout capture
while not self.should_stop:
self.msleep(100) # Sleep for 100ms and check again
except Exception as e:
self.error_occurred.emit(f"Pipeline deployment failed: {str(e)}")
except Exception as e:
self.error_occurred.emit(f"Deployment error: {str(e)}")
def stop(self):
"""Stop the deployment process."""
self.should_stop = True
if self.orchestrator:
self.orchestrator.stop()
def _format_terminal_results(self, result_dict):
"""Format inference results for terminal display in GUI."""
try:
from datetime import datetime
# Header with timestamp
timestamp = datetime.fromtimestamp(result_dict.get('timestamp', 0)).strftime("%H:%M:%S.%f")[:-3]
pipeline_id = result_dict.get('pipeline_id', 'Unknown')
output_lines = []
output_lines.append(f"\nINFERENCE RESULT [{timestamp}]")
output_lines.append(f" Pipeline ID: {pipeline_id}")
output_lines.append(" " + "="*50)
# Stage results
stage_results = result_dict.get('stage_results', {})
if stage_results:
for stage_id, result in stage_results.items():
output_lines.append(f" Stage: {stage_id}")
if isinstance(result, tuple) and len(result) == 2:
# Handle tuple results (may be (ObjectDetectionResult, result_string) or (float, result_string))
probability_or_obj, result_string = result
output_lines.append(f" Result: {result_string}")
# If first element is an object detection result, summarize detections
if hasattr(probability_or_obj, 'box_count') and hasattr(probability_or_obj, 'box_list'):
det = probability_or_obj
output_lines.append(f" Detections: {int(getattr(det, 'box_count', 0))}")
# Optional short class summary
class_counts = {}
for b in getattr(det, 'box_list', [])[:5]:
name = getattr(b, 'class_name', 'object')
class_counts[name] = class_counts.get(name, 0) + 1
if class_counts:
summary = ", ".join(f"{k} x{v}" for k, v in class_counts.items())
output_lines.append(f" Classes: {summary}")
else:
# Safely format numeric probability
try:
prob_value = float(probability_or_obj)
output_lines.append(f" Probability: {prob_value:.3f}")
# Add confidence level
if prob_value > 0.8:
confidence = "Very High"
elif prob_value > 0.6:
confidence = "High"
elif prob_value > 0.4:
confidence = "Medium"
else:
confidence = "Low"
output_lines.append(f" Confidence: {confidence}")
except (ValueError, TypeError):
output_lines.append(f" Probability: {probability_or_obj}")
elif isinstance(result, dict):
# Handle dict results
for key, value in result.items():
if key == 'probability':
try:
prob_value = float(value)
output_lines.append(f" {key.title()}: {prob_value:.3f}")
except (ValueError, TypeError):
output_lines.append(f" {key.title()}: {value}")
elif key == 'result':
output_lines.append(f" {key.title()}: {value}")
elif key == 'confidence':
output_lines.append(f" {key.title()}: {value}")
elif key == 'fused_probability':
try:
prob_value = float(value)
output_lines.append(f" Fused Probability: {prob_value:.3f}")
except (ValueError, TypeError):
output_lines.append(f" Fused Probability: {value}")
elif key == 'individual_probs':
output_lines.append(f" Individual Probabilities:")
for prob_key, prob_value in value.items():
try:
float_prob = float(prob_value)
output_lines.append(f" {prob_key}: {float_prob:.3f}")
except (ValueError, TypeError):
output_lines.append(f" {prob_key}: {prob_value}")
else:
output_lines.append(f" {key}: {value}")
else:
# Handle other result types, including detection objects
# Try to pretty-print ObjectDetectionResult-like objects
try:
if hasattr(result, 'box_count') and hasattr(result, 'box_list'):
# Summarize detections
count = int(getattr(result, 'box_count', 0))
output_lines.append(f" Detections: {count}")
# Optional: top classes summary
class_counts = {}
for b in getattr(result, 'box_list', [])[:5]:
name = getattr(b, 'class_name', 'object')
class_counts[name] = class_counts.get(name, 0) + 1
if class_counts:
summary = ", ".join(f"{k} x{v}" for k, v in class_counts.items())
output_lines.append(f" Classes: {summary}")
else:
output_lines.append(f" Raw Result: {result}")
except Exception:
output_lines.append(f" Raw Result: {result}")
output_lines.append("") # Blank line between stages
else:
output_lines.append(" No stage results available")
# Processing time if available
metadata = result_dict.get('metadata', {})
if 'total_processing_time' in metadata:
processing_time = metadata['total_processing_time']
output_lines.append(f" Processing Time: {processing_time:.3f}s")
# Real-time FPS calculation based on output queue throughput
current_fps = result_dict.get('current_pipeline_fps', 0.0)
if current_fps > 0:
output_lines.append(f" Pipeline FPS (Output Queue): {current_fps:.2f}")
else:
output_lines.append(f" Pipeline FPS (Output Queue): Calculating...")
# Additional metadata
if metadata:
interesting_keys = ['dongle_count', 'stage_count', 'queue_sizes', 'error_count']
for key in interesting_keys:
if key in metadata:
output_lines.append(f" {key.replace('_', ' ').title()}: {metadata[key]}")
output_lines.append(" " + "="*50)
return "\n".join(output_lines)
except Exception as e:
return f"❌ Error formatting terminal results: {e}"
class DeploymentDialog(QDialog):
"""Main deployment dialog with comprehensive deployment management."""
def __init__(self, pipeline_data: Dict[str, Any], parent=None):
super().__init__(parent)
self.pipeline_data = pipeline_data
self.deployment_worker = None
self.pipeline_config = None
self._latest_boxes = [] # cached detection boxes for live overlay
self._latest_letterbox = None # cached letterbox mapping for overlay
self.setWindowTitle("Deploy Pipeline to Dongles")
self.setMinimumSize(800, 600)
self.setup_ui()
self.apply_theme()
def setup_ui(self):
"""Setup the dialog UI."""
layout = QVBoxLayout(self)
# Header
header_label = QLabel("Pipeline Deployment")
header_label.setFont(QFont("Arial", 16, QFont.Bold))
header_label.setAlignment(Qt.AlignCenter)
layout.addWidget(header_label)
# Main content with tabs
self.tab_widget = QTabWidget()
# Overview tab
self.overview_tab = self.create_overview_tab()
self.tab_widget.addTab(self.overview_tab, "Overview")
# Topology tab
self.topology_tab = self.create_topology_tab()
self.tab_widget.addTab(self.topology_tab, "Analysis")
# Configuration tab
self.config_tab = self.create_configuration_tab()
self.tab_widget.addTab(self.config_tab, "Configuration")
# Deployment tab
self.deployment_tab = self.create_deployment_tab()
self.tab_widget.addTab(self.deployment_tab, "Deployment")
# Live View tab
self.live_view_tab = self.create_live_view_tab()
self.tab_widget.addTab(self.live_view_tab, "Live View")
layout.addWidget(self.tab_widget)
# Progress bar
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
# Status label
self.status_label = QLabel("Ready to deploy")
self.status_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.status_label)
# Buttons
button_layout = QHBoxLayout()
self.analyze_button = QPushButton("Analyze Pipeline")
self.analyze_button.clicked.connect(self.analyze_pipeline)
button_layout.addWidget(self.analyze_button)
self.deploy_button = QPushButton("Deploy to Dongles")
self.deploy_button.clicked.connect(self.start_deployment)
self.deploy_button.setEnabled(False)
button_layout.addWidget(self.deploy_button)
self.stop_button = QPushButton("Stop Inference")
self.stop_button.clicked.connect(self.stop_deployment)
self.stop_button.setEnabled(False)
self.stop_button.setVisible(False)
button_layout.addWidget(self.stop_button)
button_layout.addStretch()
self.close_button = QPushButton("Close")
self.close_button.clicked.connect(self.accept)
button_layout.addWidget(self.close_button)
layout.addLayout(button_layout)
# Populate initial data
self.populate_overview()
def create_overview_tab(self) -> QWidget:
"""Create pipeline overview tab."""
widget = QWidget()
layout = QVBoxLayout(widget)
# Pipeline info
info_group = QGroupBox("Pipeline Information")
info_layout = QFormLayout(info_group)
self.name_label = QLabel()
self.description_label = QLabel()
self.nodes_label = QLabel()
self.connections_label = QLabel()
info_layout.addRow("Name:", self.name_label)
info_layout.addRow("Description:", self.description_label)
info_layout.addRow("Nodes:", self.nodes_label)
info_layout.addRow("Connections:", self.connections_label)
layout.addWidget(info_group)
# Nodes table
nodes_group = QGroupBox("Pipeline Nodes")
nodes_layout = QVBoxLayout(nodes_group)
self.nodes_table = QTableWidget()
self.nodes_table.setColumnCount(3)
self.nodes_table.setHorizontalHeaderLabels(["Name", "Type", "Status"])
self.nodes_table.horizontalHeader().setStretchLastSection(True)
nodes_layout.addWidget(self.nodes_table)
layout.addWidget(nodes_group)
return widget
def create_topology_tab(self) -> QWidget:
"""Create topology analysis tab."""
widget = QWidget()
layout = QVBoxLayout(widget)
# Analysis results
self.topology_text = QTextEdit()
self.topology_text.setReadOnly(True)
self.topology_text.setFont(QFont("Consolas", 10))
self.topology_text.setText("Click 'Analyze Pipeline' to see topology analysis...")
layout.addWidget(self.topology_text)
return widget
def create_configuration_tab(self) -> QWidget:
"""Create configuration tab."""
widget = QWidget()
layout = QVBoxLayout(widget)
scroll_area = QScrollArea()
scroll_content = QWidget()
scroll_layout = QVBoxLayout(scroll_content)
# Stage configurations will be populated after analysis
self.config_content = QLabel("Run pipeline analysis to see stage configurations...")
self.config_content.setAlignment(Qt.AlignCenter)
scroll_layout.addWidget(self.config_content)
scroll_area.setWidget(scroll_content)
scroll_area.setWidgetResizable(True)
layout.addWidget(scroll_area)
return widget
def create_deployment_tab(self) -> QWidget:
"""Create deployment monitoring tab."""
widget = QWidget()
layout = QVBoxLayout(widget)
# Create splitter for deployment log and terminal output
splitter = QSplitter(Qt.Vertical)
# Deployment log
log_group = QGroupBox("Deployment Log")
log_layout = QVBoxLayout(log_group)
self.deployment_log = QTextEdit()
self.deployment_log.setReadOnly(True)
self.deployment_log.setFont(QFont("Consolas", 9))
self.deployment_log.setMaximumHeight(200)
log_layout.addWidget(self.deployment_log)
splitter.addWidget(log_group)
# Terminal output display
terminal_group = QGroupBox("Terminal Output")
terminal_layout = QVBoxLayout(terminal_group)
self.terminal_output_display = QTextEdit()
self.terminal_output_display.setReadOnly(True)
self.terminal_output_display.setFont(QFont("Consolas", 9))
self.terminal_output_display.setStyleSheet("""
QTextEdit {
background-color: #1e1e1e;
color: #ffffff;
font-family: 'Consolas', 'Monaco', monospace;
}
""")
terminal_layout.addWidget(self.terminal_output_display)
splitter.addWidget(terminal_group)
# Set splitter proportions (1:2 ratio - more space for terminal)
splitter.setSizes([200, 400])
layout.addWidget(splitter)
# Dongle status (placeholder)
status_group = QGroupBox("Dongle Status")
status_layout = QVBoxLayout(status_group)
self.dongle_status = QLabel("No dongles detected")
self.dongle_status.setAlignment(Qt.AlignCenter)
status_layout.addWidget(self.dongle_status)
layout.addWidget(status_group)
return widget
def create_live_view_tab(self) -> QWidget:
"""Create the live view tab for real-time output."""
widget = QWidget()
layout = QHBoxLayout(widget)
# Video display
video_group = QGroupBox("Live Video Feed")
video_layout = QVBoxLayout(video_group)
self.live_view_label = QLabel("Live view will appear here after deployment.")
self.live_view_label.setAlignment(Qt.AlignCenter)
self.live_view_label.setMinimumSize(640, 480)
video_layout.addWidget(self.live_view_label)
# Display threshold control
from PyQt5.QtWidgets import QDoubleSpinBox
thresh_row = QHBoxLayout()
thresh_label = QLabel("Min Conf:")
self.display_threshold_spin = QDoubleSpinBox()
self.display_threshold_spin.setRange(0.0, 1.0)
self.display_threshold_spin.setSingleStep(0.05)
self.display_threshold_spin.setValue(getattr(self, '_display_threshold', 0.5))
self.display_threshold_spin.valueChanged.connect(self.on_display_threshold_changed)
thresh_row.addWidget(thresh_label)
thresh_row.addWidget(self.display_threshold_spin)
thresh_row.addStretch()
video_layout.addLayout(thresh_row)
layout.addWidget(video_group, 2)
# Inference results
results_group = QGroupBox("Inference Results")
results_layout = QVBoxLayout(results_group)
self.results_text = QTextEdit()
self.results_text.setReadOnly(True)
results_layout.addWidget(self.results_text)
layout.addWidget(results_group, 1)
return widget
def on_display_threshold_changed(self, val: float):
"""Update in-UI display confidence threshold for overlays and summaries."""
try:
self._display_threshold = float(val)
except Exception:
pass
def populate_overview(self):
"""Populate overview tab with pipeline data."""
self.name_label.setText(self.pipeline_data.get('project_name', 'Untitled'))
self.description_label.setText(self.pipeline_data.get('description', 'No description'))
nodes = self.pipeline_data.get('nodes', [])
connections = self.pipeline_data.get('connections', [])
self.nodes_label.setText(str(len(nodes)))
self.connections_label.setText(str(len(connections)))
# Populate nodes table
self.nodes_table.setRowCount(len(nodes))
for i, node in enumerate(nodes):
self.nodes_table.setItem(i, 0, QTableWidgetItem(node.get('name', 'Unknown')))
self.nodes_table.setItem(i, 1, QTableWidgetItem(node.get('type', 'Unknown')))
self.nodes_table.setItem(i, 2, QTableWidgetItem("Ready"))
def analyze_pipeline(self):
"""Analyze pipeline topology and configuration."""
if not CONVERTER_AVAILABLE:
QMessageBox.warning(self, "Analysis Error",
"Pipeline analyzer not available. Please check installation.")
return
try:
self.status_label.setText("Analyzing pipeline...")
self.analyze_button.setEnabled(False)
# Create converter and analyze
converter = MFlowConverter()
config = converter._convert_mflow_to_config(self.pipeline_data)
self.pipeline_config = config
# Update topology tab
analysis_text = f"""Pipeline Analysis Results:
Name: {config.pipeline_name}
Description: {config.description}
Total Stages: {len(config.stage_configs)}
Input Configuration:
{json.dumps(config.input_config, indent=2)}
Output Configuration:
{json.dumps(config.output_config, indent=2)}
Stage Configurations:
"""
for i, stage_config in enumerate(config.stage_configs, 1):
analysis_text += f"\nStage {i}: {stage_config.stage_id}\n"
# Check if this is multi-series configuration
if stage_config.multi_series_config:
analysis_text += f" Mode: Multi-Series\n"
analysis_text += f" Series Configured: {list(stage_config.multi_series_config.keys())}\n"
# Show details for each series
for series_name, series_config in stage_config.multi_series_config.items():
analysis_text += f" \n {series_name} Configuration:\n"
analysis_text += f" Port IDs: {series_config.get('port_ids', [])}\n"
model_path = series_config.get('model_path', 'Not specified')
analysis_text += f" Model: {model_path}\n"
firmware_paths = series_config.get('firmware_paths', {})
if firmware_paths:
analysis_text += f" SCPU Firmware: {firmware_paths.get('scpu', 'Not specified')}\n"
analysis_text += f" NCPU Firmware: {firmware_paths.get('ncpu', 'Not specified')}\n"
else:
analysis_text += f" Firmware: Not specified\n"
else:
# Single-series (legacy) configuration
analysis_text += f" Mode: Single-Series\n"
analysis_text += f" Port IDs: {stage_config.port_ids}\n"
analysis_text += f" Model Path: {stage_config.model_path}\n"
analysis_text += f" SCPU Firmware: {stage_config.scpu_fw_path}\n"
analysis_text += f" NCPU Firmware: {stage_config.ncpu_fw_path}\n"
analysis_text += f" Upload Firmware: {stage_config.upload_fw}\n"
analysis_text += f" Max Queue Size: {stage_config.max_queue_size}\n"
self.topology_text.setText(analysis_text)
# Update configuration tab
self.update_configuration_tab(config)
# Validate configuration
is_valid, errors = converter.validate_config(config)
if is_valid:
self.status_label.setText("Pipeline analysis completed successfully")
self.deploy_button.setEnabled(True)
self.tab_widget.setCurrentIndex(1) # Switch to topology tab
else:
error_msg = "Configuration validation failed:\n" + "\n".join(errors)
QMessageBox.warning(self, "Validation Error", error_msg)
self.status_label.setText("Pipeline analysis failed validation")
except Exception as e:
QMessageBox.critical(self, "Analysis Error",
f"Failed to analyze pipeline: {str(e)}")
self.status_label.setText("Pipeline analysis failed")
finally:
self.analyze_button.setEnabled(True)
def update_configuration_tab(self, config: 'PipelineConfig'):
"""Update configuration tab with detailed stage information."""
# Clear existing content
scroll_content = QWidget()
scroll_layout = QVBoxLayout(scroll_content)
for i, stage_config in enumerate(config.stage_configs, 1):
stage_group = QGroupBox(f"Stage {i}: {stage_config.stage_id}")
stage_layout = QFormLayout(stage_group)
# Check if this is multi-series configuration
if stage_config.multi_series_config:
# Multi-series configuration display
mode_edit = QLineEdit("Multi-Series")
mode_edit.setReadOnly(True)
stage_layout.addRow("Mode:", mode_edit)
series_edit = QLineEdit(str(list(stage_config.multi_series_config.keys())))
series_edit.setReadOnly(True)
stage_layout.addRow("Series:", series_edit)
# Show details for each series
for series_name, series_config in stage_config.multi_series_config.items():
series_label = QLabel(f"--- {series_name} ---")
series_label.setStyleSheet("font-weight: bold; color: #89b4fa;")
stage_layout.addRow(series_label)
port_ids_edit = QLineEdit(str(series_config.get('port_ids', [])))
port_ids_edit.setReadOnly(True)
stage_layout.addRow(f"{series_name} Port IDs:", port_ids_edit)
model_path = series_config.get('model_path', 'Not specified')
model_path_edit = QLineEdit(model_path)
model_path_edit.setReadOnly(True)
stage_layout.addRow(f"{series_name} Model:", model_path_edit)
firmware_paths = series_config.get('firmware_paths', {})
if firmware_paths:
scpu_path = firmware_paths.get('scpu', 'Not specified')
scpu_fw_edit = QLineEdit(scpu_path)
scpu_fw_edit.setReadOnly(True)
stage_layout.addRow(f"{series_name} SCPU FW:", scpu_fw_edit)
ncpu_path = firmware_paths.get('ncpu', 'Not specified')
ncpu_fw_edit = QLineEdit(ncpu_path)
ncpu_fw_edit.setReadOnly(True)
stage_layout.addRow(f"{series_name} NCPU FW:", ncpu_fw_edit)
else:
# Single-series configuration display
mode_edit = QLineEdit("Single-Series")
mode_edit.setReadOnly(True)
stage_layout.addRow("Mode:", mode_edit)
model_path_edit = QLineEdit(stage_config.model_path)
model_path_edit.setReadOnly(True)
stage_layout.addRow("Model Path:", model_path_edit)
scpu_fw_edit = QLineEdit(stage_config.scpu_fw_path)
scpu_fw_edit.setReadOnly(True)
stage_layout.addRow("SCPU Firmware:", scpu_fw_edit)
ncpu_fw_edit = QLineEdit(stage_config.ncpu_fw_path)
ncpu_fw_edit.setReadOnly(True)
stage_layout.addRow("NCPU Firmware:", ncpu_fw_edit)
port_ids_edit = QLineEdit(str(stage_config.port_ids))
port_ids_edit.setReadOnly(True)
stage_layout.addRow("Port IDs:", port_ids_edit)
# Common fields
queue_size_spin = QSpinBox()
queue_size_spin.setValue(stage_config.max_queue_size)
queue_size_spin.setReadOnly(True)
stage_layout.addRow("Queue Size:", queue_size_spin)
upload_fw_check = QCheckBox()
upload_fw_check.setChecked(stage_config.upload_fw)
upload_fw_check.setEnabled(False)
stage_layout.addRow("Upload Firmware:", upload_fw_check)
scroll_layout.addWidget(stage_group)
# Update the configuration tab
config_tab_layout = self.config_tab.layout()
old_scroll_area = config_tab_layout.itemAt(0).widget()
config_tab_layout.removeWidget(old_scroll_area)
old_scroll_area.deleteLater()
new_scroll_area = QScrollArea()
new_scroll_area.setWidget(scroll_content)
new_scroll_area.setWidgetResizable(True)
config_tab_layout.addWidget(new_scroll_area)
def start_deployment(self):
"""Start the deployment process."""
if not self.pipeline_config:
QMessageBox.warning(self, "Deployment Error",
"Please analyze the pipeline first.")
return
# Switch to deployment tab
self.tab_widget.setCurrentIndex(3)
# Setup UI for deployment
self.progress_bar.setVisible(True)
self.progress_bar.setValue(0)
self.deploy_button.setEnabled(False)
self.close_button.setText("Cancel")
# Clear deployment log and terminal output
self.deployment_log.clear()
self.deployment_log.append("Starting pipeline deployment...")
self.terminal_output_display.clear()
self.terminal_output_display.append("Pipeline deployment started - terminal output will appear here...")
# Create and start deployment worker
self.deployment_worker = DeploymentWorker(self.pipeline_data)
self.deployment_worker.progress_updated.connect(self.update_progress)
self.deployment_worker.topology_analyzed.connect(self.update_topology_results)
self.deployment_worker.conversion_completed.connect(self.on_conversion_completed)
self.deployment_worker.deployment_started.connect(self.on_deployment_started)
self.deployment_worker.deployment_completed.connect(self.on_deployment_completed)
self.deployment_worker.error_occurred.connect(self.on_deployment_error)
self.deployment_worker.frame_updated.connect(self.update_live_view)
self.deployment_worker.result_updated.connect(self.update_inference_results)
self.deployment_worker.terminal_output.connect(self.update_terminal_output)
self.deployment_worker.stdout_captured.connect(self.update_terminal_output)
self.deployment_worker.start()
def stop_deployment(self):
"""Stop the current deployment/inference."""
if self.deployment_worker and self.deployment_worker.isRunning():
reply = QMessageBox.question(self, "Stop Inference",
"Are you sure you want to stop the inference?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.deployment_log.append("Stopping inference...")
self.status_label.setText("Stopping inference...")
# Disable stop button immediately to prevent multiple clicks
self.stop_button.setEnabled(False)
self.deployment_worker.stop()
# Wait for worker to finish in a separate thread to avoid blocking UI
def wait_for_stop():
if self.deployment_worker.wait(5000): # Wait up to 5 seconds
self.deployment_log.append("Inference stopped successfully.")
else:
self.deployment_log.append("Warning: Inference may not have stopped cleanly.")
# Update UI on main thread
self.stop_button.setVisible(False)
self.deploy_button.setEnabled(True)
self.close_button.setText("Close")
self.progress_bar.setVisible(False)
self.status_label.setText("Inference stopped")
self.dongle_status.setText("Pipeline stopped")
import threading
threading.Thread(target=wait_for_stop, daemon=True).start()
def update_progress(self, value: int, message: str):
"""Update deployment progress."""
self.progress_bar.setValue(value)
self.status_label.setText(message)
self.deployment_log.append(f"[{value}%] {message}")
def update_topology_results(self, results: Dict):
"""Update topology analysis results."""
self.deployment_log.append(f"Topology Analysis: {results['total_stages']} stages detected")
def on_conversion_completed(self, config):
"""Handle conversion completion."""
self.deployment_log.append("Pipeline conversion completed successfully")
def on_deployment_started(self):
"""Handle deployment start."""
self.deployment_log.append("Connecting to dongles...")
self.dongle_status.setText("Initializing dongles...")
# Show stop button and hide deploy button
self.stop_button.setEnabled(True)
self.stop_button.setVisible(True)
self.deploy_button.setEnabled(False)
def on_deployment_completed(self, success: bool, message: str):
"""Handle deployment completion."""
self.progress_bar.setValue(100)
if success:
self.deployment_log.append(f"SUCCESS: {message}")
self.status_label.setText("Deployment completed successfully!")
self.dongle_status.setText("Pipeline running on dongles")
# Keep stop button visible for successful deployment
self.stop_button.setEnabled(True)
self.stop_button.setVisible(True)
QMessageBox.information(self, "Deployment Success", message)
else:
self.deployment_log.append(f"FAILED: {message}")
self.status_label.setText("Deployment failed")
# Hide stop button for failed deployment
self.stop_button.setEnabled(False)
self.stop_button.setVisible(False)
self.deploy_button.setEnabled(True)
self.close_button.setText("Close")
self.progress_bar.setVisible(False)
def on_deployment_error(self, error: str):
"""Handle deployment error."""
self.deployment_log.append(f"ERROR: {error}")
self.status_label.setText("Deployment failed")
QMessageBox.critical(self, "Deployment Error", error)
# Hide stop button and show deploy button on error
self.stop_button.setEnabled(False)
self.stop_button.setVisible(False)
self.deploy_button.setEnabled(True)
self.close_button.setText("Close")
self.progress_bar.setVisible(False)
def update_live_view(self, frame):
"""Update the live view with a new frame."""
try:
# Optionally overlay latest detections before display
if hasattr(self, '_latest_boxes') and self._latest_boxes:
import cv2
H, W = frame.shape[0], frame.shape[1]
# Letterbox mapping
letter = getattr(self, '_latest_letterbox', None)
for box in self._latest_boxes:
# Filter by display threshold
sc = box.get('score', None)
try:
if sc is not None and float(sc) < getattr(self, '_display_threshold', 0.5):
continue
except Exception:
pass
x1 = float(box.get('x1', 0)); y1 = float(box.get('y1', 0))
x2 = float(box.get('x2', 0)); y2 = float(box.get('y2', 0))
mapped = False
if letter and all(k in letter for k in ('model_w','model_h','resized_w','resized_h','pad_left','pad_top')):
mw = int(letter.get('model_w', 0))
mh = int(letter.get('model_h', 0))
rw = int(letter.get('resized_w', 0))
rh = int(letter.get('resized_h', 0))
pl = int(letter.get('pad_left', 0)); pt = int(letter.get('pad_top', 0))
if rw > 0 and rh > 0:
# Reverse letterbox: remove padding, then scale to original
x1 = (x1 - pl) / rw * W; x2 = (x2 - pl) / rw * W
y1 = (y1 - pt) / rh * H; y2 = (y2 - pt) / rh * H
mapped = True
elif mw > 0 and mh > 0:
# Fallback: simple proportional mapping from model space
x1 = x1 / mw * W; x2 = x2 / mw * W
y1 = y1 / mh * H; y2 = y2 / mh * H
mapped = True
if not mapped:
# Last resort proportional mapping using typical 640 baseline
baseline = 640.0
x1 = x1 / baseline * W; x2 = x2 / baseline * W
y1 = y1 / baseline * H; y2 = y2 / baseline * H
# Clamp
xi1 = max(0, min(int(x1), W - 1)); yi1 = max(0, min(int(y1), H - 1))
xi2 = max(xi1 + 1, min(int(x2), W)); yi2 = max(yi1 + 1, min(int(y2), H))
color = (0, 255, 0)
cv2.rectangle(frame, (xi1, yi1), (xi2, yi2), color, 2)
label = box.get('class_name', 'obj')
score = box.get('score', None)
if score is not None:
label = f"{label} {score:.2f}"
cv2.putText(frame, label, (xi1, max(0, yi1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
# Convert the OpenCV frame to a QImage
height, width, channel = frame.shape
bytes_per_line = 3 * width
q_image = QImage(frame.data, width, height, bytes_per_line, QImage.Format_RGB888).rgbSwapped()
# Display the QImage in the QLabel
self.live_view_label.setPixmap(QPixmap.fromImage(q_image))
except Exception as e:
print(f"Error updating live view: {e}")
def update_inference_results(self, result_dict):
"""Update the inference results display."""
try:
import json
from datetime import datetime
# Format the results for display
timestamp = datetime.fromtimestamp(result_dict.get('timestamp', 0)).strftime("%H:%M:%S.%f")[:-3]
stage_results = result_dict.get('stage_results', {})
result_text = f"[{timestamp}] Pipeline ID: {result_dict.get('pipeline_id', 'Unknown')}\n"
# Display results from each stage
for stage_id, result in stage_results.items():
result_text += f" {stage_id}:\n"
# Cache latest detection boxes for live overlay if available
source_obj = None
if hasattr(result, 'box_count') and hasattr(result, 'box_list'):
source_obj = result
elif isinstance(result, tuple) and len(result) == 2 and hasattr(result[0], 'box_list'):
source_obj = result[0]
if source_obj is not None:
boxes = []
for b in getattr(source_obj, 'box_list', [])[:50]:
boxes.append({
'x1': getattr(b, 'x1', 0), 'y1': getattr(b, 'y1', 0),
'x2': getattr(b, 'x2', 0), 'y2': getattr(b, 'y2', 0),
'class_name': getattr(b, 'class_name', 'obj'),
'score': float(getattr(b, 'score', 0.0)) if hasattr(b, 'score') else None,
})
self._latest_boxes = boxes
# Cache letterbox mapping from result object if available
try:
self._latest_letterbox = {
'model_w': int(getattr(source_obj, 'model_input_width', 0)),
'model_h': int(getattr(source_obj, 'model_input_height', 0)),
'resized_w': int(getattr(source_obj, 'resized_img_width', 0)),
'resized_h': int(getattr(source_obj, 'resized_img_height', 0)),
'pad_left': int(getattr(source_obj, 'pad_left', 0)),
'pad_top': int(getattr(source_obj, 'pad_top', 0)),
'pad_right': int(getattr(source_obj, 'pad_right', 0)),
'pad_bottom': int(getattr(source_obj, 'pad_bottom', 0)),
}
except Exception:
self._latest_letterbox = None
if isinstance(result, tuple) and len(result) == 2:
# Handle tuple results which may be (ClassificationResult|ObjectDetectionResult|float, result_string)
prob_or_obj, result_string = result
result_text += f" Result: {result_string}\n"
# Object detection summary
if hasattr(prob_or_obj, 'box_list'):
filtered = [b for b in getattr(prob_or_obj, 'box_list', [])
if not hasattr(b, 'score') or float(getattr(b, 'score', 0.0)) >= getattr(self, '_display_threshold', 0.5)]
thresh = getattr(self, '_display_threshold', 0.5)
result_text += f" Detections (>= {thresh:.2f}): {len(filtered)}\n"
# Classification summary (e.g., Fire detection)
elif hasattr(prob_or_obj, 'probability') and hasattr(prob_or_obj, 'class_name'):
try:
p = float(getattr(prob_or_obj, 'probability', 0.0))
result_text += f" Probability: {p:.3f}\n"
except Exception:
result_text += f" Probability: {getattr(prob_or_obj, 'probability', 'N/A')}\n"
else:
# Numeric probability fallback
try:
prob_value = float(prob_or_obj)
result_text += f" Probability: {prob_value:.3f}\n"
except (ValueError, TypeError):
result_text += f" Probability: {prob_or_obj}\n"
elif isinstance(result, dict):
# Handle dict results
for key, value in result.items():
if key == 'probability':
try:
prob_value = float(value)
result_text += f" Probability: {prob_value:.3f}\n"
except (ValueError, TypeError):
result_text += f" Probability: {value}\n"
else:
result_text += f" {key}: {value}\n"
else:
# Pretty-print detection objects
try:
if hasattr(result, 'box_count') and hasattr(result, 'box_list'):
filtered = [b for b in getattr(result, 'box_list', [])
if not hasattr(b, 'score') or float(getattr(b, 'score', 0.0)) >= getattr(self, '_display_threshold', 0.5)]
thresh = getattr(self, '_display_threshold', 0.5)
result_text += f" Detections (>= {thresh:.2f}): {len(filtered)}\n"
elif hasattr(result, 'probability') and hasattr(result, 'class_name'):
try:
p = float(getattr(result, 'probability', 0.0))
result_text += f" Probability: {p:.3f}\n"
except Exception:
result_text += f" Probability: {getattr(result, 'probability', 'N/A')}\n"
else:
result_text += f" {result}\n"
except Exception:
result_text += f" {result}\n"
result_text += "-" * 50 + "\n"
# Append to results display (keep last 100 lines)
current_text = self.results_text.toPlainText()
lines = current_text.split('\n')
if len(lines) > 100:
lines = lines[-50:] # Keep last 50 lines
current_text = '\n'.join(lines)
self.results_text.setPlainText(current_text + result_text)
# Auto-scroll to bottom
scrollbar = self.results_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
except Exception as e:
print(f"Error updating inference results: {e}")
def update_terminal_output(self, terminal_text: str):
"""Update the terminal output display with new text."""
try:
# Use append() instead of setPlainText() for better performance and no truncation
self.terminal_output_display.append(terminal_text.rstrip('\n'))
# Auto-scroll to bottom
scrollbar = self.terminal_output_display.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
# Optional: Limit total lines to prevent excessive memory usage
# Only trim if we have way too many lines (e.g., > 1000)
document = self.terminal_output_display.document()
if document.lineCount() > 1000:
cursor = self.terminal_output_display.textCursor()
cursor.movePosition(cursor.Start)
cursor.movePosition(cursor.Down, cursor.KeepAnchor, 200) # Select first 200 lines
cursor.removeSelectedText()
except Exception as e:
print(f"Error updating terminal output: {e}")
def apply_theme(self):
"""Apply consistent theme to the dialog."""
self.setStyleSheet("""
QDialog {
background-color: #1e1e2e;
color: #cdd6f4;
}
QTabWidget::pane {
border: 1px solid #45475a;
background-color: #313244;
}
QTabWidget::tab-bar {
alignment: center;
}
QTabBar::tab {
background-color: #45475a;
color: #cdd6f4;
padding: 8px 16px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
QTabBar::tab:selected {
background-color: #89b4fa;
color: #1e1e2e;
}
QTabBar::tab:hover {
background-color: #585b70;
}
QGroupBox {
font-weight: bold;
border: 2px solid #45475a;
border-radius: 5px;
margin-top: 1ex;
padding-top: 5px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 10px 0 10px;
}
QPushButton {
background-color: #45475a;
color: #cdd6f4;
border: 1px solid #6c7086;
border-radius: 4px;
padding: 8px 16px;
font-weight: bold;
}
QPushButton:hover {
background-color: #585b70;
}
QPushButton:pressed {
background-color: #313244;
}
QPushButton:disabled {
background-color: #313244;
color: #6c7086;
}
QTextEdit, QLineEdit {
background-color: #313244;
color: #cdd6f4;
border: 1px solid #45475a;
border-radius: 4px;
padding: 4px;
}
QTableWidget {
background-color: #313244;
alternate-background-color: #45475a;
color: #cdd6f4;
border: 1px solid #45475a;
}
QProgressBar {
background-color: #313244;
border: 1px solid #45475a;
border-radius: 4px;
text-align: center;
}
QProgressBar::chunk {
background-color: #a6e3a1;
border-radius: 3px;
}
""")
def closeEvent(self, event):
"""Handle dialog close event."""
if self.deployment_worker and self.deployment_worker.isRunning():
reply = QMessageBox.question(self, "Cancel Deployment",
"Deployment is in progress. Are you sure you want to cancel?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.deployment_worker.stop()
self.deployment_worker.wait(3000) # Wait up to 3 seconds
event.accept()
else:
event.ignore()
else:
event.accept()