cluster4npu/ui/dialogs/deployment.py
abin be4bd617c3 fix: eliminate QTextCursor cross-thread signal warning on inference stop
Three related fixes to the QObject::connect / QTextCursor warning that
appeared when stopping inference:

1. StdoutCapture: replace signal emission with queue.Queue.put_nowait()
   so non-Qt SDK threads (Kneron shutdown) never touch Qt signal machinery.
   DeploymentWorker.stdout_captured signal removed; worker now accepts a
   stdout_queue and passes it to StdoutCapture.

2. start_deployment: create QTimer (100 ms) on main thread to drain the
   stdout queue via _drain_stdout_queue(). Connect worker.finished to
   _on_worker_finished to stop the timer and flush remaining output.

3. stop_deployment / wait_for_stop: the background thread was calling
   QTextEdit.append() and other widget methods directly, which internally
   creates QTextCursor queued connections — the real trigger of the
   warning. Fixed by having wait_for_stop emit _stop_done signal only;
   all UI updates moved to _on_stop_done slot (main thread).

Also adds QTextCursor import in main.py to pre-register the type with
Qt's meta-type system as a belt-and-suspenders measure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:56:02 +08:00

1320 lines
59 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 queue
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 into a thread-safe queue.
Uses queue.Queue instead of directly emitting a Qt signal, so that
any thread (including non-Qt SDK threads) can safely write output
without triggering cross-thread signal warnings.
The dialog drains the queue from the main thread via QTimer.
"""
def __init__(self, output_queue: queue.Queue):
self.output_queue = output_queue
self.original_stdout = None
self.original_stderr = None
def __enter__(self):
self.original_stdout = sys.stdout
self.original_stderr = sys.stderr
output_queue = self.output_queue
class TeeWriter:
def __init__(self, original):
self.original = original
def write(self, text):
if self.original is not None:
self.original.write(text)
self.original.flush()
if text.strip():
try:
output_queue.put_nowait(text)
except queue.Full:
pass # Drop if queue full; non-fatal
def flush(self):
if self.original is not None:
self.original.flush()
sys.stdout = TeeWriter(self.original_stdout)
sys.stderr = TeeWriter(self.original_stderr)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
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
def __init__(self, pipeline_data: Dict[str, Any], stdout_queue: queue.Queue):
super().__init__()
self.pipeline_data = pipeline_data
self.stdout_queue = stdout_queue # thread-safe queue; drained by dialog's QTimer
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_queue):
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."""
# Emitted from the wait_for_stop background thread; connected slot runs on main thread
_stop_done = pyqtSignal(bool) # True = stopped cleanly
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 thread-safe queue for stdout captured from non-Qt threads
self._stdout_queue: queue.Queue = queue.Queue(maxsize=1000)
# Create and start deployment worker
self.deployment_worker = DeploymentWorker(self.pipeline_data, self._stdout_queue)
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)
# Drain stdout queue from main thread every 100 ms to avoid cross-thread signal warnings
self._stdout_drain_timer = QTimer(self)
self._stdout_drain_timer.timeout.connect(self._drain_stdout_queue)
self._stdout_drain_timer.start(100)
# Stop timer and flush queue when worker finishes (runs on main thread via signal)
self.deployment_worker.finished.connect(self._on_worker_finished)
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.
# All UI updates happen in _on_stop_done (main thread, via signal).
self._stop_done.connect(self._on_stop_done)
def wait_for_stop():
success = self.deployment_worker.wait(5000) # Wait up to 5 seconds
self._stop_done.emit(success)
import threading
threading.Thread(target=wait_for_stop, daemon=True).start()
def _on_stop_done(self, success: bool):
"""Called on the main thread after the stop background thread has waited for the worker."""
# Disconnect so re-running won't accumulate connections
try:
self._stop_done.disconnect(self._on_stop_done)
except TypeError:
pass
if success:
self.deployment_log.append("Inference stopped successfully.")
else:
self.deployment_log.append("Warning: Inference may not have stopped cleanly.")
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")
def _on_worker_finished(self):
"""Called on the main thread when the deployment worker thread exits."""
if hasattr(self, '_stdout_drain_timer'):
self._stdout_drain_timer.stop()
self._drain_stdout_queue() # flush any remaining output
def _drain_stdout_queue(self):
"""Drain the stdout queue and forward lines to the terminal display (main thread only)."""
if not hasattr(self, '_stdout_queue'):
return
try:
while True:
text = self._stdout_queue.get_nowait()
self.update_terminal_output(text)
except queue.Empty:
pass
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:
self.terminal_output_display.append(terminal_text.rstrip('\n'))
# Auto-scroll to bottom
scrollbar = self.terminal_output_display.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
# Limit total lines to prevent excessive memory usage.
# Use toPlainText/setPlainText to avoid QTextCursor cross-thread warnings.
document = self.terminal_output_display.document()
if document.lineCount() > 1000:
lines = self.terminal_output_display.toPlainText().split('\n')
trimmed = '\n'.join(lines[-800:]) # Keep last 800 lines
self.terminal_output_display.setPlainText(trimmed)
# Restore scroll to bottom after setPlainText resets it
scrollbar.setValue(scrollbar.maximum())
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()