2099 lines
86 KiB
Python
2099 lines
86 KiB
Python
"""
|
|
Integrated pipeline dashboard for the Cluster4NPU UI application.
|
|
|
|
This module provides the main dashboard window that combines pipeline editing,
|
|
stage configuration, performance estimation, and dongle management in a unified
|
|
interface with a 3-panel layout.
|
|
|
|
Main Components:
|
|
- IntegratedPipelineDashboard: Main dashboard window
|
|
- Node template palette for pipeline design
|
|
- Dynamic property editing panels
|
|
- Performance estimation and hardware management
|
|
- Pipeline save/load functionality
|
|
|
|
Usage:
|
|
from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard
|
|
|
|
dashboard = IntegratedPipelineDashboard()
|
|
dashboard.show()
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import os
|
|
from typing import Optional, Dict, Any, List
|
|
|
|
from PyQt5.QtWidgets import (
|
|
QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QLineEdit, QPushButton,
|
|
QLabel, QSpinBox, QDoubleSpinBox, QComboBox, QListWidget, QCheckBox,
|
|
QSplitter, QAction, QScrollArea, QTabWidget, QTableWidget, QTableWidgetItem,
|
|
QHeaderView, QProgressBar, QGroupBox, QGridLayout, QFrame, QTextBrowser,
|
|
QSizePolicy, QMessageBox, QFileDialog, QFormLayout, QToolBar, QStatusBar
|
|
)
|
|
from PyQt5.QtCore import Qt, pyqtSignal, QTimer
|
|
from PyQt5.QtGui import QFont
|
|
|
|
try:
|
|
from NodeGraphQt import NodeGraph
|
|
NODEGRAPH_AVAILABLE = True
|
|
except ImportError:
|
|
NODEGRAPH_AVAILABLE = False
|
|
print("Warning: NodeGraphQt not available. Pipeline editor will be disabled.")
|
|
|
|
from cluster4npu_ui.config.theme import HARMONIOUS_THEME_STYLESHEET
|
|
from cluster4npu_ui.config.settings import get_settings
|
|
try:
|
|
from cluster4npu_ui.core.nodes import (
|
|
InputNode, ModelNode, PreprocessNode, PostprocessNode, OutputNode,
|
|
NODE_TYPES, create_node_property_widget
|
|
)
|
|
ADVANCED_NODES_AVAILABLE = True
|
|
except ImportError:
|
|
ADVANCED_NODES_AVAILABLE = False
|
|
|
|
# Use exact nodes that match original properties
|
|
from cluster4npu_ui.core.nodes.exact_nodes import (
|
|
ExactInputNode, ExactModelNode, ExactPreprocessNode,
|
|
ExactPostprocessNode, ExactOutputNode, EXACT_NODE_TYPES
|
|
)
|
|
|
|
# Import pipeline analysis functions
|
|
try:
|
|
from cluster4npu_ui.core.pipeline import get_stage_count, analyze_pipeline_stages, get_pipeline_summary
|
|
except ImportError:
|
|
# Fallback functions if not available
|
|
def get_stage_count(graph):
|
|
return 0
|
|
def analyze_pipeline_stages(graph):
|
|
return {}
|
|
def get_pipeline_summary(graph):
|
|
return {'stage_count': 0, 'valid': True, 'error': '', 'total_nodes': 0, 'model_nodes': 0, 'input_nodes': 0, 'output_nodes': 0, 'preprocess_nodes': 0, 'postprocess_nodes': 0, 'stages': []}
|
|
|
|
|
|
class StageCountWidget(QWidget):
|
|
"""Widget to display stage count information in the pipeline editor."""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.stage_count = 0
|
|
self.pipeline_valid = True
|
|
self.pipeline_error = ""
|
|
|
|
self.setup_ui()
|
|
self.setFixedSize(120, 22)
|
|
|
|
def setup_ui(self):
|
|
"""Setup the stage count widget UI."""
|
|
layout = QHBoxLayout()
|
|
layout.setContentsMargins(5, 2, 5, 2)
|
|
|
|
# Stage count label only (compact version)
|
|
self.stage_label = QLabel("Stages: 0")
|
|
self.stage_label.setFont(QFont("Arial", 10, QFont.Bold))
|
|
self.stage_label.setStyleSheet("color: #cdd6f4; font-weight: bold;")
|
|
|
|
layout.addWidget(self.stage_label)
|
|
self.setLayout(layout)
|
|
|
|
# Style the widget for status bar - ensure it's visible
|
|
self.setStyleSheet("""
|
|
StageCountWidget {
|
|
background-color: transparent;
|
|
border: none;
|
|
}
|
|
""")
|
|
|
|
# Ensure the widget is visible
|
|
self.setVisible(True)
|
|
self.stage_label.setVisible(True)
|
|
|
|
def update_stage_count(self, count: int, valid: bool = True, error: str = ""):
|
|
"""Update the stage count display."""
|
|
self.stage_count = count
|
|
self.pipeline_valid = valid
|
|
self.pipeline_error = error
|
|
|
|
# Update stage count with status indication
|
|
if not valid:
|
|
self.stage_label.setText(f"Stages: {count}")
|
|
self.stage_label.setStyleSheet("color: #f38ba8; font-weight: bold;")
|
|
else:
|
|
if count == 0:
|
|
self.stage_label.setText("Stages: 0")
|
|
self.stage_label.setStyleSheet("color: #f9e2af; font-weight: bold;")
|
|
else:
|
|
self.stage_label.setText(f"Stages: {count}")
|
|
self.stage_label.setStyleSheet("color: #a6e3a1; font-weight: bold;")
|
|
|
|
|
|
class IntegratedPipelineDashboard(QMainWindow):
|
|
"""
|
|
Integrated dashboard combining pipeline editor, stage configuration, and performance estimation.
|
|
|
|
This is the main application window that provides a comprehensive interface for
|
|
designing, configuring, and managing ML inference pipelines.
|
|
"""
|
|
|
|
# Signals
|
|
pipeline_modified = pyqtSignal()
|
|
node_selected = pyqtSignal(object)
|
|
pipeline_changed = pyqtSignal()
|
|
stage_count_changed = pyqtSignal(int)
|
|
|
|
def __init__(self, project_name: str = "", description: str = "", filename: Optional[str] = None):
|
|
super().__init__()
|
|
|
|
# Project information
|
|
self.project_name = project_name or "Untitled Pipeline"
|
|
self.description = description
|
|
self.current_file = filename
|
|
self.is_modified = False
|
|
|
|
# Settings
|
|
self.settings = get_settings()
|
|
|
|
# Initialize UI components that will be created later
|
|
self.props_instructions = None
|
|
self.node_props_container = None
|
|
self.node_props_layout = None
|
|
self.fps_label = None
|
|
self.latency_label = None
|
|
self.memory_label = None
|
|
self.suggestions_text = None
|
|
self.dongles_list = None
|
|
self.detected_devices = [] # Store detected device information
|
|
self.stage_count_widget = None
|
|
self.analysis_timer = None
|
|
self.previous_stage_count = 0
|
|
self.stats_label = None
|
|
|
|
# Initialize node graph if available
|
|
if NODEGRAPH_AVAILABLE:
|
|
self.setup_node_graph()
|
|
else:
|
|
self.graph = None
|
|
|
|
# Setup UI
|
|
self.setup_integrated_ui()
|
|
self.setup_menu()
|
|
self.setup_shortcuts()
|
|
self.setup_analysis_timer()
|
|
|
|
# Apply styling and configure window
|
|
self.apply_styling()
|
|
self.update_window_title()
|
|
self.setGeometry(50, 50, 1400, 900)
|
|
|
|
# Connect signals
|
|
self.pipeline_changed.connect(self.analyze_pipeline)
|
|
|
|
# Initial analysis
|
|
print("🚀 Pipeline Dashboard initialized")
|
|
self.analyze_pipeline()
|
|
|
|
# Set up a timer to hide UI elements after initialization
|
|
self.ui_cleanup_timer = QTimer()
|
|
self.ui_cleanup_timer.setSingleShot(True)
|
|
self.ui_cleanup_timer.timeout.connect(self.cleanup_node_graph_ui)
|
|
self.ui_cleanup_timer.start(1000) # 1 second delay
|
|
|
|
def setup_node_graph(self):
|
|
"""Initialize the node graph system."""
|
|
try:
|
|
self.graph = NodeGraph()
|
|
|
|
# Configure NodeGraphQt to hide unwanted UI elements
|
|
viewer = self.graph.viewer()
|
|
if viewer:
|
|
# Hide the logo/icon in bottom left corner
|
|
if hasattr(viewer, 'set_logo_visible'):
|
|
viewer.set_logo_visible(False)
|
|
elif hasattr(viewer, 'show_logo'):
|
|
viewer.show_logo(False)
|
|
|
|
# Try to hide grid
|
|
if hasattr(viewer, 'set_grid_mode'):
|
|
viewer.set_grid_mode(0) # 0 = no grid
|
|
elif hasattr(viewer, 'grid_mode'):
|
|
viewer.grid_mode = 0
|
|
|
|
# Try to hide navigation widget/toolbar
|
|
if hasattr(viewer, 'set_nav_widget_visible'):
|
|
viewer.set_nav_widget_visible(False)
|
|
elif hasattr(viewer, 'navigation_widget'):
|
|
nav_widget = viewer.navigation_widget()
|
|
if nav_widget:
|
|
nav_widget.setVisible(False)
|
|
|
|
# Try to hide any other UI elements
|
|
if hasattr(viewer, 'set_minimap_visible'):
|
|
viewer.set_minimap_visible(False)
|
|
|
|
# Hide menu bar if exists
|
|
if hasattr(viewer, 'set_menu_bar_visible'):
|
|
viewer.set_menu_bar_visible(False)
|
|
|
|
# Try to hide any toolbar elements
|
|
widget = viewer.widget if hasattr(viewer, 'widget') else None
|
|
if widget:
|
|
# Find and hide toolbar-like children
|
|
from PyQt5.QtWidgets import QToolBar, QFrame, QWidget
|
|
for child in widget.findChildren(QToolBar):
|
|
child.setVisible(False)
|
|
|
|
# Look for other UI widgets that might be the horizontal bar
|
|
for child in widget.findChildren(QFrame):
|
|
# Check if this might be the navigation bar
|
|
if hasattr(child, 'objectName') and 'nav' in child.objectName().lower():
|
|
child.setVisible(False)
|
|
# Check size and position to identify the horizontal bar
|
|
elif hasattr(child, 'geometry'):
|
|
geom = child.geometry()
|
|
# If it's a horizontal bar at the bottom left
|
|
if geom.height() < 50 and geom.width() > 100:
|
|
child.setVisible(False)
|
|
|
|
# Additional attempt to hide navigation elements
|
|
for child in widget.findChildren(QWidget):
|
|
if hasattr(child, 'objectName'):
|
|
obj_name = child.objectName().lower()
|
|
if any(keyword in obj_name for keyword in ['nav', 'toolbar', 'control', 'zoom']):
|
|
child.setVisible(False)
|
|
|
|
# Use exact nodes that match original properties
|
|
nodes_to_register = [
|
|
ExactInputNode, ExactModelNode, ExactPreprocessNode,
|
|
ExactPostprocessNode, ExactOutputNode
|
|
]
|
|
|
|
print("Registering nodes with NodeGraphQt...")
|
|
for node_class in nodes_to_register:
|
|
try:
|
|
self.graph.register_node(node_class)
|
|
print(f"✓ Registered {node_class.__name__} with identifier {node_class.__identifier__}")
|
|
except Exception as e:
|
|
print(f"✗ Failed to register {node_class.__name__}: {e}")
|
|
|
|
# Connect signals
|
|
self.graph.node_created.connect(self.mark_modified)
|
|
self.graph.nodes_deleted.connect(self.mark_modified)
|
|
self.graph.node_selection_changed.connect(self.on_node_selection_changed)
|
|
|
|
# Connect pipeline analysis signals
|
|
self.graph.node_created.connect(self.schedule_analysis)
|
|
self.graph.nodes_deleted.connect(self.schedule_analysis)
|
|
if hasattr(self.graph, 'connection_changed'):
|
|
self.graph.connection_changed.connect(self.schedule_analysis)
|
|
|
|
if hasattr(self.graph, 'property_changed'):
|
|
self.graph.property_changed.connect(self.mark_modified)
|
|
|
|
print("Node graph setup completed successfully")
|
|
|
|
except Exception as e:
|
|
print(f"Error setting up node graph: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
self.graph = None
|
|
|
|
def cleanup_node_graph_ui(self):
|
|
"""Clean up NodeGraphQt UI elements after initialization."""
|
|
if not self.graph:
|
|
return
|
|
|
|
try:
|
|
viewer = self.graph.viewer()
|
|
if viewer:
|
|
widget = viewer.widget if hasattr(viewer, 'widget') else None
|
|
if widget:
|
|
print("🧹 Cleaning up NodeGraphQt UI elements...")
|
|
|
|
# More aggressive cleanup - hide all small widgets at bottom
|
|
from PyQt5.QtWidgets import QWidget, QFrame, QLabel, QPushButton
|
|
from PyQt5.QtCore import QRect
|
|
|
|
for child in widget.findChildren(QWidget):
|
|
if hasattr(child, 'geometry'):
|
|
geom = child.geometry()
|
|
parent_geom = widget.geometry()
|
|
|
|
# Check if it's a small widget at the bottom left
|
|
if (geom.height() < 100 and
|
|
geom.width() < 200 and
|
|
geom.y() > parent_geom.height() - 100 and
|
|
geom.x() < 200):
|
|
print(f"🗑️ Hiding bottom-left widget: {child.__class__.__name__}")
|
|
child.setVisible(False)
|
|
|
|
# Also try to hide by CSS styling
|
|
try:
|
|
widget.setStyleSheet(widget.styleSheet() + """
|
|
QWidget[objectName*="nav"] { display: none; }
|
|
QWidget[objectName*="toolbar"] { display: none; }
|
|
QWidget[objectName*="control"] { display: none; }
|
|
QFrame[objectName*="zoom"] { display: none; }
|
|
""")
|
|
except:
|
|
pass
|
|
|
|
except Exception as e:
|
|
print(f"Error cleaning up NodeGraphQt UI: {e}")
|
|
|
|
def setup_integrated_ui(self):
|
|
"""Setup the integrated UI with node templates, pipeline editor and configuration panels."""
|
|
central_widget = QWidget()
|
|
self.setCentralWidget(central_widget)
|
|
|
|
# Main layout with status bar at bottom
|
|
main_layout = QVBoxLayout(central_widget)
|
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
main_layout.setSpacing(0)
|
|
|
|
# Main horizontal splitter with 3 panels
|
|
main_splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Left side: Node Template Panel (25% width)
|
|
left_panel = self.create_node_template_panel()
|
|
left_panel.setMinimumWidth(250)
|
|
left_panel.setMaximumWidth(350)
|
|
|
|
# Middle: Pipeline Editor (50% width) - without its own status bar
|
|
middle_panel = self.create_pipeline_editor_panel()
|
|
|
|
# Right side: Configuration panels (25% width)
|
|
right_panel = self.create_configuration_panel()
|
|
right_panel.setMinimumWidth(300)
|
|
right_panel.setMaximumWidth(400)
|
|
|
|
# Add widgets to splitter
|
|
main_splitter.addWidget(left_panel)
|
|
main_splitter.addWidget(middle_panel)
|
|
main_splitter.addWidget(right_panel)
|
|
main_splitter.setSizes([300, 700, 400]) # 25-50-25 split
|
|
|
|
# Add splitter to main layout
|
|
main_layout.addWidget(main_splitter)
|
|
|
|
# Add global status bar at the bottom
|
|
self.global_status_bar = self.create_status_bar_widget()
|
|
main_layout.addWidget(self.global_status_bar)
|
|
|
|
def create_node_template_panel(self) -> QWidget:
|
|
"""Create left panel with node templates."""
|
|
panel = QWidget()
|
|
layout = QVBoxLayout(panel)
|
|
layout.setContentsMargins(10, 10, 10, 10)
|
|
layout.setSpacing(10)
|
|
|
|
# Header
|
|
header = QLabel("Node Templates")
|
|
header.setStyleSheet("color: #f9e2af; font-size: 16px; font-weight: bold; padding: 10px;")
|
|
layout.addWidget(header)
|
|
|
|
# Node template buttons - use exact nodes matching original
|
|
nodes_info = [
|
|
("Input Node", "Data input source", ExactInputNode),
|
|
("Model Node", "AI inference model", ExactModelNode),
|
|
("Preprocess Node", "Data preprocessing", ExactPreprocessNode),
|
|
("Postprocess Node", "Output processing", ExactPostprocessNode),
|
|
("Output Node", "Final output", ExactOutputNode)
|
|
]
|
|
|
|
for name, description, node_class in nodes_info:
|
|
# Create container for each node type
|
|
node_container = QFrame()
|
|
node_container.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #313244;
|
|
border: 2px solid #45475a;
|
|
border-radius: 8px;
|
|
padding: 5px;
|
|
}
|
|
QFrame:hover {
|
|
border-color: #89b4fa;
|
|
background-color: #383a59;
|
|
}
|
|
""")
|
|
|
|
container_layout = QVBoxLayout(node_container)
|
|
container_layout.setContentsMargins(8, 8, 8, 8)
|
|
container_layout.setSpacing(4)
|
|
|
|
# Node name
|
|
name_label = QLabel(name)
|
|
name_label.setStyleSheet("color: #cdd6f4; font-weight: bold; font-size: 12px;")
|
|
container_layout.addWidget(name_label)
|
|
|
|
# Description
|
|
desc_label = QLabel(description)
|
|
desc_label.setStyleSheet("color: #a6adc8; font-size: 10px;")
|
|
desc_label.setWordWrap(True)
|
|
container_layout.addWidget(desc_label)
|
|
|
|
# Add button
|
|
add_btn = QPushButton("+ Add")
|
|
add_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #89b4fa;
|
|
color: #1e1e2e;
|
|
border: none;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #a6c8ff;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #7287fd;
|
|
}
|
|
""")
|
|
add_btn.clicked.connect(lambda checked, nc=node_class: self.add_node_to_graph(nc))
|
|
container_layout.addWidget(add_btn)
|
|
|
|
layout.addWidget(node_container)
|
|
|
|
# Pipeline Operations Section
|
|
operations_label = QLabel("Pipeline Operations")
|
|
operations_label.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 10px;")
|
|
layout.addWidget(operations_label)
|
|
|
|
# Create operation buttons
|
|
operations = [
|
|
("Validate Pipeline", self.validate_pipeline),
|
|
("Clear Pipeline", self.clear_pipeline),
|
|
]
|
|
|
|
for name, handler in operations:
|
|
btn = QPushButton(name)
|
|
btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #45475a;
|
|
color: #cdd6f4;
|
|
border: 1px solid #585b70;
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
margin: 2px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #585b70;
|
|
border-color: #89b4fa;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #313244;
|
|
}
|
|
""")
|
|
btn.clicked.connect(handler)
|
|
layout.addWidget(btn)
|
|
|
|
# Add stretch to push everything to top
|
|
layout.addStretch()
|
|
|
|
# Instructions
|
|
instructions = QLabel("Click 'Add' to insert nodes into the pipeline editor")
|
|
instructions.setStyleSheet("""
|
|
color: #f9e2af;
|
|
font-size: 10px;
|
|
padding: 10px;
|
|
background-color: #313244;
|
|
border-radius: 6px;
|
|
border-left: 3px solid #89b4fa;
|
|
""")
|
|
instructions.setWordWrap(True)
|
|
layout.addWidget(instructions)
|
|
|
|
return panel
|
|
|
|
def create_pipeline_editor_panel(self) -> QWidget:
|
|
"""Create the middle panel with pipeline editor."""
|
|
panel = QWidget()
|
|
layout = QVBoxLayout(panel)
|
|
layout.setContentsMargins(5, 5, 5, 5)
|
|
|
|
# Header
|
|
header = QLabel("Pipeline Editor")
|
|
header.setStyleSheet("color: #f9e2af; font-size: 16px; font-weight: bold; padding: 10px;")
|
|
layout.addWidget(header)
|
|
|
|
if self.graph and NODEGRAPH_AVAILABLE:
|
|
# Add the node graph widget directly
|
|
graph_widget = self.graph.widget
|
|
graph_widget.setMinimumHeight(400)
|
|
layout.addWidget(graph_widget)
|
|
else:
|
|
# Fallback: show placeholder
|
|
placeholder = QLabel("Pipeline Editor\n(NodeGraphQt not available)")
|
|
placeholder.setStyleSheet("""
|
|
color: #6c7086;
|
|
font-size: 14px;
|
|
padding: 40px;
|
|
background-color: #313244;
|
|
border-radius: 8px;
|
|
border: 2px dashed #45475a;
|
|
""")
|
|
placeholder.setAlignment(Qt.AlignCenter)
|
|
layout.addWidget(placeholder)
|
|
|
|
return panel
|
|
|
|
def create_pipeline_toolbar(self) -> QToolBar:
|
|
"""Create toolbar for pipeline operations."""
|
|
toolbar = QToolBar("Pipeline Operations")
|
|
toolbar.setStyleSheet("""
|
|
QToolBar {
|
|
background-color: #313244;
|
|
border: 1px solid #45475a;
|
|
spacing: 5px;
|
|
padding: 5px;
|
|
}
|
|
QToolBar QAction {
|
|
padding: 5px 10px;
|
|
margin: 2px;
|
|
border: 1px solid #45475a;
|
|
border-radius: 3px;
|
|
background-color: #45475a;
|
|
color: #cdd6f4;
|
|
}
|
|
QToolBar QAction:hover {
|
|
background-color: #585b70;
|
|
}
|
|
""")
|
|
|
|
# Add nodes actions
|
|
add_input_action = QAction("Add Input", self)
|
|
add_input_action.triggered.connect(lambda: self.add_node_to_graph(ExactInputNode))
|
|
toolbar.addAction(add_input_action)
|
|
|
|
add_model_action = QAction("Add Model", self)
|
|
add_model_action.triggered.connect(lambda: self.add_node_to_graph(ExactModelNode))
|
|
toolbar.addAction(add_model_action)
|
|
|
|
add_preprocess_action = QAction("Add Preprocess", self)
|
|
add_preprocess_action.triggered.connect(lambda: self.add_node_to_graph(ExactPreprocessNode))
|
|
toolbar.addAction(add_preprocess_action)
|
|
|
|
add_postprocess_action = QAction("Add Postprocess", self)
|
|
add_postprocess_action.triggered.connect(lambda: self.add_node_to_graph(ExactPostprocessNode))
|
|
toolbar.addAction(add_postprocess_action)
|
|
|
|
add_output_action = QAction("Add Output", self)
|
|
add_output_action.triggered.connect(lambda: self.add_node_to_graph(ExactOutputNode))
|
|
toolbar.addAction(add_output_action)
|
|
|
|
toolbar.addSeparator()
|
|
|
|
# Pipeline actions
|
|
validate_action = QAction("Validate Pipeline", self)
|
|
validate_action.triggered.connect(self.validate_pipeline)
|
|
toolbar.addAction(validate_action)
|
|
|
|
clear_action = QAction("Clear Pipeline", self)
|
|
clear_action.triggered.connect(self.clear_pipeline)
|
|
toolbar.addAction(clear_action)
|
|
|
|
toolbar.addSeparator()
|
|
|
|
# Deploy action
|
|
deploy_action = QAction("Deploy Pipeline", self)
|
|
deploy_action.setToolTip("Convert pipeline to executable format and deploy to dongles")
|
|
deploy_action.triggered.connect(self.deploy_pipeline)
|
|
deploy_action.setStyleSheet("""
|
|
QAction {
|
|
background-color: #a6e3a1;
|
|
color: #1e1e2e;
|
|
font-weight: bold;
|
|
}
|
|
QAction:hover {
|
|
background-color: #94d2a3;
|
|
}
|
|
""")
|
|
toolbar.addAction(deploy_action)
|
|
|
|
return toolbar
|
|
|
|
def setup_analysis_timer(self):
|
|
"""Setup timer for pipeline analysis."""
|
|
self.analysis_timer = QTimer()
|
|
self.analysis_timer.setSingleShot(True)
|
|
self.analysis_timer.timeout.connect(self.analyze_pipeline)
|
|
self.analysis_timer.setInterval(500) # 500ms delay
|
|
|
|
def schedule_analysis(self):
|
|
"""Schedule pipeline analysis after a delay."""
|
|
if self.analysis_timer:
|
|
self.analysis_timer.start()
|
|
|
|
def analyze_pipeline(self):
|
|
"""Analyze the current pipeline and update stage count."""
|
|
if not self.graph:
|
|
return
|
|
|
|
try:
|
|
# Get pipeline summary
|
|
summary = get_pipeline_summary(self.graph)
|
|
current_stage_count = summary['stage_count']
|
|
|
|
# Print detailed pipeline analysis
|
|
self.print_pipeline_analysis(summary, current_stage_count)
|
|
|
|
# Update stage count widget
|
|
if self.stage_count_widget:
|
|
print(f"🔄 Updating stage count widget: {current_stage_count} stages")
|
|
self.stage_count_widget.update_stage_count(
|
|
current_stage_count,
|
|
summary['valid'],
|
|
summary.get('error', '')
|
|
)
|
|
|
|
# Update statistics label
|
|
if hasattr(self, 'stats_label') and self.stats_label:
|
|
total_nodes = summary['total_nodes']
|
|
# Count connections more accurately
|
|
connection_count = 0
|
|
if self.graph:
|
|
for node in self.graph.all_nodes():
|
|
try:
|
|
if hasattr(node, 'output_ports'):
|
|
for output_port in node.output_ports():
|
|
if hasattr(output_port, 'connected_ports'):
|
|
connection_count += len(output_port.connected_ports())
|
|
elif hasattr(node, 'outputs'):
|
|
for output in node.outputs():
|
|
if hasattr(output, 'connected_ports'):
|
|
connection_count += len(output.connected_ports())
|
|
elif hasattr(output, 'connected_inputs'):
|
|
connection_count += len(output.connected_inputs())
|
|
except Exception:
|
|
# If there's any error accessing connections, skip this node
|
|
continue
|
|
|
|
self.stats_label.setText(f"Nodes: {total_nodes} | Connections: {connection_count}")
|
|
|
|
# Update info panel (if it exists)
|
|
if hasattr(self, 'info_text') and self.info_text:
|
|
self.update_info_panel(summary)
|
|
|
|
# Update previous count for next comparison
|
|
self.previous_stage_count = current_stage_count
|
|
|
|
# Emit signal
|
|
self.stage_count_changed.emit(current_stage_count)
|
|
|
|
except Exception as e:
|
|
print(f"Pipeline analysis error: {str(e)}")
|
|
if self.stage_count_widget:
|
|
self.stage_count_widget.update_stage_count(0, False, f"Analysis error: {str(e)}")
|
|
|
|
def print_pipeline_analysis(self, summary, current_stage_count):
|
|
"""Print detailed pipeline analysis to terminal."""
|
|
# Check if stage count changed
|
|
if current_stage_count != self.previous_stage_count:
|
|
if self.previous_stage_count == 0 and current_stage_count > 0:
|
|
print(f"Initial stage count: {current_stage_count}")
|
|
elif current_stage_count != self.previous_stage_count:
|
|
change = current_stage_count - self.previous_stage_count
|
|
if change > 0:
|
|
print(f"Stage count increased: {self.previous_stage_count} → {current_stage_count} (+{change})")
|
|
else:
|
|
print(f"Stage count decreased: {self.previous_stage_count} → {current_stage_count} ({change})")
|
|
|
|
# Always print current pipeline status for clarity
|
|
print(f"Current Pipeline Status:")
|
|
print(f" • Stages: {current_stage_count}")
|
|
print(f" • Total Nodes: {summary['total_nodes']}")
|
|
print(f" • Model Nodes: {summary['model_nodes']}")
|
|
print(f" • Input Nodes: {summary['input_nodes']}")
|
|
print(f" • Output Nodes: {summary['output_nodes']}")
|
|
print(f" • Preprocess Nodes: {summary['preprocess_nodes']}")
|
|
print(f" • Postprocess Nodes: {summary['postprocess_nodes']}")
|
|
print(f" • Valid: {'V' if summary['valid'] else 'X'}")
|
|
|
|
if not summary['valid'] and summary.get('error'):
|
|
print(f" • Error: {summary['error']}")
|
|
|
|
# Print stage details if available
|
|
if summary.get('stages') and len(summary['stages']) > 0:
|
|
print(f"Stage Details:")
|
|
for i, stage in enumerate(summary['stages'], 1):
|
|
model_name = stage['model_config'].get('node_name', 'Unknown Model')
|
|
preprocess_count = len(stage['preprocess_configs'])
|
|
postprocess_count = len(stage['postprocess_configs'])
|
|
|
|
stage_info = f" Stage {i}: {model_name}"
|
|
if preprocess_count > 0:
|
|
stage_info += f" (with {preprocess_count} preprocess)"
|
|
if postprocess_count > 0:
|
|
stage_info += f" (with {postprocess_count} postprocess)"
|
|
|
|
print(stage_info)
|
|
elif current_stage_count > 0:
|
|
print(f"{current_stage_count} stage(s) detected but details not available")
|
|
|
|
print("─" * 50) # Separator line
|
|
|
|
def update_info_panel(self, summary):
|
|
"""Update the pipeline info panel with analysis results."""
|
|
# This method is kept for compatibility but no longer used
|
|
# since we removed the separate info panel
|
|
pass
|
|
|
|
def clear_pipeline(self):
|
|
"""Clear the entire pipeline."""
|
|
if self.graph:
|
|
print("Clearing entire pipeline...")
|
|
self.graph.clear_session()
|
|
self.schedule_analysis()
|
|
|
|
def create_configuration_panel(self) -> QWidget:
|
|
"""Create the right panel with configuration tabs."""
|
|
panel = QWidget()
|
|
layout = QVBoxLayout(panel)
|
|
layout.setContentsMargins(5, 5, 5, 5)
|
|
layout.setSpacing(10)
|
|
|
|
# Create tabs for different configuration sections
|
|
config_tabs = QTabWidget()
|
|
config_tabs.setStyleSheet("""
|
|
QTabWidget::pane {
|
|
border: 2px solid #45475a;
|
|
border-radius: 8px;
|
|
background-color: #313244;
|
|
}
|
|
QTabWidget::tab-bar {
|
|
alignment: center;
|
|
}
|
|
QTabBar::tab {
|
|
background-color: #45475a;
|
|
color: #cdd6f4;
|
|
padding: 6px 12px;
|
|
margin: 1px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
}
|
|
QTabBar::tab:selected {
|
|
background-color: #89b4fa;
|
|
color: #1e1e2e;
|
|
font-weight: bold;
|
|
}
|
|
QTabBar::tab:hover {
|
|
background-color: #585b70;
|
|
}
|
|
""")
|
|
|
|
# Add tabs
|
|
config_tabs.addTab(self.create_node_properties_panel(), "Properties")
|
|
config_tabs.addTab(self.create_performance_panel(), "Performance")
|
|
config_tabs.addTab(self.create_dongle_panel(), "Dongles")
|
|
|
|
layout.addWidget(config_tabs)
|
|
return panel
|
|
|
|
def create_node_properties_panel(self) -> QWidget:
|
|
"""Create node properties editing panel."""
|
|
widget = QScrollArea()
|
|
content = QWidget()
|
|
layout = QVBoxLayout(content)
|
|
|
|
# Header
|
|
header = QLabel("Node Properties")
|
|
header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;")
|
|
layout.addWidget(header)
|
|
|
|
# Instructions when no node selected
|
|
self.props_instructions = QLabel("Select a node in the pipeline editor to view and edit its properties")
|
|
self.props_instructions.setStyleSheet("""
|
|
color: #a6adc8;
|
|
font-size: 12px;
|
|
padding: 20px;
|
|
background-color: #313244;
|
|
border-radius: 8px;
|
|
border: 2px dashed #45475a;
|
|
""")
|
|
self.props_instructions.setWordWrap(True)
|
|
self.props_instructions.setAlignment(Qt.AlignCenter)
|
|
layout.addWidget(self.props_instructions)
|
|
|
|
# Container for dynamic properties
|
|
self.node_props_container = QWidget()
|
|
self.node_props_layout = QVBoxLayout(self.node_props_container)
|
|
layout.addWidget(self.node_props_container)
|
|
|
|
# Initially hide the container
|
|
self.node_props_container.setVisible(False)
|
|
|
|
layout.addStretch()
|
|
widget.setWidget(content)
|
|
widget.setWidgetResizable(True)
|
|
|
|
return widget
|
|
|
|
def create_status_bar_widget(self) -> QWidget:
|
|
"""Create a global status bar widget for pipeline information."""
|
|
status_widget = QWidget()
|
|
status_widget.setFixedHeight(28)
|
|
status_widget.setStyleSheet("""
|
|
QWidget {
|
|
background-color: #1e1e2e;
|
|
border-top: 1px solid #45475a;
|
|
margin: 0px;
|
|
padding: 0px;
|
|
}
|
|
""")
|
|
|
|
layout = QHBoxLayout(status_widget)
|
|
layout.setContentsMargins(15, 3, 15, 3)
|
|
layout.setSpacing(20)
|
|
|
|
# Left side: Stage count display
|
|
self.stage_count_widget = StageCountWidget()
|
|
self.stage_count_widget.setFixedSize(120, 22)
|
|
layout.addWidget(self.stage_count_widget)
|
|
|
|
# Center spacer
|
|
layout.addStretch()
|
|
|
|
# Right side: Pipeline statistics
|
|
self.stats_label = QLabel("Nodes: 0 | Connections: 0")
|
|
self.stats_label.setStyleSheet("color: #a6adc8; font-size: 10px;")
|
|
layout.addWidget(self.stats_label)
|
|
|
|
return status_widget
|
|
|
|
def create_performance_panel(self) -> QWidget:
|
|
"""Create performance estimation panel."""
|
|
widget = QScrollArea()
|
|
content = QWidget()
|
|
layout = QVBoxLayout(content)
|
|
|
|
# Header
|
|
header = QLabel("Performance Estimation")
|
|
header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;")
|
|
layout.addWidget(header)
|
|
|
|
# Performance metrics
|
|
metrics_group = QGroupBox("Estimated Metrics")
|
|
metrics_layout = QFormLayout(metrics_group)
|
|
|
|
self.fps_label = QLabel("-- FPS")
|
|
self.latency_label = QLabel("-- ms")
|
|
self.memory_label = QLabel("-- MB")
|
|
|
|
metrics_layout.addRow("Throughput:", self.fps_label)
|
|
metrics_layout.addRow("Latency:", self.latency_label)
|
|
metrics_layout.addRow("Memory Usage:", self.memory_label)
|
|
|
|
layout.addWidget(metrics_group)
|
|
|
|
# Suggestions
|
|
suggestions_group = QGroupBox("Optimization Suggestions")
|
|
suggestions_layout = QVBoxLayout(suggestions_group)
|
|
|
|
self.suggestions_text = QTextBrowser()
|
|
self.suggestions_text.setMaximumHeight(150)
|
|
self.suggestions_text.setPlainText("Connect nodes to see performance analysis and optimization suggestions.")
|
|
suggestions_layout.addWidget(self.suggestions_text)
|
|
|
|
layout.addWidget(suggestions_group)
|
|
|
|
# Deploy section
|
|
deploy_group = QGroupBox("Pipeline Deployment")
|
|
deploy_layout = QVBoxLayout(deploy_group)
|
|
|
|
# Deploy button
|
|
self.deploy_button = QPushButton("Deploy Pipeline")
|
|
self.deploy_button.setToolTip("Convert pipeline to executable format and deploy to dongles")
|
|
self.deploy_button.clicked.connect(self.deploy_pipeline)
|
|
self.deploy_button.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #a6e3a1;
|
|
color: #1e1e2e;
|
|
border: 2px solid #a6e3a1;
|
|
border-radius: 8px;
|
|
padding: 12px 24px;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
min-height: 20px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #94d2a3;
|
|
border-color: #94d2a3;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #7dc4b0;
|
|
border-color: #7dc4b0;
|
|
}
|
|
QPushButton:disabled {
|
|
background-color: #6c7086;
|
|
color: #45475a;
|
|
border-color: #6c7086;
|
|
}
|
|
""")
|
|
deploy_layout.addWidget(self.deploy_button)
|
|
|
|
# Deployment status
|
|
self.deployment_status = QLabel("Ready to deploy")
|
|
self.deployment_status.setStyleSheet("color: #a6adc8; font-size: 11px; margin-top: 5px;")
|
|
self.deployment_status.setAlignment(Qt.AlignCenter)
|
|
deploy_layout.addWidget(self.deployment_status)
|
|
|
|
layout.addWidget(deploy_group)
|
|
|
|
layout.addStretch()
|
|
widget.setWidget(content)
|
|
widget.setWidgetResizable(True)
|
|
|
|
return widget
|
|
|
|
def create_dongle_panel(self) -> QWidget:
|
|
"""Create dongle management panel."""
|
|
widget = QScrollArea()
|
|
content = QWidget()
|
|
layout = QVBoxLayout(content)
|
|
|
|
# Header
|
|
header = QLabel("Dongle Management")
|
|
header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;")
|
|
layout.addWidget(header)
|
|
|
|
# Detect dongles button
|
|
detect_btn = QPushButton("Detect Dongles")
|
|
detect_btn.clicked.connect(self.detect_dongles)
|
|
layout.addWidget(detect_btn)
|
|
|
|
# Dongles list
|
|
self.dongles_list = QListWidget()
|
|
self.dongles_list.addItem("No dongles detected. Click 'Detect Dongles' to scan.")
|
|
layout.addWidget(self.dongles_list)
|
|
|
|
layout.addStretch()
|
|
widget.setWidget(content)
|
|
widget.setWidgetResizable(True)
|
|
|
|
return widget
|
|
|
|
def setup_menu(self):
|
|
"""Setup the menu bar."""
|
|
menubar = self.menuBar()
|
|
|
|
# File menu
|
|
file_menu = menubar.addMenu('&File')
|
|
|
|
# New pipeline
|
|
new_action = QAction('&New Pipeline', self)
|
|
new_action.setShortcut('Ctrl+N')
|
|
new_action.triggered.connect(self.new_pipeline)
|
|
file_menu.addAction(new_action)
|
|
|
|
# Open pipeline
|
|
open_action = QAction('&Open Pipeline...', self)
|
|
open_action.setShortcut('Ctrl+O')
|
|
open_action.triggered.connect(self.open_pipeline)
|
|
file_menu.addAction(open_action)
|
|
|
|
file_menu.addSeparator()
|
|
|
|
# Save pipeline
|
|
save_action = QAction('&Save Pipeline', self)
|
|
save_action.setShortcut('Ctrl+S')
|
|
save_action.triggered.connect(self.save_pipeline)
|
|
file_menu.addAction(save_action)
|
|
|
|
# Save As
|
|
save_as_action = QAction('Save &As...', self)
|
|
save_as_action.setShortcut('Ctrl+Shift+S')
|
|
save_as_action.triggered.connect(self.save_pipeline_as)
|
|
file_menu.addAction(save_as_action)
|
|
|
|
file_menu.addSeparator()
|
|
|
|
# Export
|
|
export_action = QAction('&Export Configuration...', self)
|
|
export_action.triggered.connect(self.export_configuration)
|
|
file_menu.addAction(export_action)
|
|
|
|
# Pipeline menu
|
|
pipeline_menu = menubar.addMenu('&Pipeline')
|
|
|
|
# Validate pipeline
|
|
validate_action = QAction('&Validate Pipeline', self)
|
|
validate_action.triggered.connect(self.validate_pipeline)
|
|
pipeline_menu.addAction(validate_action)
|
|
|
|
# Performance estimation
|
|
perf_action = QAction('&Performance Analysis', self)
|
|
perf_action.triggered.connect(self.update_performance_estimation)
|
|
pipeline_menu.addAction(perf_action)
|
|
|
|
def setup_shortcuts(self):
|
|
"""Setup keyboard shortcuts."""
|
|
# Delete shortcut
|
|
self.delete_shortcut = QAction("Delete", self)
|
|
self.delete_shortcut.setShortcut('Delete')
|
|
self.delete_shortcut.triggered.connect(self.delete_selected_nodes)
|
|
self.addAction(self.delete_shortcut)
|
|
|
|
def apply_styling(self):
|
|
"""Apply the application stylesheet."""
|
|
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
|
|
|
# Event handlers and utility methods
|
|
|
|
def add_node_to_graph(self, node_class):
|
|
"""Add a new node to the graph."""
|
|
if not self.graph:
|
|
QMessageBox.warning(self, "Node Graph Not Available",
|
|
"NodeGraphQt is not available. Cannot add nodes.")
|
|
return
|
|
|
|
try:
|
|
print(f"Attempting to create node with identifier: {node_class.__identifier__}")
|
|
|
|
# Try different identifier formats that NodeGraphQt might use
|
|
identifiers_to_try = [
|
|
node_class.__identifier__, # Original identifier
|
|
f"{node_class.__identifier__}.{node_class.__name__}", # Full format
|
|
node_class.__name__, # Just class name
|
|
]
|
|
|
|
node = None
|
|
for identifier in identifiers_to_try:
|
|
try:
|
|
print(f"Trying identifier: {identifier}")
|
|
node = self.graph.create_node(identifier)
|
|
print(f"Success with identifier: {identifier}")
|
|
break
|
|
except Exception as e:
|
|
print(f"Failed with {identifier}: {e}")
|
|
continue
|
|
|
|
if not node:
|
|
raise Exception("Could not create node with any identifier format")
|
|
|
|
# Position the node with some randomization to avoid overlap
|
|
import random
|
|
x_pos = random.randint(50, 300)
|
|
y_pos = random.randint(50, 300)
|
|
node.set_pos(x_pos, y_pos)
|
|
|
|
print(f"✓ Successfully created node: {node.name()}")
|
|
self.mark_modified()
|
|
|
|
except Exception as e:
|
|
error_msg = f"Failed to create node: {e}"
|
|
print(f"✗ {error_msg}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
# Show user-friendly error
|
|
QMessageBox.critical(self, "Node Creation Error",
|
|
f"Could not create {node_class.NODE_NAME}.\n\n"
|
|
f"Error: {e}\n\n"
|
|
f"This might be due to:\n"
|
|
f"• Node not properly registered\n"
|
|
f"• NodeGraphQt compatibility issue\n"
|
|
f"• Missing dependencies")
|
|
|
|
def on_node_selection_changed(self):
|
|
"""Handle node selection changes."""
|
|
if not self.graph:
|
|
return
|
|
|
|
selected_nodes = self.graph.selected_nodes()
|
|
if selected_nodes:
|
|
self.update_node_properties_panel(selected_nodes[0])
|
|
self.node_selected.emit(selected_nodes[0])
|
|
else:
|
|
self.clear_node_properties_panel()
|
|
|
|
def update_node_properties_panel(self, node):
|
|
"""Update the properties panel for the selected node."""
|
|
if not self.node_props_container:
|
|
return
|
|
|
|
# Clear existing properties
|
|
self.clear_node_properties_panel()
|
|
|
|
# Show the container and hide instructions
|
|
self.node_props_container.setVisible(True)
|
|
self.props_instructions.setVisible(False)
|
|
|
|
# Create property form
|
|
form_widget = QWidget()
|
|
form_layout = QFormLayout(form_widget)
|
|
|
|
# Node info
|
|
info_label = QLabel(f"Editing: {node.name()}")
|
|
info_label.setStyleSheet("color: #89b4fa; font-weight: bold; margin-bottom: 10px;")
|
|
form_layout.addRow(info_label)
|
|
|
|
# Get node properties - try different methods
|
|
try:
|
|
properties = {}
|
|
|
|
# Method 1: Try custom properties (for enhanced nodes)
|
|
if hasattr(node, 'get_business_properties'):
|
|
properties = node.get_business_properties()
|
|
|
|
# Method 1.5: Try ExactNode properties (with _property_options)
|
|
elif hasattr(node, '_property_options') and node._property_options:
|
|
properties = {}
|
|
for prop_name in node._property_options.keys():
|
|
if hasattr(node, 'get_property'):
|
|
try:
|
|
properties[prop_name] = node.get_property(prop_name)
|
|
except:
|
|
# If property doesn't exist, use a default value
|
|
properties[prop_name] = None
|
|
|
|
# Method 2: Try standard NodeGraphQt properties
|
|
elif hasattr(node, 'properties'):
|
|
all_props = node.properties()
|
|
# Filter out system properties, keep user properties
|
|
for key, value in all_props.items():
|
|
if not key.startswith('_') and key not in ['name', 'selected', 'disabled', 'custom']:
|
|
properties[key] = value
|
|
|
|
# Method 3: Use exact original properties based on node type
|
|
else:
|
|
node_type = node.__class__.__name__
|
|
if 'Input' in node_type:
|
|
# Exact InputNode properties from original
|
|
properties = {
|
|
'source_type': node.get_property('source_type') if hasattr(node, 'get_property') else 'Camera',
|
|
'device_id': node.get_property('device_id') if hasattr(node, 'get_property') else 0,
|
|
'source_path': node.get_property('source_path') if hasattr(node, 'get_property') else '',
|
|
'resolution': node.get_property('resolution') if hasattr(node, 'get_property') else '1920x1080',
|
|
'fps': node.get_property('fps') if hasattr(node, 'get_property') else 30
|
|
}
|
|
elif 'Model' in node_type:
|
|
# Exact ModelNode properties from original
|
|
properties = {
|
|
'model_path': node.get_property('model_path') if hasattr(node, 'get_property') else '',
|
|
'dongle_series': node.get_property('dongle_series') if hasattr(node, 'get_property') else '520',
|
|
'num_dongles': node.get_property('num_dongles') if hasattr(node, 'get_property') else 1,
|
|
'port_id': node.get_property('port_id') if hasattr(node, 'get_property') else ''
|
|
}
|
|
elif 'Preprocess' in node_type:
|
|
# Exact PreprocessNode properties from original
|
|
properties = {
|
|
'resize_width': node.get_property('resize_width') if hasattr(node, 'get_property') else 640,
|
|
'resize_height': node.get_property('resize_height') if hasattr(node, 'get_property') else 480,
|
|
'normalize': node.get_property('normalize') if hasattr(node, 'get_property') else True,
|
|
'crop_enabled': node.get_property('crop_enabled') if hasattr(node, 'get_property') else False,
|
|
'operations': node.get_property('operations') if hasattr(node, 'get_property') else 'resize,normalize'
|
|
}
|
|
elif 'Postprocess' in node_type:
|
|
# Exact PostprocessNode properties from original
|
|
properties = {
|
|
'output_format': node.get_property('output_format') if hasattr(node, 'get_property') else 'JSON',
|
|
'confidence_threshold': node.get_property('confidence_threshold') if hasattr(node, 'get_property') else 0.5,
|
|
'nms_threshold': node.get_property('nms_threshold') if hasattr(node, 'get_property') else 0.4,
|
|
'max_detections': node.get_property('max_detections') if hasattr(node, 'get_property') else 100
|
|
}
|
|
elif 'Output' in node_type:
|
|
# Exact OutputNode properties from original
|
|
properties = {
|
|
'output_type': node.get_property('output_type') if hasattr(node, 'get_property') else 'File',
|
|
'destination': node.get_property('destination') if hasattr(node, 'get_property') else '',
|
|
'format': node.get_property('format') if hasattr(node, 'get_property') else 'JSON',
|
|
'save_interval': node.get_property('save_interval') if hasattr(node, 'get_property') else 1.0
|
|
}
|
|
|
|
if properties:
|
|
for prop_name, prop_value in properties.items():
|
|
# Create widget based on property type and name
|
|
widget = self.create_property_widget_enhanced(node, prop_name, prop_value)
|
|
|
|
# Add to form
|
|
label = prop_name.replace('_', ' ').title()
|
|
form_layout.addRow(f"{label}:", widget)
|
|
else:
|
|
# Show available properties for debugging
|
|
info_text = f"Node type: {node.__class__.__name__}\n"
|
|
if hasattr(node, 'properties'):
|
|
props = node.properties()
|
|
info_text += f"Available properties: {list(props.keys())}"
|
|
else:
|
|
info_text += "No properties method found"
|
|
|
|
info_label = QLabel(info_text)
|
|
info_label.setStyleSheet("color: #f9e2af; font-size: 10px;")
|
|
form_layout.addRow(info_label)
|
|
|
|
except Exception as e:
|
|
error_label = QLabel(f"Error loading properties: {e}")
|
|
error_label.setStyleSheet("color: #f38ba8;")
|
|
form_layout.addRow(error_label)
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
self.node_props_layout.addWidget(form_widget)
|
|
|
|
def create_property_widget(self, node, prop_name: str, prop_value, options: Dict):
|
|
"""Create appropriate widget for a property."""
|
|
# Simple implementation - can be enhanced
|
|
if isinstance(prop_value, bool):
|
|
widget = QCheckBox()
|
|
widget.setChecked(prop_value)
|
|
elif isinstance(prop_value, int):
|
|
widget = QSpinBox()
|
|
widget.setValue(prop_value)
|
|
if 'min' in options:
|
|
widget.setMinimum(options['min'])
|
|
if 'max' in options:
|
|
widget.setMaximum(options['max'])
|
|
elif isinstance(prop_value, float):
|
|
widget = QDoubleSpinBox()
|
|
widget.setValue(prop_value)
|
|
if 'min' in options:
|
|
widget.setMinimum(options['min'])
|
|
if 'max' in options:
|
|
widget.setMaximum(options['max'])
|
|
elif isinstance(options, list):
|
|
widget = QComboBox()
|
|
widget.addItems(options)
|
|
if prop_value in options:
|
|
widget.setCurrentText(str(prop_value))
|
|
else:
|
|
widget = QLineEdit()
|
|
widget.setText(str(prop_value))
|
|
|
|
return widget
|
|
|
|
def create_property_widget_enhanced(self, node, prop_name: str, prop_value):
|
|
"""Create enhanced property widget with better type detection."""
|
|
# Create widget based on property name and value
|
|
widget = None
|
|
|
|
# Get property options from the node if available
|
|
prop_options = None
|
|
if hasattr(node, '_property_options') and prop_name in node._property_options:
|
|
prop_options = node._property_options[prop_name]
|
|
|
|
# Check for file path properties first (from prop_options or name pattern)
|
|
if (prop_options and isinstance(prop_options, dict) and prop_options.get('type') == 'file_path') or \
|
|
prop_name in ['model_path', 'source_path', 'destination']:
|
|
# File path property with filters from prop_options or defaults
|
|
widget = QPushButton(str(prop_value) if prop_value else 'Select File...')
|
|
widget.setStyleSheet("text-align: left; padding: 5px;")
|
|
|
|
def browse_file():
|
|
# Use filter from prop_options if available, otherwise use defaults
|
|
if prop_options and 'filter' in prop_options:
|
|
file_filter = prop_options['filter']
|
|
else:
|
|
# Fallback to original filters
|
|
filters = {
|
|
'model_path': 'Model files (*.onnx *.tflite *.pb)',
|
|
'source_path': 'Media files (*.mp4 *.avi *.mov *.mkv *.wav *.mp3)',
|
|
'destination': 'Output files (*.json *.xml *.csv *.txt)'
|
|
}
|
|
file_filter = filters.get(prop_name, 'All files (*)')
|
|
|
|
file_path, _ = QFileDialog.getOpenFileName(self, f'Select {prop_name}', '', file_filter)
|
|
if file_path:
|
|
widget.setText(file_path)
|
|
if hasattr(node, 'set_property'):
|
|
node.set_property(prop_name, file_path)
|
|
|
|
widget.clicked.connect(browse_file)
|
|
|
|
# Check for dropdown properties (list options from prop_options or predefined)
|
|
elif (prop_options and isinstance(prop_options, list)) or \
|
|
prop_name in ['source_type', 'dongle_series', 'output_format', 'format', 'output_type', 'resolution']:
|
|
# Dropdown property
|
|
widget = QComboBox()
|
|
|
|
# Use options from prop_options if available, otherwise use defaults
|
|
if prop_options and isinstance(prop_options, list):
|
|
items = prop_options
|
|
else:
|
|
# Fallback to original options
|
|
options = {
|
|
'source_type': ['Camera', 'Microphone', 'File', 'RTSP Stream', 'HTTP Stream'],
|
|
'dongle_series': ['520', '720', '1080', 'Custom'],
|
|
'output_format': ['JSON', 'XML', 'CSV', 'Binary'],
|
|
'format': ['JSON', 'XML', 'CSV', 'Binary'],
|
|
'output_type': ['File', 'API Endpoint', 'Database', 'Display', 'MQTT'],
|
|
'resolution': ['640x480', '1280x720', '1920x1080', '3840x2160', 'Custom']
|
|
}
|
|
items = options.get(prop_name, [str(prop_value)])
|
|
|
|
widget.addItems(items)
|
|
|
|
if str(prop_value) in items:
|
|
widget.setCurrentText(str(prop_value))
|
|
|
|
def on_change(text):
|
|
if hasattr(node, 'set_property'):
|
|
node.set_property(prop_name, text)
|
|
|
|
widget.currentTextChanged.connect(on_change)
|
|
|
|
elif isinstance(prop_value, bool):
|
|
# Boolean property
|
|
widget = QCheckBox()
|
|
widget.setChecked(prop_value)
|
|
|
|
def on_change(state):
|
|
if hasattr(node, 'set_property'):
|
|
node.set_property(prop_name, state == 2)
|
|
|
|
widget.stateChanged.connect(on_change)
|
|
|
|
elif isinstance(prop_value, int):
|
|
# Integer property
|
|
widget = QSpinBox()
|
|
widget.setValue(prop_value)
|
|
|
|
# Set range from prop_options if available, otherwise use defaults
|
|
if prop_options and isinstance(prop_options, dict) and 'min' in prop_options and 'max' in prop_options:
|
|
widget.setRange(prop_options['min'], prop_options['max'])
|
|
else:
|
|
# Fallback to original ranges for specific properties
|
|
widget.setRange(0, 99999) # Default range
|
|
if prop_name in ['device_id']:
|
|
widget.setRange(0, 10)
|
|
elif prop_name in ['fps']:
|
|
widget.setRange(1, 120)
|
|
elif prop_name in ['resize_width', 'resize_height']:
|
|
widget.setRange(64, 4096)
|
|
elif prop_name in ['num_dongles']:
|
|
widget.setRange(1, 16)
|
|
elif prop_name in ['max_detections']:
|
|
widget.setRange(1, 1000)
|
|
|
|
def on_change(value):
|
|
if hasattr(node, 'set_property'):
|
|
node.set_property(prop_name, value)
|
|
|
|
widget.valueChanged.connect(on_change)
|
|
|
|
elif isinstance(prop_value, float):
|
|
# Float property
|
|
widget = QDoubleSpinBox()
|
|
widget.setValue(prop_value)
|
|
widget.setDecimals(2)
|
|
|
|
# Set range and step from prop_options if available, otherwise use defaults
|
|
if prop_options and isinstance(prop_options, dict):
|
|
if 'min' in prop_options and 'max' in prop_options:
|
|
widget.setRange(prop_options['min'], prop_options['max'])
|
|
else:
|
|
widget.setRange(0.0, 999.0) # Default range
|
|
|
|
if 'step' in prop_options:
|
|
widget.setSingleStep(prop_options['step'])
|
|
else:
|
|
widget.setSingleStep(0.01) # Default step
|
|
else:
|
|
# Fallback to original ranges for specific properties
|
|
widget.setRange(0.0, 999.0) # Default range
|
|
if prop_name in ['confidence_threshold', 'nms_threshold']:
|
|
widget.setRange(0.0, 1.0)
|
|
widget.setSingleStep(0.1)
|
|
elif prop_name in ['save_interval']:
|
|
widget.setRange(0.1, 60.0)
|
|
widget.setSingleStep(0.1)
|
|
|
|
def on_change(value):
|
|
if hasattr(node, 'set_property'):
|
|
node.set_property(prop_name, value)
|
|
|
|
widget.valueChanged.connect(on_change)
|
|
|
|
else:
|
|
# String property (default)
|
|
widget = QLineEdit()
|
|
widget.setText(str(prop_value))
|
|
|
|
# Set placeholders for specific properties
|
|
placeholders = {
|
|
'model_path': 'Path to model file (.nef, .onnx, etc.)',
|
|
'destination': 'Output file path',
|
|
'resolution': 'e.g., 1920x1080'
|
|
}
|
|
|
|
if prop_name in placeholders:
|
|
widget.setPlaceholderText(placeholders[prop_name])
|
|
|
|
def on_change(text):
|
|
if hasattr(node, 'set_property'):
|
|
node.set_property(prop_name, text)
|
|
|
|
widget.textChanged.connect(on_change)
|
|
|
|
return widget
|
|
|
|
def clear_node_properties_panel(self):
|
|
"""Clear the node properties panel."""
|
|
if not self.node_props_layout:
|
|
return
|
|
|
|
# Remove all widgets
|
|
for i in reversed(range(self.node_props_layout.count())):
|
|
child = self.node_props_layout.itemAt(i).widget()
|
|
if child:
|
|
child.deleteLater()
|
|
|
|
# Show instructions and hide container
|
|
self.node_props_container.setVisible(False)
|
|
self.props_instructions.setVisible(True)
|
|
|
|
|
|
def detect_dongles(self):
|
|
"""Detect available dongles using actual device scanning."""
|
|
if not self.dongles_list:
|
|
return
|
|
|
|
self.dongles_list.clear()
|
|
|
|
try:
|
|
# Import MultiDongle for device scanning
|
|
from cluster4npu_ui.core.functions.Multidongle import MultiDongle
|
|
|
|
# Scan for available devices
|
|
devices = MultiDongle.scan_devices()
|
|
|
|
if devices:
|
|
# Add detected devices to the list
|
|
for device in devices:
|
|
port_id = device['port_id']
|
|
series = device['series']
|
|
self.dongles_list.addItem(f"{series} Dongle - Port {port_id}")
|
|
|
|
# Add summary item
|
|
self.dongles_list.addItem(f"Total: {len(devices)} device(s) detected")
|
|
|
|
# Store device info for later use
|
|
self.detected_devices = devices
|
|
|
|
else:
|
|
self.dongles_list.addItem("No Kneron devices detected")
|
|
self.detected_devices = []
|
|
|
|
except Exception as e:
|
|
# Fallback to simulation if scanning fails
|
|
self.dongles_list.addItem("Device scanning failed - using simulation")
|
|
self.dongles_list.addItem("Simulated KL520 Dongle - Port 28")
|
|
self.dongles_list.addItem("Simulated KL720 Dongle - Port 32")
|
|
self.detected_devices = []
|
|
|
|
# Print error for debugging
|
|
print(f"Dongle detection error: {str(e)}")
|
|
|
|
def get_detected_devices(self):
|
|
"""
|
|
Get the list of detected devices with their port IDs and series.
|
|
|
|
Returns:
|
|
List[Dict]: List of device information with port_id and series
|
|
"""
|
|
return getattr(self, 'detected_devices', [])
|
|
|
|
def refresh_dongle_detection(self):
|
|
"""
|
|
Refresh the dongle detection and update the UI.
|
|
This can be called when dongles are plugged/unplugged.
|
|
"""
|
|
self.detect_dongles()
|
|
|
|
# Update any other UI components that depend on dongle detection
|
|
self.update_performance_estimation()
|
|
|
|
def get_available_ports(self):
|
|
"""
|
|
Get list of available port IDs from detected devices.
|
|
|
|
Returns:
|
|
List[int]: List of available port IDs
|
|
"""
|
|
return [device['port_id'] for device in self.get_detected_devices()]
|
|
|
|
def get_device_by_port(self, port_id):
|
|
"""
|
|
Get device information by port ID.
|
|
|
|
Args:
|
|
port_id (int): Port ID to search for
|
|
|
|
Returns:
|
|
Dict or None: Device information if found, None otherwise
|
|
"""
|
|
for device in self.get_detected_devices():
|
|
if device['port_id'] == port_id:
|
|
return device
|
|
return None
|
|
|
|
def update_performance_estimation(self):
|
|
"""Update performance metrics based on pipeline and detected devices."""
|
|
if not all([self.fps_label, self.latency_label, self.memory_label]):
|
|
return
|
|
|
|
# Enhanced performance estimation with device information
|
|
if self.graph:
|
|
num_nodes = len(self.graph.all_nodes())
|
|
num_devices = len(self.get_detected_devices())
|
|
|
|
# Base performance calculation
|
|
base_fps = max(1, 60 - (num_nodes * 5))
|
|
base_latency = num_nodes * 10
|
|
base_memory = num_nodes * 50
|
|
|
|
# Adjust for device availability
|
|
if num_devices > 0:
|
|
# More devices can potentially improve performance
|
|
device_multiplier = min(1.5, 1 + (num_devices - 1) * 0.1)
|
|
estimated_fps = int(base_fps * device_multiplier)
|
|
estimated_latency = max(5, int(base_latency / device_multiplier))
|
|
estimated_memory = base_memory # Memory usage doesn't change much
|
|
else:
|
|
# No devices detected - show warning performance
|
|
estimated_fps = 1
|
|
estimated_latency = 999
|
|
estimated_memory = base_memory
|
|
|
|
self.fps_label.setText(f"{estimated_fps} FPS")
|
|
self.latency_label.setText(f"{estimated_latency} ms")
|
|
self.memory_label.setText(f"{estimated_memory} MB")
|
|
|
|
if self.suggestions_text:
|
|
suggestions = []
|
|
|
|
# Device-specific suggestions
|
|
if num_devices == 0:
|
|
suggestions.append("No Kneron devices detected. Connect dongles to enable inference.")
|
|
elif num_devices < num_nodes:
|
|
suggestions.append(f"Consider connecting more devices ({num_devices} available, {num_nodes} pipeline stages).")
|
|
|
|
# Performance suggestions
|
|
if num_nodes > 5:
|
|
suggestions.append("Consider reducing the number of pipeline stages for better performance.")
|
|
if estimated_fps < 30 and num_devices > 0:
|
|
suggestions.append("Current configuration may not achieve real-time performance.")
|
|
|
|
# Hardware-specific suggestions
|
|
detected_devices = self.get_detected_devices()
|
|
if detected_devices:
|
|
device_series = set(device['series'] for device in detected_devices)
|
|
if len(device_series) > 1:
|
|
suggestions.append(f"Mixed device types detected: {', '.join(device_series)}. Performance may vary.")
|
|
|
|
if not suggestions:
|
|
suggestions.append("Pipeline configuration looks good for optimal performance.")
|
|
|
|
self.suggestions_text.setPlainText("\n".join(suggestions))
|
|
|
|
def delete_selected_nodes(self):
|
|
"""Delete selected nodes from the graph."""
|
|
if not self.graph:
|
|
return
|
|
|
|
selected_nodes = self.graph.selected_nodes()
|
|
if selected_nodes:
|
|
for node in selected_nodes:
|
|
self.graph.delete_node(node)
|
|
self.mark_modified()
|
|
|
|
def validate_pipeline(self):
|
|
"""Validate the current pipeline."""
|
|
if not self.graph:
|
|
QMessageBox.information(self, "Validation", "No pipeline to validate.")
|
|
return
|
|
|
|
print("🔍 Validating pipeline...")
|
|
summary = get_pipeline_summary(self.graph)
|
|
|
|
if summary['valid']:
|
|
print(f"Pipeline validation passed - {summary['stage_count']} stages, {summary['total_nodes']} nodes")
|
|
QMessageBox.information(self, "Pipeline Validation",
|
|
f"Pipeline is valid!\n\n"
|
|
f"Stages: {summary['stage_count']}\n"
|
|
f"Total nodes: {summary['total_nodes']}")
|
|
else:
|
|
print(f"Pipeline validation failed: {summary['error']}")
|
|
QMessageBox.warning(self, "Pipeline Validation",
|
|
f"Pipeline validation failed:\n\n{summary['error']}")
|
|
|
|
# File operations
|
|
|
|
def new_pipeline(self):
|
|
"""Create a new pipeline."""
|
|
if self.is_modified:
|
|
reply = QMessageBox.question(self, "Save Changes",
|
|
"Save changes to current pipeline?",
|
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
|
|
if reply == QMessageBox.Yes:
|
|
self.save_pipeline()
|
|
elif reply == QMessageBox.Cancel:
|
|
return
|
|
|
|
# Clear the graph
|
|
if self.graph:
|
|
self.graph.clear_session()
|
|
|
|
self.project_name = "Untitled Pipeline"
|
|
self.current_file = None
|
|
self.is_modified = False
|
|
self.update_window_title()
|
|
|
|
def open_pipeline(self):
|
|
"""Open a pipeline file."""
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
self, "Open Pipeline",
|
|
self.settings.get_default_project_location(),
|
|
"Pipeline files (*.mflow);;All files (*)"
|
|
)
|
|
|
|
if file_path:
|
|
self.load_pipeline_file(file_path)
|
|
|
|
def save_pipeline(self):
|
|
"""Save the current pipeline."""
|
|
if self.current_file:
|
|
self.save_to_file(self.current_file)
|
|
else:
|
|
self.save_pipeline_as()
|
|
|
|
def save_pipeline_as(self):
|
|
"""Save pipeline with a new name."""
|
|
file_path, _ = QFileDialog.getSaveFileName(
|
|
self, "Save Pipeline",
|
|
os.path.join(self.settings.get_default_project_location(), f"{self.project_name}.mflow"),
|
|
"Pipeline files (*.mflow)"
|
|
)
|
|
|
|
if file_path:
|
|
self.save_to_file(file_path)
|
|
|
|
def save_to_file(self, file_path: str):
|
|
"""Save pipeline to specified file."""
|
|
try:
|
|
pipeline_data = {
|
|
'project_name': self.project_name,
|
|
'description': self.description,
|
|
'nodes': [],
|
|
'connections': [],
|
|
'version': '1.0'
|
|
}
|
|
|
|
# Save node data if graph is available
|
|
if self.graph:
|
|
for node in self.graph.all_nodes():
|
|
node_data = {
|
|
'id': node.id,
|
|
'name': node.name(),
|
|
'type': node.__class__.__name__,
|
|
'pos': node.pos()
|
|
}
|
|
if hasattr(node, 'get_business_properties'):
|
|
node_data['properties'] = node.get_business_properties()
|
|
pipeline_data['nodes'].append(node_data)
|
|
|
|
# Save connections
|
|
for node in self.graph.all_nodes():
|
|
for output_port in node.output_ports():
|
|
for input_port in output_port.connected_ports():
|
|
connection_data = {
|
|
'input_node': input_port.node().id,
|
|
'input_port': input_port.name(),
|
|
'output_node': node.id,
|
|
'output_port': output_port.name()
|
|
}
|
|
pipeline_data['connections'].append(connection_data)
|
|
|
|
with open(file_path, 'w') as f:
|
|
json.dump(pipeline_data, f, indent=2)
|
|
|
|
self.current_file = file_path
|
|
self.settings.add_recent_file(file_path)
|
|
self.mark_saved()
|
|
QMessageBox.information(self, "Saved", f"Pipeline saved to {file_path}")
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Save Error", f"Failed to save pipeline: {e}")
|
|
|
|
def load_pipeline_file(self, file_path: str):
|
|
"""Load pipeline from file."""
|
|
try:
|
|
with open(file_path, 'r') as f:
|
|
pipeline_data = json.load(f)
|
|
|
|
self.project_name = pipeline_data.get('project_name', 'Loaded Pipeline')
|
|
self.description = pipeline_data.get('description', '')
|
|
self.current_file = file_path
|
|
|
|
# Clear existing pipeline
|
|
if self.graph:
|
|
self.graph.clear_session()
|
|
|
|
# Load nodes and connections
|
|
self._load_nodes_from_data(pipeline_data.get('nodes', []))
|
|
self._load_connections_from_data(pipeline_data.get('connections', []))
|
|
|
|
self.settings.add_recent_file(file_path)
|
|
self.mark_saved()
|
|
self.update_window_title()
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Load Error", f"Failed to load pipeline: {e}")
|
|
|
|
def export_configuration(self):
|
|
"""Export pipeline configuration."""
|
|
QMessageBox.information(self, "Export", "Export functionality will be implemented in a future version.")
|
|
|
|
def _load_nodes_from_data(self, nodes_data):
|
|
"""Load nodes from saved data."""
|
|
if not self.graph:
|
|
return
|
|
|
|
# Import node types
|
|
from core.nodes.exact_nodes import EXACT_NODE_TYPES
|
|
|
|
# Create a mapping from class names to node classes
|
|
class_to_node_type = {}
|
|
for node_name, node_class in EXACT_NODE_TYPES.items():
|
|
class_to_node_type[node_class.__name__] = node_class
|
|
|
|
# Create a mapping from old IDs to new nodes
|
|
self._node_id_mapping = {}
|
|
|
|
for node_data in nodes_data:
|
|
try:
|
|
node_type = node_data.get('type')
|
|
old_node_id = node_data.get('id')
|
|
|
|
if node_type and node_type in class_to_node_type:
|
|
node_class = class_to_node_type[node_type]
|
|
|
|
# Try different identifier formats
|
|
identifiers_to_try = [
|
|
node_class.__identifier__,
|
|
f"{node_class.__identifier__}.{node_class.__name__}",
|
|
node_class.__name__
|
|
]
|
|
|
|
node = None
|
|
for identifier in identifiers_to_try:
|
|
try:
|
|
node = self.graph.create_node(identifier)
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if node:
|
|
# Map old ID to new node
|
|
if old_node_id:
|
|
self._node_id_mapping[old_node_id] = node
|
|
print(f"Mapped old ID {old_node_id} to new node {node.id}")
|
|
|
|
# Set node properties
|
|
if 'name' in node_data:
|
|
node.set_name(node_data['name'])
|
|
if 'pos' in node_data:
|
|
node.set_pos(*node_data['pos'])
|
|
|
|
# Restore business properties
|
|
if 'properties' in node_data:
|
|
for prop_name, prop_value in node_data['properties'].items():
|
|
try:
|
|
node.set_property(prop_name, prop_value)
|
|
except Exception as e:
|
|
print(f"Warning: Could not set property {prop_name}: {e}")
|
|
|
|
except Exception as e:
|
|
print(f"Error loading node {node_data}: {e}")
|
|
|
|
def _load_connections_from_data(self, connections_data):
|
|
"""Load connections from saved data."""
|
|
if not self.graph:
|
|
return
|
|
|
|
print(f"Loading {len(connections_data)} connections...")
|
|
|
|
# Check if we have the node ID mapping
|
|
if not hasattr(self, '_node_id_mapping'):
|
|
print(" Warning: No node ID mapping available")
|
|
return
|
|
|
|
# Create connections between nodes
|
|
for i, connection_data in enumerate(connections_data):
|
|
try:
|
|
input_node_id = connection_data.get('input_node')
|
|
input_port_name = connection_data.get('input_port')
|
|
output_node_id = connection_data.get('output_node')
|
|
output_port_name = connection_data.get('output_port')
|
|
|
|
print(f"Connection {i+1}: {output_node_id}:{output_port_name} -> {input_node_id}:{input_port_name}")
|
|
|
|
# Find the nodes using the ID mapping
|
|
input_node = self._node_id_mapping.get(input_node_id)
|
|
output_node = self._node_id_mapping.get(output_node_id)
|
|
|
|
if not input_node:
|
|
print(f" Warning: Input node {input_node_id} not found in mapping")
|
|
continue
|
|
if not output_node:
|
|
print(f" Warning: Output node {output_node_id} not found in mapping")
|
|
continue
|
|
|
|
# Get the ports
|
|
input_port = input_node.get_input(input_port_name)
|
|
output_port = output_node.get_output(output_port_name)
|
|
|
|
if not input_port:
|
|
print(f" Warning: Input port '{input_port_name}' not found on node {input_node.name()}")
|
|
continue
|
|
if not output_port:
|
|
print(f" Warning: Output port '{output_port_name}' not found on node {output_node.name()}")
|
|
continue
|
|
|
|
# Create the connection - output connects to input
|
|
output_port.connect_to(input_port)
|
|
print(f" ✓ Connection created successfully")
|
|
|
|
except Exception as e:
|
|
print(f"Error loading connection {connection_data}: {e}")
|
|
|
|
# State management
|
|
|
|
def mark_modified(self):
|
|
"""Mark the pipeline as modified."""
|
|
self.is_modified = True
|
|
self.update_window_title()
|
|
self.pipeline_modified.emit()
|
|
|
|
# Schedule pipeline analysis
|
|
self.schedule_analysis()
|
|
|
|
# Update performance estimation when pipeline changes
|
|
self.update_performance_estimation()
|
|
|
|
def mark_saved(self):
|
|
"""Mark the pipeline as saved."""
|
|
self.is_modified = False
|
|
self.update_window_title()
|
|
|
|
def update_window_title(self):
|
|
"""Update the window title."""
|
|
title = f"Cluster4NPU - {self.project_name}"
|
|
if self.is_modified:
|
|
title += " *"
|
|
if self.current_file:
|
|
title += f" - {os.path.basename(self.current_file)}"
|
|
self.setWindowTitle(title)
|
|
|
|
def closeEvent(self, event):
|
|
"""Handle window close event."""
|
|
if self.is_modified:
|
|
reply = QMessageBox.question(self, "Save Changes",
|
|
"Save changes before closing?",
|
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
|
|
if reply == QMessageBox.Yes:
|
|
self.save_pipeline()
|
|
event.accept()
|
|
elif reply == QMessageBox.No:
|
|
event.accept()
|
|
else:
|
|
event.ignore()
|
|
else:
|
|
event.accept()
|
|
|
|
# Pipeline Deployment
|
|
|
|
def deploy_pipeline(self):
|
|
"""Deploy the current pipeline to dongles."""
|
|
try:
|
|
# First validate the pipeline
|
|
if not self.validate_pipeline_for_deployment():
|
|
return
|
|
|
|
# Convert current pipeline to .mflow format
|
|
pipeline_data = self.export_pipeline_data()
|
|
|
|
# Show deployment dialog
|
|
self.show_deployment_dialog(pipeline_data)
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Deployment Error",
|
|
f"Failed to prepare pipeline for deployment: {str(e)}")
|
|
|
|
def validate_pipeline_for_deployment(self) -> bool:
|
|
"""Validate pipeline is ready for deployment."""
|
|
if not self.graph:
|
|
QMessageBox.warning(self, "Deployment Error",
|
|
"No pipeline to deploy. Please create a pipeline first.")
|
|
return False
|
|
|
|
# Check if pipeline has required nodes
|
|
all_nodes = self.graph.all_nodes()
|
|
if not all_nodes:
|
|
QMessageBox.warning(self, "Deployment Error",
|
|
"Pipeline is empty. Please add nodes to your pipeline.")
|
|
return False
|
|
|
|
# Check for required node types
|
|
has_input = any(self.is_input_node(node) for node in all_nodes)
|
|
has_model = any(self.is_model_node(node) for node in all_nodes)
|
|
has_output = any(self.is_output_node(node) for node in all_nodes)
|
|
|
|
if not has_input:
|
|
QMessageBox.warning(self, "Deployment Error",
|
|
"Pipeline must have at least one Input node.")
|
|
return False
|
|
|
|
if not has_model:
|
|
QMessageBox.warning(self, "Deployment Error",
|
|
"Pipeline must have at least one Model node.")
|
|
return False
|
|
|
|
if not has_output:
|
|
QMessageBox.warning(self, "Deployment Error",
|
|
"Pipeline must have at least one Output node.")
|
|
return False
|
|
|
|
# Validate model node configurations
|
|
validation_errors = []
|
|
for node in all_nodes:
|
|
if self.is_model_node(node):
|
|
errors = self.validate_model_node_for_deployment(node)
|
|
validation_errors.extend(errors)
|
|
|
|
if validation_errors:
|
|
error_msg = "Please fix the following issues before deployment:\n\n"
|
|
error_msg += "\n".join(f"• {error}" for error in validation_errors)
|
|
QMessageBox.warning(self, "Deployment Validation", error_msg)
|
|
return False
|
|
|
|
return True
|
|
|
|
def validate_model_node_for_deployment(self, node) -> List[str]:
|
|
"""Validate a model node for deployment requirements."""
|
|
errors = []
|
|
|
|
try:
|
|
# Get node properties
|
|
if hasattr(node, 'get_property'):
|
|
model_path = node.get_property('model_path')
|
|
scpu_fw_path = node.get_property('scpu_fw_path')
|
|
ncpu_fw_path = node.get_property('ncpu_fw_path')
|
|
port_id = node.get_property('port_id')
|
|
else:
|
|
errors.append(f"Model node '{node.name()}' cannot read properties")
|
|
return errors
|
|
|
|
# Check model path
|
|
if not model_path or not model_path.strip():
|
|
errors.append(f"Model node '{node.name()}' missing model path")
|
|
elif not os.path.exists(model_path):
|
|
errors.append(f"Model file not found: {model_path}")
|
|
elif not model_path.endswith('.nef'):
|
|
errors.append(f"Model file must be .nef format: {model_path}")
|
|
|
|
# Check firmware paths
|
|
if not scpu_fw_path or not scpu_fw_path.strip():
|
|
errors.append(f"Model node '{node.name()}' missing SCPU firmware path")
|
|
elif not os.path.exists(scpu_fw_path):
|
|
errors.append(f"SCPU firmware not found: {scpu_fw_path}")
|
|
|
|
if not ncpu_fw_path or not ncpu_fw_path.strip():
|
|
errors.append(f"Model node '{node.name()}' missing NCPU firmware path")
|
|
elif not os.path.exists(ncpu_fw_path):
|
|
errors.append(f"NCPU firmware not found: {ncpu_fw_path}")
|
|
|
|
# Check port ID
|
|
if not port_id or not port_id.strip():
|
|
errors.append(f"Model node '{node.name()}' missing port ID")
|
|
else:
|
|
# Validate port ID format
|
|
try:
|
|
port_ids = [int(p.strip()) for p in port_id.split(',') if p.strip()]
|
|
if not port_ids:
|
|
errors.append(f"Model node '{node.name()}' has invalid port ID format")
|
|
except ValueError:
|
|
errors.append(f"Model node '{node.name()}' has invalid port ID: {port_id}")
|
|
|
|
except Exception as e:
|
|
errors.append(f"Error validating model node '{node.name()}': {str(e)}")
|
|
|
|
return errors
|
|
|
|
def export_pipeline_data(self) -> Dict[str, Any]:
|
|
"""Export current pipeline to dictionary format for deployment."""
|
|
pipeline_data = {
|
|
'project_name': self.project_name,
|
|
'description': self.description,
|
|
'nodes': [],
|
|
'connections': [],
|
|
'version': '1.0'
|
|
}
|
|
|
|
if not self.graph:
|
|
return pipeline_data
|
|
|
|
# Export nodes
|
|
for node in self.graph.all_nodes():
|
|
node_data = {
|
|
'id': node.id,
|
|
'name': node.name(),
|
|
'type': node.__class__.__name__,
|
|
'pos': node.pos(),
|
|
'properties': {}
|
|
}
|
|
|
|
# Get node properties
|
|
if hasattr(node, 'get_business_properties'):
|
|
node_data['properties'] = node.get_business_properties()
|
|
elif hasattr(node, '_property_options') and node._property_options:
|
|
for prop_name in node._property_options.keys():
|
|
if hasattr(node, 'get_property'):
|
|
try:
|
|
node_data['properties'][prop_name] = node.get_property(prop_name)
|
|
except:
|
|
pass
|
|
|
|
pipeline_data['nodes'].append(node_data)
|
|
|
|
# Export connections
|
|
for node in self.graph.all_nodes():
|
|
if hasattr(node, 'output_ports'):
|
|
for output_port in node.output_ports():
|
|
if hasattr(output_port, 'connected_ports'):
|
|
for input_port in output_port.connected_ports():
|
|
connection_data = {
|
|
'input_node': input_port.node().id,
|
|
'input_port': input_port.name(),
|
|
'output_node': node.id,
|
|
'output_port': output_port.name()
|
|
}
|
|
pipeline_data['connections'].append(connection_data)
|
|
|
|
return pipeline_data
|
|
|
|
def show_deployment_dialog(self, pipeline_data: Dict[str, Any]):
|
|
"""Show deployment dialog and handle deployment process."""
|
|
from ..dialogs.deployment import DeploymentDialog
|
|
|
|
dialog = DeploymentDialog(pipeline_data, parent=self)
|
|
if dialog.exec_() == dialog.Accepted:
|
|
# Deployment was successful or initiated
|
|
self.statusBar().showMessage("Pipeline deployment initiated...", 3000)
|
|
|
|
def is_input_node(self, node) -> bool:
|
|
"""Check if node is an input node."""
|
|
return ('input' in str(type(node)).lower() or
|
|
hasattr(node, 'NODE_NAME') and 'input' in str(node.NODE_NAME).lower())
|
|
|
|
def is_model_node(self, node) -> bool:
|
|
"""Check if node is a model node."""
|
|
return ('model' in str(type(node)).lower() or
|
|
hasattr(node, 'NODE_NAME') and 'model' in str(node.NODE_NAME).lower())
|
|
|
|
def is_output_node(self, node) -> bool:
|
|
"""Check if node is an output node."""
|
|
return ('output' in str(type(node)).lower() or
|
|
hasattr(node, 'NODE_NAME') and 'output' in str(node.NODE_NAME).lower()) |