diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..113263e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,191 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**cluster4npu** is a high-performance multi-stage inference pipeline system for Kneron NPU dongles. The project enables flexible single-stage and cascaded multi-stage AI inference workflows optimized for real-time video processing and high-throughput scenarios. + +### Core Architecture + +- **InferencePipeline**: Main orchestrator managing multi-stage workflows with automatic queue management and thread coordination +- **MultiDongle**: Hardware abstraction layer for Kneron NPU devices (KL520, KL720, etc.) +- **StageConfig**: Configuration system for individual pipeline stages +- **PipelineData**: Data structure that flows through pipeline stages, accumulating results +- **PreProcessor/PostProcessor**: Flexible data transformation components for inter-stage processing + +### Key Design Patterns + +- **Producer-Consumer**: Each stage runs in separate threads with input/output queues +- **Pipeline Architecture**: Linear data flow through configurable stages with result accumulation +- **Hardware Abstraction**: MultiDongle encapsulates Kneron SDK complexity +- **Callback-Based**: Asynchronous result handling via configurable callbacks + +## Development Commands + +### Environment Setup +```bash +# Setup virtual environment with uv +uv venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +# Install dependencies +uv pip install -r requirements.txt +``` + +### Running Examples +```bash +# Single-stage pipeline +uv run python src/cluster4npu/test.py --example single + +# Two-stage cascade pipeline +uv run python src/cluster4npu/test.py --example cascade + +# Complex multi-stage pipeline +uv run python src/cluster4npu/test.py --example complex + +# Basic MultiDongle usage +uv run python src/cluster4npu/Multidongle.py + +# Complete UI application with full workflow +uv run python UI.py + +# UI integration examples +uv run python ui_integration_example.py + +# Test UI configuration system +uv run python ui_config.py +``` + +### UI Application Workflow +The UI.py provides a complete visual workflow: + +1. **Dashboard/Home** - Main entry point with recent files +2. **Pipeline Editor** - Visual node-based pipeline design +3. **Stage Configuration** - Dongle allocation and hardware setup +4. **Performance Estimation** - FPS calculations and optimization +5. **Save & Deploy** - Export configurations and cost estimation +6. **Monitoring & Management** - Real-time pipeline monitoring + +```bash +# Access different workflow stages directly: +# 1. Create new pipeline → Pipeline Editor +# 2. Configure Stages & Deploy → Stage Configuration +# 3. Pipeline menu → Performance Analysis → Performance Panel +# 4. Pipeline menu → Deploy Pipeline → Save & Deploy Dialog +``` + +### Testing +```bash +# Run pipeline tests +uv run python test_pipeline.py + +# Test MultiDongle functionality +uv run python src/cluster4npu/test.py +``` + +## Hardware Requirements + +- **Kneron NPU dongles**: KL520, KL720, etc. +- **Firmware files**: `fw_scpu.bin`, `fw_ncpu.bin` +- **Models**: `.nef` format files +- **USB ports**: Multiple ports required for multi-dongle setups + +## Critical Implementation Notes + +### Pipeline Configuration +- Each stage requires unique `stage_id` and dedicated `port_ids` +- Queue sizes (`max_queue_size`) must be balanced between memory usage and throughput +- Stages process sequentially - output from stage N becomes input to stage N+1 + +### Thread Safety +- All pipeline operations are thread-safe +- Each stage runs in isolated worker threads +- Use callbacks for result handling, not direct queue access + +### Data Flow +``` +Input → Stage1 → Stage2 → ... → StageN → Output + ↓ ↓ ↓ ↓ + Queue Process Process Result + + Results + Results Callback +``` + +### Hardware Management +- Always call `initialize()` before `start()` +- Always call `stop()` for clean shutdown +- Firmware upload (`upload_fw=True`) only needed once per session +- Port IDs must match actual USB connections + +### Error Handling +- Pipeline continues on individual stage errors +- Failed stages return error results rather than blocking +- Comprehensive statistics available via `get_pipeline_statistics()` + +## UI Application Architecture + +### Complete Workflow Components + +- **DashboardLogin**: Main entry point with project management +- **PipelineEditor**: Node-based visual pipeline design using NodeGraphQt +- **StageConfigurationDialog**: Hardware allocation and dongle assignment +- **PerformanceEstimationPanel**: Real-time performance analysis and optimization +- **SaveDeployDialog**: Export configurations and deployment cost estimation +- **MonitoringDashboard**: Live pipeline monitoring and cluster management + +### UI Integration System + +- **ui_config.py**: Configuration management and UI/core integration +- **ui_integration_example.py**: Demonstrates conversion from UI to core tools +- **UIIntegration class**: Bridges UI configurations to InferencePipeline + +### Key UI Features + +- **Auto-dongle allocation**: Smart assignment of dongles to pipeline stages +- **Performance estimation**: Real-time FPS and latency calculations +- **Cost analysis**: Hardware and operational cost projections +- **Export formats**: Python scripts, JSON configs, YAML, Docker containers +- **Live monitoring**: Real-time metrics and cluster scaling controls + +## Code Patterns + +### Basic Pipeline Setup +```python +config = StageConfig( + stage_id="unique_name", + port_ids=[28, 32], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="model.nef", + upload_fw=True +) + +pipeline = InferencePipeline([config]) +pipeline.initialize() +pipeline.start() +pipeline.set_result_callback(callback_func) +# ... processing ... +pipeline.stop() +``` + +### Inter-Stage Processing +```python +# Custom preprocessing for stage input +preprocessor = PreProcessor(resize_fn=custom_resize_func) + +# Custom postprocessing for stage output +postprocessor = PostProcessor(process_fn=custom_process_func) + +config = StageConfig( + # ... basic config ... + input_preprocessor=preprocessor, + output_postprocessor=postprocessor +) +``` + +## Performance Considerations + +- **Queue Sizing**: Smaller queues = lower latency, larger queues = higher throughput +- **Dongle Distribution**: Spread dongles across stages for optimal parallelization +- **Processing Functions**: Keep preprocessors/postprocessors lightweight +- **Memory Management**: Monitor queue sizes to prevent memory buildup \ No newline at end of file diff --git a/Flowchart.jpg b/Flowchart.jpg new file mode 100644 index 0000000..3c27e39 Binary files /dev/null and b/Flowchart.jpg differ diff --git a/README.md b/README.md index d3cfc69..7fc7d6e 100644 --- a/README.md +++ b/README.md @@ -1 +1,488 @@ -# Cluster4NPU \ No newline at end of file +# InferencePipeline + +A high-performance multi-stage inference pipeline system designed for Kneron NPU dongles, enabling flexible single-stage and cascaded multi-stage AI inference workflows. + + + +## Installation + +This project uses [uv](https://github.com/astral-sh/uv) for fast Python package management. + +```bash +# Install uv if you haven't already +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Create and activate virtual environment +uv venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install dependencies +uv pip install -r requirements.txt +``` + +### Requirements + +```txt +"numpy>=2.2.6", +"opencv-python>=4.11.0.86", +``` + +### Hardware Requirements + +- Kneron AI dongles (KL520, KL720, etc.) +- USB ports for device connections +- Compatible firmware files (`fw_scpu.bin`, `fw_ncpu.bin`) +- Trained model files (`.nef` format) + +## Quick Start + +### Single-Stage Pipeline + +Replace your existing MultiDongle usage with InferencePipeline for enhanced features: + +```python +from InferencePipeline import InferencePipeline, StageConfig + +# Configure single stage +stage_config = StageConfig( + stage_id="fire_detection", + port_ids=[28, 32], # USB port IDs for your dongles + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="fire_detection_520.nef", + upload_fw=True +) + +# Create and start pipeline +pipeline = InferencePipeline([stage_config], pipeline_name="FireDetection") +pipeline.initialize() +pipeline.start() + +# Set up result callback +def handle_result(pipeline_data): + result = pipeline_data.stage_results.get("fire_detection", {}) + print(f"🔥 Detection: {result.get('result', 'Unknown')} " + f"(Probability: {result.get('probability', 0.0):.3f})") + +pipeline.set_result_callback(handle_result) + +# Process frames +import cv2 +cap = cv2.VideoCapture(0) + +try: + while True: + ret, frame = cap.read() + if ret: + pipeline.put_data(frame) + if cv2.waitKey(1) & 0xFF == ord('q'): + break +finally: + cap.release() + pipeline.stop() +``` + +### Multi-Stage Cascade Pipeline + +Chain multiple models for complex workflows: + +```python +from InferencePipeline import InferencePipeline, StageConfig +from Multidongle import PreProcessor, PostProcessor + +# Custom preprocessing for second stage +def roi_extraction(frame, target_size): + """Extract region of interest from detection results""" + # Extract center region as example + h, w = frame.shape[:2] + center_crop = frame[h//4:3*h//4, w//4:3*w//4] + return cv2.resize(center_crop, target_size) + +# Custom result fusion +def combine_results(raw_output, **kwargs): + """Combine detection + classification results""" + classification_prob = float(raw_output[0]) if raw_output.size > 0 else 0.0 + detection_conf = kwargs.get('detection_conf', 0.5) + + # Weighted combination + combined_score = (classification_prob * 0.7) + (detection_conf * 0.3) + + return { + 'combined_probability': combined_score, + 'classification_prob': classification_prob, + 'detection_conf': detection_conf, + 'result': 'Fire Detected' if combined_score > 0.6 else 'No Fire', + 'confidence': 'High' if combined_score > 0.8 else 'Low' + } + +# Stage 1: Object Detection +detection_stage = StageConfig( + stage_id="object_detection", + port_ids=[28, 30], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="object_detection_520.nef", + upload_fw=True +) + +# Stage 2: Fire Classification with preprocessing +classification_stage = StageConfig( + stage_id="fire_classification", + port_ids=[32, 34], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="fire_classification_520.nef", + upload_fw=True, + input_preprocessor=PreProcessor(resize_fn=roi_extraction), + output_postprocessor=PostProcessor(process_fn=combine_results) +) + +# Create two-stage pipeline +pipeline = InferencePipeline( + [detection_stage, classification_stage], + pipeline_name="DetectionClassificationCascade" +) + +# Enhanced result handler +def handle_cascade_result(pipeline_data): + detection = pipeline_data.stage_results.get("object_detection", {}) + classification = pipeline_data.stage_results.get("fire_classification", {}) + + print(f"🎯 Detection: {detection.get('result', 'Unknown')} " + f"(Conf: {detection.get('probability', 0.0):.3f})") + print(f"🔥 Classification: {classification.get('result', 'Unknown')} " + f"(Combined: {classification.get('combined_probability', 0.0):.3f})") + print(f"⏱️ Processing Time: {pipeline_data.metadata.get('total_processing_time', 0.0):.3f}s") + print("-" * 50) + +pipeline.set_result_callback(handle_cascade_result) +pipeline.initialize() +pipeline.start() + +# Your processing loop here... +``` + +## Usage Examples + +### Example 1: Real-time Webcam Processing + +```python +from InferencePipeline import InferencePipeline, StageConfig +from Multidongle import WebcamSource + +def run_realtime_detection(): + # Configure pipeline + config = StageConfig( + stage_id="realtime_detection", + port_ids=[28, 32], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="your_model.nef", + upload_fw=True, + max_queue_size=30 # Prevent memory buildup + ) + + pipeline = InferencePipeline([config]) + pipeline.initialize() + pipeline.start() + + # Use webcam source + source = WebcamSource(camera_id=0) + source.start() + + def display_results(pipeline_data): + result = pipeline_data.stage_results["realtime_detection"] + probability = result.get('probability', 0.0) + detection = result.get('result', 'Unknown') + + # Your visualization logic here + print(f"Detection: {detection} ({probability:.3f})") + + pipeline.set_result_callback(display_results) + + try: + while True: + frame = source.get_frame() + if frame is not None: + pipeline.put_data(frame) + time.sleep(0.033) # ~30 FPS + except KeyboardInterrupt: + print("Stopping...") + finally: + source.stop() + pipeline.stop() + +if __name__ == "__main__": + run_realtime_detection() +``` + +### Example 2: Complex Multi-Modal Pipeline + +```python +def run_multimodal_pipeline(): + """Multi-modal fire detection with RGB, edge, and thermal-like analysis""" + + def edge_preprocessing(frame, target_size): + """Extract edge features""" + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + edges = cv2.Canny(gray, 50, 150) + edges_3ch = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR) + return cv2.resize(edges_3ch, target_size) + + def thermal_preprocessing(frame, target_size): + """Simulate thermal processing""" + hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) + thermal_like = hsv[:, :, 2] # Value channel + thermal_3ch = cv2.cvtColor(thermal_like, cv2.COLOR_GRAY2BGR) + return cv2.resize(thermal_3ch, target_size) + + def fusion_postprocessing(raw_output, **kwargs): + """Fuse results from multiple modalities""" + if raw_output.size > 0: + current_prob = float(raw_output[0]) + rgb_conf = kwargs.get('rgb_conf', 0.5) + edge_conf = kwargs.get('edge_conf', 0.5) + + # Weighted fusion + fused_prob = (current_prob * 0.5) + (rgb_conf * 0.3) + (edge_conf * 0.2) + + return { + 'fused_probability': fused_prob, + 'modality_scores': { + 'thermal': current_prob, + 'rgb': rgb_conf, + 'edge': edge_conf + }, + 'result': 'Fire Detected' if fused_prob > 0.6 else 'No Fire', + 'confidence': 'Very High' if fused_prob > 0.9 else 'High' if fused_prob > 0.7 else 'Medium' + } + return {'fused_probability': 0.0, 'result': 'No Fire'} + + # Define stages + stages = [ + StageConfig("rgb_analysis", [28, 30], "fw_scpu.bin", "fw_ncpu.bin", "rgb_model.nef", True), + StageConfig("edge_analysis", [32, 34], "fw_scpu.bin", "fw_ncpu.bin", "edge_model.nef", True, + input_preprocessor=PreProcessor(resize_fn=edge_preprocessing)), + StageConfig("thermal_analysis", [36, 38], "fw_scpu.bin", "fw_ncpu.bin", "thermal_model.nef", True, + input_preprocessor=PreProcessor(resize_fn=thermal_preprocessing)), + StageConfig("fusion", [40, 42], "fw_scpu.bin", "fw_ncpu.bin", "fusion_model.nef", True, + output_postprocessor=PostProcessor(process_fn=fusion_postprocessing)) + ] + + pipeline = InferencePipeline(stages, pipeline_name="MultiModalFireDetection") + + def handle_multimodal_result(pipeline_data): + print(f"\n🔥 Multi-Modal Fire Detection Results:") + for stage_id, result in pipeline_data.stage_results.items(): + if 'probability' in result: + print(f" {stage_id}: {result['result']} ({result['probability']:.3f})") + + if 'fusion' in pipeline_data.stage_results: + fusion = pipeline_data.stage_results['fusion'] + print(f" 🎯 FINAL: {fusion['result']} (Fused: {fusion['fused_probability']:.3f})") + print(f" Confidence: {fusion.get('confidence', 'Unknown')}") + + pipeline.set_result_callback(handle_multimodal_result) + + # Start pipeline + pipeline.initialize() + pipeline.start() + + # Your processing logic here... +``` + +### Example 3: Batch Processing + +```python +def process_image_batch(image_paths): + """Process a batch of images through pipeline""" + + config = StageConfig( + stage_id="batch_processing", + port_ids=[28, 32], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="batch_model.nef", + upload_fw=True + ) + + pipeline = InferencePipeline([config]) + pipeline.initialize() + pipeline.start() + + results = [] + + def collect_result(pipeline_data): + result = pipeline_data.stage_results["batch_processing"] + results.append({ + 'pipeline_id': pipeline_data.pipeline_id, + 'result': result, + 'processing_time': pipeline_data.metadata.get('total_processing_time', 0.0) + }) + + pipeline.set_result_callback(collect_result) + + # Submit all images + for img_path in image_paths: + image = cv2.imread(img_path) + if image is not None: + pipeline.put_data(image) + + # Wait for all results + import time + while len(results) < len(image_paths): + time.sleep(0.1) + + pipeline.stop() + return results +``` + +## Configuration + +### StageConfig Parameters + +```python +StageConfig( + stage_id="unique_stage_name", # Required: Unique identifier + port_ids=[28, 32], # Required: USB port IDs for dongles + scpu_fw_path="fw_scpu.bin", # Required: SCPU firmware path + ncpu_fw_path="fw_ncpu.bin", # Required: NCPU firmware path + model_path="model.nef", # Required: Model file path + upload_fw=True, # Upload firmware on init + max_queue_size=50, # Queue size limit + input_preprocessor=None, # Optional: Inter-stage preprocessing + output_postprocessor=None, # Optional: Inter-stage postprocessing + stage_preprocessor=None, # Optional: MultiDongle preprocessing + stage_postprocessor=None # Optional: MultiDongle postprocessing +) +``` + +### Performance Tuning + +```python +# For high-throughput scenarios +config = StageConfig( + stage_id="high_performance", + port_ids=[28, 30, 32, 34], # Use more dongles + max_queue_size=100, # Larger queues + # ... other params +) + +# For low-latency scenarios +config = StageConfig( + stage_id="low_latency", + port_ids=[28, 32], + max_queue_size=10, # Smaller queues + # ... other params +) +``` + +## Statistics and Monitoring + +```python +# Enable statistics reporting +def print_stats(stats): + print(f"\n📊 Pipeline Statistics:") + print(f" Input: {stats['pipeline_input_submitted']}") + print(f" Completed: {stats['pipeline_completed']}") + print(f" Success Rate: {stats['pipeline_completed']/max(stats['pipeline_input_submitted'], 1)*100:.1f}%") + + for stage_stat in stats['stage_statistics']: + print(f" Stage {stage_stat['stage_id']}: " + f"Processed={stage_stat['processed_count']}, " + f"AvgTime={stage_stat['avg_processing_time']:.3f}s") + +pipeline.set_stats_callback(print_stats) +pipeline.start_stats_reporting(interval=5.0) # Report every 5 seconds +``` + +## Running Examples + +The project includes comprehensive examples in `test.py`: + +```bash +# Single-stage pipeline +uv run python test.py --example single + +# Two-stage cascade pipeline +uv run python test.py --example cascade + +# Complex multi-stage pipeline +uv run python test.py --example complex +``` + +## API Reference + +### InferencePipeline + +Main pipeline orchestrator class. + +**Methods:** +- `initialize()`: Initialize all pipeline stages +- `start()`: Start pipeline processing threads +- `stop()`: Gracefully stop pipeline +- `put_data(data, timeout=1.0)`: Submit data for processing +- `get_result(timeout=0.1)`: Get processed results +- `set_result_callback(callback)`: Set success callback +- `set_error_callback(callback)`: Set error callback +- `get_pipeline_statistics()`: Get performance metrics + +### StageConfig + +Configuration for individual pipeline stages. + +### PipelineData + +Data structure flowing through pipeline stages. + +**Attributes:** +- `data`: Main data payload +- `metadata`: Processing metadata +- `stage_results`: Results from each stage +- `pipeline_id`: Unique identifier +- `timestamp`: Creation timestamp + +## Performance Considerations + +1. **Queue Sizing**: Balance memory usage vs. throughput with `max_queue_size` +2. **Dongle Distribution**: Distribute dongles across stages for optimal performance +3. **Preprocessing**: Minimize expensive operations in preprocessors +4. **Memory Management**: Monitor queue sizes and processing times +5. **Threading**: Pipeline uses multiple threads - ensure thread-safe operations + +## Troubleshooting + +### Common Issues + +**Pipeline hangs or stops processing:** +- Check dongle connections and firmware compatibility +- Monitor queue sizes for bottlenecks +- Verify model file paths and formats + +**High memory usage:** +- Reduce `max_queue_size` parameters +- Ensure proper cleanup in custom processors +- Monitor statistics for processing times + +**Poor performance:** +- Distribute dongles optimally across stages +- Profile preprocessing/postprocessing functions +- Consider batch processing for high throughput + +### Debug Mode + +Enable detailed logging for troubleshooting: + +```python +import logging +logging.basicConfig(level=logging.DEBUG) + +# Pipeline will output detailed processing information +``` \ No newline at end of file diff --git a/UI.py b/UI.py new file mode 100644 index 0000000..84c12ac --- /dev/null +++ b/UI.py @@ -0,0 +1,3346 @@ +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 + try: + node_type = node.type_() if callable(node.type_) else str(getattr(node, 'type_', 'Unknown')) + 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 1: Try to get properties from NodeGraphQt node + 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() + 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'], + 'InputNode': ['input_path', 'source_type', 'fps', 'source_path'], + 'OutputNode': ['output_path', 'output_format', 'save_results'], + 'PreprocessNode': ['resize_width', 'resize_height', 'operations'], + 'PostprocessNode': ['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_()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c30a523..b20b376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,11 @@ name = "cluster4npu" version = "0.1.0" description = "Add your description here" readme = "README.md" -requires-python = ">=3.12" +requires-python = "<=3.12" dependencies = [ + "nodegraphqt>=0.6.38", "numpy>=2.2.6", + "odengraphqt>=0.7.4", "opencv-python>=4.11.0.86", + "pyqt5>=5.15.11", ] diff --git a/src/cluster4npu/InferencePipeline.py b/src/cluster4npu/InferencePipeline.py new file mode 100644 index 0000000..4571420 --- /dev/null +++ b/src/cluster4npu/InferencePipeline.py @@ -0,0 +1,563 @@ +from typing import List, Dict, Any, Optional, Callable, Union +import threading +import queue +import time +import traceback +from dataclasses import dataclass +from concurrent.futures import ThreadPoolExecutor +import numpy as np + +from Multidongle import MultiDongle, PreProcessor, PostProcessor, DataProcessor + +@dataclass +class StageConfig: + """Configuration for a single pipeline stage""" + stage_id: str + port_ids: List[int] + scpu_fw_path: str + ncpu_fw_path: str + model_path: str + upload_fw: bool = False + max_queue_size: int = 50 + # Inter-stage processing + input_preprocessor: Optional[PreProcessor] = None # Before this stage + output_postprocessor: Optional[PostProcessor] = None # After this stage + # Stage-specific processing + stage_preprocessor: Optional[PreProcessor] = None # MultiDongle preprocessor + stage_postprocessor: Optional[PostProcessor] = None # MultiDongle postprocessor + +@dataclass +class PipelineData: + """Data structure flowing through pipeline""" + data: Any # Main data (image, features, etc.) + metadata: Dict[str, Any] # Additional info + stage_results: Dict[str, Any] # Results from each stage + pipeline_id: str # Unique identifier for this data flow + timestamp: float + +class PipelineStage: + """Single stage in the inference pipeline""" + + def __init__(self, config: StageConfig): + self.config = config + self.stage_id = config.stage_id + + # Initialize MultiDongle for this stage + self.multidongle = MultiDongle( + port_id=config.port_ids, + scpu_fw_path=config.scpu_fw_path, + ncpu_fw_path=config.ncpu_fw_path, + model_path=config.model_path, + upload_fw=config.upload_fw, + preprocessor=config.stage_preprocessor, + postprocessor=config.stage_postprocessor, + max_queue_size=config.max_queue_size + ) + + # Inter-stage processors + self.input_preprocessor = config.input_preprocessor + self.output_postprocessor = config.output_postprocessor + + # Threading for this stage + self.input_queue = queue.Queue(maxsize=config.max_queue_size) + self.output_queue = queue.Queue(maxsize=config.max_queue_size) + self.worker_thread = None + self.running = False + self._stop_event = threading.Event() + + # Statistics + self.processed_count = 0 + self.error_count = 0 + self.processing_times = [] + + def initialize(self): + """Initialize the stage""" + print(f"[Stage {self.stage_id}] Initializing...") + try: + self.multidongle.initialize() + self.multidongle.start() + print(f"[Stage {self.stage_id}] Initialized successfully") + except Exception as e: + print(f"[Stage {self.stage_id}] Initialization failed: {e}") + raise + + def start(self): + """Start the stage worker thread""" + if self.worker_thread and self.worker_thread.is_alive(): + return + + self.running = True + self._stop_event.clear() + self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True) + self.worker_thread.start() + print(f"[Stage {self.stage_id}] Worker thread started") + + def stop(self): + """Stop the stage gracefully""" + print(f"[Stage {self.stage_id}] Stopping...") + self.running = False + self._stop_event.set() + + # Put sentinel to unblock worker + try: + self.input_queue.put(None, timeout=1.0) + except queue.Full: + pass + + # Wait for worker thread + if self.worker_thread and self.worker_thread.is_alive(): + self.worker_thread.join(timeout=3.0) + if self.worker_thread.is_alive(): + print(f"[Stage {self.stage_id}] Warning: Worker thread didn't stop cleanly") + + # Stop MultiDongle + self.multidongle.stop() + print(f"[Stage {self.stage_id}] Stopped") + + def _worker_loop(self): + """Main worker loop for processing data""" + print(f"[Stage {self.stage_id}] Worker loop started") + + while self.running and not self._stop_event.is_set(): + try: + # Get input data + try: + pipeline_data = self.input_queue.get(timeout=0.1) + if pipeline_data is None: # Sentinel value + continue + except queue.Empty: + continue + + start_time = time.time() + + # Process data through this stage + processed_data = self._process_data(pipeline_data) + + # Record processing time + processing_time = time.time() - start_time + self.processing_times.append(processing_time) + if len(self.processing_times) > 1000: # Keep only recent times + self.processing_times = self.processing_times[-500:] + + self.processed_count += 1 + + # Put result to output queue + try: + self.output_queue.put(processed_data, block=False) + except queue.Full: + # Drop oldest and add new + try: + self.output_queue.get_nowait() + self.output_queue.put(processed_data, block=False) + except queue.Empty: + pass + + except Exception as e: + self.error_count += 1 + print(f"[Stage {self.stage_id}] Processing error: {e}") + traceback.print_exc() + + print(f"[Stage {self.stage_id}] Worker loop stopped") + + def _process_data(self, pipeline_data: PipelineData) -> PipelineData: + """Process data through this stage""" + try: + current_data = pipeline_data.data + + # Debug: Print data info + if isinstance(current_data, np.ndarray): + print(f"[Stage {self.stage_id}] Input data: shape={current_data.shape}, dtype={current_data.dtype}") + + # Step 1: Input preprocessing (inter-stage) + if self.input_preprocessor: + if isinstance(current_data, np.ndarray): + print(f"[Stage {self.stage_id}] Applying input preprocessor...") + current_data = self.input_preprocessor.process( + current_data, + self.multidongle.model_input_shape, + 'BGR565' # Default format + ) + print(f"[Stage {self.stage_id}] After input preprocess: shape={current_data.shape}, dtype={current_data.dtype}") + + # Step 2: Always preprocess image data for MultiDongle + processed_data = None + if isinstance(current_data, np.ndarray) and len(current_data.shape) == 3: + # Always use MultiDongle's preprocess_frame to ensure correct format + print(f"[Stage {self.stage_id}] Preprocessing frame for MultiDongle...") + processed_data = self.multidongle.preprocess_frame(current_data, 'BGR565') + print(f"[Stage {self.stage_id}] After MultiDongle preprocess: shape={processed_data.shape}, dtype={processed_data.dtype}") + + # Validate processed data + if processed_data is None: + raise ValueError("MultiDongle preprocess_frame returned None") + if not isinstance(processed_data, np.ndarray): + raise ValueError(f"MultiDongle preprocess_frame returned {type(processed_data)}, expected np.ndarray") + + elif isinstance(current_data, dict) and 'raw_output' in current_data: + # This is result from previous stage, not suitable for direct inference + print(f"[Stage {self.stage_id}] Warning: Received processed result instead of image data") + processed_data = current_data + else: + print(f"[Stage {self.stage_id}] Warning: Unexpected data type: {type(current_data)}") + processed_data = current_data + + # Step 3: MultiDongle inference + if isinstance(processed_data, np.ndarray): + print(f"[Stage {self.stage_id}] Sending to MultiDongle: shape={processed_data.shape}, dtype={processed_data.dtype}") + self.multidongle.put_input(processed_data, 'BGR565') + + # Get inference result with timeout + inference_result = {} + timeout_start = time.time() + while time.time() - timeout_start < 5.0: # 5 second timeout + result = self.multidongle.get_latest_inference_result(timeout=0.1) + if result: + inference_result = result + break + time.sleep(0.01) + + if not inference_result: + print(f"[Stage {self.stage_id}] Warning: No inference result received") + inference_result = {'probability': 0.0, 'result': 'No Result'} + + # Step 3: Output postprocessing (inter-stage) + processed_result = inference_result + if self.output_postprocessor: + if 'raw_output' in inference_result: + processed_result = self.output_postprocessor.process( + inference_result['raw_output'] + ) + # Merge with original result + processed_result.update(inference_result) + + # Step 4: Update pipeline data + pipeline_data.stage_results[self.stage_id] = processed_result + pipeline_data.data = processed_result # Pass result as data to next stage + pipeline_data.metadata[f'{self.stage_id}_timestamp'] = time.time() + + return pipeline_data + + except Exception as e: + print(f"[Stage {self.stage_id}] Data processing error: {e}") + # Return data with error info + pipeline_data.stage_results[self.stage_id] = { + 'error': str(e), + 'probability': 0.0, + 'result': 'Processing Error' + } + return pipeline_data + + def put_data(self, data: PipelineData, timeout: float = 1.0) -> bool: + """Put data into this stage's input queue""" + try: + self.input_queue.put(data, timeout=timeout) + return True + except queue.Full: + return False + + def get_result(self, timeout: float = 0.1) -> Optional[PipelineData]: + """Get result from this stage's output queue""" + try: + return self.output_queue.get(timeout=timeout) + except queue.Empty: + return None + + def get_statistics(self) -> Dict[str, Any]: + """Get stage statistics""" + avg_processing_time = ( + sum(self.processing_times) / len(self.processing_times) + if self.processing_times else 0.0 + ) + + multidongle_stats = self.multidongle.get_statistics() + + return { + 'stage_id': self.stage_id, + 'processed_count': self.processed_count, + 'error_count': self.error_count, + 'avg_processing_time': avg_processing_time, + 'input_queue_size': self.input_queue.qsize(), + 'output_queue_size': self.output_queue.qsize(), + 'multidongle_stats': multidongle_stats + } + +class InferencePipeline: + """Multi-stage inference pipeline""" + + def __init__(self, stage_configs: List[StageConfig], + final_postprocessor: Optional[PostProcessor] = None, + pipeline_name: str = "InferencePipeline"): + """ + Initialize inference pipeline + :param stage_configs: List of stage configurations + :param final_postprocessor: Final postprocessor after all stages + :param pipeline_name: Name for this pipeline instance + """ + self.pipeline_name = pipeline_name + self.stage_configs = stage_configs + self.final_postprocessor = final_postprocessor + + # Create stages + self.stages: List[PipelineStage] = [] + for config in stage_configs: + stage = PipelineStage(config) + self.stages.append(stage) + + # Pipeline coordinator + self.coordinator_thread = None + self.running = False + self._stop_event = threading.Event() + + # Input/Output queues for the entire pipeline + self.pipeline_input_queue = queue.Queue(maxsize=100) + self.pipeline_output_queue = queue.Queue(maxsize=100) + + # Callbacks + self.result_callback = None + self.error_callback = None + self.stats_callback = None + + # Statistics + self.pipeline_counter = 0 + self.completed_counter = 0 + self.error_counter = 0 + + def initialize(self): + """Initialize all stages""" + print(f"[{self.pipeline_name}] Initializing pipeline with {len(self.stages)} stages...") + + for i, stage in enumerate(self.stages): + try: + stage.initialize() + print(f"[{self.pipeline_name}] Stage {i+1}/{len(self.stages)} initialized") + except Exception as e: + print(f"[{self.pipeline_name}] Failed to initialize stage {stage.stage_id}: {e}") + # Cleanup already initialized stages + for j in range(i): + self.stages[j].stop() + raise + + print(f"[{self.pipeline_name}] All stages initialized successfully") + + def start(self): + """Start the pipeline""" + print(f"[{self.pipeline_name}] Starting pipeline...") + + # Start all stages + for stage in self.stages: + stage.start() + + # Start coordinator + self.running = True + self._stop_event.clear() + self.coordinator_thread = threading.Thread(target=self._coordinator_loop, daemon=True) + self.coordinator_thread.start() + + print(f"[{self.pipeline_name}] Pipeline started successfully") + + def stop(self): + """Stop the pipeline gracefully""" + print(f"[{self.pipeline_name}] Stopping pipeline...") + + self.running = False + self._stop_event.set() + + # Stop coordinator + if self.coordinator_thread and self.coordinator_thread.is_alive(): + try: + self.pipeline_input_queue.put(None, timeout=1.0) + except queue.Full: + pass + self.coordinator_thread.join(timeout=3.0) + + # Stop all stages + for stage in self.stages: + stage.stop() + + print(f"[{self.pipeline_name}] Pipeline stopped") + + def _coordinator_loop(self): + """Coordinate data flow between stages""" + print(f"[{self.pipeline_name}] Coordinator started") + + while self.running and not self._stop_event.is_set(): + try: + # Get input data + try: + input_data = self.pipeline_input_queue.get(timeout=0.1) + if input_data is None: # Sentinel + continue + except queue.Empty: + continue + + # Create pipeline data + pipeline_data = PipelineData( + data=input_data, + metadata={'start_timestamp': time.time()}, + stage_results={}, + pipeline_id=f"pipeline_{self.pipeline_counter}", + timestamp=time.time() + ) + self.pipeline_counter += 1 + + # Process through each stage + current_data = pipeline_data + success = True + + for i, stage in enumerate(self.stages): + # Send data to stage + if not stage.put_data(current_data, timeout=1.0): + print(f"[{self.pipeline_name}] Stage {stage.stage_id} input queue full, dropping data") + success = False + break + + # Get result from stage + result_data = None + timeout_start = time.time() + while time.time() - timeout_start < 10.0: # 10 second timeout per stage + result_data = stage.get_result(timeout=0.1) + if result_data: + break + if self._stop_event.is_set(): + break + time.sleep(0.01) + + if not result_data: + print(f"[{self.pipeline_name}] Stage {stage.stage_id} timeout") + success = False + break + + current_data = result_data + + # Final postprocessing + if success and self.final_postprocessor: + try: + if isinstance(current_data.data, dict) and 'raw_output' in current_data.data: + final_result = self.final_postprocessor.process(current_data.data['raw_output']) + current_data.stage_results['final'] = final_result + current_data.data = final_result + except Exception as e: + print(f"[{self.pipeline_name}] Final postprocessing error: {e}") + + # Output result + if success: + current_data.metadata['end_timestamp'] = time.time() + current_data.metadata['total_processing_time'] = ( + current_data.metadata['end_timestamp'] - + current_data.metadata['start_timestamp'] + ) + + try: + self.pipeline_output_queue.put(current_data, block=False) + self.completed_counter += 1 + + # Call result callback + if self.result_callback: + self.result_callback(current_data) + + except queue.Full: + # Drop oldest and add new + try: + self.pipeline_output_queue.get_nowait() + self.pipeline_output_queue.put(current_data, block=False) + except queue.Empty: + pass + else: + self.error_counter += 1 + if self.error_callback: + self.error_callback(current_data) + + except Exception as e: + print(f"[{self.pipeline_name}] Coordinator error: {e}") + traceback.print_exc() + self.error_counter += 1 + + print(f"[{self.pipeline_name}] Coordinator stopped") + + def put_data(self, data: Any, timeout: float = 1.0) -> bool: + """Put data into pipeline""" + try: + self.pipeline_input_queue.put(data, timeout=timeout) + return True + except queue.Full: + return False + + def get_result(self, timeout: float = 0.1) -> Optional[PipelineData]: + """Get result from pipeline""" + try: + return self.pipeline_output_queue.get(timeout=timeout) + except queue.Empty: + return None + + def set_result_callback(self, callback: Callable[[PipelineData], None]): + """Set callback for successful results""" + self.result_callback = callback + + def set_error_callback(self, callback: Callable[[PipelineData], None]): + """Set callback for errors""" + self.error_callback = callback + + def set_stats_callback(self, callback: Callable[[Dict[str, Any]], None]): + """Set callback for statistics""" + self.stats_callback = callback + + def get_pipeline_statistics(self) -> Dict[str, Any]: + """Get comprehensive pipeline statistics""" + stage_stats = [] + for stage in self.stages: + stage_stats.append(stage.get_statistics()) + + return { + 'pipeline_name': self.pipeline_name, + 'total_stages': len(self.stages), + 'pipeline_input_submitted': self.pipeline_counter, + 'pipeline_completed': self.completed_counter, + 'pipeline_errors': self.error_counter, + 'pipeline_input_queue_size': self.pipeline_input_queue.qsize(), + 'pipeline_output_queue_size': self.pipeline_output_queue.qsize(), + 'stage_statistics': stage_stats + } + + def start_stats_reporting(self, interval: float = 5.0): + """Start periodic statistics reporting""" + def stats_loop(): + while self.running: + if self.stats_callback: + stats = self.get_pipeline_statistics() + self.stats_callback(stats) + time.sleep(interval) + + stats_thread = threading.Thread(target=stats_loop, daemon=True) + stats_thread.start() + +# Utility functions for common inter-stage processing +def create_feature_extractor_preprocessor() -> PreProcessor: + """Create preprocessor for feature extraction stage""" + def extract_features(frame, target_size): + # Example: extract edges, keypoints, etc. + import cv2 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + edges = cv2.Canny(gray, 50, 150) + return cv2.resize(edges, target_size) + + return PreProcessor(resize_fn=extract_features) + +def create_result_aggregator_postprocessor() -> PostProcessor: + """Create postprocessor for aggregating multiple stage results""" + def aggregate_results(raw_output, **kwargs): + # Example: combine results from multiple stages + if isinstance(raw_output, dict): + # If raw_output is already processed results + return raw_output + + # Standard processing + if raw_output.size > 0: + probability = float(raw_output[0]) + return { + 'aggregated_probability': probability, + 'confidence': 'High' if probability > 0.8 else 'Medium' if probability > 0.5 else 'Low', + 'result': 'Detected' if probability > 0.5 else 'Not Detected' + } + return {'aggregated_probability': 0.0, 'confidence': 'Low', 'result': 'Not Detected'} + + return PostProcessor(process_fn=aggregate_results) \ No newline at end of file diff --git a/src/cluster4npu/Multidongle.py b/src/cluster4npu/Multidongle.py new file mode 100644 index 0000000..0dfb2df --- /dev/null +++ b/src/cluster4npu/Multidongle.py @@ -0,0 +1,505 @@ +from typing import Union, Tuple +import os +import sys +import argparse +import time +import threading +import queue +import numpy as np +import kp +import cv2 +import time +from abc import ABC, abstractmethod +from typing import Callable, Optional, Any, Dict + + +class PreProcessor(DataProcessor): # type: ignore + def __init__(self, resize_fn: Optional[Callable] = None, + format_convert_fn: Optional[Callable] = None): + self.resize_fn = resize_fn or self._default_resize + self.format_convert_fn = format_convert_fn or self._default_format_convert + + def process(self, frame: np.ndarray, target_size: tuple, target_format: str) -> np.ndarray: + """Main processing pipeline""" + resized = self.resize_fn(frame, target_size) + return self.format_convert_fn(resized, target_format) + + def _default_resize(self, frame: np.ndarray, target_size: tuple) -> np.ndarray: + """Default resize implementation""" + return cv2.resize(frame, target_size) + + def _default_format_convert(self, frame: np.ndarray, target_format: str) -> np.ndarray: + """Default format conversion""" + if target_format == 'BGR565': + return cv2.cvtColor(frame, cv2.COLOR_BGR2BGR565) + elif target_format == 'RGB8888': + return cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA) + return frame + +class MultiDongle: + # Curently, only BGR565, RGB8888, YUYV, and RAW8 formats are supported + _FORMAT_MAPPING = { + 'BGR565': kp.ImageFormat.KP_IMAGE_FORMAT_RGB565, + 'RGB8888': kp.ImageFormat.KP_IMAGE_FORMAT_RGBA8888, + 'YUYV': kp.ImageFormat.KP_IMAGE_FORMAT_YUYV, + 'RAW8': kp.ImageFormat.KP_IMAGE_FORMAT_RAW8, + # 'YCBCR422_CRY1CBY0': kp.ImageFormat.KP_IMAGE_FORMAT_YCBCR422_CRY1CBY0, + # 'YCBCR422_CBY1CRY0': kp.ImageFormat.KP_IMAGE_FORMAT_CBY1CRY0, + # 'YCBCR422_Y1CRY0CB': kp.ImageFormat.KP_IMAGE_FORMAT_Y1CRY0CB, + # 'YCBCR422_Y1CBY0CR': kp.ImageFormat.KP_IMAGE_FORMAT_Y1CBY0CR, + # 'YCBCR422_CRY0CBY1': kp.ImageFormat.KP_IMAGE_FORMAT_CRY0CBY1, + # 'YCBCR422_CBY0CRY1': kp.ImageFormat.KP_IMAGE_FORMAT_CBY0CRY1, + # 'YCBCR422_Y0CRY1CB': kp.ImageFormat.KP_IMAGE_FORMAT_Y0CRY1CB, + # 'YCBCR422_Y0CBY1CR': kp.ImageFormat.KP_IMAGE_FORMAT_Y0CBY1CR, + } + + def __init__(self, port_id: list, scpu_fw_path: str, ncpu_fw_path: str, model_path: str, upload_fw: bool = False): + """ + Initialize the MultiDongle class. + :param port_id: List of USB port IDs for the same layer's devices. + :param scpu_fw_path: Path to the SCPU firmware file. + :param ncpu_fw_path: Path to the NCPU firmware file. + :param model_path: Path to the model file. + :param upload_fw: Flag to indicate whether to upload firmware. + """ + self.port_id = port_id + self.upload_fw = upload_fw + + # Check if the firmware is needed + if self.upload_fw: + self.scpu_fw_path = scpu_fw_path + self.ncpu_fw_path = ncpu_fw_path + + self.model_path = model_path + self.device_group = None + + # generic_inference_input_descriptor will be prepared in initialize + self.model_nef_descriptor = None + self.generic_inference_input_descriptor = None + # Queues for data + # Input queue for images to be sent + self._input_queue = queue.Queue() + # Output queue for received results + self._output_queue = queue.Queue() + + # Threading attributes + self._send_thread = None + self._receive_thread = None + self._stop_event = threading.Event() # Event to signal threads to stop + + self._inference_counter = 0 + + def initialize(self): + """ + Connect devices, upload firmware (if upload_fw is True), and upload model. + Must be called before start(). + """ + # Connect device and assign to self.device_group + try: + print('[Connect Device]') + self.device_group = kp.core.connect_devices(usb_port_ids=self.port_id) + print(' - Success') + except kp.ApiKPException as exception: + print('Error: connect device fail, port ID = \'{}\', error msg: [{}]'.format(self.port_id, str(exception))) + sys.exit(1) + + # setting timeout of the usb communication with the device + # print('[Set Device Timeout]') + # kp.core.set_timeout(device_group=self.device_group, milliseconds=5000) + # print(' - Success') + + if self.upload_fw: + try: + print('[Upload Firmware]') + kp.core.load_firmware_from_file(device_group=self.device_group, + scpu_fw_path=self.scpu_fw_path, + ncpu_fw_path=self.ncpu_fw_path) + print(' - Success') + except kp.ApiKPException as exception: + print('Error: upload firmware failed, error = \'{}\''.format(str(exception))) + sys.exit(1) + + # upload model to device + try: + print('[Upload Model]') + self.model_nef_descriptor = kp.core.load_model_from_file(device_group=self.device_group, + file_path=self.model_path) + print(' - Success') + except kp.ApiKPException as exception: + print('Error: upload model failed, error = \'{}\''.format(str(exception))) + sys.exit(1) + + # Extract model input dimensions automatically from model metadata + if self.model_nef_descriptor and self.model_nef_descriptor.models: + model = self.model_nef_descriptor.models[0] + if hasattr(model, 'input_nodes') and model.input_nodes: + input_node = model.input_nodes[0] + # From your JSON: "shape_npu": [1, 3, 128, 128] -> (width, height) + shape = input_node.tensor_shape_info.data.shape_npu + self.model_input_shape = (shape[3], shape[2]) # (width, height) + self.model_input_channels = shape[1] # 3 for RGB + print(f"Model input shape detected: {self.model_input_shape}, channels: {self.model_input_channels}") + else: + self.model_input_shape = (128, 128) # fallback + self.model_input_channels = 3 + print("Using default input shape (128, 128)") + else: + self.model_input_shape = (128, 128) + self.model_input_channels = 3 + print("Model info not available, using default shape") + + # Prepare generic inference input descriptor after model is loaded + if self.model_nef_descriptor: + self.generic_inference_input_descriptor = kp.GenericImageInferenceDescriptor( + model_id=self.model_nef_descriptor.models[0].id, + ) + else: + print("Warning: Could not get generic inference input descriptor from model.") + self.generic_inference_input_descriptor = None + + def preprocess_frame(self, frame: np.ndarray, target_format: str = 'BGR565') -> np.ndarray: + """ + Preprocess frame for inference + """ + resized_frame = cv2.resize(frame, self.model_input_shape) + + if target_format == 'BGR565': + return cv2.cvtColor(resized_frame, cv2.COLOR_BGR2BGR565) + elif target_format == 'RGB8888': + return cv2.cvtColor(resized_frame, cv2.COLOR_BGR2RGBA) + elif target_format == 'YUYV': + return cv2.cvtColor(resized_frame, cv2.COLOR_BGR2YUV_YUYV) + else: + return resized_frame # RAW8 or other formats + + def get_latest_inference_result(self, timeout: float = 0.01) -> Tuple[float, str]: + """ + Get the latest inference result + Returns: (probability, result_string) or (None, None) if no result + """ + output_descriptor = self.get_output(timeout=timeout) + if not output_descriptor: + return None, None + + # Process the output descriptor + if hasattr(output_descriptor, 'header') and \ + hasattr(output_descriptor.header, 'num_output_node') and \ + hasattr(output_descriptor.header, 'inference_number'): + + inf_node_output_list = [] + retrieval_successful = True + + for node_idx in range(output_descriptor.header.num_output_node): + try: + inference_float_node_output = kp.inference.generic_inference_retrieve_float_node( + node_idx=node_idx, + generic_raw_result=output_descriptor, + channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW + ) + inf_node_output_list.append(inference_float_node_output.ndarray.copy()) + except kp.ApiKPException as e: + retrieval_successful = False + break + except Exception as e: + retrieval_successful = False + break + + if retrieval_successful and inf_node_output_list: + # Process output nodes + if output_descriptor.header.num_output_node == 1: + raw_output_array = inf_node_output_list[0].flatten() + else: + concatenated_outputs = [arr.flatten() for arr in inf_node_output_list] + raw_output_array = np.concatenate(concatenated_outputs) if concatenated_outputs else np.array([]) + + if raw_output_array.size > 0: + probability = postprocess(raw_output_array) + result_str = "Fire" if probability > 0.5 else "No Fire" + return probability, result_str + + return None, None + + + # Modified _send_thread_func to get data from input queue + def _send_thread_func(self): + """Internal function run by the send thread, gets images from input queue.""" + print("Send thread started.") + while not self._stop_event.is_set(): + if self.generic_inference_input_descriptor is None: + # Wait for descriptor to be ready or stop + self._stop_event.wait(0.1) # Avoid busy waiting + continue + + try: + # Get image and format from the input queue + # Blocks until an item is available or stop event is set/timeout occurs + try: + # Use get with timeout or check stop event in a loop + # This pattern allows thread to check stop event while waiting on queue + item = self._input_queue.get(block=True, timeout=0.1) + # Check if this is our sentinel value + if item is None: + continue + + # Now safely unpack the tuple + image_data, image_format_enum = item + except queue.Empty: + # If queue is empty after timeout, check stop event and continue loop + continue + + # Configure and send the image + self._inference_counter += 1 # Increment counter for each image + self.generic_inference_input_descriptor.inference_number = self._inference_counter + self.generic_inference_input_descriptor.input_node_image_list = [kp.GenericInputNodeImage( + image=image_data, + image_format=image_format_enum, # Use the format from the queue + resize_mode=kp.ResizeMode.KP_RESIZE_ENABLE, + padding_mode=kp.PaddingMode.KP_PADDING_CORNER, + normalize_mode=kp.NormalizeMode.KP_NORMALIZE_KNERON + )] + + kp.inference.generic_image_inference_send(device_group=self.device_group, + generic_inference_input_descriptor=self.generic_inference_input_descriptor) + # print("Image sent.") # Optional: add log + # No need for sleep here usually, as queue.get is blocking + except kp.ApiKPException as exception: + print(f' - Error in send thread: inference send failed, error = {exception}') + self._stop_event.set() # Signal other thread to stop + except Exception as e: + print(f' - Unexpected error in send thread: {e}') + self._stop_event.set() + + print("Send thread stopped.") + + # _receive_thread_func remains the same + def _receive_thread_func(self): + """Internal function run by the receive thread, puts results into output queue.""" + print("Receive thread started.") + while not self._stop_event.is_set(): + try: + generic_inference_output_descriptor = kp.inference.generic_image_inference_receive(device_group=self.device_group) + self._output_queue.put(generic_inference_output_descriptor) + except kp.ApiKPException as exception: + if not self._stop_event.is_set(): # Avoid printing error if we are already stopping + print(f' - Error in receive thread: inference receive failed, error = {exception}') + self._stop_event.set() + except Exception as e: + print(f' - Unexpected error in receive thread: {e}') + self._stop_event.set() + + print("Receive thread stopped.") + + def start(self): + """ + Start the send and receive threads. + Must be called after initialize(). + """ + if self.device_group is None: + raise RuntimeError("MultiDongle not initialized. Call initialize() first.") + + if self._send_thread is None or not self._send_thread.is_alive(): + self._stop_event.clear() # Clear stop event for a new start + self._send_thread = threading.Thread(target=self._send_thread_func, daemon=True) + self._send_thread.start() + print("Send thread started.") + + if self._receive_thread is None or not self._receive_thread.is_alive(): + self._receive_thread = threading.Thread(target=self._receive_thread_func, daemon=True) + self._receive_thread.start() + print("Receive thread started.") + + def stop(self): + """Improved stop method with better cleanup""" + if self._stop_event.is_set(): + return # Already stopping + + print("Stopping threads...") + self._stop_event.set() + + # Clear queues to unblock threads + while not self._input_queue.empty(): + try: + self._input_queue.get_nowait() + except queue.Empty: + break + + # Signal send thread to wake up + self._input_queue.put(None) + + # Join threads with timeout + for thread, name in [(self._send_thread, "Send"), (self._receive_thread, "Receive")]: + if thread and thread.is_alive(): + thread.join(timeout=2.0) + if thread.is_alive(): + print(f"Warning: {name} thread didn't stop cleanly") + + def put_input(self, image: Union[str, np.ndarray], format: str, target_size: Tuple[int, int] = None): + """ + Put an image into the input queue with flexible preprocessing + """ + if isinstance(image, str): + image_data = cv2.imread(image) + if image_data is None: + raise FileNotFoundError(f"Image file not found at {image}") + if target_size: + image_data = cv2.resize(image_data, target_size) + elif isinstance(image, np.ndarray): + # Don't modify original array, make copy if needed + image_data = image.copy() if target_size is None else cv2.resize(image, target_size) + else: + raise ValueError("Image must be a file path (str) or a numpy array (ndarray).") + + if format in self._FORMAT_MAPPING: + image_format_enum = self._FORMAT_MAPPING[format] + else: + raise ValueError(f"Unsupported format: {format}") + + self._input_queue.put((image_data, image_format_enum)) + + def get_output(self, timeout: float = None): + """ + Get the next received data from the output queue. + This method is non-blocking by default unless a timeout is specified. + :param timeout: Time in seconds to wait for data. If None, it's non-blocking. + :return: Received data (e.g., kp.GenericInferenceOutputDescriptor) or None if no data available within timeout. + """ + try: + return self._output_queue.get(block=timeout is not None, timeout=timeout) + except queue.Empty: + return None + + def __del__(self): + """Ensure resources are released when the object is garbage collected.""" + self.stop() + if self.device_group: + try: + kp.core.disconnect_devices(device_group=self.device_group) + print("Device group disconnected in destructor.") + except Exception as e: + print(f"Error disconnecting device group in destructor: {e}") + +def postprocess(raw_model_output: list) -> float: + """ + Post-processes the raw model output. + Assumes the model output is a list/array where the first element is the desired probability. + """ + if raw_model_output and len(raw_model_output) > 0: + probability = raw_model_output[0] + return float(probability) + return 0.0 # Default or error value + +class WebcamInferenceRunner: + def __init__(self, multidongle: MultiDongle, image_format: str = 'BGR565'): + self.multidongle = multidongle + self.image_format = image_format + self.latest_probability = 0.0 + self.result_str = "No Fire" + + # Statistics tracking + self.processed_inference_count = 0 + self.inference_fps_start_time = None + self.display_fps_start_time = None + self.display_frame_counter = 0 + + def run(self, camera_id: int = 0): + cap = cv2.VideoCapture(camera_id) + if not cap.isOpened(): + raise RuntimeError("Cannot open webcam") + + try: + while True: + ret, frame = cap.read() + if not ret: + break + + # Track display FPS + if self.display_fps_start_time is None: + self.display_fps_start_time = time.time() + self.display_frame_counter += 1 + + # Preprocess and send frame + processed_frame = self.multidongle.preprocess_frame(frame, self.image_format) + self.multidongle.put_input(processed_frame, self.image_format) + + # Get inference result + prob, result = self.multidongle.get_latest_inference_result() + if prob is not None: + # Track inference FPS + if self.inference_fps_start_time is None: + self.inference_fps_start_time = time.time() + self.processed_inference_count += 1 + + self.latest_probability = prob + self.result_str = result + + # Display frame with results + self._display_results(frame) + + if cv2.waitKey(1) & 0xFF == ord('q'): + break + + finally: + # self._print_statistics() + cap.release() + cv2.destroyAllWindows() + + def _display_results(self, frame): + display_frame = frame.copy() + text_color = (0, 255, 0) if "Fire" in self.result_str else (0, 0, 255) + + # Display inference result + cv2.putText(display_frame, f"{self.result_str} (Prob: {self.latest_probability:.2f})", + (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, text_color, 2) + + # Calculate and display inference FPS + if self.inference_fps_start_time and self.processed_inference_count > 0: + elapsed_time = time.time() - self.inference_fps_start_time + if elapsed_time > 0: + inference_fps = self.processed_inference_count / elapsed_time + cv2.putText(display_frame, f"Inference FPS: {inference_fps:.2f}", + (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2) + + cv2.imshow('Fire Detection', display_frame) + + # def _print_statistics(self): + # """Print final statistics""" + # print(f"\n--- Summary ---") + # print(f"Total inferences processed: {self.processed_inference_count}") + + # if self.inference_fps_start_time and self.processed_inference_count > 0: + # elapsed = time.time() - self.inference_fps_start_time + # if elapsed > 0: + # avg_inference_fps = self.processed_inference_count / elapsed + # print(f"Average Inference FPS: {avg_inference_fps:.2f}") + + # if self.display_fps_start_time and self.display_frame_counter > 0: + # elapsed = time.time() - self.display_fps_start_time + # if elapsed > 0: + # avg_display_fps = self.display_frame_counter / elapsed + # print(f"Average Display FPS: {avg_display_fps:.2f}") + +if __name__ == "__main__": + PORT_IDS = [28, 32] + SCPU_FW = r'fw_scpu.bin' + NCPU_FW = r'fw_ncpu.bin' + MODEL_PATH = r'fire_detection_520.nef' + + try: + # Initialize inference engine + print("Initializing MultiDongle...") + multidongle = MultiDongle(PORT_IDS, SCPU_FW, NCPU_FW, MODEL_PATH, upload_fw=True) + multidongle.initialize() + multidongle.start() + + # Run using the new runner class + print("Starting webcam inference...") + runner = WebcamInferenceRunner(multidongle, 'BGR565') + runner.run() + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + finally: + if 'multidongle' in locals(): + multidongle.stop() \ No newline at end of file diff --git a/src/cluster4npu/test.py b/src/cluster4npu/test.py new file mode 100644 index 0000000..bf5682e --- /dev/null +++ b/src/cluster4npu/test.py @@ -0,0 +1,407 @@ +""" +InferencePipeline Usage Examples +================================ + +This file demonstrates how to use the InferencePipeline for various scenarios: +1. Single stage (equivalent to MultiDongle) +2. Two-stage cascade (detection -> classification) +3. Multi-stage complex pipeline +""" + +import cv2 +import numpy as np +import time +from InferencePipeline import ( + InferencePipeline, StageConfig, + create_feature_extractor_preprocessor, + create_result_aggregator_postprocessor +) +from Multidongle import PreProcessor, PostProcessor, WebcamSource, RTSPSource + +# ============================================================================= +# Example 1: Single Stage Pipeline (Basic Usage) +# ============================================================================= + +def example_single_stage(): + """Single stage pipeline - equivalent to using MultiDongle directly""" + print("=== Single Stage Pipeline Example ===") + + # Create stage configuration + stage_config = StageConfig( + stage_id="fire_detection", + port_ids=[28, 32], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="fire_detection_520.nef", + upload_fw=True, + max_queue_size=30 + # Note: No inter-stage processors needed for single stage + # MultiDongle will handle internal preprocessing/postprocessing + ) + + # Create pipeline with single stage + pipeline = InferencePipeline( + stage_configs=[stage_config], + pipeline_name="SingleStageFireDetection" + ) + + # Initialize and start + pipeline.initialize() + pipeline.start() + + # Process some data + data_source = WebcamSource(camera_id=0) + data_source.start() + + def handle_result(pipeline_data): + result = pipeline_data.stage_results.get("fire_detection", {}) + print(f"Fire Detection: {result.get('result', 'Unknown')} " + f"(Prob: {result.get('probability', 0.0):.3f})") + + def handle_error(pipeline_data): + print(f"❌ Error: {pipeline_data.stage_results}") + + pipeline.set_result_callback(handle_result) + pipeline.set_error_callback(handle_error) + + try: + print("🚀 Starting single stage pipeline...") + for i in range(100): # Process 100 frames + frame = data_source.get_frame() + if frame is not None: + success = pipeline.put_data(frame, timeout=1.0) + if not success: + print("Pipeline input queue full, dropping frame") + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + finally: + data_source.stop() + pipeline.stop() + print("Single stage pipeline test completed") + +# ============================================================================= +# Example 2: Two-Stage Cascade Pipeline +# ============================================================================= + +def example_two_stage_cascade(): + """Two-stage cascade: Object Detection -> Fire Classification""" + print("=== Two-Stage Cascade Pipeline Example ===") + + # Custom preprocessor for second stage + def roi_extraction_preprocess(frame, target_size): + """Extract ROI from detection results and prepare for classification""" + # This would normally extract bounding box from first stage results + # For demo, we'll just do center crop + h, w = frame.shape[:2] if len(frame.shape) == 3 else frame.shape + center_x, center_y = w // 2, h // 2 + crop_size = min(w, h) // 2 + + x1 = max(0, center_x - crop_size // 2) + y1 = max(0, center_y - crop_size // 2) + x2 = min(w, center_x + crop_size // 2) + y2 = min(h, center_y + crop_size // 2) + + if len(frame.shape) == 3: + cropped = frame[y1:y2, x1:x2] + else: + cropped = frame[y1:y2, x1:x2] + + return cv2.resize(cropped, target_size) + + # Custom postprocessor for combining results + def combine_detection_classification(raw_output, **kwargs): + """Combine detection and classification results""" + if raw_output.size > 0: + classification_prob = float(raw_output[0]) + + # Get detection result from metadata (would be passed from first stage) + detection_confidence = kwargs.get('detection_conf', 0.5) + + # Combined confidence + combined_prob = (classification_prob * 0.7) + (detection_confidence * 0.3) + + return { + 'combined_probability': combined_prob, + 'classification_prob': classification_prob, + 'detection_conf': detection_confidence, + 'result': 'Fire Detected' if combined_prob > 0.6 else 'No Fire', + 'confidence': 'High' if combined_prob > 0.8 else 'Medium' if combined_prob > 0.5 else 'Low' + } + return {'combined_probability': 0.0, 'result': 'No Fire', 'confidence': 'Low'} + + # Set up callbacks + def handle_cascade_result(pipeline_data): + """Handle results from cascade pipeline""" + detection_result = pipeline_data.stage_results.get("object_detection", {}) + classification_result = pipeline_data.stage_results.get("fire_classification", {}) + + print(f"Detection: {detection_result.get('result', 'Unknown')} " + f"(Prob: {detection_result.get('probability', 0.0):.3f})") + print(f"Classification: {classification_result.get('result', 'Unknown')} " + f"(Combined: {classification_result.get('combined_probability', 0.0):.3f})") + print(f"Processing Time: {pipeline_data.metadata.get('total_processing_time', 0.0):.3f}s") + print("-" * 50) + + def handle_pipeline_stats(stats): + """Handle pipeline statistics""" + print(f"\n📊 Pipeline Stats:") + print(f" Submitted: {stats['pipeline_input_submitted']}") + print(f" Completed: {stats['pipeline_completed']}") + print(f" Errors: {stats['pipeline_errors']}") + + for stage_stat in stats['stage_statistics']: + print(f" Stage {stage_stat['stage_id']}: " + f"Processed={stage_stat['processed_count']}, " + f"AvgTime={stage_stat['avg_processing_time']:.3f}s") + + # Stage 1: Object Detection + stage1_config = StageConfig( + stage_id="object_detection", + port_ids=[28, 30], # First set of dongles + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="object_detection_520.nef", + upload_fw=True, + max_queue_size=30 + ) + + # Stage 2: Fire Classification + stage2_config = StageConfig( + stage_id="fire_classification", + port_ids=[32, 34], # Second set of dongles + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="fire_classification_520.nef", + upload_fw=True, + max_queue_size=30, + # Inter-stage processing + input_preprocessor=PreProcessor(resize_fn=roi_extraction_preprocess), + output_postprocessor=PostProcessor(process_fn=combine_detection_classification) + ) + + # Create two-stage pipeline + pipeline = InferencePipeline( + stage_configs=[stage1_config, stage2_config], + pipeline_name="TwoStageCascade" + ) + + pipeline.set_result_callback(handle_cascade_result) + pipeline.set_stats_callback(handle_pipeline_stats) + + # Initialize and start + pipeline.initialize() + pipeline.start() + pipeline.start_stats_reporting(interval=10.0) # Stats every 10 seconds + + # Process data + # data_source = RTSPSource("rtsp://your-camera-url") + data_source = WebcamSource(0) + data_source.start() + + try: + frame_count = 0 + while frame_count < 200: + frame = data_source.get_frame() + if frame is not None: + if pipeline.put_data(frame, timeout=1.0): + frame_count += 1 + else: + print("Pipeline input queue full, dropping frame") + time.sleep(0.05) + except KeyboardInterrupt: + print("\nStopping cascade pipeline...") + finally: + data_source.stop() + pipeline.stop() + +# ============================================================================= +# Example 3: Complex Multi-Stage Pipeline +# ============================================================================= + +def example_complex_pipeline(): + """Complex multi-stage pipeline with feature extraction and fusion""" + print("=== Complex Multi-Stage Pipeline Example ===") + + # Custom processors for different stages + def edge_detection_preprocess(frame, target_size): + """Extract edge features""" + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + edges = cv2.Canny(gray, 50, 150) + edges_3ch = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR) + return cv2.resize(edges_3ch, target_size) + + def thermal_simulation_preprocess(frame, target_size): + """Simulate thermal-like processing""" + # Convert to HSV and extract V channel as pseudo-thermal + hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) + thermal_like = hsv[:, :, 2] # Value channel + thermal_3ch = cv2.cvtColor(thermal_like, cv2.COLOR_GRAY2BGR) + return cv2.resize(thermal_3ch, target_size) + + def fusion_postprocess(raw_output, **kwargs): + """Fuse results from multiple modalities""" + if raw_output.size > 0: + current_prob = float(raw_output[0]) + + # This would get previous stage results from pipeline metadata + # For demo, we'll simulate + rgb_confidence = kwargs.get('rgb_conf', 0.5) + edge_confidence = kwargs.get('edge_conf', 0.5) + + # Weighted fusion + fused_prob = (current_prob * 0.5) + (rgb_confidence * 0.3) + (edge_confidence * 0.2) + + return { + 'fused_probability': fused_prob, + 'individual_probs': { + 'thermal': current_prob, + 'rgb': rgb_confidence, + 'edge': edge_confidence + }, + 'result': 'Fire Detected' if fused_prob > 0.6 else 'No Fire', + 'confidence': 'Very High' if fused_prob > 0.9 else 'High' if fused_prob > 0.7 else 'Medium' if fused_prob > 0.5 else 'Low' + } + return {'fused_probability': 0.0, 'result': 'No Fire', 'confidence': 'Low'} + + # Stage 1: RGB Analysis + rgb_stage = StageConfig( + stage_id="rgb_analysis", + port_ids=[28, 30], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="rgb_fire_detection_520.nef", + upload_fw=True + ) + + # Stage 2: Edge Feature Analysis + edge_stage = StageConfig( + stage_id="edge_analysis", + port_ids=[32, 34], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="edge_fire_detection_520.nef", + upload_fw=True, + input_preprocessor=PreProcessor(resize_fn=edge_detection_preprocess) + ) + + # Stage 3: Thermal-like Analysis + thermal_stage = StageConfig( + stage_id="thermal_analysis", + port_ids=[36, 38], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="thermal_fire_detection_520.nef", + upload_fw=True, + input_preprocessor=PreProcessor(resize_fn=thermal_simulation_preprocess) + ) + + # Stage 4: Fusion + fusion_stage = StageConfig( + stage_id="result_fusion", + port_ids=[40, 42], + scpu_fw_path="fw_scpu.bin", + ncpu_fw_path="fw_ncpu.bin", + model_path="fusion_520.nef", + upload_fw=True, + output_postprocessor=PostProcessor(process_fn=fusion_postprocess) + ) + + # Create complex pipeline + pipeline = InferencePipeline( + stage_configs=[rgb_stage, edge_stage, thermal_stage, fusion_stage], + pipeline_name="ComplexMultiModalPipeline" + ) + + # Advanced result handling + def handle_complex_result(pipeline_data): + """Handle complex pipeline results""" + print(f"\n🔥 Multi-Modal Fire Detection Results:") + print(f" Pipeline ID: {pipeline_data.pipeline_id}") + + for stage_id, result in pipeline_data.stage_results.items(): + if 'probability' in result: + print(f" {stage_id}: {result.get('result', 'Unknown')} " + f"(Prob: {result.get('probability', 0.0):.3f})") + + # Final fused result + if 'result_fusion' in pipeline_data.stage_results: + fusion_result = pipeline_data.stage_results['result_fusion'] + print(f" 🎯 FINAL: {fusion_result.get('result', 'Unknown')} " + f"(Fused: {fusion_result.get('fused_probability', 0.0):.3f})") + print(f" Confidence: {fusion_result.get('confidence', 'Unknown')}") + + print(f" Total Processing Time: {pipeline_data.metadata.get('total_processing_time', 0.0):.3f}s") + print("=" * 60) + + def handle_error(pipeline_data): + """Handle pipeline errors""" + print(f"❌ Pipeline Error for {pipeline_data.pipeline_id}") + for stage_id, result in pipeline_data.stage_results.items(): + if 'error' in result: + print(f" Stage {stage_id} error: {result['error']}") + + pipeline.set_result_callback(handle_complex_result) + pipeline.set_error_callback(handle_error) + + # Initialize and start + try: + pipeline.initialize() + pipeline.start() + + # Simulate data input + data_source = WebcamSource(camera_id=0) + data_source.start() + + print("🚀 Complex pipeline started. Processing frames...") + + frame_count = 0 + start_time = time.time() + + while frame_count < 50: # Process 50 frames for demo + frame = data_source.get_frame() + if frame is not None: + if pipeline.put_data(frame): + frame_count += 1 + if frame_count % 10 == 0: + elapsed = time.time() - start_time + fps = frame_count / elapsed + print(f"📈 Processed {frame_count} frames, Pipeline FPS: {fps:.2f}") + time.sleep(0.1) + + except Exception as e: + print(f"Error in complex pipeline: {e}") + finally: + data_source.stop() + pipeline.stop() + + # Final statistics + final_stats = pipeline.get_pipeline_statistics() + print(f"\n📊 Final Pipeline Statistics:") + print(f" Total Input: {final_stats['pipeline_input_submitted']}") + print(f" Completed: {final_stats['pipeline_completed']}") + print(f" Success Rate: {final_stats['pipeline_completed']/max(final_stats['pipeline_input_submitted'], 1)*100:.1f}%") + +# ============================================================================= +# Main Function - Run Examples +# ============================================================================= + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="InferencePipeline Examples") + parser.add_argument("--example", choices=["single", "cascade", "complex"], + default="single", help="Which example to run") + args = parser.parse_args() + + if args.example == "single": + example_single_stage() + elif args.example == "cascade": + example_two_stage_cascade() + elif args.example == "complex": + example_complex_pipeline() + else: + print("Available examples:") + print(" python pipeline_example.py --example single") + print(" python pipeline_example.py --example cascade") + print(" python pipeline_example.py --example complex") \ No newline at end of file diff --git a/test_ui.py b/test_ui.py new file mode 100644 index 0000000..5eed4b1 --- /dev/null +++ b/test_ui.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Simple test script to verify UI functionality +""" + +import sys +import os + +# Add the current directory to the path +sys.path.insert(0, os.path.dirname(__file__)) + +from PyQt5.QtWidgets import QApplication +from UI import DashboardLogin + +def main(): + app = QApplication(sys.argv) + + # Create and show the dashboard + dashboard = DashboardLogin() + dashboard.show() + + print("✅ UI Application Started Successfully!") + print("📋 Available buttons on main screen:") + print(" 1. 🚀 Create New Pipeline") + print(" 2. 📁 Open Existing Pipeline") + print(" 3. ⚙️ Configure Stages & Deploy") + print() + print("🎯 Click the third button 'Configure Stages & Deploy' to test the new workflow!") + print(" This will open the Stage Configuration dialog with:") + print(" • Dongle allocation controls") + print(" • Performance estimation") + print(" • Save & Deploy functionality") + print() + print("Press Ctrl+C or close the window to exit") + + # Run the application + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ui_config.py b/ui_config.py new file mode 100644 index 0000000..792c939 --- /dev/null +++ b/ui_config.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +""" +UI Configuration and Integration Settings +========================================= + +This module provides configuration settings and helper functions for integrating +the UI application with cluster4npu tools. +""" + +import os +import json +from typing import Dict, List, Any, Optional +from dataclasses import dataclass, asdict + + +@dataclass +class UISettings: + """UI application settings""" + theme: str = "harmonious_dark" + auto_save_interval: int = 300 # seconds + max_recent_files: int = 10 + default_dongle_count: int = 16 + default_fw_paths: Dict[str, str] = None + + def __post_init__(self): + if self.default_fw_paths is None: + self.default_fw_paths = { + "scpu": "fw_scpu.bin", + "ncpu": "fw_ncpu.bin" + } + + +@dataclass +class ClusterConfig: + """Cluster hardware configuration""" + available_dongles: int = 16 + dongle_series: str = "KL520" + port_range_start: int = 28 + port_range_end: int = 60 + power_limit_watts: int = 200 + cooling_type: str = "standard" + + +class UIIntegration: + """Integration layer between UI and cluster4npu tools""" + + def __init__(self, config_path: Optional[str] = None): + self.config_path = config_path or os.path.expanduser("~/.cluster4npu_ui_config.json") + self.ui_settings = UISettings() + self.cluster_config = ClusterConfig() + self.load_config() + + def load_config(self): + """Load configuration from file""" + try: + if os.path.exists(self.config_path): + with open(self.config_path, 'r') as f: + data = json.load(f) + + if 'ui_settings' in data: + self.ui_settings = UISettings(**data['ui_settings']) + if 'cluster_config' in data: + self.cluster_config = ClusterConfig(**data['cluster_config']) + + except Exception as e: + print(f"Warning: Could not load UI config: {e}") + + def save_config(self): + """Save configuration to file""" + try: + data = { + 'ui_settings': asdict(self.ui_settings), + 'cluster_config': asdict(self.cluster_config) + } + + with open(self.config_path, 'w') as f: + json.dump(data, f, indent=2) + + except Exception as e: + print(f"Warning: Could not save UI config: {e}") + + def get_available_ports(self) -> List[int]: + """Get list of available USB ports""" + return list(range( + self.cluster_config.port_range_start, + self.cluster_config.port_range_end + 1, + 2 # Even numbers only for dongles + )) + + def validate_stage_config(self, stage_config: Dict[str, Any]) -> Dict[str, Any]: + """ + Validate and normalize a stage configuration from UI + + Args: + stage_config: Raw stage configuration from UI + + Returns: + Validated and normalized configuration + """ + # Ensure required fields + normalized = { + 'name': stage_config.get('name', 'Unnamed Stage'), + 'dongles': max(1, min(stage_config.get('dongles', 2), self.cluster_config.available_dongles)), + 'port_ids': stage_config.get('port_ids', 'auto'), + 'model_path': stage_config.get('model_path', ''), + } + + # Auto-assign ports if needed + if normalized['port_ids'] == 'auto': + available_ports = self.get_available_ports() + dongles_needed = normalized['dongles'] + normalized['port_ids'] = ','.join(map(str, available_ports[:dongles_needed])) + + # Validate model path + if normalized['model_path'] and not os.path.exists(normalized['model_path']): + print(f"Warning: Model file not found: {normalized['model_path']}") + + return normalized + + def convert_ui_to_inference_config(self, ui_stages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Convert UI stage configurations to InferencePipeline StageConfig format + + Args: + ui_stages: List of stage configurations from UI + + Returns: + List of configurations ready for InferencePipeline + """ + inference_configs = [] + + for stage in ui_stages: + validated = self.validate_stage_config(stage) + + # Parse port IDs + if isinstance(validated['port_ids'], str): + port_ids = [int(p.strip()) for p in validated['port_ids'].split(',') if p.strip()] + else: + port_ids = validated['port_ids'] + + config = { + 'stage_id': validated['name'].lower().replace(' ', '_').replace('-', '_'), + 'port_ids': port_ids, + 'scpu_fw_path': self.ui_settings.default_fw_paths['scpu'], + 'ncpu_fw_path': self.ui_settings.default_fw_paths['ncpu'], + 'model_path': validated['model_path'] or f"default_{len(inference_configs)}.nef", + 'upload_fw': True, + 'max_queue_size': 50 + } + + inference_configs.append(config) + + return inference_configs + + def estimate_performance(self, ui_stages: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Estimate performance metrics for given stage configurations + + Args: + ui_stages: List of stage configurations from UI + + Returns: + Performance metrics dictionary + """ + total_dongles = sum(stage.get('dongles', 2) for stage in ui_stages) + + # Performance estimation based on dongle series + fps_per_dongle = { + 'KL520': 30, + 'KL720': 45, + 'KL1080': 60 + }.get(self.cluster_config.dongle_series, 30) + + stage_fps = [] + stage_latencies = [] + + for stage in ui_stages: + dongles = stage.get('dongles', 2) + stage_fps_val = dongles * fps_per_dongle + stage_latency = 1000 / stage_fps_val # ms + + stage_fps.append(stage_fps_val) + stage_latencies.append(stage_latency) + + # Pipeline metrics + pipeline_fps = min(stage_fps) if stage_fps else 0 + total_latency = sum(stage_latencies) + + # Resource utilization + utilization = (total_dongles / self.cluster_config.available_dongles) * 100 + + # Power estimation (simplified) + estimated_power = total_dongles * 5 # 5W per dongle + + return { + 'total_dongles': total_dongles, + 'available_dongles': self.cluster_config.available_dongles, + 'utilization_percent': utilization, + 'pipeline_fps': pipeline_fps, + 'total_latency': total_latency, + 'stage_fps': stage_fps, + 'stage_latencies': stage_latencies, + 'estimated_power_watts': estimated_power, + 'power_limit_watts': self.cluster_config.power_limit_watts, + 'within_power_budget': estimated_power <= self.cluster_config.power_limit_watts + } + + def generate_deployment_script(self, ui_stages: List[Dict[str, Any]], + script_format: str = "python") -> str: + """ + Generate deployment script from UI configurations + + Args: + ui_stages: List of stage configurations from UI + script_format: Format for the script ("python", "json", "yaml") + + Returns: + Generated script content + """ + inference_configs = self.convert_ui_to_inference_config(ui_stages) + + if script_format == "python": + return self._generate_python_script(inference_configs) + elif script_format == "json": + return json.dumps({ + "pipeline_name": "UI_Generated_Pipeline", + "stages": inference_configs, + "ui_settings": asdict(self.ui_settings), + "cluster_config": asdict(self.cluster_config) + }, indent=2) + elif script_format == "yaml": + return self._generate_yaml_script(inference_configs) + else: + raise ValueError(f"Unsupported script format: {script_format}") + + def _generate_python_script(self, inference_configs: List[Dict[str, Any]]) -> str: + """Generate Python deployment script""" + script = '''#!/usr/bin/env python3 +""" +Generated Deployment Script +Created by cluster4npu UI +""" + +import sys +import os +import time +sys.path.append(os.path.join(os.path.dirname(__file__), 'src')) + +from src.cluster4npu.InferencePipeline import InferencePipeline, StageConfig + +def create_pipeline(): + """Create and configure the inference pipeline""" + stage_configs = [ +''' + + for config in inference_configs: + script += f''' StageConfig( + stage_id="{config['stage_id']}", + port_ids={config['port_ids']}, + scpu_fw_path="{config['scpu_fw_path']}", + ncpu_fw_path="{config['ncpu_fw_path']}", + model_path="{config['model_path']}", + upload_fw={config['upload_fw']}, + max_queue_size={config['max_queue_size']} + ), +''' + + script += ''' ] + + return InferencePipeline(stage_configs, pipeline_name="UI_Generated_Pipeline") + +def main(): + """Main execution function""" + print("🚀 Starting UI-generated pipeline...") + + pipeline = create_pipeline() + + try: + print("⚡ Initializing pipeline...") + pipeline.initialize() + + print("▶️ Starting pipeline...") + pipeline.start() + + # Set up callbacks + def handle_results(pipeline_data): + print(f"📊 Results: {pipeline_data.stage_results}") + + def handle_errors(pipeline_data): + print(f"❌ Error: {pipeline_data.stage_results}") + + pipeline.set_result_callback(handle_results) + pipeline.set_error_callback(handle_errors) + + print("✅ Pipeline running. Press Ctrl+C to stop.") + + # Run until interrupted + while True: + time.sleep(1) + + except KeyboardInterrupt: + print("\\n🛑 Stopping pipeline...") + except Exception as e: + print(f"❌ Pipeline error: {e}") + finally: + pipeline.stop() + print("✅ Pipeline stopped.") + +if __name__ == "__main__": + main() +''' + return script + + def _generate_yaml_script(self, inference_configs: List[Dict[str, Any]]) -> str: + """Generate YAML configuration""" + yaml_content = '''# cluster4npu Pipeline Configuration +# Generated by UI Application + +pipeline: + name: "UI_Generated_Pipeline" + +stages: +''' + + for config in inference_configs: + yaml_content += f''' - stage_id: "{config['stage_id']}" + port_ids: {config['port_ids']} + scpu_fw_path: "{config['scpu_fw_path']}" + ncpu_fw_path: "{config['ncpu_fw_path']}" + model_path: "{config['model_path']}" + upload_fw: {str(config['upload_fw']).lower()} + max_queue_size: {config['max_queue_size']} + +''' + + yaml_content += f''' +# Cluster Configuration +cluster: + available_dongles: {self.cluster_config.available_dongles} + dongle_series: "{self.cluster_config.dongle_series}" + power_limit_watts: {self.cluster_config.power_limit_watts} + +# UI Settings +ui: + theme: "{self.ui_settings.theme}" + auto_save_interval: {self.ui_settings.auto_save_interval} +''' + + return yaml_content + + +# Global integration instance +ui_integration = UIIntegration() + + +def get_integration() -> UIIntegration: + """Get the global UI integration instance""" + return ui_integration + + +# Convenience functions for UI components +def validate_stage_configs(ui_stages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Validate UI stage configurations""" + return [ui_integration.validate_stage_config(stage) for stage in ui_stages] + + +def estimate_pipeline_performance(ui_stages: List[Dict[str, Any]]) -> Dict[str, Any]: + """Estimate performance for UI stage configurations""" + return ui_integration.estimate_performance(ui_stages) + + +def export_pipeline_config(ui_stages: List[Dict[str, Any]], format_type: str = "python") -> str: + """Export UI configurations to deployment scripts""" + return ui_integration.generate_deployment_script(ui_stages, format_type) + + +def get_available_ports() -> List[int]: + """Get list of available dongle ports""" + return ui_integration.get_available_ports() + + +def save_ui_settings(): + """Save current UI settings""" + ui_integration.save_config() + + +if __name__ == "__main__": + # Test the integration + print("🧪 Testing UI Integration...") + + # Sample UI stage configurations + test_stages = [ + {'name': 'Input Stage', 'dongles': 2, 'port_ids': 'auto', 'model_path': 'input.nef'}, + {'name': 'Processing Stage', 'dongles': 4, 'port_ids': '32,34,36,38', 'model_path': 'process.nef'}, + {'name': 'Output Stage', 'dongles': 2, 'port_ids': 'auto', 'model_path': 'output.nef'} + ] + + # Test validation + validated = validate_stage_configs(test_stages) + print(f"✅ Validated {len(validated)} stages") + + # Test performance estimation + performance = estimate_pipeline_performance(test_stages) + print(f"📊 Pipeline FPS: {performance['pipeline_fps']:.1f}") + print(f"📊 Total Latency: {performance['total_latency']:.1f} ms") + print(f"📊 Power Usage: {performance['estimated_power_watts']} W") + + # Test script generation + python_script = export_pipeline_config(test_stages, "python") + print(f"🐍 Generated Python script ({len(python_script)} chars)") + + json_config = export_pipeline_config(test_stages, "json") + print(f"📄 Generated JSON config ({len(json_config)} chars)") + + print("✅ Integration test completed!") \ No newline at end of file diff --git a/ui_integration_example.py b/ui_integration_example.py new file mode 100644 index 0000000..e84d9d3 --- /dev/null +++ b/ui_integration_example.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +""" +UI Integration Example for cluster4npu Tools +============================================ + +This file demonstrates how to integrate the UI application with the core cluster4npu tools: +- InferencePipeline +- Multidongle +- StageConfig + +Usage: + python ui_integration_example.py + +This example shows how stage configurations from the UI can be converted +to actual InferencePipeline configurations and executed. +""" + +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), 'src')) + +try: + from src.cluster4npu.InferencePipeline import InferencePipeline, StageConfig + from src.cluster4npu.Multidongle import PreProcessor, PostProcessor + CLUSTER4NPU_AVAILABLE = True +except ImportError: + print("cluster4npu modules not available - running in simulation mode") + CLUSTER4NPU_AVAILABLE = False + + # Mock classes for demonstration + class StageConfig: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + class InferencePipeline: + def __init__(self, stages, **kwargs): + self.stages = stages + + def initialize(self): + print("Mock: Initializing pipeline...") + + def start(self): + print("Mock: Starting pipeline...") + + def stop(self): + print("Mock: Stopping pipeline...") + + +def convert_ui_config_to_pipeline(stage_configs): + """ + Convert UI stage configurations to InferencePipeline configurations + + Args: + stage_configs: List of stage configurations from UI + + Returns: + List of StageConfig objects for InferencePipeline + """ + pipeline_stages = [] + + for config in stage_configs: + # Parse port IDs + if config['port_ids'] == 'auto': + # Auto-assign ports based on stage index + stage_idx = stage_configs.index(config) + port_ids = [28 + (stage_idx * 2), 30 + (stage_idx * 2)] + else: + # Parse comma-separated port IDs + port_ids = [int(p.strip()) for p in config['port_ids'].split(',') if p.strip()] + + # Create StageConfig + stage_config = StageConfig( + stage_id=config['name'].lower().replace(' ', '_'), + port_ids=port_ids, + scpu_fw_path="fw_scpu.bin", # Default firmware paths + ncpu_fw_path="fw_ncpu.bin", + model_path=config['model_path'] or "default_model.nef", + upload_fw=True, + max_queue_size=50 + ) + + pipeline_stages.append(stage_config) + + print(f"✓ Created stage: {config['name']}") + print(f" - Dongles: {config['dongles']}") + print(f" - Ports: {port_ids}") + print(f" - Model: {config['model_path'] or 'default_model.nef'}") + print() + + return pipeline_stages + + +def create_sample_ui_config(): + """Create a sample UI configuration for testing""" + return [ + { + 'name': 'Input Processing', + 'dongles': 2, + 'port_ids': '28,30', + 'model_path': 'models/input_processor.nef' + }, + { + 'name': 'Main Inference', + 'dongles': 4, + 'port_ids': '32,34,36,38', + 'model_path': 'models/main_model.nef' + }, + { + 'name': 'Post Processing', + 'dongles': 2, + 'port_ids': 'auto', + 'model_path': 'models/post_processor.nef' + } + ] + + +def run_pipeline_from_ui_config(stage_configs): + """ + Run an InferencePipeline based on UI stage configurations + + Args: + stage_configs: List of stage configurations from UI + """ + print("🚀 Converting UI Configuration to Pipeline...") + print("=" * 50) + + # Convert UI config to pipeline stages + pipeline_stages = convert_ui_config_to_pipeline(stage_configs) + + print(f"📊 Created {len(pipeline_stages)} pipeline stages") + print() + + # Create and run pipeline + try: + print("🔧 Initializing InferencePipeline...") + pipeline = InferencePipeline( + stage_configs=pipeline_stages, + pipeline_name="UI_Generated_Pipeline" + ) + + if CLUSTER4NPU_AVAILABLE: + print("⚡ Starting pipeline (real hardware)...") + pipeline.initialize() + pipeline.start() + + # Set up result callback + def handle_results(pipeline_data): + print(f"📊 Pipeline Results: {pipeline_data.stage_results}") + + pipeline.set_result_callback(handle_results) + + print("✅ Pipeline running! Press Ctrl+C to stop...") + + try: + import time + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\n🛑 Stopping pipeline...") + pipeline.stop() + print("✅ Pipeline stopped successfully") + + else: + print("🎭 Running in simulation mode...") + pipeline.initialize() + pipeline.start() + + # Simulate some processing + import time + for i in range(5): + print(f"⏳ Processing frame {i+1}...") + time.sleep(1) + + pipeline.stop() + print("✅ Simulation complete") + + except Exception as e: + print(f"❌ Error running pipeline: {e}") + return False + + return True + + +def calculate_performance_metrics(stage_configs): + """ + Calculate performance metrics based on stage configurations + + Args: + stage_configs: List of stage configurations from UI + + Returns: + Dict with performance metrics + """ + total_dongles = sum(config['dongles'] for config in stage_configs) + + # Simple performance estimation + base_fps_per_dongle = 30 + stage_fps = [] + + for config in stage_configs: + stage_fps.append(config['dongles'] * base_fps_per_dongle) + + # Pipeline FPS is limited by slowest stage + pipeline_fps = min(stage_fps) if stage_fps else 0 + + # Total latency is sum of stage latencies + total_latency = sum(1000 / fps for fps in stage_fps) # ms + + return { + 'total_dongles': total_dongles, + 'pipeline_fps': pipeline_fps, + 'total_latency': total_latency, + 'stage_fps': stage_fps, + 'bottleneck_stage': stage_configs[stage_fps.index(min(stage_fps))]['name'] if stage_fps else None + } + + +def export_configuration(stage_configs, format_type="python"): + """ + Export stage configuration to various formats + + Args: + stage_configs: List of stage configurations from UI + format_type: Export format ("python", "json", "yaml") + """ + if format_type == "python": + return generate_python_script(stage_configs) + elif format_type == "json": + import json + return json.dumps(stage_configs, indent=2) + elif format_type == "yaml": + yaml_content = "# Pipeline Configuration\nstages:\n" + for config in stage_configs: + yaml_content += f" - name: {config['name']}\n" + yaml_content += f" dongles: {config['dongles']}\n" + yaml_content += f" port_ids: '{config['port_ids']}'\n" + yaml_content += f" model_path: '{config['model_path']}'\n" + return yaml_content + else: + raise ValueError(f"Unsupported format: {format_type}") + + +def generate_python_script(stage_configs): + """Generate a standalone Python script from stage configurations""" + script = '''#!/usr/bin/env python3 +""" +Generated Pipeline Script +Auto-generated from UI configuration +""" + +from src.cluster4npu.InferencePipeline import InferencePipeline, StageConfig +import time + +def main(): + # Stage configurations generated from UI + stage_configs = [ +''' + + for config in 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['model_path']}", + upload_fw=True, + max_queue_size=50 + ), +''' + + script += ''' ] + + # Create and run pipeline + pipeline = InferencePipeline(stage_configs, pipeline_name="GeneratedPipeline") + + try: + print("Initializing pipeline...") + pipeline.initialize() + + print("Starting pipeline...") + pipeline.start() + + def handle_results(pipeline_data): + print(f"Results: {pipeline_data.stage_results}") + + pipeline.set_result_callback(handle_results) + + print("Pipeline running. Press Ctrl+C to stop.") + while True: + time.sleep(1) + + except KeyboardInterrupt: + print("Stopping pipeline...") + finally: + pipeline.stop() + print("Pipeline stopped.") + +if __name__ == "__main__": + main() +''' + + return script + + +def main(): + """Main function demonstrating UI integration""" + print("🎯 cluster4npu UI Integration Example") + print("=" * 40) + print() + + # Create sample configuration (as would come from UI) + stage_configs = create_sample_ui_config() + + print("📋 Sample UI Configuration:") + for i, config in enumerate(stage_configs, 1): + print(f" {i}. {config['name']}: {config['dongles']} dongles, ports {config['port_ids']}") + print() + + # Calculate performance metrics + metrics = calculate_performance_metrics(stage_configs) + print("📊 Performance Metrics:") + print(f" • Total Dongles: {metrics['total_dongles']}") + print(f" • Pipeline FPS: {metrics['pipeline_fps']:.1f}") + print(f" • Total Latency: {metrics['total_latency']:.1f} ms") + print(f" • Bottleneck Stage: {metrics['bottleneck_stage']}") + print() + + # Export configuration + print("📄 Export Examples:") + print("\n--- Python Script ---") + python_script = export_configuration(stage_configs, "python") + print(python_script[:300] + "...") + + print("\n--- JSON Config ---") + json_config = export_configuration(stage_configs, "json") + print(json_config) + + print("\n--- YAML Config ---") + yaml_config = export_configuration(stage_configs, "yaml") + print(yaml_config) + + # Ask user if they want to run the pipeline + try: + user_input = input("\n🚀 Run the pipeline? (y/N): ").strip().lower() + if user_input == 'y': + success = run_pipeline_from_ui_config(stage_configs) + if success: + print("✅ Integration example completed successfully!") + else: + print("❌ Integration example failed.") + else: + print("✅ Integration example completed (pipeline not run).") + except (KeyboardInterrupt, EOFError): + print("\n✅ Integration example completed.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/uv.lock b/uv.lock index 93fdae2..e9debcf 100644 --- a/uv.lock +++ b/uv.lock @@ -12,14 +12,34 @@ name = "cluster4npu" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "nodegraphqt" }, { name = "numpy" }, + { name = "odengraphqt" }, { name = "opencv-python" }, + { name = "pyqt5" }, + { name = "qt-py" }, ] [package.metadata] requires-dist = [ + { name = "nodegraphqt", specifier = ">=0.6.38" }, { name = "numpy", specifier = ">=2.2.6" }, + { name = "odengraphqt", specifier = ">=0.7.4" }, { name = "opencv-python", specifier = ">=4.11.0.86" }, + { name = "pyqt5", specifier = ">=5.15.11" }, + { name = "qt-py", specifier = ">=1.4.6" }, +] + +[[package]] +name = "nodegraphqt" +version = "0.6.38" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "qt-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/b00e0c38a705890a6a121fdc25cc8d1590464a5556f2a912acb617b00cf7/nodegraphqt-0.6.38.tar.gz", hash = "sha256:918fb5e35622804c76095ff254bf7552c87628dca72ebc0adb0bcbf703a19a73", size = 111150, upload-time = "2024-10-07T01:55:05.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9a/06d9a6785d46f1b9f4873f0b125a1114e239224b857644626addba2aafe6/NodeGraphQt-0.6.38-py3-none-any.whl", hash = "sha256:de79eee416fbce80e1787e5ece526a840e47eb8bbc9dc913629944f6a23951e3", size = 135105, upload-time = "2024-10-07T01:55:03.754Z" }, ] [[package]] @@ -60,6 +80,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, ] +[[package]] +name = "odengraphqt" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6" }, + { name = "qtpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/47/d4656eb0042a1a7d51c6f969c6a93a693c24b5682dc05fd1bb8eb3f87187/OdenGraphQt-0.7.4.tar.gz", hash = "sha256:91a8238620e3616a680d15832db44c412f96563472f0bd5296da2ff6460a06fe", size = 119687, upload-time = "2024-04-02T10:09:45.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/24/891913458f9909cd2a7aab55de2ca0143c1f1ad7d0d6deca65a58542412c/OdenGraphQt-0.7.4-py3-none-any.whl", hash = "sha256:999a355536e06eaa17cb0d3fa754927b497a945f5b7e4e21e46541af06dc21cb", size = 142848, upload-time = "2024-04-02T10:09:43.939Z" }, +] + [[package]] name = "opencv-python" version = "4.11.0.86" @@ -76,3 +110,153 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337, upload-time = "2025-01-16T13:52:13.549Z" }, { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" }, ] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pyqt5" +version = "5.15.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt5-qt5" }, + { name = "pyqt5-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/07/c9ed0bd428df6f87183fca565a79fee19fa7c88c7f00a7f011ab4379e77a/PyQt5-5.15.11.tar.gz", hash = "sha256:fda45743ebb4a27b4b1a51c6d8ef455c4c1b5d610c90d2934c7802b5c1557c52", size = 3216775, upload-time = "2024-07-19T08:39:57.756Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/64/42ec1b0bd72d87f87bde6ceb6869f444d91a2d601f2e67cd05febc0346a1/PyQt5-5.15.11-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b03dd9380bb13c804f0bdb0f4956067f281785b5e12303d529f0462f9afdc2", size = 6579776, upload-time = "2024-07-19T08:39:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/49/f5/3fb696f4683ea45d68b7e77302eff173493ac81e43d63adb60fa760b9f91/PyQt5-5.15.11-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:6cd75628f6e732b1ffcfe709ab833a0716c0445d7aec8046a48d5843352becb6", size = 7016415, upload-time = "2024-07-19T08:39:32.977Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8c/4065950f9d013c4b2e588fe33cf04e564c2322842d84dbcbce5ba1dc28b0/PyQt5-5.15.11-cp38-abi3-manylinux_2_17_x86_64.whl", hash = "sha256:cd672a6738d1ae33ef7d9efa8e6cb0a1525ecf53ec86da80a9e1b6ec38c8d0f1", size = 8188103, upload-time = "2024-07-19T08:39:40.561Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/ae5a5b4f9b826b29ea4be841b2f2d951bcf5ae1d802f3732b145b57c5355/PyQt5-5.15.11-cp38-abi3-win32.whl", hash = "sha256:76be0322ceda5deecd1708a8d628e698089a1cea80d1a49d242a6d579a40babd", size = 5433308, upload-time = "2024-07-19T08:39:46.932Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/68eb9f3d19ce65df01b6c7b7a577ad3bbc9ab3a5dd3491a4756e71838ec9/PyQt5-5.15.11-cp38-abi3-win_amd64.whl", hash = "sha256:bdde598a3bb95022131a5c9ea62e0a96bd6fb28932cc1619fd7ba211531b7517", size = 6865864, upload-time = "2024-07-19T08:39:53.572Z" }, +] + +[[package]] +name = "pyqt5-qt5" +version = "5.15.17" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f9/accb06e76e23fb23053d48cc24fd78dec6ed14cb4d5cbadb0fd4a0c1b02e/PyQt5_Qt5-5.15.17-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8b8094108e748b4bbd315737cfed81291d2d228de43278f0b8bd7d2b808d2b9", size = 39972275, upload-time = "2025-05-24T11:15:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/87/1a/e1601ad6934cc489b8f1e967494f23958465cf1943712f054c5a306e9029/PyQt5_Qt5-5.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b68628f9b8261156f91d2f72ebc8dfb28697c4b83549245d9a68195bd2d74f0c", size = 37135109, upload-time = "2025-05-24T11:15:59.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/13d25a9ff2ac236a264b4603abaa39fa8bb9a7aa430519bb5f545c5b008d/PyQt5_Qt5-5.15.17-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b018f75d1cc61146396fa5af14da1db77c5d6318030e5e366f09ffdf7bd358d8", size = 61112954, upload-time = "2025-05-24T11:16:26.036Z" }, +] + +[[package]] +name = "pyqt5-sip" +version = "12.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/79/086b50414bafa71df494398ad277d72e58229a3d1c1b1c766d12b14c2e6d/pyqt5_sip-12.17.0.tar.gz", hash = "sha256:682dadcdbd2239af9fdc0c0628e2776b820e128bec88b49b8d692fe682f90b4f", size = 104042, upload-time = "2025-02-02T17:13:11.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/e6/e51367c28d69b5a462f38987f6024e766fd8205f121fe2f4d8ba2a6886b9/PyQt5_sip-12.17.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ea08341c8a5da00c81df0d689ecd4ee47a95e1ecad9e362581c92513f2068005", size = 124650, upload-time = "2025-02-02T17:12:50.595Z" }, + { url = "https://files.pythonhosted.org/packages/64/3b/e6d1f772b41d8445d6faf86cc9da65910484ebd9f7df83abc5d4955437d0/PyQt5_sip-12.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4a92478d6808040fbe614bb61500fbb3f19f72714b99369ec28d26a7e3494115", size = 281893, upload-time = "2025-02-02T17:12:51.966Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/d17fc2ddb9156a593710c88afd98abcf4055a2224b772f8bec2c6eea879c/PyQt5_sip-12.17.0-cp312-cp312-win32.whl", hash = "sha256:b0ff280b28813e9bfd3a4de99490739fc29b776dc48f1c849caca7239a10fc8b", size = 49438, upload-time = "2025-02-02T17:12:54.426Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c5/1174988d52c732d07033cf9a5067142b01d76be7731c6394a64d5c3ef65c/PyQt5_sip-12.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:54c31de7706d8a9a8c0fc3ea2c70468aba54b027d4974803f8eace9c22aad41c", size = 58017, upload-time = "2025-02-02T17:12:56.31Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5d/f234e505af1a85189310521447ebc6052ebb697efded850d0f2b2555f7aa/PyQt5_sip-12.17.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c7a7ff355e369616b6bcb41d45b742327c104b2bf1674ec79b8d67f8f2fa9543", size = 124580, upload-time = "2025-02-02T17:12:58.158Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cb/3b2050e9644d0021bdf25ddf7e4c3526e1edd0198879e76ba308e5d44faf/PyQt5_sip-12.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:419b9027e92b0b707632c370cfc6dc1f3b43c6313242fc4db57a537029bd179c", size = 281563, upload-time = "2025-02-02T17:12:59.421Z" }, + { url = "https://files.pythonhosted.org/packages/51/61/b8ebde7e0b32d0de44c521a0ace31439885b0423d7d45d010a2f7d92808c/PyQt5_sip-12.17.0-cp313-cp313-win32.whl", hash = "sha256:351beab964a19f5671b2a3e816ecf4d3543a99a7e0650f88a947fea251a7589f", size = 49383, upload-time = "2025-02-02T17:13:00.597Z" }, + { url = "https://files.pythonhosted.org/packages/15/ed/ff94d6b2910e7627380cb1fc9a518ff966e6d78285c8e54c9422b68305db/PyQt5_sip-12.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:672c209d05661fab8e17607c193bf43991d268a1eefbc2c4551fbf30fd8bb2ca", size = 58022, upload-time = "2025-02-02T17:13:01.738Z" }, +] + +[[package]] +name = "pyside6" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/64/3a56578e01a4d282f15c42f2f0a0322c1e010d1339901d1a52880a678806/PySide6-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:6d1fd95651cdbdea741af21e155350986eca31ff015fc4c721ce01c2a110a4cc", size = 531916, upload-time = "2024-12-02T08:44:13.424Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9b/923e4bf34c85e04f7b60e89e27e150a08b5e6a2b5950227e3010c6d9d2ba/PySide6-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d6adc5d53313249bbe02edb673877c1d437e215d71e88da78412520653f5c9f", size = 532709, upload-time = "2024-12-02T08:44:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/366c05e29a17a9e85edffd147dacfbabc76ee7e6e0f9583328559eb74fbb/PySide6-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ddeeaeca8ebd0ddb1ded30dd33e9240a40f330cc91832de346ba6c9d0cd1253e", size = 532709, upload-time = "2024-12-02T08:44:18.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/e6/4cffea422cca3f5bc3d595739b3a35ee710e9864f8ca5c6cf48376864ac0/PySide6-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:866eeaca3ffead6b9d30fa3ed395d5624da0246d7586c8b8207e77ac65d82458", size = 538388, upload-time = "2024-12-02T08:44:20.222Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/3d/7fb4334d5250a9fa23ca57b81a77e60edf77d2f60bc5ca0ba9a8e3bc56fb/PySide6_Addons-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:879c12346b4b76f5d5ee6499d8ca53b5666c0c998b8fdf8780f08f69ea95d6f9", size = 302212966, upload-time = "2024-12-02T08:40:14.687Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f6/f3071f51e39e9fbe186aafc1c8d8a0b2a4bd9eb393fee702b73ed3eef5ae/PySide6_Addons-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f80cc03c1ac54132c6f800aa461dced64acd7d1646898db164ccb56fe3c23dd4", size = 160308867, upload-time = "2024-12-02T08:41:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/48/12/9ff2937b571feccde5261e5be6806bdc5208f29a826783bacec756667384/PySide6_Addons-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:570a25016d80046274f454ed0bb06734f478ce6c21be5dec62b624773fc7504e", size = 156107988, upload-time = "2024-12-02T08:42:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/32e2cadc50996ea855d35baba03e0b783f5ed9ae82f3da67623e66ef44a5/PySide6_Addons-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:d7c8c1e89ee0db84631d5b8fdb9129d9d2a0ffb3b4cb2f5192dc8367dd980db4", size = 127967740, upload-time = "2024-12-02T08:42:58.509Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/b9/1de4473bc02b9bd325b996352f88db3a235e7e227a3d6a8bd6d3744ebb52/PySide6_Essentials-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:bd05155245e3cd1572e68d72772e78fadfd713575bbfdd2c5e060d5278e390e9", size = 164790658, upload-time = "2024-12-02T08:39:25.101Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cc/5af1e0c0306cd75864fba49934977d0a96bec4b293b2244f6f80460c2ff5/PySide6_Essentials-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f600b149e65b57acd6a444edb17615adc42cc2491548ae443ccb574036d86b1", size = 95271238, upload-time = "2024-12-02T08:40:13.922Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/21e45a27ec195e01b7af9935e8fa207c30f6afd5389e563fa4be2558281b/PySide6_Essentials-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf8a3c9ee0b997eb18fb00cb09aacaa28b8a51ce3c295a252cc594c5530aba56", size = 93125810, upload-time = "2024-12-02T08:40:57.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6f/bdc288149c92664a487816055ba55fa5884f1e07bc35b66c5d22530d0a6d/PySide6_Essentials-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:d5ed4ddb149f36d65bc49ae4260b2d213ee88b2d9a309012ae27f38158c2d1b6", size = 72570590, upload-time = "2024-12-02T08:41:32.36Z" }, +] + +[[package]] +name = "qt-py" +version = "1.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-pyside2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/7a/7dfe58082cead77f0600f5244a9f92caab683da99f2a2e36fa24870a41ca/qt_py-1.4.6.tar.gz", hash = "sha256:d26f808a093754f0b44858745965bab138525cffc77c1296a3293171b2e2469f", size = 57847, upload-time = "2025-05-13T04:21:08.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/0d/3486a49ee1550b048a913fe2004588f84f469714950b073cbf2261d6e349/qt_py-1.4.6-py2.py3-none-any.whl", hash = "sha256:1e0f8da9af74f2b3448904fab313f6f79cad56b82895f1a2c541243f00cc244e", size = 42358, upload-time = "2025-05-13T04:21:06.657Z" }, +] + +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + +[[package]] +name = "shiboken6" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/66/1acae15fe8126356e8ad460b5dfdc2a17af51de9044c1a3c0e4f9ae69356/shiboken6-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:9a2f51d1ddd3b6d193a0f0fdc09f8d41f2092bc664723c9b9efc1056660d0608", size = 399604, upload-time = "2024-12-02T08:37:22.778Z" }, + { url = "https://files.pythonhosted.org/packages/58/21/e5af942e6fc5a8c6b973aac8d822415ac54041b6861c3d835be9d217f538/shiboken6-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1dc4c1976809b0e68872bb98474cccd590455bdcd015f0e0639907e94af27b6a", size = 203095, upload-time = "2024-12-02T08:37:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/711c7801386d49f9261eeace3f9dbe8f21b2d28b85d4d3b9e6342379c440/shiboken6-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ab5b60602ca6227103138aae89c4f5df3b1b8e249cbc8ec9e6e2a57f20ad9a91", size = 200113, upload-time = "2024-12-02T08:37:25.672Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5f/3e9aa2b2fd1e24ff7e99717fa1ce3198556433e7ef611728e86f1fd70f94/shiboken6-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:3ea127fd72be113b73cacd70e06687ad6f83c1c888047833c7dcdd5cf8e7f586", size = 1149267, upload-time = "2024-12-02T08:37:27.642Z" }, +] + +[[package]] +name = "types-pyside2" +version = "5.15.2.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/b9/b9691abe89b0dd6f02e52604dda35112e202084970edf1515eba22e45ab8/types_pyside2-5.15.2.1.7.tar.gz", hash = "sha256:1d65072deb97481ad481b3414f94d02fd5da07f5e709c2d439ced14f79b2537c", size = 539112, upload-time = "2024-03-11T19:17:12.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/19/b093a69c7964ab9abea8130fc4ca7e5f1f0f9c19433e53e2ca41a38d1285/types_pyside2-5.15.2.1.7-py2.py3-none-any.whl", hash = "sha256:a7bec4cb4657179415ca7ec7c70a45f9f9938664e22f385c85fd7cd724b07d4d", size = 572176, upload-time = "2024-03-11T19:17:11.079Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +]