Major Features: • Advanced topological sorting algorithm with cycle detection and resolution • Intelligent pipeline optimization with parallelization analysis • Critical path analysis and performance metrics calculation • Comprehensive .mflow file converter for seamless UI-to-API integration • Complete modular UI framework with node-based pipeline editor • Enhanced model node properties (scpu_fw_path, ncpu_fw_path) • Professional output formatting without emoji decorations Technical Improvements: • Graph theory algorithms (DFS, BFS, topological sort) • Automatic dependency resolution and conflict prevention • Multi-criteria pipeline optimization • Real-time stage count calculation and validation • Comprehensive configuration validation and error handling • Modular architecture with clean separation of concerns New Components: • MFlow converter with topology analysis (core/functions/mflow_converter.py) • Complete node system with exact property matching • Pipeline editor with visual node connections • Performance estimation and dongle management panels • Comprehensive test suite and demonstration scripts 🤖 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
3423 lines
140 KiB
Python
3423 lines
140 KiB
Python
import sys
|
||
import json
|
||
import os
|
||
|
||
# Ensure QApplication exists before any widget creation
|
||
from PyQt5.QtWidgets import QApplication
|
||
if not QApplication.instance():
|
||
app = QApplication(sys.argv)
|
||
else:
|
||
app = QApplication.instance()
|
||
|
||
from PyQt5.QtWidgets import (
|
||
QMainWindow, QVBoxLayout, QHBoxLayout,
|
||
QWidget, QLineEdit, QPushButton, QDialog, QTextEdit,
|
||
QFormLayout, QDialogButtonBox, QMessageBox, QFileDialog,
|
||
QLabel, QSpinBox, QDoubleSpinBox, QComboBox, QListWidget, QCheckBox, QSplitter, QAction, QScrollArea,
|
||
QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QSlider,
|
||
QGroupBox, QGridLayout, QFrame, QTreeWidget, QTreeWidgetItem, QTextBrowser, QSizePolicy
|
||
)
|
||
from PyQt5.QtCore import Qt
|
||
from PyQt5.QtGui import QFont
|
||
|
||
from NodeGraphQt import NodeGraph, BaseNode, PropertiesBinWidget
|
||
|
||
# Harmonious theme with complementary color palette
|
||
HARMONIOUS_THEME_STYLESHEET = """
|
||
QWidget {
|
||
background-color: #1e1e2e;
|
||
color: #cdd6f4;
|
||
font-family: "Inter", "SF Pro Display", "Segoe UI", sans-serif;
|
||
font-size: 13px;
|
||
}
|
||
QMainWindow {
|
||
background-color: #181825;
|
||
}
|
||
QDialog {
|
||
background-color: #1e1e2e;
|
||
border: 1px solid #313244;
|
||
}
|
||
QLabel {
|
||
color: #f9e2af;
|
||
font-weight: 500;
|
||
}
|
||
QLineEdit, QTextEdit, QSpinBox, QDoubleSpinBox, QComboBox {
|
||
background-color: #313244;
|
||
border: 2px solid #45475a;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
color: #cdd6f4;
|
||
selection-background-color: #74c7ec;
|
||
font-size: 13px;
|
||
}
|
||
QLineEdit:focus, QTextEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus {
|
||
border-color: #89b4fa;
|
||
background-color: #383a59;
|
||
outline: none;
|
||
box-shadow: 0 0 0 3px rgba(137, 180, 250, 0.1);
|
||
}
|
||
QLineEdit:hover, QTextEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover, QComboBox:hover {
|
||
border-color: #585b70;
|
||
}
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
color: #1e1e2e;
|
||
border: none;
|
||
padding: 10px 16px;
|
||
border-radius: 8px;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
min-height: 16px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #a6c8ff, stop:1 #89dceb);
|
||
transform: translateY(-1px);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #7287fd, stop:1 #5fb3d3);
|
||
}
|
||
QPushButton:disabled {
|
||
background-color: #45475a;
|
||
color: #6c7086;
|
||
}
|
||
QDialogButtonBox QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
color: #1e1e2e;
|
||
min-width: 90px;
|
||
margin: 2px;
|
||
}
|
||
QDialogButtonBox QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #a6c8ff, stop:1 #89dceb);
|
||
}
|
||
QDialogButtonBox QPushButton[text="Cancel"] {
|
||
background-color: #585b70;
|
||
color: #cdd6f4;
|
||
border: 1px solid #6c7086;
|
||
}
|
||
QDialogButtonBox QPushButton[text="Cancel"]:hover {
|
||
background-color: #6c7086;
|
||
}
|
||
QListWidget {
|
||
background-color: #313244;
|
||
border: 2px solid #45475a;
|
||
border-radius: 8px;
|
||
outline: none;
|
||
}
|
||
QListWidget::item {
|
||
padding: 12px;
|
||
border-bottom: 1px solid #45475a;
|
||
color: #cdd6f4;
|
||
border-radius: 4px;
|
||
margin: 2px;
|
||
}
|
||
QListWidget::item:selected {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
color: #1e1e2e;
|
||
border-radius: 6px;
|
||
}
|
||
QListWidget::item:hover {
|
||
background-color: #383a59;
|
||
border-radius: 6px;
|
||
}
|
||
QSplitter::handle {
|
||
background-color: #45475a;
|
||
width: 3px;
|
||
height: 3px;
|
||
}
|
||
QSplitter::handle:hover {
|
||
background-color: #89b4fa;
|
||
}
|
||
QCheckBox {
|
||
color: #cdd6f4;
|
||
spacing: 8px;
|
||
}
|
||
QCheckBox::indicator {
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 2px solid #45475a;
|
||
border-radius: 4px;
|
||
background-color: #313244;
|
||
}
|
||
QCheckBox::indicator:checked {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
border-color: #89b4fa;
|
||
}
|
||
QCheckBox::indicator:hover {
|
||
border-color: #89b4fa;
|
||
}
|
||
QScrollArea {
|
||
border: none;
|
||
background-color: #1e1e2e;
|
||
}
|
||
QScrollBar:vertical {
|
||
background-color: #313244;
|
||
width: 14px;
|
||
border-radius: 7px;
|
||
margin: 0px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
border-radius: 7px;
|
||
min-height: 20px;
|
||
margin: 2px;
|
||
}
|
||
QScrollBar::handle:vertical:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #a6c8ff, stop:1 #89dceb);
|
||
}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||
border: none;
|
||
background: none;
|
||
height: 0px;
|
||
}
|
||
QMenuBar {
|
||
background-color: #181825;
|
||
color: #cdd6f4;
|
||
border-bottom: 1px solid #313244;
|
||
padding: 4px;
|
||
}
|
||
QMenuBar::item {
|
||
padding: 8px 12px;
|
||
background-color: transparent;
|
||
border-radius: 6px;
|
||
}
|
||
QMenuBar::item:selected {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
color: #1e1e2e;
|
||
}
|
||
QMenu {
|
||
background-color: #313244;
|
||
color: #cdd6f4;
|
||
border: 1px solid #45475a;
|
||
border-radius: 8px;
|
||
padding: 4px;
|
||
}
|
||
QMenu::item {
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
}
|
||
QMenu::item:selected {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
color: #1e1e2e;
|
||
}
|
||
QComboBox::drop-down {
|
||
border: none;
|
||
width: 30px;
|
||
border-radius: 4px;
|
||
}
|
||
QComboBox::down-arrow {
|
||
image: none;
|
||
border: 5px solid transparent;
|
||
border-top: 6px solid #cdd6f4;
|
||
margin-right: 8px;
|
||
}
|
||
QFormLayout QLabel {
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: #f9e2af;
|
||
}
|
||
QTextEdit {
|
||
line-height: 1.4;
|
||
}
|
||
/* Custom accent colors for different UI states */
|
||
.success {
|
||
color: #a6e3a1;
|
||
}
|
||
.warning {
|
||
color: #f9e2af;
|
||
}
|
||
.error {
|
||
color: #f38ba8;
|
||
}
|
||
.info {
|
||
color: #89b4fa;
|
||
}
|
||
"""
|
||
|
||
|
||
# 1. 修改节点类,只使用简单的create_property
|
||
# 更新节点类,添加业务相关属性
|
||
class ModelNode(BaseNode):
|
||
"""Model node for ML inference"""
|
||
|
||
__identifier__ = 'com.cluster.model_node'
|
||
NODE_NAME = 'Model Node'
|
||
|
||
def __init__(self):
|
||
super(ModelNode, self).__init__()
|
||
|
||
self.add_input('input', multi_input=False, color=(255, 140, 0))
|
||
self.add_output('output', color=(0, 255, 0))
|
||
self.set_color(65, 84, 102)
|
||
|
||
# 业务属性
|
||
self.create_property('model_path', '')
|
||
self.create_property('dongle_series', '520')
|
||
self.create_property('num_dongles', 1)
|
||
self.create_property('port_id', '')
|
||
|
||
# 属性选项和验证规则
|
||
self._property_options = {
|
||
'dongle_series': ['520', '720', '1080', 'Custom'],
|
||
'num_dongles': {'min': 1, 'max': 16},
|
||
'model_path': {'type': 'file_path', 'filter': 'Model files (*.onnx *.tflite *.pb)'},
|
||
'port_id': {'placeholder': 'e.g., 8080 or auto'}
|
||
}
|
||
|
||
class PreprocessNode(BaseNode):
|
||
"""Preprocessing node"""
|
||
|
||
__identifier__ = 'com.cluster.preprocess_node'
|
||
NODE_NAME = 'Preprocess Node'
|
||
|
||
def __init__(self):
|
||
super(PreprocessNode, self).__init__()
|
||
|
||
self.add_input('input', multi_input=False, color=(255, 140, 0))
|
||
self.add_output('output', color=(0, 255, 0))
|
||
self.set_color(45, 126, 72)
|
||
|
||
# 预处理业务属性
|
||
self.create_property('resize_width', 640)
|
||
self.create_property('resize_height', 480)
|
||
self.create_property('normalize', True)
|
||
self.create_property('crop_enabled', False)
|
||
self.create_property('operations', 'resize,normalize')
|
||
|
||
self._property_options = {
|
||
'resize_width': {'min': 64, 'max': 4096},
|
||
'resize_height': {'min': 64, 'max': 4096},
|
||
'operations': {'placeholder': 'comma-separated: resize,normalize,crop'}
|
||
}
|
||
|
||
class PostprocessNode(BaseNode):
|
||
"""Postprocessing node"""
|
||
|
||
__identifier__ = 'com.cluster.postprocess_node'
|
||
NODE_NAME = 'Postprocess Node'
|
||
|
||
def __init__(self):
|
||
super(PostprocessNode, self).__init__()
|
||
|
||
self.add_input('input', multi_input=False, color=(255, 140, 0))
|
||
self.add_output('output', color=(0, 255, 0))
|
||
self.set_color(153, 51, 51)
|
||
|
||
# 后处理业务属性
|
||
self.create_property('output_format', 'JSON')
|
||
self.create_property('confidence_threshold', 0.5)
|
||
self.create_property('nms_threshold', 0.4)
|
||
self.create_property('max_detections', 100)
|
||
|
||
self._property_options = {
|
||
'output_format': ['JSON', 'XML', 'CSV', 'Binary'],
|
||
'confidence_threshold': {'min': 0.0, 'max': 1.0, 'step': 0.1},
|
||
'nms_threshold': {'min': 0.0, 'max': 1.0, 'step': 0.1},
|
||
'max_detections': {'min': 1, 'max': 1000}
|
||
}
|
||
|
||
class InputNode(BaseNode):
|
||
"""Input data source node"""
|
||
|
||
__identifier__ = 'com.cluster.input_node'
|
||
NODE_NAME = 'Input Node'
|
||
|
||
def __init__(self):
|
||
super(InputNode, self).__init__()
|
||
|
||
self.add_output('output', color=(0, 255, 0))
|
||
self.set_color(83, 133, 204)
|
||
|
||
# 输入源业务属性
|
||
self.create_property('source_type', 'Camera')
|
||
self.create_property('device_id', 0)
|
||
self.create_property('source_path', '')
|
||
self.create_property('resolution', '1920x1080')
|
||
self.create_property('fps', 30)
|
||
|
||
self._property_options = {
|
||
'source_type': ['Camera', 'Microphone', 'File', 'RTSP Stream', 'HTTP Stream'],
|
||
'device_id': {'min': 0, 'max': 10},
|
||
'resolution': ['640x480', '1280x720', '1920x1080', '3840x2160', 'Custom'],
|
||
'fps': {'min': 1, 'max': 120},
|
||
'source_path': {'type': 'file_path', 'filter': 'Media files (*.mp4 *.avi *.mov *.mkv *.wav *.mp3)'}
|
||
}
|
||
|
||
class OutputNode(BaseNode):
|
||
"""Output data sink node"""
|
||
|
||
__identifier__ = 'com.cluster.output_node'
|
||
NODE_NAME = 'Output Node'
|
||
|
||
def __init__(self):
|
||
super(OutputNode, self).__init__()
|
||
|
||
self.add_input('input', multi_input=False, color=(255, 140, 0))
|
||
self.set_color(255, 140, 0)
|
||
|
||
# 输出业务属性
|
||
self.create_property('output_type', 'File')
|
||
self.create_property('destination', '')
|
||
self.create_property('format', 'JSON')
|
||
self.create_property('save_interval', 1.0)
|
||
|
||
self._property_options = {
|
||
'output_type': ['File', 'API Endpoint', 'Database', 'Display', 'MQTT'],
|
||
'format': ['JSON', 'XML', 'CSV', 'Binary'],
|
||
'destination': {'type': 'file_path', 'filter': 'Output files (*.json *.xml *.csv *.txt)'},
|
||
'save_interval': {'min': 0.1, 'max': 60.0, 'step': 0.1}
|
||
}
|
||
|
||
# 修复后的CustomPropertiesWidget
|
||
class CustomPropertiesWidget(QWidget):
|
||
def __init__(self, graph):
|
||
super().__init__()
|
||
self.graph = graph
|
||
self.current_node = None
|
||
self.property_widgets = {}
|
||
self.original_values = {} # Store original values for reset functionality
|
||
|
||
self.setup_ui()
|
||
print("Connecting node selection changed signal...")
|
||
self.graph.node_selection_changed.connect(self.on_selection_changed)
|
||
print("Signal connected successfully")
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
# 标题
|
||
self.title = QLabel("Business Properties")
|
||
self.title.setFont(QFont("Arial", 12, QFont.Bold))
|
||
self.title.setStyleSheet("color: #343a40; padding: 10px; background-color: #e9ecef; border-radius: 4px;")
|
||
layout.addWidget(self.title)
|
||
|
||
# 调试信息标签
|
||
self.debug_label = QLabel("No node selected")
|
||
self.debug_label.setStyleSheet("color: #6c757d; font-size: 10px; padding: 5px;")
|
||
layout.addWidget(self.debug_label)
|
||
|
||
# 滚动区域
|
||
self.scroll_area = QScrollArea()
|
||
self.properties_container = QWidget()
|
||
self.properties_layout = QFormLayout(self.properties_container)
|
||
self.properties_layout.setSpacing(8)
|
||
|
||
self.scroll_area.setWidget(self.properties_container)
|
||
self.scroll_area.setWidgetResizable(True)
|
||
layout.addWidget(self.scroll_area)
|
||
|
||
# 底部按钮
|
||
self.button_layout = QHBoxLayout()
|
||
self.apply_btn = QPushButton("Apply Changes")
|
||
self.apply_btn.clicked.connect(self.apply_changes)
|
||
self.reset_btn = QPushButton("Reset")
|
||
self.reset_btn.clicked.connect(self.reset_properties)
|
||
|
||
self.button_layout.addWidget(self.apply_btn)
|
||
self.button_layout.addWidget(self.reset_btn)
|
||
layout.addLayout(self.button_layout)
|
||
|
||
def on_selection_changed(self):
|
||
print("Selection changed event triggered")
|
||
selected = self.graph.selected_nodes()
|
||
print(f"Selected nodes: {[node.name() for node in selected]}")
|
||
|
||
if selected:
|
||
self.load_node_properties(selected[0])
|
||
else:
|
||
self.clear_properties()
|
||
|
||
def clear_properties(self):
|
||
print("Clearing properties...")
|
||
for widget in self.property_widgets.values():
|
||
widget.deleteLater()
|
||
self.property_widgets.clear()
|
||
|
||
for i in reversed(range(self.properties_layout.count())):
|
||
item = self.properties_layout.itemAt(i)
|
||
if item and item.widget():
|
||
item.widget().deleteLater()
|
||
|
||
self.title.setText("Business Properties")
|
||
self.debug_label.setText("No node selected")
|
||
|
||
def load_node_properties(self, node):
|
||
print(f"Loading properties for node: {node.name()}")
|
||
|
||
self.clear_properties()
|
||
self.current_node = node
|
||
|
||
self.title.setText(f"Business Properties - {node.name()}")
|
||
|
||
# 从custom字典中获取业务属性
|
||
try:
|
||
all_properties = node.properties()
|
||
print(f"All properties: {all_properties}")
|
||
|
||
# 检查是否有custom字典
|
||
if 'custom' in all_properties:
|
||
custom_properties = all_properties['custom']
|
||
print(f"Custom properties found: {custom_properties}")
|
||
|
||
# Store original values for reset functionality
|
||
self.original_values = custom_properties.copy()
|
||
|
||
self.debug_label.setText(f"Found {len(custom_properties)} business properties")
|
||
|
||
if not custom_properties:
|
||
no_props_label = QLabel("No business properties found for this node type.")
|
||
no_props_label.setStyleSheet("color: #dc3545; font-style: italic; padding: 10px;")
|
||
self.properties_layout.addRow(no_props_label)
|
||
return
|
||
|
||
for prop_name, value in custom_properties.items():
|
||
try:
|
||
print(f"Property {prop_name} = {value}")
|
||
|
||
widget = self.create_property_widget(prop_name, value, node)
|
||
if widget:
|
||
self.property_widgets[prop_name] = widget
|
||
label = QLabel(prop_name.replace('_', ' ').title() + ":")
|
||
label.setStyleSheet("font-weight: bold; color: #495057;")
|
||
self.properties_layout.addRow(label, widget)
|
||
print(f"Added widget for {prop_name}")
|
||
except Exception as e:
|
||
print(f"Error loading property {prop_name}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# 添加错误显示但不停止加载其他属性
|
||
error_label = QLabel(f"Error loading {prop_name}")
|
||
error_label.setStyleSheet("color: #dc3545; font-style: italic;")
|
||
self.properties_layout.addRow(f"{prop_name}:", error_label)
|
||
else:
|
||
print("No custom properties found")
|
||
self.debug_label.setText("No custom properties found")
|
||
self.original_values = {}
|
||
no_props_label = QLabel("This node type has no configurable properties.")
|
||
no_props_label.setStyleSheet("color: #6c757d; font-style: italic; padding: 10px;")
|
||
self.properties_layout.addRow(no_props_label)
|
||
|
||
except Exception as e:
|
||
print(f"Error accessing node properties: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.debug_label.setText("Error loading properties")
|
||
|
||
def create_property_widget(self, prop_name, value, node):
|
||
"""根据属性类型和选项创建对应的输入控件"""
|
||
print(f"Creating widget for {prop_name}, value: {value}")
|
||
|
||
options = None
|
||
if hasattr(node, '_property_options') and prop_name in node._property_options:
|
||
options = node._property_options[prop_name]
|
||
print(f"Found options for {prop_name}: {options}")
|
||
|
||
# 文件路径选择器
|
||
if isinstance(options, dict) and options.get('type') == 'file_path':
|
||
container = QWidget()
|
||
layout = QHBoxLayout(container)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
line_edit = QLineEdit()
|
||
line_edit.setText(str(value))
|
||
line_edit.setPlaceholderText(options.get('placeholder', 'Select file...'))
|
||
|
||
browse_btn = QPushButton("Browse")
|
||
browse_btn.setMaximumWidth(70)
|
||
browse_btn.clicked.connect(lambda: self.browse_file(line_edit, options.get('filter', 'All files (*.*)')))
|
||
|
||
layout.addWidget(line_edit)
|
||
layout.addWidget(browse_btn)
|
||
|
||
# 绑定变化事件 - 使用直接属性更新
|
||
line_edit.textChanged.connect(lambda text: self.update_node_property(node, prop_name, text))
|
||
return container
|
||
|
||
# 下拉选择
|
||
elif isinstance(options, list):
|
||
widget = QComboBox()
|
||
widget.addItems(options)
|
||
if str(value) in options:
|
||
widget.setCurrentText(str(value))
|
||
widget.currentTextChanged.connect(lambda text: self.update_node_property(node, prop_name, text))
|
||
return widget
|
||
|
||
# 数值范围控件 - 修复类型转换
|
||
elif isinstance(options, dict) and 'min' in options and 'max' in options:
|
||
if isinstance(value, float) or options.get('step'):
|
||
widget = QDoubleSpinBox()
|
||
widget.setDecimals(1)
|
||
widget.setSingleStep(options.get('step', 0.1))
|
||
widget.setValue(float(value) if isinstance(value, (int, float)) else float(options['min']))
|
||
else:
|
||
widget = QSpinBox()
|
||
# 修复: 确保传入int类型
|
||
widget.setValue(int(value) if isinstance(value, (int, float)) else int(options['min']))
|
||
|
||
widget.setRange(options['min'], options['max'])
|
||
widget.valueChanged.connect(lambda val: self.update_node_property(node, prop_name, val))
|
||
return widget
|
||
|
||
# 布尔值
|
||
elif isinstance(value, bool):
|
||
widget = QCheckBox()
|
||
widget.setChecked(value)
|
||
widget.toggled.connect(lambda checked: self.update_node_property(node, prop_name, checked))
|
||
return widget
|
||
|
||
# 普通文本输入
|
||
else:
|
||
widget = QLineEdit()
|
||
widget.setText(str(value))
|
||
if isinstance(options, dict) and 'placeholder' in options:
|
||
widget.setPlaceholderText(options['placeholder'])
|
||
widget.textChanged.connect(lambda text: self.update_node_property(node, prop_name, text))
|
||
return widget
|
||
|
||
def update_node_property(self, node, prop_name, value):
|
||
"""更新节点属性 - 使用直接属性更新方式"""
|
||
try:
|
||
print(f"Updating {prop_name} = {value}")
|
||
|
||
# 尝试直接设置属性(如果NodeGraphQt支持)
|
||
try:
|
||
node.set_property(prop_name, value)
|
||
print(f"Successfully updated {prop_name} using set_property")
|
||
return
|
||
except Exception as e:
|
||
print(f"set_property failed: {e}")
|
||
|
||
# 如果直接设置失败,尝试通过节点内部更新
|
||
# 直接修改节点的_property_changed属性(如果存在)
|
||
if hasattr(node, '_model') and hasattr(node._model, '_custom_properties'):
|
||
if not hasattr(node._model, '_custom_properties'):
|
||
node._model._custom_properties = {}
|
||
node._model._custom_properties[prop_name] = value
|
||
print(f"Updated via _custom_properties: {prop_name} = {value}")
|
||
return
|
||
|
||
# 最后的备用方案:直接修改properties字典(可能不持久化)
|
||
properties = node.properties()
|
||
if 'custom' in properties:
|
||
properties['custom'][prop_name] = value
|
||
print(f"Updated via properties dict: {prop_name} = {value}")
|
||
|
||
except Exception as e:
|
||
print(f"Error updating property {prop_name}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# 显示错误但不崩溃
|
||
QMessageBox.warning(self, "Property Update Error",
|
||
f"Failed to update {prop_name}: {str(e)}\n\n"
|
||
f"Value will be reset when node is reselected.")
|
||
|
||
def browse_file(self, line_edit, file_filter):
|
||
"""打开文件浏览对话框"""
|
||
filename, _ = QFileDialog.getOpenFileName(self, "Select File", "", file_filter)
|
||
if filename:
|
||
line_edit.setText(filename)
|
||
|
||
def apply_changes(self):
|
||
"""应用所有更改"""
|
||
if self.current_node:
|
||
# 尝试保存当前所有属性值
|
||
try:
|
||
saved_count = 0
|
||
for prop_name, widget in self.property_widgets.items():
|
||
# 根据widget类型获取当前值
|
||
if isinstance(widget, QLineEdit):
|
||
value = widget.text()
|
||
elif isinstance(widget, QComboBox):
|
||
value = widget.currentText()
|
||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||
value = widget.value()
|
||
elif isinstance(widget, QCheckBox):
|
||
value = widget.isChecked()
|
||
elif isinstance(widget, QWidget): # 文件路径容器
|
||
line_edit = widget.findChild(QLineEdit)
|
||
value = line_edit.text() if line_edit else ""
|
||
else:
|
||
continue
|
||
|
||
self.update_node_property(self.current_node, prop_name, value)
|
||
saved_count += 1
|
||
|
||
QMessageBox.information(self, "Applied",
|
||
f"Applied {saved_count} properties to {self.current_node.name()}")
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Apply Error", f"Error applying changes: {str(e)}")
|
||
|
||
def reset_properties(self):
|
||
"""Reset properties to original values when node was first loaded"""
|
||
if self.current_node:
|
||
reply = QMessageBox.question(self, "Reset Properties",
|
||
"Reset all properties to their original values?",
|
||
QMessageBox.Yes | QMessageBox.No)
|
||
if reply == QMessageBox.Yes:
|
||
try:
|
||
# Use original values if available, otherwise fall back to defaults
|
||
values_to_restore = self.original_values if self.original_values else self.get_default_values_for_node(self.current_node)
|
||
|
||
if not values_to_restore:
|
||
QMessageBox.information(self, "No Reset Available",
|
||
"No original or default values available to reset.")
|
||
return
|
||
|
||
# Reset each property to its original/default value
|
||
reset_count = 0
|
||
for prop_name, original_value in values_to_restore.items():
|
||
try:
|
||
self.update_node_property(self.current_node, prop_name, original_value)
|
||
reset_count += 1
|
||
print(f"Reset {prop_name} to {original_value}")
|
||
except Exception as e:
|
||
print(f"Error resetting property {prop_name}: {e}")
|
||
|
||
# Update the UI widgets to show the reset values
|
||
self.update_ui_widgets_with_values(values_to_restore)
|
||
|
||
source_type = "original" if self.original_values else "default"
|
||
QMessageBox.information(self, "Reset Complete",
|
||
f"Reset {reset_count} properties to {source_type} values.")
|
||
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Reset Error", f"Error resetting properties: {str(e)}")
|
||
|
||
def update_ui_widgets_with_values(self, values_dict):
|
||
"""Update the UI widgets to display the specified values"""
|
||
for prop_name, value in values_dict.items():
|
||
if prop_name in self.property_widgets:
|
||
widget = self.property_widgets[prop_name]
|
||
try:
|
||
# Update widget based on type
|
||
if isinstance(widget, QLineEdit):
|
||
widget.setText(str(value))
|
||
elif isinstance(widget, QComboBox):
|
||
widget.setCurrentText(str(value))
|
||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||
widget.setValue(value)
|
||
elif isinstance(widget, QCheckBox):
|
||
widget.setChecked(bool(value))
|
||
elif isinstance(widget, QWidget): # File path container
|
||
line_edit = widget.findChild(QLineEdit)
|
||
if line_edit:
|
||
line_edit.setText(str(value))
|
||
except Exception as e:
|
||
print(f"Error updating widget for {prop_name}: {e}")
|
||
|
||
def get_default_values_for_node(self, node):
|
||
"""Get the default property values for a specific node type"""
|
||
# Define default values for each node type
|
||
defaults = {
|
||
'ModelNode': {
|
||
'model_path': '',
|
||
'dongle_series': '520',
|
||
'num_dongles': 1,
|
||
'port_id': ''
|
||
},
|
||
'PreprocessNode': {
|
||
'resize_width': 640,
|
||
'resize_height': 480,
|
||
'normalize': True,
|
||
'crop_enabled': False,
|
||
'operations': 'resize,normalize'
|
||
},
|
||
'PostprocessNode': {
|
||
'output_format': 'JSON',
|
||
'confidence_threshold': 0.5,
|
||
'nms_threshold': 0.4,
|
||
'max_detections': 100
|
||
},
|
||
'InputNode': {
|
||
'source_type': 'Camera',
|
||
'device_id': 0,
|
||
'source_path': '',
|
||
'resolution': '1920x1080',
|
||
'fps': 30
|
||
},
|
||
'OutputNode': {
|
||
'output_type': 'File',
|
||
'destination': '',
|
||
'format': 'JSON',
|
||
'save_interval': 1.0
|
||
}
|
||
}
|
||
|
||
# Get the node class name
|
||
node_class_name = node.__class__.__name__
|
||
|
||
# Return the defaults for this node type, or empty dict if not found
|
||
return defaults.get(node_class_name, {})
|
||
|
||
class CreatePipelineDialog(QDialog):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Create New Pipeline")
|
||
self.setMinimumWidth(450)
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
|
||
self.projectNameInput = QLineEdit(self)
|
||
self.descriptionInput = QTextEdit(self)
|
||
self.descriptionInput.setPlaceholderText("Optional (briefly describe the pipeline's purpose)")
|
||
self.descriptionInput.setFixedHeight(80)
|
||
|
||
formLayout = QFormLayout()
|
||
formLayout.setSpacing(10)
|
||
formLayout.addRow("Project Name:", self.projectNameInput)
|
||
formLayout.addRow("Description:", self.descriptionInput)
|
||
|
||
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||
self.buttonBox.accepted.connect(self.handle_ok)
|
||
self.buttonBox.rejected.connect(self.reject)
|
||
|
||
mainLayout = QVBoxLayout(self)
|
||
mainLayout.setContentsMargins(15, 15, 15, 15)
|
||
mainLayout.addLayout(formLayout)
|
||
mainLayout.addSpacing(10)
|
||
mainLayout.addWidget(self.buttonBox)
|
||
|
||
def handle_ok(self):
|
||
if not self.projectNameInput.text().strip():
|
||
QMessageBox.warning(self, "Input Error", "Project Name cannot be empty.")
|
||
return
|
||
self.accept()
|
||
|
||
def get_data(self):
|
||
return {
|
||
"project_name": self.projectNameInput.text().strip(),
|
||
"description": self.descriptionInput.toPlainText().strip()
|
||
}
|
||
|
||
class SimplePropertiesDialog(QDialog): # Corrected and placed before PipelineEditor
|
||
"""Simple properties dialog as fallback"""
|
||
|
||
def __init__(self, node, parent=None):
|
||
super().__init__(parent)
|
||
self.node = node
|
||
self.setWindowTitle(f"Properties - {node.name()}")
|
||
self.setMinimumWidth(400)
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Node info
|
||
info_label = QLabel(f"Node: {node.name()}\nType: {node.type_}")
|
||
info_label.setFont(QFont("Arial", 12, QFont.Bold))
|
||
layout.addWidget(info_label)
|
||
|
||
# Properties
|
||
form_layout = QFormLayout()
|
||
self.property_widgets = {}
|
||
|
||
try:
|
||
if hasattr(node, 'properties'):
|
||
for prop_name in node.properties():
|
||
try:
|
||
current_value = node.get_property(prop_name)
|
||
|
||
# Create simple text input for all properties
|
||
widget = QLineEdit()
|
||
widget.setText(str(current_value))
|
||
|
||
self.property_widgets[prop_name] = widget
|
||
form_layout.addRow(f"{prop_name}:", widget)
|
||
|
||
except Exception as e:
|
||
print(f"Error loading property {prop_name} for node {node.name()}: {e}")
|
||
# Optionally add a label to the dialog indicating error for this property
|
||
error_prop_label = QLabel(f"(Error loading {prop_name})")
|
||
form_layout.addRow(f"{prop_name}:", error_prop_label)
|
||
|
||
except Exception as e:
|
||
print(f"Error loading properties for node {node.name()}: {e}")
|
||
error_label = QLabel(f"Error loading properties: {e}")
|
||
layout.addWidget(error_label)
|
||
|
||
layout.addLayout(form_layout)
|
||
|
||
# Buttons
|
||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||
button_box.accepted.connect(self.save_properties)
|
||
button_box.rejected.connect(self.reject)
|
||
layout.addWidget(button_box)
|
||
|
||
def save_properties(self):
|
||
"""Save property changes"""
|
||
try:
|
||
for prop_name, widget in self.property_widgets.items():
|
||
if not isinstance(widget, QLineEdit): # Skip if widget is not an input (e.g. error label)
|
||
continue
|
||
text_value = widget.text()
|
||
|
||
# Try to convert to appropriate type
|
||
try:
|
||
# Get original value to determine type
|
||
original_value = self.node.get_property(prop_name) # Assuming this doesn't error again
|
||
if isinstance(original_value, bool):
|
||
value = text_value.lower() in ('true', '1', 'yes', 'on')
|
||
elif isinstance(original_value, int):
|
||
value = int(text_value)
|
||
elif isinstance(original_value, float):
|
||
value = float(text_value)
|
||
else:
|
||
value = text_value
|
||
except (ValueError, TypeError): # Fallback if conversion fails
|
||
value = text_value
|
||
except Exception as e_get_prop: # Handle case where get_property failed during save_properties' type check
|
||
print(f"Could not get original property type for {prop_name} during save. Saving as string. Error: {e_get_prop}")
|
||
value = text_value # Save as string if original type unknown
|
||
|
||
# Set the property
|
||
self.node.set_property(prop_name, value)
|
||
|
||
self.accept()
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to save properties: {str(e)}")
|
||
|
||
class NodePalette(QWidget):
|
||
"""Node palette for adding nodes"""
|
||
def __init__(self, graph):
|
||
super().__init__()
|
||
self.graph = graph
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
title = QLabel("Node Palette")
|
||
title.setFont(QFont("Arial", 12, QFont.Bold))
|
||
layout.addWidget(title)
|
||
|
||
# Node creation buttons
|
||
nodes = [
|
||
("Input", InputNode),
|
||
("Model", ModelNode),
|
||
("Preprocess", PreprocessNode),
|
||
("Postprocess", PostprocessNode),
|
||
("Output", OutputNode)
|
||
]
|
||
|
||
for name, node_class in nodes:
|
||
btn = QPushButton(name)
|
||
btn.clicked.connect(lambda checked, cls=node_class: self.create_node(cls))
|
||
layout.addWidget(btn)
|
||
|
||
# Instructions
|
||
instructions = QLabel()
|
||
instructions.setText(
|
||
"📍 Click buttons to add nodes\n"
|
||
"⌨️ Or press TAB in graph area to search nodes\n"
|
||
"🔗 Drag from 🟢GREEN output to 🟠ORANGE input\n"
|
||
"⚙️ Double-click nodes to see properties\n"
|
||
"🗑️ Select + Delete key to remove\n"
|
||
"💾 Ctrl+S to save\n"
|
||
"🎯 Right-click for context menu\n"
|
||
"📋 View menu → Properties Panel\n"
|
||
"🔍 Debug menu → Show Registered Nodes"
|
||
)
|
||
instructions.setWordWrap(True)
|
||
instructions.setStyleSheet("color: #6c757d; font-size: 10px; margin: 10px 0; padding: 10px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px;")
|
||
layout.addWidget(instructions)
|
||
|
||
layout.addStretch()
|
||
|
||
def create_node(self, node_class):
|
||
"""Create a new node and add to graph"""
|
||
try:
|
||
# Use class name instead of NODE_NAME for identifier
|
||
node_id = f"{node_class.__identifier__}.{node_class.__name__}"
|
||
# print(f"Creating node: {node_id}") # Optional: for debugging
|
||
node = self.graph.create_node(node_id)
|
||
if node: # Check if node was created
|
||
node.set_pos(100, 100) # Position new nodes at visible location
|
||
# print(f"Successfully created node: {node_id}") # Optional: for debugging
|
||
else:
|
||
# This case might happen if registration was incomplete or ID is wrong.
|
||
raise Exception(f"Graph returned None for create_node with ID: {node_id}")
|
||
|
||
except Exception as e:
|
||
print(f"Failed to create node {node_class.__name__}: {e}")
|
||
QMessageBox.warning(self, "Node Creation Error",
|
||
f"Failed to create {node_class.__name__}: {str(e)}\n\n"
|
||
f"Try using Tab key to search for nodes, or check console for registration errors.")
|
||
|
||
class IntegratedPipelineDashboard(QMainWindow):
|
||
"""Integrated dashboard combining pipeline editor, stage configuration, and performance estimation"""
|
||
|
||
def __init__(self, project_name="", description="", filename=None):
|
||
super().__init__()
|
||
self.project_name = project_name
|
||
self.description = description
|
||
self.current_file = filename
|
||
self.is_modified = False
|
||
|
||
# Initialize attributes that will be used by methods
|
||
self.allocation_layout = None
|
||
self.stage_configs_layout = None
|
||
self.stages_spinbox = None
|
||
self.dongles_list = None
|
||
self.fps_label = None
|
||
self.latency_label = None
|
||
self.memory_label = None
|
||
self.suggestions_text = None
|
||
self.props_instructions = None
|
||
self.node_props_container = None
|
||
self.node_props_layout = None
|
||
|
||
# Initialize node graph
|
||
self.graph = NodeGraph(properties_bin_class=None)
|
||
|
||
# Register custom nodes
|
||
nodes_to_register = [InputNode, ModelNode, PreprocessNode, PostprocessNode, OutputNode]
|
||
for node_class in nodes_to_register:
|
||
try:
|
||
self.graph.register_node(node_class)
|
||
# Uncomment for debugging: print(f"✅ Registered {node_class.__name__}")
|
||
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.property_changed.connect(self.mark_modified)
|
||
|
||
if hasattr(self.graph, 'port_connected'):
|
||
self.graph.port_connected.connect(self.on_port_connected)
|
||
if hasattr(self.graph, 'port_disconnected'):
|
||
self.graph.port_disconnected.connect(self.on_port_disconnected)
|
||
|
||
self.setup_integrated_ui()
|
||
self.setup_menu()
|
||
|
||
# Add keyboard shortcut for delete
|
||
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)
|
||
|
||
self.update_window_title()
|
||
self.setGeometry(50, 50, 2000, 1200) # Wider window for 3-panel layout
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
|
||
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 horizontal splitter with 3 panels
|
||
main_splitter = QSplitter(Qt.Horizontal)
|
||
main_splitter.setStyleSheet("""
|
||
QSplitter::handle {
|
||
background-color: #45475a;
|
||
width: 3px;
|
||
}
|
||
QSplitter::handle:hover {
|
||
background-color: #89b4fa;
|
||
}
|
||
""")
|
||
|
||
# Left side: Node Template Panel (20% width)
|
||
left_panel = self.create_node_template_panel()
|
||
left_panel.setMinimumWidth(250)
|
||
left_panel.setMaximumWidth(350)
|
||
|
||
# Middle: Pipeline Editor (50% width)
|
||
editor_widget = QWidget()
|
||
editor_layout = QVBoxLayout(editor_widget)
|
||
editor_layout.setContentsMargins(5, 5, 5, 5)
|
||
|
||
# Add pipeline editor title
|
||
editor_title = QLabel("Pipeline Editor")
|
||
editor_title.setStyleSheet("color: #f9e2af; font-size: 16px; font-weight: bold; padding: 10px;")
|
||
editor_layout.addWidget(editor_title)
|
||
|
||
# Add the node graph widget
|
||
graph_widget = self.graph.widget
|
||
graph_widget.setMinimumHeight(400)
|
||
editor_layout.addWidget(graph_widget)
|
||
|
||
# Right side: Configuration panels (30% width)
|
||
right_panel = QWidget()
|
||
right_panel.setMinimumWidth(350)
|
||
right_panel.setMaximumWidth(450)
|
||
right_layout = QVBoxLayout(right_panel)
|
||
right_layout.setContentsMargins(5, 5, 5, 5)
|
||
right_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;
|
||
}
|
||
""")
|
||
|
||
# Node Properties Tab (most important for editing)
|
||
node_props_panel = self.create_node_properties_panel()
|
||
config_tabs.addTab(node_props_panel, "📝 Properties")
|
||
|
||
# Stage Configuration Tab
|
||
stage_config = self.create_stage_config_panel()
|
||
config_tabs.addTab(stage_config, "⚙️ Stages")
|
||
|
||
# Performance Estimation Tab
|
||
performance_panel = self.create_performance_panel()
|
||
config_tabs.addTab(performance_panel, "📊 Performance")
|
||
|
||
# Dongle Management Tab
|
||
dongle_panel = self.create_dongle_panel()
|
||
config_tabs.addTab(dongle_panel, "🔌 Dongles")
|
||
|
||
right_layout.addWidget(config_tabs)
|
||
|
||
# Add widgets to splitter
|
||
main_splitter.addWidget(left_panel)
|
||
main_splitter.addWidget(editor_widget)
|
||
main_splitter.addWidget(right_panel)
|
||
main_splitter.setSizes([300, 800, 400]) # 20-50-30 split
|
||
|
||
# Set main layout
|
||
main_layout = QVBoxLayout(central_widget)
|
||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||
main_layout.addWidget(main_splitter)
|
||
|
||
def create_node_template_panel(self):
|
||
"""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
|
||
nodes_info = [
|
||
("🎯 Input Node", "Data input source", InputNode),
|
||
("🧠 Model Node", "AI inference model", ModelNode),
|
||
("⚙️ Preprocess Node", "Data preprocessing", PreprocessNode),
|
||
("🔧 Postprocess Node", "Output processing", PostprocessNode),
|
||
("📤 Output Node", "Final output", OutputNode)
|
||
]
|
||
|
||
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)
|
||
|
||
# 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_node_properties_panel(self):
|
||
"""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 (will be populated when node is selected)
|
||
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)
|
||
|
||
# Connect to node selection changes
|
||
self.graph.node_selection_changed.connect(self.update_node_properties_panel)
|
||
|
||
return widget
|
||
|
||
def add_node_to_graph(self, node_class):
|
||
"""Add a new node to the graph"""
|
||
try:
|
||
# Create node instance directly and add to graph
|
||
node = node_class()
|
||
self.graph.add_node(node)
|
||
|
||
# Position it in a reasonable location (center with some randomness)
|
||
import random
|
||
x = random.randint(-100, 100)
|
||
y = random.randint(-100, 100)
|
||
node.set_pos(x, y)
|
||
|
||
print(f"✅ Added {node_class.__name__} to graph at position ({x}, {y})")
|
||
self.mark_modified()
|
||
|
||
# Auto-update performance estimation
|
||
self.update_performance_estimation()
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to add {node_class.__name__}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
QMessageBox.warning(self, "Error", f"Failed to add {node_class.__name__}:\n{str(e)}")
|
||
|
||
def update_node_properties_panel(self):
|
||
"""Update the node properties panel based on selected node"""
|
||
try:
|
||
# Clear existing properties
|
||
for i in reversed(range(self.node_props_layout.count())):
|
||
child = self.node_props_layout.itemAt(i).widget()
|
||
if child:
|
||
child.setParent(None)
|
||
|
||
selected_nodes = self.graph.selected_nodes()
|
||
|
||
if not selected_nodes:
|
||
# Show instructions when no node selected
|
||
self.props_instructions.setVisible(True)
|
||
self.node_props_container.setVisible(False)
|
||
return
|
||
|
||
# Hide instructions and show properties
|
||
self.props_instructions.setVisible(False)
|
||
self.node_props_container.setVisible(True)
|
||
|
||
# Get first selected node
|
||
node = selected_nodes[0]
|
||
|
||
# Create properties form for the node
|
||
self.create_node_properties_form(node)
|
||
|
||
except Exception as e:
|
||
print(f"Error updating node properties panel: {e}")
|
||
|
||
def create_node_properties_form(self, node):
|
||
"""Create properties form for a specific node"""
|
||
try:
|
||
# Get node name safely
|
||
try:
|
||
node_name = node.name() if callable(node.name) else str(node.name)
|
||
except:
|
||
node_name = "Unknown Node"
|
||
|
||
# Get node type safely with clean display names
|
||
try:
|
||
raw_type = node.type_() if callable(node.type_) else str(getattr(node, 'type_', 'Unknown'))
|
||
|
||
# Check if it has a clean NODE_NAME attribute first
|
||
if hasattr(node, 'NODE_NAME'):
|
||
node_type = node.NODE_NAME
|
||
else:
|
||
# Extract clean name from full identifier or class name
|
||
if 'com.cluster.' in raw_type:
|
||
# Extract from full identifier like com.cluster.input_node.ExactInputNode
|
||
if raw_type.endswith('.ExactInputNode'):
|
||
node_type = 'Input Node'
|
||
elif raw_type.endswith('.ExactModelNode'):
|
||
node_type = 'Model Node'
|
||
elif raw_type.endswith('.ExactPreprocessNode'):
|
||
node_type = 'Preprocess Node'
|
||
elif raw_type.endswith('.ExactPostprocessNode'):
|
||
node_type = 'Postprocess Node'
|
||
elif raw_type.endswith('.ExactOutputNode'):
|
||
node_type = 'Output Node'
|
||
else:
|
||
# Fallback: extract base name
|
||
parts = raw_type.split('.')
|
||
if len(parts) >= 3:
|
||
base_name = parts[2].replace('_', ' ').title() + ' Node'
|
||
node_type = base_name
|
||
else:
|
||
node_type = raw_type
|
||
else:
|
||
# Extract from class name like ExactInputNode
|
||
class_name = node.__class__.__name__
|
||
if class_name.startswith('Exact') and class_name.endswith('Node'):
|
||
# Remove 'Exact' prefix and add space before 'Node'
|
||
clean_name = class_name[5:] # Remove 'Exact'
|
||
if clean_name == 'InputNode':
|
||
node_type = 'Input Node'
|
||
elif clean_name == 'ModelNode':
|
||
node_type = 'Model Node'
|
||
elif clean_name == 'PreprocessNode':
|
||
node_type = 'Preprocess Node'
|
||
elif clean_name == 'PostprocessNode':
|
||
node_type = 'Postprocess Node'
|
||
elif clean_name == 'OutputNode':
|
||
node_type = 'Output Node'
|
||
else:
|
||
node_type = clean_name
|
||
else:
|
||
node_type = class_name
|
||
except:
|
||
node_type = "Unknown Type"
|
||
|
||
# Node info header
|
||
info_group = QGroupBox(f"{node_name} Properties")
|
||
info_layout = QFormLayout(info_group)
|
||
|
||
# Node name (editable)
|
||
name_edit = QLineEdit(node_name)
|
||
def update_node_name(text):
|
||
try:
|
||
if hasattr(node, 'set_name') and callable(node.set_name):
|
||
node.set_name(text)
|
||
self.mark_modified()
|
||
except Exception as e:
|
||
print(f"Error updating node name: {e}")
|
||
|
||
name_edit.textChanged.connect(update_node_name)
|
||
info_layout.addRow("Name:", name_edit)
|
||
|
||
# Node type (read-only)
|
||
type_label = QLabel(node_type)
|
||
type_label.setStyleSheet("color: #a6adc8;")
|
||
info_layout.addRow("Type:", type_label)
|
||
|
||
self.node_props_layout.addWidget(info_group)
|
||
|
||
# Get node properties - NodeGraphQt uses different property access methods
|
||
custom_props = {}
|
||
|
||
# Method 0: Try to get business properties first (highest priority)
|
||
try:
|
||
if hasattr(node, 'get_business_properties') and callable(node.get_business_properties):
|
||
business_props = node.get_business_properties()
|
||
if business_props:
|
||
custom_props = business_props
|
||
print(f"Found properties via get_business_properties(): {list(custom_props.keys())}")
|
||
elif hasattr(node, '_business_properties') and node._business_properties:
|
||
custom_props = node._business_properties.copy()
|
||
print(f"Found properties via _business_properties: {list(custom_props.keys())}")
|
||
|
||
# Check if node has a custom display properties method
|
||
if hasattr(node, 'get_display_properties') and callable(node.get_display_properties):
|
||
display_props = node.get_display_properties()
|
||
if display_props and custom_props:
|
||
# Filter to only show the specified display properties
|
||
filtered_props = {k: v for k, v in custom_props.items() if k in display_props}
|
||
if filtered_props:
|
||
custom_props = filtered_props
|
||
print(f"Filtered to display properties: {list(custom_props.keys())}")
|
||
|
||
except Exception as e:
|
||
print(f"Method 0 - get_business_properties() failed: {e}")
|
||
|
||
# Method 1: Try to get properties from NodeGraphQt node (only if no business properties found)
|
||
if not custom_props:
|
||
try:
|
||
if hasattr(node, 'properties'):
|
||
# Get all properties from the node
|
||
all_props = node.properties()
|
||
print(f"All node properties: {list(all_props.keys())}")
|
||
|
||
# Check if there's a 'custom' property that contains our properties
|
||
if 'custom' in all_props:
|
||
custom_value = all_props['custom']
|
||
print(f"Custom property value type: {type(custom_value)}, value: {custom_value}")
|
||
|
||
# If custom property contains a dict, use it
|
||
if isinstance(custom_value, dict):
|
||
custom_props = custom_value
|
||
# If custom property is accessible via get_property, try that
|
||
elif hasattr(node, 'get_property'):
|
||
try:
|
||
custom_from_get = node.get_property('custom')
|
||
if isinstance(custom_from_get, dict):
|
||
custom_props = custom_from_get
|
||
except:
|
||
pass
|
||
|
||
# Also include other potentially useful properties
|
||
useful_props = {}
|
||
for k, v in all_props.items():
|
||
if k not in {'name', 'id', 'selected', 'disabled', 'visible', 'pos', 'color',
|
||
'type_', 'icon', 'border_color', 'text_color', 'width', 'height',
|
||
'layout_direction', 'port_deletion_allowed', 'subgraph_session'}:
|
||
useful_props[k] = v
|
||
|
||
# Merge custom_props with other useful properties
|
||
if useful_props:
|
||
custom_props.update(useful_props)
|
||
|
||
print(f"Found properties via node.properties(): {list(custom_props.keys())}")
|
||
except Exception as e:
|
||
print(f"Method 1 - node.properties() failed: {e}")
|
||
|
||
# Method 2: Try to access properties via get_property (for NodeGraphQt created properties)
|
||
# This should work for properties created with create_property()
|
||
if not custom_props:
|
||
try:
|
||
# Get all properties defined for this node type
|
||
if hasattr(node, 'get_property'):
|
||
# Define properties for different node types
|
||
node_type_properties = {
|
||
'ModelNode': ['model_path', 'dongle_series', 'num_dongles', 'port_id'],
|
||
'ExactModelNode': ['model_path', 'dongle_series', 'num_dongles', 'port_id'],
|
||
'InputNode': ['input_path', 'source_type', 'fps', 'source_path'],
|
||
'ExactInputNode': ['source_type', 'device_id', 'source_path', 'resolution', 'fps'],
|
||
'OutputNode': ['output_path', 'output_format', 'save_results'],
|
||
'ExactOutputNode': ['output_type', 'destination', 'format', 'save_interval'],
|
||
'PreprocessNode': ['resize_width', 'resize_height', 'operations'],
|
||
'ExactPreprocessNode': ['resize_width', 'resize_height', 'normalize', 'crop_enabled', 'operations'],
|
||
'PostprocessNode': ['confidence_threshold', 'nms_threshold', 'max_detections'],
|
||
'ExactPostprocessNode': ['output_format', 'confidence_threshold', 'nms_threshold', 'max_detections']
|
||
}
|
||
|
||
# Try to determine node type
|
||
node_class_name = node.__class__.__name__
|
||
properties_to_check = []
|
||
|
||
if node_class_name in node_type_properties:
|
||
properties_to_check = node_type_properties[node_class_name]
|
||
else:
|
||
# Try all known properties if we can't determine the type
|
||
properties_to_check = []
|
||
for props_list in node_type_properties.values():
|
||
properties_to_check.extend(props_list)
|
||
|
||
print(f"Checking properties for {node_class_name}: {properties_to_check}")
|
||
|
||
for prop in properties_to_check:
|
||
try:
|
||
value = node.get_property(prop)
|
||
# Only add if the property actually exists (not None and not raising exception)
|
||
custom_props[prop] = value
|
||
print(f" Found {prop}: {value}")
|
||
except Exception as prop_error:
|
||
# Property doesn't exist for this node, which is expected
|
||
pass
|
||
|
||
print(f"Found properties via get_property(): {list(custom_props.keys())}")
|
||
except Exception as e:
|
||
print(f"Method 2 - get_property() failed: {e}")
|
||
|
||
# Method 3: Try to access custom attribute if it exists
|
||
if not custom_props:
|
||
try:
|
||
if hasattr(node, 'custom'):
|
||
custom_attr = node.custom if not callable(node.custom) else node.custom()
|
||
if isinstance(custom_attr, dict):
|
||
custom_props = custom_attr
|
||
print(f"Found properties via custom attribute: {list(custom_props.keys())}")
|
||
except Exception as e:
|
||
print(f"Method 3 - custom attribute failed: {e}")
|
||
|
||
if custom_props:
|
||
custom_group = QGroupBox("Custom Properties")
|
||
custom_layout = QFormLayout(custom_group)
|
||
|
||
for prop_name, prop_value in custom_props.items():
|
||
if prop_name in ['model_path', 'input_path', 'output_path']:
|
||
# File path property
|
||
path_layout = QHBoxLayout()
|
||
path_edit = QLineEdit(str(prop_value))
|
||
browse_btn = QPushButton("Browse...")
|
||
browse_btn.clicked.connect(lambda checked, pe=path_edit, pn=prop_name: self.browse_file_for_property(pe, pn))
|
||
|
||
path_layout.addWidget(path_edit)
|
||
path_layout.addWidget(browse_btn)
|
||
|
||
path_widget = QWidget()
|
||
path_widget.setLayout(path_layout)
|
||
custom_layout.addRow(prop_name.replace("_", " ").title() + ":", path_widget)
|
||
|
||
# Connect to update node property
|
||
path_edit.textChanged.connect(lambda text, pn=prop_name: self.update_node_property(node, pn, text))
|
||
|
||
elif isinstance(prop_value, (int, float)):
|
||
# Numeric property
|
||
if isinstance(prop_value, int):
|
||
spin_box = QSpinBox()
|
||
spin_box.setRange(-999999, 999999)
|
||
spin_box.setValue(prop_value)
|
||
spin_box.valueChanged.connect(lambda val, pn=prop_name: self.update_node_property(node, pn, val))
|
||
else:
|
||
spin_box = QDoubleSpinBox()
|
||
spin_box.setRange(-999999.0, 999999.0)
|
||
spin_box.setValue(prop_value)
|
||
spin_box.valueChanged.connect(lambda val, pn=prop_name: self.update_node_property(node, pn, val))
|
||
|
||
custom_layout.addRow(prop_name.replace("_", " ").title() + ":", spin_box)
|
||
|
||
elif isinstance(prop_value, bool):
|
||
# Boolean property
|
||
check_box = QCheckBox()
|
||
check_box.setChecked(prop_value)
|
||
check_box.toggled.connect(lambda checked, pn=prop_name: self.update_node_property(node, pn, checked))
|
||
custom_layout.addRow(prop_name.replace("_", " ").title() + ":", check_box)
|
||
|
||
else:
|
||
# String property
|
||
text_edit = QLineEdit(str(prop_value))
|
||
text_edit.textChanged.connect(lambda text, pn=prop_name: self.update_node_property(node, pn, text))
|
||
custom_layout.addRow(prop_name.replace("_", " ").title() + ":", text_edit)
|
||
|
||
self.node_props_layout.addWidget(custom_group)
|
||
|
||
# Position info (read-only)
|
||
pos_group = QGroupBox("Position")
|
||
pos_layout = QFormLayout(pos_group)
|
||
|
||
# Get position safely
|
||
try:
|
||
pos = node.pos() if callable(node.pos) else node.pos
|
||
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||
x, y = pos[0], pos[1]
|
||
else:
|
||
x, y = 0.0, 0.0
|
||
except Exception as e:
|
||
print(f"Error getting node position: {e}")
|
||
x, y = 0.0, 0.0
|
||
|
||
x_label = QLabel(f"{x:.1f}")
|
||
y_label = QLabel(f"{y:.1f}")
|
||
x_label.setStyleSheet("color: #a6adc8;")
|
||
y_label.setStyleSheet("color: #a6adc8;")
|
||
|
||
pos_layout.addRow("X:", x_label)
|
||
pos_layout.addRow("Y:", y_label)
|
||
|
||
self.node_props_layout.addWidget(pos_group)
|
||
|
||
except Exception as e:
|
||
print(f"Error creating properties form: {e}")
|
||
error_label = QLabel(f"Error loading properties: {str(e)}")
|
||
error_label.setStyleSheet("color: #f38ba8;")
|
||
self.node_props_layout.addWidget(error_label)
|
||
|
||
def update_node_property(self, node, property_name, value):
|
||
"""Update a node's custom property"""
|
||
try:
|
||
success = False
|
||
|
||
# Method 1: Try NodeGraphQt set_property
|
||
if hasattr(node, 'set_property') and callable(node.set_property):
|
||
try:
|
||
node.set_property(property_name, value)
|
||
success = True
|
||
print(f"✅ Updated via set_property: {property_name} = {value}")
|
||
except Exception as e:
|
||
print(f"set_property failed: {e}")
|
||
|
||
# Method 2: Try custom attribute
|
||
if not success and hasattr(node, 'custom'):
|
||
try:
|
||
if not callable(node.custom):
|
||
node.custom[property_name] = value
|
||
success = True
|
||
print(f"✅ Updated via custom attribute: {property_name} = {value}")
|
||
else:
|
||
print(f"Warning: Cannot update property {property_name} - custom is a method")
|
||
except Exception as e:
|
||
print(f"custom attribute update failed: {e}")
|
||
|
||
# Method 3: Try direct attribute setting
|
||
if not success:
|
||
try:
|
||
setattr(node, property_name, value)
|
||
success = True
|
||
print(f"✅ Updated via setattr: {property_name} = {value}")
|
||
except Exception as e:
|
||
print(f"setattr failed: {e}")
|
||
|
||
if success:
|
||
self.mark_modified()
|
||
|
||
# Get node name safely for logging
|
||
try:
|
||
node_name = node.name() if callable(node.name) else str(node.name)
|
||
except:
|
||
node_name = "Unknown Node"
|
||
|
||
print(f"✅ Successfully updated {node_name}.{property_name} = {value}")
|
||
else:
|
||
print(f"❌ Failed to update property {property_name} with all methods")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error updating node property {property_name}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def browse_file_for_property(self, line_edit, property_name):
|
||
"""Open file browser for file path properties"""
|
||
try:
|
||
if 'model' in property_name.lower():
|
||
file_filter = "Model files (*.nef *.onnx *.tflite);;All files (*.*)"
|
||
title = "Select Model File"
|
||
elif 'input' in property_name.lower():
|
||
file_filter = "Media files (*.mp4 *.avi *.jpg *.png *.bmp);;All files (*.*)"
|
||
title = "Select Input File"
|
||
else:
|
||
file_filter = "All files (*.*)"
|
||
title = "Select File"
|
||
|
||
filename, _ = QFileDialog.getOpenFileName(self, title, "", file_filter)
|
||
if filename:
|
||
line_edit.setText(filename)
|
||
|
||
except Exception as e:
|
||
print(f"Error browsing file: {e}")
|
||
|
||
def create_stage_config_panel(self):
|
||
"""Create stage configuration panel"""
|
||
widget = QScrollArea()
|
||
content = QWidget()
|
||
layout = QVBoxLayout(content)
|
||
|
||
# Header
|
||
header = QLabel("Stage Configuration")
|
||
header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;")
|
||
layout.addWidget(header)
|
||
|
||
# Number of stages
|
||
stages_layout = QHBoxLayout()
|
||
stages_layout.addWidget(QLabel("Number of Stages:"))
|
||
self.stages_spinbox = QSpinBox()
|
||
self.stages_spinbox.setRange(1, 10)
|
||
self.stages_spinbox.setValue(2)
|
||
self.stages_spinbox.valueChanged.connect(self.update_stage_configs)
|
||
stages_layout.addWidget(self.stages_spinbox)
|
||
layout.addLayout(stages_layout)
|
||
|
||
# Container for dynamic stage configurations
|
||
self.stage_configs_container = QWidget()
|
||
self.stage_configs_layout = QVBoxLayout(self.stage_configs_container)
|
||
layout.addWidget(self.stage_configs_container)
|
||
|
||
# Initialize with default stages
|
||
self.update_stage_configs()
|
||
|
||
layout.addStretch()
|
||
widget.setWidget(content)
|
||
widget.setWidgetResizable(True)
|
||
return widget
|
||
|
||
def create_performance_panel(self):
|
||
"""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("Pipeline Metrics")
|
||
metrics_layout = QGridLayout(metrics_group)
|
||
|
||
# FPS estimation
|
||
metrics_layout.addWidget(QLabel("Estimated FPS:"), 0, 0)
|
||
self.fps_label = QLabel("0.0")
|
||
self.fps_label.setStyleSheet("color: #a6e3a1; font-weight: bold;")
|
||
metrics_layout.addWidget(self.fps_label, 0, 1)
|
||
|
||
# Latency estimation
|
||
metrics_layout.addWidget(QLabel("Pipeline Latency:"), 1, 0)
|
||
self.latency_label = QLabel("0.0 ms")
|
||
self.latency_label.setStyleSheet("color: #fab387; font-weight: bold;")
|
||
metrics_layout.addWidget(self.latency_label, 1, 1)
|
||
|
||
# Memory usage
|
||
metrics_layout.addWidget(QLabel("Memory Usage:"), 2, 0)
|
||
self.memory_label = QLabel("0 MB")
|
||
self.memory_label.setStyleSheet("color: #f38ba8; font-weight: bold;")
|
||
metrics_layout.addWidget(self.memory_label, 2, 1)
|
||
|
||
layout.addWidget(metrics_group)
|
||
|
||
# Performance optimization suggestions
|
||
suggestions_group = QGroupBox("Optimization Suggestions")
|
||
suggestions_layout = QVBoxLayout(suggestions_group)
|
||
|
||
self.suggestions_text = QTextBrowser()
|
||
self.suggestions_text.setMaximumHeight(150)
|
||
self.suggestions_text.setText("• Connect nodes to see performance analysis\n• Add stages to see optimization suggestions")
|
||
suggestions_layout.addWidget(self.suggestions_text)
|
||
|
||
layout.addWidget(suggestions_group)
|
||
|
||
# Update button
|
||
update_btn = QPushButton("🔄 Update Performance")
|
||
update_btn.clicked.connect(self.update_performance_estimation)
|
||
layout.addWidget(update_btn)
|
||
|
||
layout.addStretch()
|
||
widget.setWidget(content)
|
||
widget.setWidgetResizable(True)
|
||
return widget
|
||
|
||
def create_dongle_panel(self):
|
||
"""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)
|
||
|
||
# Available dongles
|
||
available_group = QGroupBox("Available Dongles")
|
||
available_layout = QVBoxLayout(available_group)
|
||
|
||
# Auto-detect button
|
||
detect_btn = QPushButton("🔍 Auto-Detect Dongles")
|
||
detect_btn.clicked.connect(self.detect_dongles)
|
||
available_layout.addWidget(detect_btn)
|
||
|
||
# Dongles list
|
||
self.dongles_list = QListWidget()
|
||
self.dongles_list.setMaximumHeight(120)
|
||
available_layout.addWidget(self.dongles_list)
|
||
|
||
layout.addWidget(available_group)
|
||
|
||
# Dongle allocation
|
||
allocation_group = QGroupBox("Stage Allocation")
|
||
allocation_layout = QVBoxLayout(allocation_group)
|
||
|
||
# Container for allocation widgets
|
||
self.allocation_container = QWidget()
|
||
self.allocation_layout = QVBoxLayout(self.allocation_container)
|
||
allocation_layout.addWidget(self.allocation_container)
|
||
|
||
layout.addWidget(allocation_group)
|
||
|
||
# Initialize dongle detection
|
||
self.detect_dongles()
|
||
|
||
layout.addStretch()
|
||
widget.setWidget(content)
|
||
widget.setWidgetResizable(True)
|
||
return widget
|
||
|
||
def update_stage_configs(self):
|
||
"""Update stage configuration widgets based on number of stages"""
|
||
if not self.stage_configs_layout or not self.stages_spinbox:
|
||
return
|
||
|
||
# Clear existing configs
|
||
for i in reversed(range(self.stage_configs_layout.count())):
|
||
self.stage_configs_layout.itemAt(i).widget().setParent(None)
|
||
|
||
# Add new stage configs
|
||
num_stages = self.stages_spinbox.value()
|
||
for i in range(num_stages):
|
||
stage_group = QGroupBox(f"Stage {i+1}")
|
||
stage_layout = QFormLayout(stage_group)
|
||
|
||
# Model path
|
||
model_edit = QLineEdit()
|
||
model_edit.setPlaceholderText("model.nef")
|
||
stage_layout.addRow("Model Path:", model_edit)
|
||
|
||
# Port IDs
|
||
ports_edit = QLineEdit()
|
||
ports_edit.setPlaceholderText("28,32")
|
||
stage_layout.addRow("Port IDs:", ports_edit)
|
||
|
||
# Queue size
|
||
queue_spin = QSpinBox()
|
||
queue_spin.setRange(1, 100)
|
||
queue_spin.setValue(10)
|
||
stage_layout.addRow("Queue Size:", queue_spin)
|
||
|
||
self.stage_configs_layout.addWidget(stage_group)
|
||
|
||
# Update allocation panel
|
||
self.update_allocation_panel()
|
||
|
||
def update_allocation_panel(self):
|
||
"""Update dongle allocation panel"""
|
||
if not self.allocation_layout or not self.stages_spinbox or not self.dongles_list:
|
||
return
|
||
|
||
# Clear existing allocations
|
||
for i in reversed(range(self.allocation_layout.count())):
|
||
self.allocation_layout.itemAt(i).widget().setParent(None)
|
||
|
||
# Add allocation widgets for each stage
|
||
num_stages = self.stages_spinbox.value()
|
||
for i in range(num_stages):
|
||
alloc_layout = QHBoxLayout()
|
||
alloc_layout.addWidget(QLabel(f"Stage {i+1}:"))
|
||
|
||
dongle_combo = QComboBox()
|
||
dongle_combo.addItem("Auto-assign")
|
||
# Add detected dongles
|
||
for j in range(self.dongles_list.count()):
|
||
dongle_combo.addItem(self.dongles_list.item(j).text())
|
||
|
||
alloc_layout.addWidget(dongle_combo)
|
||
|
||
alloc_widget = QWidget()
|
||
alloc_widget.setLayout(alloc_layout)
|
||
self.allocation_layout.addWidget(alloc_widget)
|
||
|
||
def detect_dongles(self):
|
||
"""Simulate dongle detection"""
|
||
if not self.dongles_list:
|
||
return
|
||
|
||
self.dongles_list.clear()
|
||
# Simulate detected dongles
|
||
dongles = ["KL520 Dongle (Port 28)", "KL520 Dongle (Port 32)", "KL720 Dongle (Port 36)"]
|
||
for dongle in dongles:
|
||
self.dongles_list.addItem(f"🔌 {dongle}")
|
||
|
||
def update_performance_estimation(self):
|
||
"""Update performance metrics based on current pipeline"""
|
||
if not all([self.fps_label, self.latency_label, self.memory_label, self.suggestions_text, self.stages_spinbox]):
|
||
return
|
||
|
||
# Simulate performance calculation
|
||
num_nodes = len(self.graph.all_nodes())
|
||
num_stages = self.stages_spinbox.value()
|
||
|
||
# Simple estimation logic
|
||
base_fps = 30.0
|
||
fps = base_fps / max(1, num_stages * 0.5)
|
||
latency = num_stages * 15 + num_nodes * 5
|
||
memory = num_stages * 50 + num_nodes * 20
|
||
|
||
self.fps_label.setText(f"{fps:.1f}")
|
||
self.latency_label.setText(f"{latency:.1f} ms")
|
||
self.memory_label.setText(f"{memory} MB")
|
||
|
||
# Generate suggestions
|
||
suggestions = []
|
||
if num_stages > 3:
|
||
suggestions.append("• Consider reducing stages for better performance")
|
||
if num_nodes > 5:
|
||
suggestions.append("• Complex pipelines may benefit from optimization")
|
||
if fps < 15:
|
||
suggestions.append("• Low FPS detected - check dongle allocation")
|
||
|
||
if not suggestions:
|
||
suggestions = ["• Pipeline looks optimized!", "• Consider adding more stages for complex workflows"]
|
||
|
||
self.suggestions_text.setText("\n".join(suggestions))
|
||
|
||
def update_window_title(self):
|
||
title = f"Pipeline Dashboard - {self.project_name or 'Untitled'}"
|
||
if self.current_file:
|
||
title += f" [{os.path.basename(self.current_file)}]"
|
||
if self.is_modified:
|
||
title += "*"
|
||
self.setWindowTitle(title)
|
||
|
||
def mark_modified(self, *args, **kwargs):
|
||
if not self.is_modified:
|
||
self.is_modified = True
|
||
self.update_window_title()
|
||
|
||
def mark_saved(self):
|
||
if self.is_modified:
|
||
self.is_modified = False
|
||
self.update_window_title()
|
||
|
||
def on_port_connected(self, *args, **kwargs):
|
||
"""Handle port connection - update performance automatically"""
|
||
self.update_performance_estimation()
|
||
|
||
def on_port_disconnected(self, *args, **kwargs):
|
||
"""Handle port disconnection - update performance automatically"""
|
||
self.update_performance_estimation()
|
||
|
||
def delete_selected_nodes(self):
|
||
"""Delete selected nodes"""
|
||
selected_nodes = self.graph.selected_nodes()
|
||
for node in selected_nodes:
|
||
self.graph.delete_node(node)
|
||
|
||
def setup_menu(self):
|
||
"""Setup menu bar"""
|
||
menubar = self.menuBar()
|
||
|
||
# File menu
|
||
file_menu = menubar.addMenu('File')
|
||
|
||
save_action = QAction('Save', self)
|
||
save_action.setShortcut('Ctrl+S')
|
||
save_action.triggered.connect(self.save_pipeline)
|
||
file_menu.addAction(save_action)
|
||
|
||
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_action = QAction('Export Configuration', self)
|
||
export_action.triggered.connect(self.export_configuration)
|
||
file_menu.addAction(export_action)
|
||
|
||
def save_pipeline(self):
|
||
"""Save pipeline to current file"""
|
||
if self.current_file:
|
||
self.save_to_file(self.current_file)
|
||
else:
|
||
self.save_pipeline_as()
|
||
|
||
def save_pipeline_as(self):
|
||
"""Save pipeline with file dialog"""
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self, "Save Pipeline As", f"{self.project_name}.mflow",
|
||
"MFlow files (*.mflow);;All files (*.*)")
|
||
|
||
if filename:
|
||
self.current_file = filename
|
||
self.save_to_file(filename)
|
||
|
||
def save_to_file(self, filename):
|
||
"""Save pipeline data to file"""
|
||
try:
|
||
performance_metrics = {}
|
||
if all([self.fps_label, self.latency_label, self.memory_label]):
|
||
performance_metrics = {
|
||
"fps": self.fps_label.text(),
|
||
"latency": self.latency_label.text(),
|
||
"memory": self.memory_label.text()
|
||
}
|
||
|
||
pipeline_data = {
|
||
"project_name": self.project_name,
|
||
"description": self.description,
|
||
"graph_data": self.graph.serialize_session(),
|
||
"stage_configs": self.get_stage_configs(),
|
||
"performance_metrics": performance_metrics,
|
||
"metadata": {"version": "1.0", "editor": "IntegratedDashboard"}
|
||
}
|
||
|
||
with open(filename, 'w') as f:
|
||
json.dump(pipeline_data, f, indent=2)
|
||
|
||
self.mark_saved()
|
||
QMessageBox.information(self, "Success", f"Pipeline saved to {os.path.basename(filename)}")
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to save pipeline: {str(e)}")
|
||
|
||
def get_stage_configs(self):
|
||
"""Extract stage configurations from UI"""
|
||
if not self.stage_configs_layout:
|
||
return []
|
||
|
||
configs = []
|
||
for i in range(self.stage_configs_layout.count()):
|
||
widget = self.stage_configs_layout.itemAt(i).widget()
|
||
if isinstance(widget, QGroupBox):
|
||
layout = widget.layout()
|
||
config = {}
|
||
for j in range(0, layout.rowCount()):
|
||
label_item = layout.itemAt(j, QFormLayout.LabelRole)
|
||
field_item = layout.itemAt(j, QFormLayout.FieldRole)
|
||
if label_item and field_item:
|
||
label = label_item.widget().text().replace(":", "")
|
||
field = field_item.widget()
|
||
if isinstance(field, QLineEdit):
|
||
config[label.lower().replace(" ", "_")] = field.text()
|
||
elif isinstance(field, QSpinBox):
|
||
config[label.lower().replace(" ", "_")] = field.value()
|
||
configs.append(config)
|
||
return configs
|
||
|
||
def load_pipeline_file(self, filename):
|
||
"""Load pipeline from file"""
|
||
try:
|
||
with open(filename, 'r') as f:
|
||
data = json.load(f)
|
||
|
||
# Load graph data
|
||
if "graph_data" in data:
|
||
self.graph.deserialize_session(data["graph_data"])
|
||
|
||
# Load stage configs if available
|
||
if "stage_configs" in data:
|
||
self.load_stage_configs(data["stage_configs"])
|
||
|
||
# Load performance metrics if available
|
||
if "performance_metrics" in data:
|
||
metrics = data["performance_metrics"]
|
||
self.fps_label.setText(metrics.get("fps", "0.0"))
|
||
self.latency_label.setText(metrics.get("latency", "0.0 ms"))
|
||
self.memory_label.setText(metrics.get("memory", "0 MB"))
|
||
|
||
self.mark_saved()
|
||
self.update_performance_estimation()
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to load pipeline: {str(e)}")
|
||
|
||
def load_stage_configs(self, configs):
|
||
"""Load stage configurations into UI"""
|
||
if not self.stages_spinbox or not self.stage_configs_layout or not configs:
|
||
return
|
||
|
||
self.stages_spinbox.setValue(len(configs))
|
||
# The update_stage_configs will be called automatically
|
||
# Then we can populate the fields
|
||
for i, config in enumerate(configs):
|
||
if i < self.stage_configs_layout.count():
|
||
widget = self.stage_configs_layout.itemAt(i).widget()
|
||
if isinstance(widget, QGroupBox):
|
||
layout = widget.layout()
|
||
for j in range(0, layout.rowCount()):
|
||
label_item = layout.itemAt(j, QFormLayout.LabelRole)
|
||
field_item = layout.itemAt(j, QFormLayout.FieldRole)
|
||
if label_item and field_item:
|
||
label = label_item.widget().text().replace(":", "")
|
||
field = field_item.widget()
|
||
key = label.lower().replace(" ", "_")
|
||
if key in config:
|
||
if isinstance(field, QLineEdit):
|
||
field.setText(str(config[key]))
|
||
elif isinstance(field, QSpinBox):
|
||
field.setValue(int(config[key]))
|
||
|
||
def export_configuration(self):
|
||
"""Export pipeline configuration for deployment"""
|
||
try:
|
||
dongles = []
|
||
if self.dongles_list:
|
||
dongles = [self.dongles_list.item(i).text() for i in range(self.dongles_list.count())]
|
||
|
||
performance_estimate = {}
|
||
if all([self.fps_label, self.latency_label, self.memory_label]):
|
||
performance_estimate = {
|
||
"fps": self.fps_label.text(),
|
||
"latency": self.latency_label.text(),
|
||
"memory": self.memory_label.text()
|
||
}
|
||
|
||
config_data = {
|
||
"pipeline_name": self.project_name,
|
||
"stages": self.get_stage_configs(),
|
||
"performance_estimate": performance_estimate,
|
||
"dongles": dongles,
|
||
"export_timestamp": json.dumps({"timestamp": "2024-01-01T00:00:00Z"})
|
||
}
|
||
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self, "Export Configuration", f"{self.project_name}_config.json",
|
||
"JSON files (*.json);;All files (*.*)")
|
||
|
||
if filename:
|
||
with open(filename, 'w') as f:
|
||
json.dump(config_data, f, indent=2)
|
||
|
||
QMessageBox.information(self, "Success", f"Configuration exported to {os.path.basename(filename)}")
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to export configuration: {str(e)}")
|
||
|
||
class PipelineEditor(QMainWindow):
|
||
"""Main pipeline editor using NodeGraphQt"""
|
||
|
||
def __init__(self, project_name="", description="", filename=None):
|
||
super().__init__()
|
||
self.project_name = project_name
|
||
self.description = description
|
||
self.current_file = filename
|
||
self.is_modified = False
|
||
|
||
# Initialize node graph
|
||
self.graph = NodeGraph(properties_bin_class=None)
|
||
|
||
# Register custom nodes
|
||
nodes_to_register = [InputNode, ModelNode, PreprocessNode, PostprocessNode, OutputNode]
|
||
|
||
for node_class in nodes_to_register:
|
||
try:
|
||
self.graph.register_node(node_class)
|
||
except Exception as e:
|
||
print(f"Failed to register {node_class.__name__}: {e}")
|
||
|
||
# --- MODIFICATION START ---
|
||
# Create properties bin widget to be docked in the main window.
|
||
try:
|
||
# 完全禁用原来的PropertiesBinWidget,使用我们的自定义面板
|
||
print("Creating CustomPropertiesWidget...") # 调试信息
|
||
self.properties_bin = CustomPropertiesWidget(self.graph)
|
||
self.properties_bin.setMinimumWidth(300)
|
||
print("CustomPropertiesWidget created successfully") # 调试信息
|
||
except Exception as e:
|
||
print(f"Failed to create CustomPropertiesWidget: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.properties_bin = None
|
||
# --- MODIFICATION END ---
|
||
|
||
# Connect signals
|
||
# We no longer need to connect node_double_clicked to show properties.
|
||
# The PropertiesBinWidget handles selection changes automatically.
|
||
self.graph.node_created.connect(self.mark_modified) # type: ignore
|
||
self.graph.nodes_deleted.connect(self.mark_modified) # type: ignore
|
||
self.graph.property_changed.connect(self.mark_modified) # type: ignore
|
||
|
||
if hasattr(self.graph, 'port_connected'):
|
||
self.graph.port_connected.connect(self.on_port_connected)
|
||
if hasattr(self.graph, 'port_disconnected'):
|
||
self.graph.port_disconnected.connect(self.on_port_disconnected)
|
||
|
||
self.setup_ui()
|
||
self.setup_menu()
|
||
|
||
# Add keyboard shortcut for delete
|
||
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)
|
||
|
||
self.update_window_title()
|
||
self.setGeometry(100, 100, 1600, 900) # Increased width for the new panel
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
|
||
def update_window_title(self):
|
||
title = f"Pipeline Editor - {self.project_name or 'Untitled'}"
|
||
if self.current_file:
|
||
title += f" [{os.path.basename(self.current_file)}]"
|
||
if self.is_modified:
|
||
title += "*"
|
||
self.setWindowTitle(title)
|
||
|
||
def mark_modified(self, *args, **kwargs):
|
||
if not self.is_modified:
|
||
self.is_modified = True
|
||
self.update_window_title()
|
||
|
||
def mark_saved(self):
|
||
if self.is_modified:
|
||
self.is_modified = False
|
||
self.update_window_title()
|
||
|
||
# This method is no longer needed as the primary way to show properties.
|
||
# Kept for the fallback dialog.
|
||
def display_properties_bin(self, node):
|
||
if not self.properties_bin:
|
||
self.show_simple_properties_dialog(node)
|
||
# If the properties bin exists, it updates automatically.
|
||
# Double-clicking can still be used to ensure it's visible.
|
||
elif self.properties_bin and not self.properties_bin.isVisible():
|
||
self.properties_bin.show()
|
||
|
||
def show_simple_properties_dialog(self, node):
|
||
try:
|
||
dialog = SimplePropertiesDialog(node, self)
|
||
if dialog.exec_() == QDialog.Accepted:
|
||
self.mark_modified()
|
||
|
||
except Exception as e:
|
||
print(f"Error with interactive SimplePropertiesDialog for {node.name()}: {e}")
|
||
QMessageBox.warning(self, "Properties Error",
|
||
f"Could not display interactive properties for {node.name()}: {str(e)}")
|
||
|
||
def on_port_connected(self, input_port, output_port):
|
||
self.mark_modified()
|
||
|
||
def on_port_disconnected(self, input_port, output_port):
|
||
self.mark_modified()
|
||
|
||
def show_connections(self):
|
||
all_nodes = self.graph.all_nodes()
|
||
connections = []
|
||
|
||
for node in all_nodes:
|
||
for output_port in node.output_ports():
|
||
for connected_input_port in output_port.connected_ports():
|
||
connections.append(f"{node.name()}.{output_port.name()} → {connected_input_port.node().name()}.{connected_input_port.name()}")
|
||
|
||
QMessageBox.information(self, "Graph Connections", "Current connections:\n" + "\n".join(connections) if connections else "No connections found.")
|
||
|
||
def show_registered_nodes(self):
|
||
try:
|
||
registered_info = []
|
||
if hasattr(self.graph, 'all_registered_nodes'):
|
||
nodes_dict = self.graph.all_registered_nodes()
|
||
registered_info.append(f"all_registered_nodes(): {list(nodes_dict.keys())}")
|
||
|
||
msg = "Registered node information:\n\n" + "\n\n".join(registered_info)
|
||
QMessageBox.information(self, "Registered Nodes Debug", msg)
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Debug Error", f"Error getting registered nodes: {e}")
|
||
|
||
def setup_ui(self):
|
||
"""Setup main UI with a docked properties panel."""
|
||
central_widget = QWidget()
|
||
self.setCentralWidget(central_widget)
|
||
|
||
layout = QHBoxLayout(central_widget)
|
||
|
||
# --- MODIFICATION START ---
|
||
# The main splitter will now hold three widgets: Palette, Graph, Properties.
|
||
main_splitter = QSplitter(Qt.Horizontal)
|
||
|
||
# 1. Left Panel: Node Palette
|
||
self.palette = NodePalette(self.graph)
|
||
self.palette.setMaximumWidth(250)
|
||
main_splitter.addWidget(self.palette)
|
||
|
||
# 2. Center Panel: Node Graph
|
||
graph_widget = self.graph.widget
|
||
graph_widget.setFocusPolicy(Qt.StrongFocus)
|
||
main_splitter.addWidget(graph_widget)
|
||
|
||
# 3. Right Panel: Properties Bin
|
||
if self.properties_bin:
|
||
main_splitter.addWidget(self.properties_bin)
|
||
# Set initial sizes for the three panels
|
||
main_splitter.setSizes([220, 1080, 300])
|
||
else:
|
||
# Fallback if properties bin failed to create
|
||
main_splitter.setSizes([250, 1350])
|
||
# --- MODIFICATION END ---
|
||
|
||
layout.addWidget(main_splitter)
|
||
graph_widget.setFocus()
|
||
|
||
def setup_menu(self):
|
||
menubar = self.menuBar()
|
||
|
||
file_menu = menubar.addMenu('File')
|
||
# ... (File menu remains the same)
|
||
save_action = file_menu.addAction('Save')
|
||
save_action.setShortcut('Ctrl+S')
|
||
save_action.triggered.connect(self.save_pipeline)
|
||
save_as_action = file_menu.addAction('Save As...')
|
||
save_as_action.triggered.connect(self.save_pipeline_as)
|
||
open_action = file_menu.addAction('Open...')
|
||
open_action.setShortcut('Ctrl+O')
|
||
open_action.triggered.connect(self.open_pipeline)
|
||
file_menu.addSeparator()
|
||
close_action = file_menu.addAction('Close')
|
||
close_action.triggered.connect(self.close)
|
||
|
||
edit_menu = menubar.addMenu('Edit')
|
||
# ... (Edit menu remains the same)
|
||
undo_action = edit_menu.addAction('Undo')
|
||
undo_action.setShortcut('Ctrl+Z')
|
||
undo_action.triggered.connect(self.graph.undo_stack().undo)
|
||
redo_action = edit_menu.addAction('Redo')
|
||
redo_action.setShortcut('Ctrl+Y')
|
||
redo_action.triggered.connect(self.graph.undo_stack().redo)
|
||
edit_menu.addSeparator()
|
||
delete_action = edit_menu.addAction('Delete Selected')
|
||
delete_action.setShortcut('Delete')
|
||
delete_action.triggered.connect(self.delete_selected_nodes)
|
||
self.addAction(delete_action)
|
||
select_all_action = edit_menu.addAction('Select All')
|
||
select_all_action.setShortcut('Ctrl+A')
|
||
select_all_action.triggered.connect(self.graph.select_all)
|
||
clear_selection_action = edit_menu.addAction('Clear Selection')
|
||
clear_selection_action.setShortcut('Ctrl+D')
|
||
clear_selection_action.triggered.connect(self.graph.clear_selection)
|
||
|
||
view_menu = menubar.addMenu('View')
|
||
|
||
# --- MODIFICATION START ---
|
||
# Change the action to be a checkable toggle for the properties panel.
|
||
if self.properties_bin:
|
||
properties_action = view_menu.addAction('Toggle Properties Panel')
|
||
properties_action.setCheckable(True)
|
||
properties_action.setChecked(True) # Start with the panel visible.
|
||
properties_action.triggered.connect(self.toggle_properties_panel)
|
||
# --- MODIFICATION END ---
|
||
|
||
# Pipeline menu
|
||
pipeline_menu = menubar.addMenu('Pipeline')
|
||
configure_stages_action = pipeline_menu.addAction('Configure Stages')
|
||
configure_stages_action.triggered.connect(self.show_stage_configuration_from_editor)
|
||
performance_action = pipeline_menu.addAction('Performance Analysis')
|
||
performance_action.triggered.connect(self.show_performance_analysis)
|
||
deploy_action = pipeline_menu.addAction('Deploy Pipeline')
|
||
deploy_action.triggered.connect(self.show_deploy_dialog)
|
||
|
||
view_menu.addSeparator()
|
||
fit_action = view_menu.addAction('Fit to Selection')
|
||
fit_action.setShortcut('F')
|
||
fit_action.triggered.connect(self.graph.fit_to_selection)
|
||
auto_layout_action = view_menu.addAction('Auto Layout All')
|
||
auto_layout_action.triggered.connect(self.graph.auto_layout_nodes) # type: ignore
|
||
|
||
debug_menu = menubar.addMenu('Debug')
|
||
# ... (Debug menu remains the same)
|
||
show_connections_action = debug_menu.addAction('Show All Connections')
|
||
show_connections_action.triggered.connect(self.show_connections)
|
||
show_registered_action = debug_menu.addAction('Show Registered Nodes')
|
||
show_registered_action.triggered.connect(self.show_registered_nodes)
|
||
|
||
# --- NEW METHOD START ---
|
||
def toggle_properties_panel(self, checked):
|
||
"""Show or hide the properties panel."""
|
||
if self.properties_bin:
|
||
self.properties_bin.setVisible(checked)
|
||
# --- NEW METHOD END ---
|
||
|
||
# This method is now replaced by toggle_properties_panel
|
||
def show_properties_panel(self):
|
||
"""Show properties panel safely (Legacy, now used for fallback)."""
|
||
if self.properties_bin:
|
||
if not self.properties_bin.isVisible():
|
||
self.properties_bin.show()
|
||
self.properties_bin.raise_()
|
||
self.properties_bin.activateWindow()
|
||
else:
|
||
selected_nodes = self.graph.selected_nodes()
|
||
if selected_nodes:
|
||
self.show_simple_properties_dialog(selected_nodes[0])
|
||
else:
|
||
QMessageBox.information(self, "Properties Panel", "Properties panel is not available.")
|
||
|
||
def delete_selected_nodes(self):
|
||
selected_nodes = self.graph.selected_nodes()
|
||
if selected_nodes:
|
||
try:
|
||
self.graph.delete_nodes(selected_nodes)
|
||
except Exception as e:
|
||
print(f"Error deleting nodes: {e}")
|
||
QMessageBox.warning(self, "Delete Error", f"Failed to delete nodes: {e}")
|
||
|
||
def save_pipeline(self):
|
||
if self.current_file:
|
||
return self._save_to_file(self.current_file)
|
||
else:
|
||
return self.save_pipeline_as()
|
||
|
||
def save_pipeline_as(self):
|
||
default_name = f"{self.project_name or 'untitled'}.mflow"
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self, "Save Pipeline", default_name,
|
||
"MFlow files (*.mflow);;All files (*.*)")
|
||
|
||
if filename:
|
||
if self._save_to_file(filename):
|
||
self.current_file = filename
|
||
self.project_name = os.path.splitext(os.path.basename(filename))[0]
|
||
self.mark_saved()
|
||
return True
|
||
return False
|
||
|
||
def _save_to_file(self, filename):
|
||
try:
|
||
graph_data = self.graph.serialize_session()
|
||
pipeline_data = {
|
||
"project_name": self.project_name,
|
||
"description": self.description,
|
||
"graph_data": graph_data,
|
||
"metadata": { "version": "1.0", "editor": "NodeGraphQt" }
|
||
}
|
||
with open(filename, 'w') as f:
|
||
json.dump(pipeline_data, f, indent=2)
|
||
|
||
self.mark_saved()
|
||
QMessageBox.information(self, "Saved", f"Pipeline saved to {os.path.basename(filename)}")
|
||
return True
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to save to {filename}: {str(e)}")
|
||
return False
|
||
|
||
def open_pipeline(self):
|
||
if self.is_modified:
|
||
reply = QMessageBox.question(self, 'Save Changes',
|
||
"Current pipeline has unsaved changes. Save before opening a new one?",
|
||
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
|
||
QMessageBox.Save)
|
||
if reply == QMessageBox.Save:
|
||
if not self.save_pipeline():
|
||
return
|
||
elif reply == QMessageBox.Cancel:
|
||
return
|
||
|
||
filename, _ = QFileDialog.getOpenFileName(
|
||
self, "Open Pipeline", "",
|
||
"MFlow files (*.mflow);;All files (*.*)")
|
||
|
||
if filename:
|
||
self.load_pipeline_file(filename)
|
||
|
||
def load_pipeline_file(self, filename):
|
||
try:
|
||
with open(filename, 'r') as f:
|
||
data = json.load(f)
|
||
|
||
self.project_name = data.get("project_name", os.path.splitext(os.path.basename(filename))[0])
|
||
self.description = data.get("description", "")
|
||
|
||
self.graph.clear_session()
|
||
if "graph_data" in data:
|
||
self.graph.deserialize_session(data["graph_data"])
|
||
|
||
self.current_file = filename
|
||
self.mark_saved()
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to open {filename}: {str(e)}")
|
||
self.project_name = "Untitled"
|
||
self.description = ""
|
||
self.current_file = None
|
||
self.graph.clear_session()
|
||
self.mark_saved()
|
||
|
||
|
||
def show_stage_configuration_from_editor(self):
|
||
"""Show stage configuration from editor"""
|
||
# Get current pipeline data
|
||
current_data = {
|
||
"graph_data": self.graph.serialize_session(),
|
||
"project_name": self.project_name,
|
||
"description": self.description
|
||
}
|
||
|
||
config_dialog = StageConfigurationDialog(current_data, self)
|
||
config_dialog.exec_()
|
||
|
||
def show_performance_analysis(self):
|
||
"""Show performance analysis for current pipeline"""
|
||
# Generate sample stage configs from current pipeline
|
||
stage_configs = [
|
||
{"name": "Input Processing", "dongles": 2, "port_ids": "28,30", "model_path": ""},
|
||
{"name": "Main Inference", "dongles": 4, "port_ids": "32,34,36,38", "model_path": ""},
|
||
{"name": "Post Processing", "dongles": 2, "port_ids": "40,42", "model_path": ""}
|
||
]
|
||
|
||
perf_panel = PerformanceEstimationPanel(stage_configs, self)
|
||
perf_panel.exec_()
|
||
|
||
def show_deploy_dialog(self):
|
||
"""Show deployment dialog for current pipeline"""
|
||
# Generate sample stage configs from current pipeline
|
||
stage_configs = [
|
||
{"name": "Input Processing", "dongles": 2, "port_ids": "28,30", "model_path": ""},
|
||
{"name": "Main Inference", "dongles": 4, "port_ids": "32,34,36,38", "model_path": ""},
|
||
{"name": "Post Processing", "dongles": 2, "port_ids": "40,42", "model_path": ""}
|
||
]
|
||
|
||
deploy_dialog = SaveDeployDialog(stage_configs, self)
|
||
deploy_dialog.exec_()
|
||
|
||
def closeEvent(self, event):
|
||
if self.is_modified:
|
||
reply = QMessageBox.question(self, 'Save Changes',
|
||
"The pipeline has unsaved changes. Do you want to save them?",
|
||
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
|
||
QMessageBox.Save)
|
||
|
||
if reply == QMessageBox.Save:
|
||
if self.save_pipeline():
|
||
event.accept()
|
||
else:
|
||
event.ignore()
|
||
elif reply == QMessageBox.Discard:
|
||
event.accept()
|
||
else:
|
||
event.ignore()
|
||
else:
|
||
event.accept()
|
||
|
||
|
||
class DashboardLogin(QWidget):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
self.pipeline_editor = None # Keep track of the editor window
|
||
self.recent_files = self.load_recent_files()
|
||
# Store current project settings
|
||
self.current_project_name = ""
|
||
self.current_description = ""
|
||
self.current_pipeline_data = {}
|
||
self.initUI()
|
||
|
||
def initUI(self):
|
||
self.setWindowTitle("Cluster Dashboard - ML Pipeline Builder")
|
||
self.setGeometry(300, 300, 700, 500)
|
||
|
||
# Create main scroll area to ensure all content is viewable
|
||
scrollArea = QScrollArea(self)
|
||
scrollContent = QWidget()
|
||
scrollArea.setWidget(scrollContent)
|
||
scrollArea.setWidgetResizable(True)
|
||
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
|
||
mainLayout = QVBoxLayout(scrollContent)
|
||
mainLayout.setContentsMargins(40, 40, 40, 40)
|
||
mainLayout.setSpacing(25)
|
||
|
||
# Header section with title and subtitle
|
||
headerLayout = QVBoxLayout()
|
||
headerLayout.setSpacing(8)
|
||
|
||
titleLabel = QLabel("Cluster")
|
||
titleFont = QFont("Segoe UI", 32, QFont.Bold)
|
||
titleLabel.setFont(titleFont)
|
||
titleLabel.setAlignment(Qt.AlignCenter)
|
||
titleLabel.setStyleSheet("color: #89b4fa; margin-bottom: 5px;")
|
||
|
||
subtitleLabel = QLabel("AI Pipeline Builder & Execution Platform")
|
||
subtitleFont = QFont("Inter", 14)
|
||
subtitleLabel.setFont(subtitleFont)
|
||
subtitleLabel.setAlignment(Qt.AlignCenter)
|
||
subtitleLabel.setStyleSheet("color: #a6adc8; margin-bottom: 20px;")
|
||
|
||
headerLayout.addWidget(titleLabel)
|
||
headerLayout.addWidget(subtitleLabel)
|
||
|
||
# Action buttons section
|
||
buttonLayout = QVBoxLayout()
|
||
buttonLayout.setSpacing(15)
|
||
|
||
# Create new pipeline button (primary action)
|
||
self.createNewPipelineButton = QPushButton("🚀 Create New Pipeline")
|
||
self.createNewPipelineButton.setMinimumHeight(55)
|
||
self.createNewPipelineButton.clicked.connect(self.create_new_pipeline_dialog)
|
||
self.createNewPipelineButton.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
color: #1e1e2e;
|
||
border: none;
|
||
padding: 15px 25px;
|
||
border-radius: 12px;
|
||
font-weight: 700;
|
||
font-size: 16px;
|
||
text-align: left;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #a6c8ff, stop:1 #89dceb);
|
||
transform: translateY(-2px);
|
||
}
|
||
""")
|
||
|
||
# Edit existing pipeline button
|
||
self.editPipelineButton = QPushButton("📁 Edit Previous Pipeline")
|
||
self.editPipelineButton.setMinimumHeight(55)
|
||
self.editPipelineButton.clicked.connect(self.edit_previous_pipeline)
|
||
self.editPipelineButton.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #313244;
|
||
color: #cdd6f4;
|
||
border: 2px solid #89b4fa;
|
||
padding: 15px 25px;
|
||
border-radius: 12px;
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
text-align: left;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #383a59;
|
||
border-color: #a6c8ff;
|
||
color: #ffffff;
|
||
}
|
||
""")
|
||
|
||
# Recent files section
|
||
recentLabel = QLabel("Recent Pipelines")
|
||
recentLabel.setFont(QFont("Segoe UI", 12, QFont.Bold))
|
||
recentLabel.setStyleSheet("color: #f9e2af; margin-top: 10px;")
|
||
|
||
self.recentFilesList = QListWidget()
|
||
self.recentFilesList.setMaximumHeight(120)
|
||
self.recentFilesList.setStyleSheet("""
|
||
QListWidget {
|
||
background-color: #313244;
|
||
border: 2px solid #45475a;
|
||
border-radius: 12px;
|
||
padding: 8px;
|
||
}
|
||
QListWidget::item {
|
||
padding: 10px 14px;
|
||
border-radius: 6px;
|
||
margin: 2px;
|
||
color: #cdd6f4;
|
||
}
|
||
QListWidget::item:hover {
|
||
background-color: #383a59;
|
||
}
|
||
QListWidget::item:selected {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec);
|
||
color: #1e1e2e;
|
||
}
|
||
""")
|
||
self.populate_recent_files()
|
||
self.recentFilesList.itemDoubleClicked.connect(self.open_recent_file)
|
||
|
||
# Help/Info section
|
||
infoLabel = QLabel("💡 Quick Start Guide")
|
||
infoLabel.setFont(QFont("Segoe UI", 12, QFont.Bold))
|
||
infoLabel.setStyleSheet("color: #f9e2af; margin-top: 15px;")
|
||
|
||
helpText = QLabel(
|
||
"• Create a new pipeline to build ML workflows visually\n"
|
||
"• Edit existing .mflow files to continue work\n"
|
||
"• Drag and connect nodes to create processing chains\n"
|
||
"• Configure dongle properties in the integrated panel\n"
|
||
"• Monitor performance and deploy your pipelines"
|
||
)
|
||
helpText.setStyleSheet("""
|
||
color: #a6adc8;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
padding: 16px;
|
||
background-color: #313244;
|
||
border-radius: 12px;
|
||
border-left: 4px solid #89b4fa;
|
||
""")
|
||
helpText.setWordWrap(True)
|
||
|
||
# Add all sections to main layout
|
||
buttonLayout.addWidget(self.createNewPipelineButton)
|
||
buttonLayout.addWidget(self.editPipelineButton)
|
||
|
||
mainLayout.addLayout(headerLayout)
|
||
mainLayout.addSpacing(20)
|
||
mainLayout.addLayout(buttonLayout)
|
||
mainLayout.addSpacing(15)
|
||
mainLayout.addWidget(recentLabel)
|
||
mainLayout.addWidget(self.recentFilesList)
|
||
mainLayout.addSpacing(10)
|
||
mainLayout.addWidget(infoLabel)
|
||
mainLayout.addWidget(helpText)
|
||
mainLayout.addStretch(1)
|
||
|
||
# Set the scroll area as the main widget
|
||
containerLayout = QVBoxLayout(self)
|
||
containerLayout.setContentsMargins(0, 0, 0, 0)
|
||
containerLayout.addWidget(scrollArea)
|
||
|
||
def load_recent_files(self):
|
||
"""Load recent files from settings or return empty list"""
|
||
try:
|
||
recent_files_path = os.path.expanduser("~/.cluster_recent_files.json")
|
||
if os.path.exists(recent_files_path):
|
||
with open(recent_files_path, 'r') as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
print(f"Error loading recent files: {e}")
|
||
return []
|
||
|
||
def save_recent_files(self):
|
||
"""Save recent files to settings"""
|
||
try:
|
||
recent_files_path = os.path.expanduser("~/.cluster_recent_files.json")
|
||
with open(recent_files_path, 'w') as f:
|
||
json.dump(self.recent_files, f, indent=2)
|
||
except Exception as e:
|
||
print(f"Error saving recent files: {e}")
|
||
|
||
def add_recent_file(self, filepath):
|
||
"""Add file to recent files list"""
|
||
if filepath in self.recent_files:
|
||
self.recent_files.remove(filepath)
|
||
self.recent_files.insert(0, filepath)
|
||
# Keep only last 5 files
|
||
self.recent_files = self.recent_files[:5]
|
||
self.save_recent_files()
|
||
self.populate_recent_files()
|
||
|
||
def populate_recent_files(self):
|
||
"""Populate the recent files list widget"""
|
||
self.recentFilesList.clear()
|
||
for filepath in self.recent_files:
|
||
if os.path.exists(filepath):
|
||
filename = os.path.basename(filepath)
|
||
self.recentFilesList.addItem(f"📄 {filename}")
|
||
else:
|
||
# Remove non-existent files
|
||
self.recent_files.remove(filepath)
|
||
|
||
if not self.recentFilesList.count():
|
||
self.recentFilesList.addItem("No recent files")
|
||
|
||
def open_recent_file(self, item):
|
||
"""Open a recent file when double-clicked"""
|
||
if item.text() == "No recent files":
|
||
return
|
||
|
||
filename = item.text().replace("📄 ", "")
|
||
for filepath in self.recent_files:
|
||
if os.path.basename(filepath) == filename:
|
||
try:
|
||
with open(filepath, 'r') as f:
|
||
data = json.load(f)
|
||
project_name = data.get("project_name", os.path.splitext(filename)[0])
|
||
description = data.get("description", "")
|
||
|
||
# Store loaded pipeline data in dashboard
|
||
self.current_project_name = project_name
|
||
self.current_description = description
|
||
self.current_pipeline_data = data
|
||
|
||
self._open_editor_window(project_name, description, filepath)
|
||
break
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to open {filename}: {str(e)}")
|
||
|
||
def _open_editor_window(self, project_name, description, filename):
|
||
"""Helper to ensure only one editor is managed by dashboard, or handle existing."""
|
||
if self.pipeline_editor and self.pipeline_editor.isVisible():
|
||
# Ask if user wants to close current editor or if it's okay
|
||
# For simplicity here, we just bring it to front.
|
||
# A more robust solution might involve managing multiple editors or prompting user.
|
||
self.pipeline_editor.raise_()
|
||
self.pipeline_editor.activateWindow()
|
||
QMessageBox.information(self, "Editor Open", "An editor window is already open.")
|
||
# Optionally, load the new data into the existing editor if desired, after prompting.
|
||
return
|
||
|
||
# Create the integrated dashboard instead of separate pipeline editor
|
||
self.pipeline_editor = IntegratedPipelineDashboard(project_name, description, filename)
|
||
if filename and os.path.exists(filename): # If filename is provided and exists, load it
|
||
self.pipeline_editor.load_pipeline_file(filename)
|
||
self.pipeline_editor.show()
|
||
|
||
|
||
def edit_previous_pipeline(self):
|
||
"""Open existing pipeline"""
|
||
filename, _ = QFileDialog.getOpenFileName(
|
||
self, "Open Pipeline", "",
|
||
"MFlow files (*.mflow);;All files (*.*)")
|
||
|
||
if filename:
|
||
try:
|
||
# Basic check, more validation could be in PipelineEditor.load_pipeline_file
|
||
with open(filename, 'r') as f:
|
||
data = json.load(f)
|
||
project_name = data.get("project_name", os.path.splitext(os.path.basename(filename))[0])
|
||
description = data.get("description", "")
|
||
|
||
# Store loaded pipeline data in dashboard
|
||
self.current_project_name = project_name
|
||
self.current_description = description
|
||
self.current_pipeline_data = data
|
||
|
||
self._open_editor_window(project_name, description, filename)
|
||
self.add_recent_file(filename)
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to open pipeline {filename}: {str(e)}")
|
||
|
||
def show_stage_configuration(self):
|
||
"""Show stage configuration dialog for quick pipeline setup"""
|
||
# Use current project data if available, otherwise create minimal pipeline
|
||
if self.current_pipeline_data:
|
||
pipeline_data = self.current_pipeline_data.copy()
|
||
else:
|
||
# Create a minimal pipeline with current project settings
|
||
pipeline_data = {
|
||
"project_name": self.current_project_name or "Untitled Pipeline",
|
||
"description": self.current_description or "Pipeline configuration",
|
||
"graph_data": {
|
||
"nodes": {
|
||
"node1": {"type_": "ModelNode", "custom": {"model_path": ""}},
|
||
"node2": {"type_": "ModelNode", "custom": {"model_path": ""}}
|
||
}
|
||
},
|
||
"metadata": {"version": "1.0", "editor": "Dashboard"}
|
||
}
|
||
|
||
config_dialog = StageConfigurationDialog(pipeline_data, self)
|
||
if config_dialog.exec_():
|
||
# Update stored pipeline data with stage configurations
|
||
self.current_pipeline_data = pipeline_data
|
||
self.current_pipeline_data["stage_configs"] = config_dialog.get_stage_configs()
|
||
|
||
def create_new_pipeline_dialog(self):
|
||
"""Create new pipeline"""
|
||
dialog = CreatePipelineDialog(self)
|
||
if dialog.exec_():
|
||
data = dialog.get_data()
|
||
|
||
# Store project settings in dashboard
|
||
self.current_project_name = data['project_name']
|
||
self.current_description = data['description']
|
||
|
||
# Prompt for initial save location
|
||
save_filename, _ = QFileDialog.getSaveFileName(
|
||
self, "Save New Pipeline As", f"{data['project_name']}.mflow",
|
||
"MFlow files (*.mflow);;All files (*.*)")
|
||
|
||
if save_filename:
|
||
try:
|
||
# Create an empty pipeline structure to save
|
||
empty_graph_data = NodeGraph().serialize_session() # Get empty session data
|
||
pipeline_data = {
|
||
"project_name": data['project_name'],
|
||
"description": data['description'],
|
||
"graph_data": empty_graph_data, # Start with an empty graph
|
||
"metadata": { "version": "1.0", "editor": "NodeGraphQt" }
|
||
}
|
||
|
||
# Store current pipeline data in dashboard
|
||
self.current_pipeline_data = pipeline_data
|
||
|
||
with open(save_filename, 'w') as f:
|
||
json.dump(pipeline_data, f, indent=2)
|
||
|
||
QMessageBox.information(
|
||
self, "Pipeline Created",
|
||
f"New pipeline '{data['project_name']}' has been created and saved as {os.path.basename(save_filename)}."
|
||
)
|
||
|
||
# Open editor with this new, saved pipeline
|
||
self._open_editor_window(data['project_name'], data['description'], save_filename)
|
||
self.add_recent_file(save_filename)
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to create and save new pipeline: {str(e)}")
|
||
|
||
|
||
class StageConfigurationDialog(QDialog):
|
||
"""Dialog for configuring pipeline stages and dongle allocation"""
|
||
|
||
def __init__(self, pipeline_data=None, parent=None):
|
||
super().__init__(parent)
|
||
self.pipeline_data = pipeline_data or {}
|
||
self.stage_configs = []
|
||
self.total_dongles_available = 16 # Default max dongles
|
||
self.setWindowTitle("Stage Configuration & Dongle Allocation")
|
||
self.setMinimumSize(800, 600)
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
self.setup_ui()
|
||
self.load_stages_from_pipeline()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Header with project information
|
||
project_name = self.pipeline_data.get("project_name", "Untitled Pipeline")
|
||
header = QLabel(f"Configure Pipeline Stages - {project_name}")
|
||
header.setFont(QFont("Arial", 16, QFont.Bold))
|
||
header.setStyleSheet("color: #89b4fa; margin-bottom: 5px;")
|
||
layout.addWidget(header)
|
||
|
||
# Project description if available
|
||
description = self.pipeline_data.get("description", "")
|
||
if description:
|
||
desc_label = QLabel(f"Description: {description}")
|
||
desc_label.setStyleSheet("color: #a6adc8; margin-bottom: 10px; font-style: italic;")
|
||
desc_label.setWordWrap(True)
|
||
layout.addWidget(desc_label)
|
||
|
||
# Instructions
|
||
instructions = QLabel(
|
||
"Break your pipeline into sections (A → B → C...) and assign dongles for optimal performance"
|
||
)
|
||
instructions.setStyleSheet("color: #a6adc8; margin-bottom: 15px;")
|
||
instructions.setWordWrap(True)
|
||
layout.addWidget(instructions)
|
||
|
||
# Main content area
|
||
content_splitter = QSplitter(Qt.Horizontal)
|
||
|
||
# Left side: Stage list and controls
|
||
left_widget = QWidget()
|
||
left_layout = QVBoxLayout(left_widget)
|
||
|
||
# Stage list
|
||
stages_group = QGroupBox("Pipeline Stages")
|
||
stages_layout = QVBoxLayout(stages_group)
|
||
|
||
self.stages_list = QListWidget()
|
||
self.stages_list.currentItemChanged.connect(self.on_stage_selected)
|
||
stages_layout.addWidget(self.stages_list)
|
||
|
||
# Stage controls
|
||
stage_controls = QHBoxLayout()
|
||
self.add_stage_btn = QPushButton("➕ Add Stage")
|
||
self.add_stage_btn.clicked.connect(self.add_stage)
|
||
self.remove_stage_btn = QPushButton("➖ Remove Stage")
|
||
self.remove_stage_btn.clicked.connect(self.remove_stage)
|
||
self.auto_balance_btn = QPushButton("⚖️ Auto-Balance")
|
||
self.auto_balance_btn.clicked.connect(self.auto_balance_dongles)
|
||
|
||
stage_controls.addWidget(self.add_stage_btn)
|
||
stage_controls.addWidget(self.remove_stage_btn)
|
||
stage_controls.addWidget(self.auto_balance_btn)
|
||
stages_layout.addLayout(stage_controls)
|
||
|
||
left_layout.addWidget(stages_group)
|
||
|
||
# Right side: Stage configuration
|
||
right_widget = QWidget()
|
||
right_layout = QVBoxLayout(right_widget)
|
||
|
||
# Stage details
|
||
details_group = QGroupBox("Stage Configuration")
|
||
details_layout = QFormLayout(details_group)
|
||
|
||
self.stage_name_input = QLineEdit()
|
||
self.stage_name_input.textChanged.connect(self.update_current_stage)
|
||
details_layout.addRow("Stage Name:", self.stage_name_input)
|
||
|
||
# Dongle allocation
|
||
dongle_layout = QHBoxLayout()
|
||
self.dongle_count_slider = QSlider(Qt.Horizontal)
|
||
self.dongle_count_slider.setRange(1, 8)
|
||
self.dongle_count_slider.setValue(2)
|
||
self.dongle_count_slider.valueChanged.connect(self.update_dongle_count)
|
||
|
||
self.dongle_count_label = QLabel("2 dongles")
|
||
dongle_layout.addWidget(self.dongle_count_slider)
|
||
dongle_layout.addWidget(self.dongle_count_label)
|
||
details_layout.addRow("Dongles Assigned:", dongle_layout)
|
||
|
||
# Port IDs
|
||
self.port_ids_input = QLineEdit()
|
||
self.port_ids_input.setPlaceholderText("e.g., 28,30,32 or auto")
|
||
self.port_ids_input.textChanged.connect(self.update_current_stage)
|
||
details_layout.addRow("Port IDs:", self.port_ids_input)
|
||
|
||
# Model selection
|
||
self.model_path_input = QLineEdit()
|
||
model_browse_layout = QHBoxLayout()
|
||
model_browse_btn = QPushButton("Browse")
|
||
model_browse_btn.clicked.connect(self.browse_model_file)
|
||
model_browse_layout.addWidget(self.model_path_input)
|
||
model_browse_layout.addWidget(model_browse_btn)
|
||
details_layout.addRow("Model File:", model_browse_layout)
|
||
|
||
# Performance estimates
|
||
perf_group = QGroupBox("Performance Estimation")
|
||
perf_layout = QFormLayout(perf_group)
|
||
|
||
self.estimated_fps_label = QLabel("--")
|
||
self.estimated_latency_label = QLabel("--")
|
||
self.throughput_label = QLabel("--")
|
||
|
||
perf_layout.addRow("Estimated FPS:", self.estimated_fps_label)
|
||
perf_layout.addRow("Latency:", self.estimated_latency_label)
|
||
perf_layout.addRow("Throughput:", self.throughput_label)
|
||
|
||
right_layout.addWidget(details_group)
|
||
right_layout.addWidget(perf_group)
|
||
|
||
# Resource summary
|
||
resource_group = QGroupBox("Resource Summary")
|
||
resource_layout = QFormLayout(resource_group)
|
||
|
||
self.total_dongles_label = QLabel(f"0 / {self.total_dongles_available}")
|
||
self.pipeline_fps_label = QLabel("--")
|
||
resource_layout.addRow("Total Dongles Used:", self.total_dongles_label)
|
||
resource_layout.addRow("Pipeline FPS:", self.pipeline_fps_label)
|
||
|
||
right_layout.addWidget(resource_group)
|
||
right_layout.addStretch()
|
||
|
||
content_splitter.addWidget(left_widget)
|
||
content_splitter.addWidget(right_widget)
|
||
content_splitter.setSizes([400, 400])
|
||
layout.addWidget(content_splitter)
|
||
|
||
# Buttons
|
||
button_layout = QHBoxLayout()
|
||
self.next_btn = QPushButton("Next: Performance Estimation")
|
||
self.next_btn.clicked.connect(self.show_performance_panel)
|
||
self.cancel_btn = QPushButton("Cancel")
|
||
self.cancel_btn.clicked.connect(self.reject)
|
||
|
||
button_layout.addStretch()
|
||
button_layout.addWidget(self.cancel_btn)
|
||
button_layout.addWidget(self.next_btn)
|
||
layout.addLayout(button_layout)
|
||
|
||
def load_stages_from_pipeline(self):
|
||
"""Extract stages from pipeline data"""
|
||
if not self.pipeline_data.get('graph_data'):
|
||
# Add default stage if no pipeline data
|
||
self.add_stage()
|
||
return
|
||
|
||
# Parse graph data to identify stages
|
||
# This is a simplified implementation
|
||
nodes = self.pipeline_data.get('graph_data', {}).get('nodes', {})
|
||
model_nodes = [node for node in nodes.values() if 'Model' in node.get('type_', '')]
|
||
|
||
if not model_nodes:
|
||
self.add_stage()
|
||
return
|
||
|
||
for i, node in enumerate(model_nodes):
|
||
stage_config = {
|
||
'name': f"Stage {i+1}",
|
||
'dongles': 2,
|
||
'port_ids': 'auto',
|
||
'model_path': node.get('custom', {}).get('model_path', ''),
|
||
'node_id': node.get('id', '')
|
||
}
|
||
self.stage_configs.append(stage_config)
|
||
|
||
self.update_stages_list()
|
||
|
||
def add_stage(self):
|
||
"""Add a new stage"""
|
||
stage_num = len(self.stage_configs) + 1
|
||
stage_config = {
|
||
'name': f"Stage {stage_num}",
|
||
'dongles': 2,
|
||
'port_ids': 'auto',
|
||
'model_path': '',
|
||
'node_id': ''
|
||
}
|
||
self.stage_configs.append(stage_config)
|
||
self.update_stages_list()
|
||
|
||
# Select the new stage
|
||
self.stages_list.setCurrentRow(len(self.stage_configs) - 1)
|
||
|
||
def remove_stage(self):
|
||
"""Remove selected stage"""
|
||
current_row = self.stages_list.currentRow()
|
||
if current_row >= 0 and self.stage_configs:
|
||
self.stage_configs.pop(current_row)
|
||
self.update_stages_list()
|
||
|
||
# Select previous stage or first stage
|
||
if self.stage_configs:
|
||
new_row = min(current_row, len(self.stage_configs) - 1)
|
||
self.stages_list.setCurrentRow(new_row)
|
||
|
||
def update_stages_list(self):
|
||
"""Update the stages list widget"""
|
||
self.stages_list.clear()
|
||
for i, config in enumerate(self.stage_configs):
|
||
dongles = config['dongles']
|
||
item_text = f"{config['name']} ({dongles} dongles)"
|
||
self.stages_list.addItem(item_text)
|
||
|
||
self.update_resource_summary()
|
||
|
||
def on_stage_selected(self, current, previous):
|
||
"""Handle stage selection"""
|
||
row = self.stages_list.currentRow()
|
||
if row >= 0 and row < len(self.stage_configs):
|
||
config = self.stage_configs[row]
|
||
|
||
# Update UI with selected stage data
|
||
self.stage_name_input.setText(config['name'])
|
||
self.dongle_count_slider.setValue(config['dongles'])
|
||
self.port_ids_input.setText(config['port_ids'])
|
||
self.model_path_input.setText(config['model_path'])
|
||
|
||
# Update performance estimates
|
||
self.update_performance_estimates(config)
|
||
|
||
def update_current_stage(self):
|
||
"""Update current stage configuration"""
|
||
row = self.stages_list.currentRow()
|
||
if row >= 0 and row < len(self.stage_configs):
|
||
config = self.stage_configs[row]
|
||
config['name'] = self.stage_name_input.text()
|
||
config['port_ids'] = self.port_ids_input.text()
|
||
config['model_path'] = self.model_path_input.text()
|
||
|
||
self.update_stages_list()
|
||
self.stages_list.setCurrentRow(row) # Maintain selection
|
||
|
||
def update_dongle_count(self, value):
|
||
"""Update dongle count for current stage"""
|
||
self.dongle_count_label.setText(f"{value} dongles")
|
||
|
||
row = self.stages_list.currentRow()
|
||
if row >= 0 and row < len(self.stage_configs):
|
||
self.stage_configs[row]['dongles'] = value
|
||
self.update_stages_list()
|
||
self.stages_list.setCurrentRow(row)
|
||
|
||
# Update performance estimates
|
||
self.update_performance_estimates(self.stage_configs[row])
|
||
|
||
def auto_balance_dongles(self):
|
||
"""Automatically balance dongles across stages"""
|
||
if not self.stage_configs:
|
||
return
|
||
|
||
num_stages = len(self.stage_configs)
|
||
dongles_per_stage = max(1, self.total_dongles_available // num_stages)
|
||
remaining = self.total_dongles_available % num_stages
|
||
|
||
for i, config in enumerate(self.stage_configs):
|
||
config['dongles'] = dongles_per_stage + (1 if i < remaining else 0)
|
||
|
||
self.update_stages_list()
|
||
# Refresh current stage display
|
||
current_row = self.stages_list.currentRow()
|
||
if current_row >= 0:
|
||
self.on_stage_selected(None, None)
|
||
|
||
def browse_model_file(self):
|
||
"""Browse for model file"""
|
||
filename, _ = QFileDialog.getOpenFileName(
|
||
self, "Select Model File", "",
|
||
"Model files (*.nef *.onnx *.tflite);;All files (*.*)"
|
||
)
|
||
if filename:
|
||
self.model_path_input.setText(filename)
|
||
self.update_current_stage()
|
||
|
||
def update_performance_estimates(self, config):
|
||
"""Calculate and display performance estimates"""
|
||
dongles = config['dongles']
|
||
|
||
# Simple performance estimation based on dongle count
|
||
base_fps = 30 # Base FPS per dongle
|
||
estimated_fps = dongles * base_fps
|
||
latency = 1000 / max(estimated_fps, 1) # ms
|
||
throughput = estimated_fps * 1 # frames per second
|
||
|
||
self.estimated_fps_label.setText(f"{estimated_fps:.1f} FPS")
|
||
self.estimated_latency_label.setText(f"{latency:.1f} ms")
|
||
self.throughput_label.setText(f"{throughput:.1f} frames/sec")
|
||
|
||
def update_resource_summary(self):
|
||
"""Update resource usage summary"""
|
||
total_dongles = sum(config['dongles'] for config in self.stage_configs)
|
||
self.total_dongles_label.setText(f"{total_dongles} / {self.total_dongles_available}")
|
||
|
||
# Calculate overall pipeline FPS (limited by slowest stage)
|
||
if self.stage_configs:
|
||
min_fps = min(config['dongles'] * 30 for config in self.stage_configs)
|
||
self.pipeline_fps_label.setText(f"{min_fps:.1f} FPS")
|
||
else:
|
||
self.pipeline_fps_label.setText("--")
|
||
|
||
def show_performance_panel(self):
|
||
"""Show performance estimation panel"""
|
||
if not self.stage_configs:
|
||
QMessageBox.warning(self, "No Stages", "Please add at least one stage before proceeding.")
|
||
return
|
||
|
||
# Validate stage configurations
|
||
for config in self.stage_configs:
|
||
if not config['name'].strip():
|
||
QMessageBox.warning(self, "Invalid Configuration", "All stages must have names.")
|
||
return
|
||
|
||
self.accept()
|
||
|
||
# Show performance panel
|
||
perf_panel = PerformanceEstimationPanel(self.stage_configs, self.parent())
|
||
perf_panel.exec_()
|
||
|
||
def get_stage_configs(self):
|
||
"""Get configured stages"""
|
||
return self.stage_configs
|
||
|
||
|
||
# Simplified versions of the other dialogs for now
|
||
class PerformanceEstimationPanel(QDialog):
|
||
"""Performance estimation and tweaking panel"""
|
||
|
||
def __init__(self, stage_configs, parent=None):
|
||
super().__init__(parent)
|
||
self.stage_configs = stage_configs
|
||
self.setWindowTitle("Performance Estimation & Optimization")
|
||
self.setMinimumSize(600, 400)
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Header
|
||
header = QLabel("Pipeline Performance Analysis")
|
||
header.setFont(QFont("Arial", 16, QFont.Bold))
|
||
header.setStyleSheet("color: #89b4fa; margin-bottom: 10px;")
|
||
layout.addWidget(header)
|
||
|
||
# Performance summary
|
||
summary_text = f"Analyzing {len(self.stage_configs)} stages:\n"
|
||
total_dongles = sum(config['dongles'] for config in self.stage_configs)
|
||
min_fps = min(config['dongles'] * 30 for config in self.stage_configs) if self.stage_configs else 0
|
||
|
||
summary_text += f"• Total dongles: {total_dongles}\n"
|
||
summary_text += f"• Pipeline FPS: {min_fps:.1f}\n"
|
||
summary_text += f"• Estimated latency: {sum(1000/(config['dongles']*30) for config in self.stage_configs):.1f} ms"
|
||
|
||
summary_label = QLabel(summary_text)
|
||
summary_label.setStyleSheet("padding: 20px; background-color: #313244; border-radius: 8px;")
|
||
layout.addWidget(summary_label)
|
||
|
||
# Buttons
|
||
button_layout = QHBoxLayout()
|
||
self.deploy_btn = QPushButton("Next: Save & Deploy")
|
||
self.deploy_btn.clicked.connect(self.show_deploy_dialog)
|
||
self.back_btn = QPushButton("← Back")
|
||
self.back_btn.clicked.connect(self.reject)
|
||
|
||
button_layout.addWidget(self.back_btn)
|
||
button_layout.addStretch()
|
||
button_layout.addWidget(self.deploy_btn)
|
||
layout.addLayout(button_layout)
|
||
|
||
def show_deploy_dialog(self):
|
||
"""Show deployment dialog"""
|
||
self.accept()
|
||
deploy_dialog = SaveDeployDialog(self.stage_configs, self.parent())
|
||
deploy_dialog.exec_()
|
||
|
||
|
||
class SaveDeployDialog(QDialog):
|
||
"""Save and Deploy dialog with cost estimation"""
|
||
|
||
def __init__(self, stage_configs, parent=None):
|
||
super().__init__(parent)
|
||
self.stage_configs = stage_configs
|
||
self.setWindowTitle("Save & Deploy Pipeline")
|
||
self.setMinimumSize(600, 400)
|
||
self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Header
|
||
header = QLabel("Save & Deploy Configuration")
|
||
header.setFont(QFont("Arial", 16, QFont.Bold))
|
||
header.setStyleSheet("color: #89b4fa; margin-bottom: 10px;")
|
||
layout.addWidget(header)
|
||
|
||
# Export options
|
||
export_group = QGroupBox("Export Configuration")
|
||
export_layout = QFormLayout(export_group)
|
||
|
||
self.config_name_input = QLineEdit()
|
||
self.config_name_input.setText(f"pipeline_config_{time.strftime('%Y%m%d_%H%M%S')}")
|
||
export_layout.addRow("Configuration Name:", self.config_name_input)
|
||
|
||
self.export_format_combo = QComboBox()
|
||
self.export_format_combo.addItems(["Python Script", "JSON Config", "YAML Pipeline"])
|
||
export_layout.addRow("Export Format:", self.export_format_combo)
|
||
|
||
self.export_btn = QPushButton("💾 Export Configuration")
|
||
self.export_btn.clicked.connect(self.export_configuration)
|
||
export_layout.addRow("", self.export_btn)
|
||
|
||
layout.addWidget(export_group)
|
||
|
||
# Cost estimation
|
||
total_dongles = sum(config['dongles'] for config in self.stage_configs)
|
||
cost_text = f"Cost Estimation:\n"
|
||
cost_text += f"• Hardware: {total_dongles} dongles (~${total_dongles * 299:,})\n"
|
||
cost_text += f"• Power: {total_dongles * 5}W continuous\n"
|
||
cost_text += f"• Monthly operating: ~${total_dongles * 15:.2f}"
|
||
|
||
cost_label = QLabel(cost_text)
|
||
cost_label.setStyleSheet("padding: 20px; background-color: #313244; border-radius: 8px;")
|
||
layout.addWidget(cost_label)
|
||
|
||
# Buttons
|
||
button_layout = QHBoxLayout()
|
||
self.deploy_btn = QPushButton("🚀 Deploy Pipeline")
|
||
self.deploy_btn.clicked.connect(self.deploy_pipeline)
|
||
self.back_btn = QPushButton("← Back")
|
||
self.back_btn.clicked.connect(self.reject)
|
||
|
||
button_layout.addWidget(self.back_btn)
|
||
button_layout.addStretch()
|
||
button_layout.addWidget(self.deploy_btn)
|
||
layout.addLayout(button_layout)
|
||
|
||
def export_configuration(self):
|
||
"""Export configuration to file"""
|
||
format_type = self.export_format_combo.currentText()
|
||
config_name = self.config_name_input.text().strip()
|
||
|
||
if not config_name:
|
||
QMessageBox.warning(self, "Invalid Name", "Please enter a configuration name.")
|
||
return
|
||
|
||
# Generate content based on format
|
||
if format_type == "Python Script":
|
||
content = self.generate_python_script()
|
||
ext = ".py"
|
||
elif format_type == "JSON Config":
|
||
content = json.dumps({
|
||
"stages": [{"name": c['name'], "dongles": c['dongles'], "port_ids": c['port_ids']}
|
||
for c in self.stage_configs]
|
||
}, indent=2)
|
||
ext = ".json"
|
||
else: # YAML
|
||
content = "stages:\n"
|
||
for config in self.stage_configs:
|
||
content += f" - name: {config['name']}\n dongles: {config['dongles']}\n port_ids: {config['port_ids']}\n"
|
||
ext = ".yaml"
|
||
|
||
# Save file
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self, f"Save {format_type}", f"{config_name}{ext}",
|
||
f"{format_type} files (*{ext});;All files (*.*)"
|
||
)
|
||
|
||
if filename:
|
||
try:
|
||
with open(filename, 'w') as f:
|
||
f.write(content)
|
||
QMessageBox.information(self, "Exported", f"Configuration exported to {os.path.basename(filename)}")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Export Error", f"Failed to export: {str(e)}")
|
||
|
||
def generate_python_script(self):
|
||
"""Generate Python script"""
|
||
script = '''#!/usr/bin/env python3
|
||
"""
|
||
Generated Pipeline Configuration
|
||
"""
|
||
|
||
from src.cluster4npu.InferencePipeline import InferencePipeline, StageConfig
|
||
|
||
def main():
|
||
stage_configs = [
|
||
'''
|
||
|
||
for config in self.stage_configs:
|
||
port_ids = config['port_ids'].split(',') if ',' in config['port_ids'] else [28, 30]
|
||
script += f''' StageConfig(
|
||
stage_id="{config['name'].lower().replace(' ', '_')}",
|
||
port_ids={port_ids},
|
||
scpu_fw_path="fw_scpu.bin",
|
||
ncpu_fw_path="fw_ncpu.bin",
|
||
model_path="{config.get('model_path', 'model.nef')}",
|
||
upload_fw=True
|
||
),
|
||
'''
|
||
|
||
script += ''' ]
|
||
|
||
pipeline = InferencePipeline(stage_configs)
|
||
pipeline.initialize()
|
||
pipeline.start()
|
||
|
||
try:
|
||
import time
|
||
while True:
|
||
time.sleep(1)
|
||
except KeyboardInterrupt:
|
||
pipeline.stop()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
'''
|
||
return script
|
||
|
||
def deploy_pipeline(self):
|
||
"""Deploy the pipeline"""
|
||
QMessageBox.information(
|
||
self, "Deployment Started",
|
||
"Pipeline deployment initiated!\n\nThis would start the actual hardware deployment process."
|
||
)
|
||
self.accept()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
app.setFont(QFont("Arial", 9))
|
||
dashboard = DashboardLogin()
|
||
dashboard.show()
|
||
sys.exit(app.exec_()) |