From 914ce68da8f8d449dafe95c2bffb288df54da6cd Mon Sep 17 00:00:00 2001 From: Mason Date: Mon, 4 Aug 2025 22:42:14 +0800 Subject: [PATCH] update project --- KL520DemoGenericImageInferenceMultiThread.py | 216 -- ...KnModelZooGenericDataInferenceMMSegSTDC.py | 215 -- UI.md | 115 - UI.py | 3423 ----------------- check_dependencies.py | 132 - cluster4npu_ui/.gitignore | 1 - cluster4npu_ui/CLAUDE.md | 75 - cluster4npu_ui/DEVELOPMENT_ROADMAP.md | 131 - cluster4npu_ui/Flowchart.jpg | Bin 214832 -> 0 bytes cluster4npu_ui/PROJECT_SUMMARY.md | 138 - cluster4npu_ui/README.md | 259 -- cluster4npu_ui/__init__.py | 55 - cluster4npu_ui/config/__init__.py | 31 - cluster4npu_ui/config/settings.py | 321 -- cluster4npu_ui/config/theme.py | 262 -- cluster4npu_ui/core/__init__.py | 28 - .../core/functions/InferencePipeline.py | 686 ---- cluster4npu_ui/core/functions/Multidongle.py | 796 ---- .../core/functions/camera_source.py | 151 - .../core/functions/demo_topology_clean.py | 375 -- .../core/functions/mflow_converter.py | 697 ---- .../core/functions/result_handler.py | 97 - cluster4npu_ui/core/functions/test.py | 407 -- cluster4npu_ui/core/functions/video_source.py | 138 - .../core/functions/workflow_orchestrator.py | 197 - cluster4npu_ui/core/nodes/__init__.py | 58 - cluster4npu_ui/core/nodes/base_node.py | 231 -- cluster4npu_ui/core/nodes/exact_nodes.py | 383 -- cluster4npu_ui/core/nodes/input_node.py | 290 -- cluster4npu_ui/core/nodes/model_node.py | 174 - cluster4npu_ui/core/nodes/output_node.py | 370 -- cluster4npu_ui/core/nodes/postprocess_node.py | 286 -- cluster4npu_ui/core/nodes/preprocess_node.py | 240 -- .../core/nodes/simple_input_node.py | 129 - cluster4npu_ui/core/pipeline.py | 545 --- cluster4npu_ui/example.py | 504 --- cluster4npu_ui/main.py | 82 - cluster4npu_ui/release_note.md | 20 - cluster4npu_ui/resources/__init__.py | 63 - cluster4npu_ui/test.mflow | 90 - cluster4npu_ui/tests/debug_deployment.py | 273 -- cluster4npu_ui/tests/deploy_demo.py | 290 -- .../tests/deployment_terminal_example.py | 237 -- .../tests/device_detection_example.py | 135 - cluster4npu_ui/tests/test_deploy.py | 104 - cluster4npu_ui/tests/test_deploy_simple.py | 199 - .../tests/test_exact_node_logging.py | 223 -- .../tests/test_final_implementation.py | 180 - cluster4npu_ui/tests/test_integration.py | 172 - cluster4npu_ui/tests/test_logging_demo.py | 203 - cluster4npu_ui/tests/test_modifications.py | 125 - cluster4npu_ui/tests/test_node_detection.py | 125 - cluster4npu_ui/tests/test_pipeline_editor.py | 95 - cluster4npu_ui/tests/test_stage_function.py | 253 -- .../tests/test_stage_improvements.py | 186 - cluster4npu_ui/tests/test_status_bar_fixes.py | 251 -- cluster4npu_ui/tests/test_topology.py | 306 -- .../tests/test_topology_standalone.py | 375 -- cluster4npu_ui/tests/test_ui_deployment.py | 115 - cluster4npu_ui/tests/test_ui_fixes.py | 237 -- cluster4npu_ui/ui/__init__.py | 30 - cluster4npu_ui/ui/components/__init__.py | 27 - .../ui/components/common_widgets.py | 0 cluster4npu_ui/ui/components/node_palette.py | 0 .../ui/components/properties_widget.py | 0 cluster4npu_ui/ui/dialogs/__init__.py | 35 - cluster4npu_ui/ui/dialogs/create_pipeline.py | 0 cluster4npu_ui/ui/dialogs/deployment.py | 1017 ----- cluster4npu_ui/ui/dialogs/performance.py | 0 cluster4npu_ui/ui/dialogs/properties.py | 0 cluster4npu_ui/ui/dialogs/save_deploy.py | 0 cluster4npu_ui/ui/dialogs/stage_config.py | 0 cluster4npu_ui/ui/windows/__init__.py | 25 - cluster4npu_ui/ui/windows/dashboard.py | 2364 ------------ cluster4npu_ui/ui/windows/login.py | 459 --- cluster4npu_ui/ui/windows/pipeline_editor.py | 667 ---- cluster4npu_ui/utils/__init__.py | 28 - cluster4npu_ui/utils/file_utils.py | 0 cluster4npu_ui/utils/ui_utils.py | 0 debug_registration.py | 117 - demo_modular_app.py | 253 -- pyproject.toml | 13 - src/cluster4npu/__init__.py | 0 test.py | 297 -- test_pipeline.py | 534 --- test_ui.py | 40 - ui_config.py | 415 -- ui_integration_example.py | 359 -- uv.lock | 212 - 89 files changed, 23387 deletions(-) delete mode 100644 KL520DemoGenericImageInferenceMultiThread.py delete mode 100644 KL720KnModelZooGenericDataInferenceMMSegSTDC.py delete mode 100644 UI.md delete mode 100644 UI.py delete mode 100644 check_dependencies.py delete mode 100644 cluster4npu_ui/.gitignore delete mode 100644 cluster4npu_ui/CLAUDE.md delete mode 100644 cluster4npu_ui/DEVELOPMENT_ROADMAP.md delete mode 100644 cluster4npu_ui/Flowchart.jpg delete mode 100644 cluster4npu_ui/PROJECT_SUMMARY.md delete mode 100644 cluster4npu_ui/README.md delete mode 100644 cluster4npu_ui/__init__.py delete mode 100644 cluster4npu_ui/config/__init__.py delete mode 100644 cluster4npu_ui/config/settings.py delete mode 100644 cluster4npu_ui/config/theme.py delete mode 100644 cluster4npu_ui/core/__init__.py delete mode 100644 cluster4npu_ui/core/functions/InferencePipeline.py delete mode 100644 cluster4npu_ui/core/functions/Multidongle.py delete mode 100644 cluster4npu_ui/core/functions/camera_source.py delete mode 100644 cluster4npu_ui/core/functions/demo_topology_clean.py delete mode 100644 cluster4npu_ui/core/functions/mflow_converter.py delete mode 100644 cluster4npu_ui/core/functions/result_handler.py delete mode 100644 cluster4npu_ui/core/functions/test.py delete mode 100644 cluster4npu_ui/core/functions/video_source.py delete mode 100644 cluster4npu_ui/core/functions/workflow_orchestrator.py delete mode 100644 cluster4npu_ui/core/nodes/__init__.py delete mode 100644 cluster4npu_ui/core/nodes/base_node.py delete mode 100644 cluster4npu_ui/core/nodes/exact_nodes.py delete mode 100644 cluster4npu_ui/core/nodes/input_node.py delete mode 100644 cluster4npu_ui/core/nodes/model_node.py delete mode 100644 cluster4npu_ui/core/nodes/output_node.py delete mode 100644 cluster4npu_ui/core/nodes/postprocess_node.py delete mode 100644 cluster4npu_ui/core/nodes/preprocess_node.py delete mode 100644 cluster4npu_ui/core/nodes/simple_input_node.py delete mode 100644 cluster4npu_ui/core/pipeline.py delete mode 100644 cluster4npu_ui/example.py delete mode 100644 cluster4npu_ui/main.py delete mode 100644 cluster4npu_ui/release_note.md delete mode 100644 cluster4npu_ui/resources/__init__.py delete mode 100644 cluster4npu_ui/test.mflow delete mode 100644 cluster4npu_ui/tests/debug_deployment.py delete mode 100644 cluster4npu_ui/tests/deploy_demo.py delete mode 100644 cluster4npu_ui/tests/deployment_terminal_example.py delete mode 100644 cluster4npu_ui/tests/device_detection_example.py delete mode 100644 cluster4npu_ui/tests/test_deploy.py delete mode 100644 cluster4npu_ui/tests/test_deploy_simple.py delete mode 100644 cluster4npu_ui/tests/test_exact_node_logging.py delete mode 100644 cluster4npu_ui/tests/test_final_implementation.py delete mode 100644 cluster4npu_ui/tests/test_integration.py delete mode 100644 cluster4npu_ui/tests/test_logging_demo.py delete mode 100644 cluster4npu_ui/tests/test_modifications.py delete mode 100644 cluster4npu_ui/tests/test_node_detection.py delete mode 100644 cluster4npu_ui/tests/test_pipeline_editor.py delete mode 100644 cluster4npu_ui/tests/test_stage_function.py delete mode 100644 cluster4npu_ui/tests/test_stage_improvements.py delete mode 100644 cluster4npu_ui/tests/test_status_bar_fixes.py delete mode 100644 cluster4npu_ui/tests/test_topology.py delete mode 100644 cluster4npu_ui/tests/test_topology_standalone.py delete mode 100644 cluster4npu_ui/tests/test_ui_deployment.py delete mode 100644 cluster4npu_ui/tests/test_ui_fixes.py delete mode 100644 cluster4npu_ui/ui/__init__.py delete mode 100644 cluster4npu_ui/ui/components/__init__.py delete mode 100644 cluster4npu_ui/ui/components/common_widgets.py delete mode 100644 cluster4npu_ui/ui/components/node_palette.py delete mode 100644 cluster4npu_ui/ui/components/properties_widget.py delete mode 100644 cluster4npu_ui/ui/dialogs/__init__.py delete mode 100644 cluster4npu_ui/ui/dialogs/create_pipeline.py delete mode 100644 cluster4npu_ui/ui/dialogs/deployment.py delete mode 100644 cluster4npu_ui/ui/dialogs/performance.py delete mode 100644 cluster4npu_ui/ui/dialogs/properties.py delete mode 100644 cluster4npu_ui/ui/dialogs/save_deploy.py delete mode 100644 cluster4npu_ui/ui/dialogs/stage_config.py delete mode 100644 cluster4npu_ui/ui/windows/__init__.py delete mode 100644 cluster4npu_ui/ui/windows/dashboard.py delete mode 100644 cluster4npu_ui/ui/windows/login.py delete mode 100644 cluster4npu_ui/ui/windows/pipeline_editor.py delete mode 100644 cluster4npu_ui/utils/__init__.py delete mode 100644 cluster4npu_ui/utils/file_utils.py delete mode 100644 cluster4npu_ui/utils/ui_utils.py delete mode 100644 debug_registration.py delete mode 100644 demo_modular_app.py delete mode 100644 pyproject.toml delete mode 100644 src/cluster4npu/__init__.py delete mode 100644 test.py delete mode 100644 test_pipeline.py delete mode 100644 test_ui.py delete mode 100644 ui_config.py delete mode 100644 ui_integration_example.py delete mode 100644 uv.lock diff --git a/KL520DemoGenericImageInferenceMultiThread.py b/KL520DemoGenericImageInferenceMultiThread.py deleted file mode 100644 index f3405a1..0000000 --- a/KL520DemoGenericImageInferenceMultiThread.py +++ /dev/null @@ -1,216 +0,0 @@ -# ****************************************************************************** -# Copyright (c) 2021-2022. Kneron Inc. All rights reserved. * -# ****************************************************************************** - -from typing import Union -import os -import sys -import argparse -import time -import threading -import queue -import numpy as np -from utils.ExampleHelper import get_device_usb_speed_by_port_id -import kp -import cv2 - -PWD = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(1, os.path.join(PWD, '..')) - -SCPU_FW_PATH = os.path.join(PWD, '../../res/firmware/KL520/fw_scpu.bin') -NCPU_FW_PATH = os.path.join(PWD, '../../res/firmware/KL520/fw_ncpu.bin') -MODEL_FILE_PATH = os.path.join(PWD, '../../res/models/KL520/tiny_yolo_v3/models_520.nef') -IMAGE_FILE_PATH = os.path.join(PWD, '../../res/images/bike_cars_street_224x224.bmp') -LOOP_TIME = 100 - - -def _image_send_function(_device_group: kp.DeviceGroup, - _loop_time: int, - _generic_inference_input_descriptor: kp.GenericImageInferenceDescriptor, - _image: Union[bytes, np.ndarray], - _image_format: kp.ImageFormat) -> None: - for _loop in range(_loop_time): - try: - _generic_inference_input_descriptor.inference_number = _loop - _generic_inference_input_descriptor.input_node_image_list = [kp.GenericInputNodeImage( - image=_image, - image_format=_image_format, - 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=device_group, - generic_inference_input_descriptor=_generic_inference_input_descriptor) - except kp.ApiKPException as exception: - print(' - Error: inference failed, error = {}'.format(exception)) - exit(0) - - -def _result_receive_function(_device_group: kp.DeviceGroup, - _loop_time: int, - _result_queue: queue.Queue) -> None: - _generic_raw_result = None - - for _loop in range(_loop_time): - try: - _generic_raw_result = kp.inference.generic_image_inference_receive(device_group=device_group) - - if _generic_raw_result.header.inference_number != _loop: - print(' - Error: incorrect inference_number {} at frame {}'.format( - _generic_raw_result.header.inference_number, _loop)) - - print('.', end='', flush=True) - - except kp.ApiKPException as exception: - print(' - Error: inference failed, error = {}'.format(exception)) - exit(0) - - _result_queue.put(_generic_raw_result) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='KL520 Demo Generic Image Inference Multi-Thread Example.') - parser.add_argument('-p', - '--port_id', - help='Using specified port ID for connecting device (Default: port ID of first scanned Kneron ' - 'device)', - default=0, - type=int) - args = parser.parse_args() - - usb_port_id = args.port_id - - """ - check device USB speed (Recommend run KL520 at high speed) - """ - try: - if kp.UsbSpeed.KP_USB_SPEED_HIGH != get_device_usb_speed_by_port_id(usb_port_id=usb_port_id): - print('\033[91m' + '[Error] Device is not run at high speed.' + '\033[0m') - exit(0) - except Exception as exception: - print('Error: check device USB speed fail, port ID = \'{}\', error msg: [{}]'.format(usb_port_id, - str(exception))) - exit(0) - - """ - connect the device - """ - try: - print('[Connect Device]') - device_group = kp.core.connect_devices(usb_port_ids=[usb_port_id]) - print(' - Success') - except kp.ApiKPException as exception: - print('Error: connect device fail, port ID = \'{}\', error msg: [{}]'.format(usb_port_id, - str(exception))) - exit(0) - - """ - setting timeout of the usb communication with the device - """ - print('[Set Device Timeout]') - kp.core.set_timeout(device_group=device_group, milliseconds=5000) - print(' - Success') - - """ - upload firmware to device - """ - try: - print('[Upload Firmware]') - kp.core.load_firmware_from_file(device_group=device_group, - scpu_fw_path=SCPU_FW_PATH, - ncpu_fw_path=NCPU_FW_PATH) - print(' - Success') - except kp.ApiKPException as exception: - print('Error: upload firmware failed, error = \'{}\''.format(str(exception))) - exit(0) - - """ - upload model to device - """ - try: - print('[Upload Model]') - model_nef_descriptor = kp.core.load_model_from_file(device_group=device_group, - file_path=MODEL_FILE_PATH) - print(' - Success') - except kp.ApiKPException as exception: - print('Error: upload model failed, error = \'{}\''.format(str(exception))) - exit(0) - - """ - prepare the image - """ - print('[Read Image]') - img = cv2.imread(filename=IMAGE_FILE_PATH) - img_bgr565 = cv2.cvtColor(src=img, code=cv2.COLOR_BGR2BGR565) - print(' - Success') - - """ - prepare generic image inference input descriptor - """ - generic_inference_input_descriptor = kp.GenericImageInferenceDescriptor( - model_id=model_nef_descriptor.models[0].id, - ) - - """ - starting inference work - """ - print('[Starting Inference Work]') - print(' - Starting inference loop {} times'.format(LOOP_TIME)) - print(' - ', end='') - result_queue = queue.Queue() - - send_thread = threading.Thread(target=_image_send_function, args=(device_group, - LOOP_TIME, - generic_inference_input_descriptor, - img_bgr565, - kp.ImageFormat.KP_IMAGE_FORMAT_RGB565)) - - receive_thread = threading.Thread(target=_result_receive_function, args=(device_group, - LOOP_TIME, - result_queue)) - - start_inference_time = time.time() - - send_thread.start() - receive_thread.start() - - try: - while send_thread.is_alive(): - send_thread.join(1) - - while receive_thread.is_alive(): - receive_thread.join(1) - except (KeyboardInterrupt, SystemExit): - print('\n - Received keyboard interrupt, quitting threads.') - exit(0) - - end_inference_time = time.time() - time_spent = end_inference_time - start_inference_time - - try: - generic_raw_result = result_queue.get(timeout=3) - except Exception as exception: - print('Error: Result queue is empty !') - exit(0) - print() - - print('[Result]') - print(" - Total inference {} images".format(LOOP_TIME)) - print(" - Time spent: {:.2f} secs, FPS = {:.1f}".format(time_spent, LOOP_TIME / time_spent)) - - """ - retrieve inference node output - """ - print('[Retrieve Inference Node Output ]') - inf_node_output_list = [] - for node_idx in range(generic_raw_result.header.num_output_node): - inference_float_node_output = kp.inference.generic_inference_retrieve_float_node(node_idx=node_idx, - generic_raw_result=generic_raw_result, - channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW) - inf_node_output_list.append(inference_float_node_output) - - print(' - Success') - - print('[Result]') - print(inf_node_output_list) diff --git a/KL720KnModelZooGenericDataInferenceMMSegSTDC.py b/KL720KnModelZooGenericDataInferenceMMSegSTDC.py deleted file mode 100644 index 8130897..0000000 --- a/KL720KnModelZooGenericDataInferenceMMSegSTDC.py +++ /dev/null @@ -1,215 +0,0 @@ -# ****************************************************************************** -# Copyright (c) 2022. Kneron Inc. All rights reserved. * -# ****************************************************************************** - -import os -import sys -import argparse - -PWD = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(1, os.path.join(PWD, '..')) -sys.path.insert(1, os.path.join(PWD, '../example/')) - -from utils.ExampleHelper import get_device_usb_speed_by_port_id -import kp -import cv2 -import numpy as np -import math -import multiprocessing -import threading - - -def get_palette(mapping, seed=9487): - np.random.seed(seed) - return [list(np.random.choice(range(256), size=3)) - for _ in range(mapping)] - - -def convert_numpy_to_rgba_and_width_align_4(data): - """Converts the numpy data into RGBA. - - 720 input is 4 byte width aligned. - - """ - - height, width, channel = data.shape - - width_aligned = 4 * math.ceil(width / 4.0) - aligned_data = np.zeros((height, width_aligned, 4), dtype=np.int8) - aligned_data[:height, :width, :channel] = data - aligned_data = aligned_data.flatten() - - return aligned_data.tobytes() - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='KL720 Kneron Model Zoo Generic Data Inference Example - STDC.') - parser.add_argument('-p', - '--port_id', - help='Using specified port ID for connecting device (Default: port ID of first scanned Kneron ' - 'device)', - default=0, - type=int) - parser.add_argument('-img', - '--img_path', - help='input image path', - default=os.path.join(PWD, '../../res/images/pic_0456_jpg.rf.6aa4e19498fc69214a37fc278b23aa6b_leftImg8bit.png'), - type=str) - parser.add_argument('-nef', - '--nef_model_path', - help='input NEF model path', - default=os.path.join(PWD, - '../../res/models/KL720/kn-model-zoo-mmseg_stdc/724models_720.nef'), - type=str) - - args = parser.parse_args() - - assert args.img_path is not None, "need to set input image but got None" - assert args.nef_model_path is not None, "need to set nef model path but got None" - - usb_port_id = args.port_id - nef_model_path = args.nef_model_path - image_file_path = args.img_path - - """ - check device USB speed (Recommend run KL720 at super speed) - """ - try: - if kp.UsbSpeed.KP_USB_SPEED_SUPER != get_device_usb_speed_by_port_id(usb_port_id=usb_port_id): - print('\033[91m' + '[Warning] Device is not run at super speed.' + '\033[0m') - except Exception as exception: - print('Error: check device USB speed fail, port ID = \'{}\', error msg: [{}]'.format(usb_port_id, - str(exception))) - exit(0) - - """ - connect the device - """ - try: - print('[Connect Device]') - device_group = kp.core.connect_devices(usb_port_ids=[usb_port_id]) - print(' - Success') - except kp.ApiKPException as exception: - print('Error: connect device fail, port ID = \'{}\', error msg: [{}]'.format(usb_port_id, - str(exception))) - exit(0) - - """ - setting timeout of the usb communication with the device - """ - print('[Set Device Timeout]') - kp.core.set_timeout(device_group=device_group, milliseconds=5000) - print(' - Success') - - """ - upload model to device - """ - try: - print('[Upload Model]') - model_nef_descriptor = kp.core.load_model_from_file(device_group=device_group, - file_path=nef_model_path) - print(' - Success') - except kp.ApiKPException as exception: - print('Error: upload model failed, error = \'{}\''.format(str(exception))) - exit(0) - - """ - extract input radix from NEF - """ - nef_radix = model_nef_descriptor.models[0].input_nodes[0].quantization_parameters.v1.quantized_fixed_point_descriptor_list[0].radix # only support single model NEF - - """ - prepare the image - """ - nef_model_width = model_nef_descriptor.models[0].input_nodes[0].tensor_shape_info.v1.shape_npu[3] - nef_model_height = model_nef_descriptor.models[0].input_nodes[0].tensor_shape_info.v1.shape_npu[2] - print('[Read Image]') - img = cv2.imread(filename=image_file_path) - img_height, img_width, img_channels = img.shape - - # resize to model input size - img = cv2.resize(img, (nef_model_width, nef_model_height), interpolation=cv2.INTER_AREA) - - # to rgb - img_input = cv2.cvtColor(src=img, code=cv2.COLOR_BGR2RGB) - - # this model trained with normalize method: (data - 128)/256 , - img_input = img_input / 256. - img_input -= 0.5 - - # toolchain calculate the radix value from input data (after normalization), and set it into NEF model. - # NPU will divide input data "2^radix" automatically, so, we have to scaling the input data here due to this reason. - img_input *= pow(2, nef_radix) - - # convert rgb to rgba and width align 4, due to npu requirement. - img_buffer = convert_numpy_to_rgba_and_width_align_4(img_input) - - print(' - Success') - - """ - prepare generic data inference input descriptor - """ - generic_inference_input_descriptor = kp.GenericDataInferenceDescriptor( - model_id=model_nef_descriptor.models[0].id, - inference_number=0, - input_node_data_list=[kp.GenericInputNodeData(buffer=img_buffer)] - ) - - """ - starting inference work - """ - print('[Starting Inference Work]') - try: - kp.inference.generic_data_inference_send(device_group=device_group, - generic_inference_input_descriptor=generic_inference_input_descriptor) - - generic_raw_result = kp.inference.generic_data_inference_receive(device_group=device_group) - except kp.ApiKPException as exception: - print(' - Error: inference failed, error = {}'.format(exception)) - exit(0) - print() - - """ - retrieve inference node output - """ - print('[Retrieve Inference Node Output ]') - inf_node_output_list = [] - for node_idx in range(generic_raw_result.header.num_output_node): - inference_float_node_output = kp.inference.generic_inference_retrieve_float_node( - node_idx=node_idx, - generic_raw_result=generic_raw_result, - channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW) - inf_node_output_list.append(inference_float_node_output) - - print(' - Success') - - o_im = cv2.imread(filename=image_file_path) - - # change output array data order from nchw to hwc - pred = inf_node_output_list[0].ndarray.squeeze().transpose(1, 2, 0) # should only one output node - - # channel number means all possible class number - n_c = pred.shape[2] - - # upscaling inference result array to origin image size - pred = cv2.resize(pred, (o_im.shape[1], o_im.shape[0]), interpolation=cv2.INTER_LINEAR) - - # find max score class - pred = pred.argmax(2) - - print('[Result]') - print(' - segmentation result \n{}'.format(pred)) - - """ - output result image - """ - colors = get_palette(n_c) - seg_res_vis = np.zeros(o_im.shape, np.uint8) - for c in range(n_c): - seg_res_vis[pred == c] = colors[c] - - print('[Output Result Image]') - output_img_name = 'output_{}'.format(os.path.basename(image_file_path)) - - print(' - Output Segmentation result on \'{}\''.format(output_img_name)) - cv2.imwrite(output_img_name, seg_res_vis) diff --git a/UI.md b/UI.md deleted file mode 100644 index 8069b9e..0000000 --- a/UI.md +++ /dev/null @@ -1,115 +0,0 @@ -這個應用程式的核心是 NodeGraphQt 函式庫,它讓使用者可以像在流程圖中一樣,拖拉節點並將它們連接起來,形成一個完整的處理流程。 - -以下將程式碼拆解成各個主要部分進行說明: - -1. 總體結構與依賴 (Overall Structure & Dependencies) -程式碼的頂部引入了必要的函式庫: - -PyQt5: 用於建立整個桌面應用程式的圖形介面 (GUI),包括視窗、按鈕、輸入框等所有視覺元件。 - -NodeGraphQt: 這是核心,一個專為建立節點式圖形化介面而設計的函式庫。它提供了節點圖(Node Graph)、節點(Node)和屬性編輯器(Properties Bin)等基礎建設。 - -sys, json, os: Python 的標準函式庫,分別用於系統互動、處理 JSON 資料(用於儲存/載入管線)和作業系統相關功能(如處理檔案路徑)。 - -2. 外觀與風格 (Appearance & Styling) -HARMONIOUS_THEME_STYLESHEET: 這是一個非常長的字串,包含了 QSS(Qt Style Sheets,類似於網頁的 CSS)。它定義了整個應用程式的外觀,包括顏色、字體、邊框、圓角等,創造了一個現代化且風格統一的深色主題(Dark Mode)。這使得所有 UI 元件看起來都非常和諧。 - -3. 自訂節點類別 (Custom Node Classes) -這是應用程式的核心業務邏輯。開發者繼承了 NodeGraphQt 的 BaseNode,定義了幾種代表 ML 管線中不同處理步驟的節點。 - -InputNode: 代表資料來源,例如攝影機、麥克風或檔案。 - -PreprocessNode: 代表前處理節點,負責在模型推論前處理資料,如調整圖片大小、正規化等。 - -ModelNode: 代表模型推論節點,是執行核心 AI 運算的地方。它有模型路徑、使用的硬體 (dongle) 數量等屬性。 - -PostprocessNode: 代表後處理節點,負責處理模型輸出,如過濾結果、轉換格式等。 - -OutputNode: 代表最終的輸出點,例如將結果存成檔案或傳送到某個 API。 - -在每個節點的 __init__ 方法中: - -add_input() 和 add_output(): 定義節點的輸入/輸出埠,用於連接其他節點。 - -set_color(): 設定節點在圖形介面中的顏色,以方便區分。 - -create_property(): 這是關鍵。這個方法為節點定義了「業務屬性」(Business Properties)。例如,ModelNode 有 model_path、num_dongles 等屬性。這些屬性可以在 UI 上被使用者編輯。 - -_property_options: 一個字典,用來定義屬性編輯器該如何呈現這些屬性(例如,提供一個下拉選單、設定數值的最大最小值、或提供檔案選擇按鈕)。 - -4. 核心 UI 元件 (Core UI Components) -這些是構成主視窗的各個面板和視窗。 - -DashboardLogin (啟動儀表板) -用途: 這是應用程式的進入點,一個歡迎畫面或啟動器。 - -功能: - -提供 "Create New Pipeline"(建立新管線)和 "Edit Previous Pipeline"(編輯舊管線)的選項。 - -顯示最近開啟過的檔案列表 (.mflow 檔)。 - -當使用者選擇建立或開啟後,它會實例化並顯示 IntegratedPipelineDashboard 主視窗。 - -IntegratedPipelineDashboard (整合式主視窗) -用途: 這是應用程式最主要、功能最完整的操作介面。 - -佈局: 它採用一個三欄式佈局(使用 QSplitter): - -左側面板 (Node Templates): 顯示所有可用的節點類型(Input, Model 等),使用者點擊按鈕即可在中間的編輯器中新增節點。 - -中間面板 (Pipeline Editor): 這是 NodeGraphQt 的主畫布,使用者可以在這裡拖動、連接節點,建立整個 ML 管線。 - -右側面板 (Configuration Tabs): 一個頁籤式視窗,包含了多個設定面板: - -Properties: 最重要的頁籤。當使用者在中間的畫布上選中一個節點時,這裡會動態顯示該節點的所有「業務屬性」並提供對應的編輯工具(輸入框、下拉選單、滑桿等)。這是由 update_node_properties_panel 方法動態生成的。 - -Stages: 設定管線的「階段」(Stage),可以將多個節點分配到不同階段。 - -Performance: 顯示模擬的效能指標,如 FPS、延遲等。 - -Dongles: 管理硬體加速器 (dongle) 的分配。 - -PipelineEditor (另一個主視窗版本) -用途: 這看起來是 IntegratedPipelineDashboard 的一個較早期或替代版本。它也提供了一個節點編輯器,但它的屬性編輯器 (CustomPropertiesWidget) 是一個可以停靠 (dockable) 的獨立視窗,而不是整合在右側的頁籤中。 - -CustomPropertiesWidget (自訂屬性編輯器) -用途: 這個類別被 PipelineEditor 使用,它取代了 NodeGraphQt 預設的屬性面板。 - -功能: - -它監聽節點被選中的事件 (node_selection_changed)。 - -當節點被選中時,它會讀取節點的 custom 屬性和 _property_options 字典。 - -根據屬性的類型和選項,動態地建立對應的 UI 元件(如 QLineEdit, QComboBox, QSpinBox)。 - -提供 "Apply" 和 "Reset" 按鈕來儲存或重置屬性。 - -5. 對話方塊 (Dialogs) -這些是彈出式視窗,用於完成特定任務。 - -CreatePipelineDialog: 一個簡單的表單,讓使用者輸入新專案的名稱和描述。 - -StageConfigurationDialog: 一個更複雜的對話方塊,用於將管線劃分為多個「階段」,並為每個階段分配資源(如 dongle 數量)。 - -PerformanceEstimationPanel & SaveDeployDialog: 流程中的後續步驟,用於效能分析和最終部署配置的儲存。 - -6. 程式啟動點 (Execution Block) -Python - -if __name__ == '__main__': - # ... - app.setFont(QFont("Arial", 9)) - dashboard = DashboardLogin() - dashboard.show() - sys.exit(app.exec_()) -if __name__ == '__main__':: 這是 Python 程式的標準入口。當這個 UI.py 檔案被直接執行時,這段程式碼會被觸發。 - -app = QApplication(sys.argv): 建立 PyQt 應用程式的實例,這是任何 PyQt UI 執行的第一步。 - -dashboard = DashboardLogin(): 建立我們設計的啟動器視窗。 - -dashboard.show(): 顯示這個視窗。 - -sys.exit(app.exec_()): 啟動應用程式的事件迴圈 (event loop)。程式會停在這裡,等待並處理使用者的操作(如點擊、輸入等),直到使用者關閉視窗,程式才會結束。 \ No newline at end of file diff --git a/UI.py b/UI.py deleted file mode 100644 index 9058424..0000000 --- a/UI.py +++ /dev/null @@ -1,3423 +0,0 @@ -import sys -import json -import os - -# Ensure QApplication exists before any widget creation -from PyQt5.QtWidgets import QApplication -if not QApplication.instance(): - app = QApplication(sys.argv) -else: - app = QApplication.instance() - -from PyQt5.QtWidgets import ( - QMainWindow, QVBoxLayout, QHBoxLayout, - QWidget, QLineEdit, QPushButton, QDialog, QTextEdit, - QFormLayout, QDialogButtonBox, QMessageBox, QFileDialog, - QLabel, QSpinBox, QDoubleSpinBox, QComboBox, QListWidget, QCheckBox, QSplitter, QAction, QScrollArea, - QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QSlider, - QGroupBox, QGridLayout, QFrame, QTreeWidget, QTreeWidgetItem, QTextBrowser, QSizePolicy -) -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QFont - -from NodeGraphQt import NodeGraph, BaseNode, PropertiesBinWidget - -# Harmonious theme with complementary color palette -HARMONIOUS_THEME_STYLESHEET = """ - QWidget { - background-color: #1e1e2e; - color: #cdd6f4; - font-family: "Inter", "SF Pro Display", "Segoe UI", sans-serif; - font-size: 13px; - } - QMainWindow { - background-color: #181825; - } - QDialog { - background-color: #1e1e2e; - border: 1px solid #313244; - } - QLabel { - color: #f9e2af; - font-weight: 500; - } - QLineEdit, QTextEdit, QSpinBox, QDoubleSpinBox, QComboBox { - background-color: #313244; - border: 2px solid #45475a; - padding: 8px 12px; - border-radius: 8px; - color: #cdd6f4; - selection-background-color: #74c7ec; - font-size: 13px; - } - QLineEdit:focus, QTextEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { - border-color: #89b4fa; - background-color: #383a59; - outline: none; - box-shadow: 0 0 0 3px rgba(137, 180, 250, 0.1); - } - QLineEdit:hover, QTextEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover, QComboBox:hover { - border-color: #585b70; - } - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - border: none; - padding: 10px 16px; - border-radius: 8px; - font-weight: 600; - font-size: 13px; - min-height: 16px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #a6c8ff, stop:1 #89dceb); - transform: translateY(-1px); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #7287fd, stop:1 #5fb3d3); - } - QPushButton:disabled { - background-color: #45475a; - color: #6c7086; - } - QDialogButtonBox QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - min-width: 90px; - margin: 2px; - } - QDialogButtonBox QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #a6c8ff, stop:1 #89dceb); - } - QDialogButtonBox QPushButton[text="Cancel"] { - background-color: #585b70; - color: #cdd6f4; - border: 1px solid #6c7086; - } - QDialogButtonBox QPushButton[text="Cancel"]:hover { - background-color: #6c7086; - } - QListWidget { - background-color: #313244; - border: 2px solid #45475a; - border-radius: 8px; - outline: none; - } - QListWidget::item { - padding: 12px; - border-bottom: 1px solid #45475a; - color: #cdd6f4; - border-radius: 4px; - margin: 2px; - } - QListWidget::item:selected { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - border-radius: 6px; - } - QListWidget::item:hover { - background-color: #383a59; - border-radius: 6px; - } - QSplitter::handle { - background-color: #45475a; - width: 3px; - height: 3px; - } - QSplitter::handle:hover { - background-color: #89b4fa; - } - QCheckBox { - color: #cdd6f4; - spacing: 8px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - border: 2px solid #45475a; - border-radius: 4px; - background-color: #313244; - } - QCheckBox::indicator:checked { - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #89b4fa, stop:1 #74c7ec); - border-color: #89b4fa; - } - QCheckBox::indicator:hover { - border-color: #89b4fa; - } - QScrollArea { - border: none; - background-color: #1e1e2e; - } - QScrollBar:vertical { - background-color: #313244; - width: 14px; - border-radius: 7px; - margin: 0px; - } - QScrollBar::handle:vertical { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec); - border-radius: 7px; - min-height: 20px; - margin: 2px; - } - QScrollBar::handle:vertical:hover { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #a6c8ff, stop:1 #89dceb); - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - border: none; - background: none; - height: 0px; - } - QMenuBar { - background-color: #181825; - color: #cdd6f4; - border-bottom: 1px solid #313244; - padding: 4px; - } - QMenuBar::item { - padding: 8px 12px; - background-color: transparent; - border-radius: 6px; - } - QMenuBar::item:selected { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - } - QMenu { - background-color: #313244; - color: #cdd6f4; - border: 1px solid #45475a; - border-radius: 8px; - padding: 4px; - } - QMenu::item { - padding: 8px 16px; - border-radius: 4px; - } - QMenu::item:selected { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - } - QComboBox::drop-down { - border: none; - width: 30px; - border-radius: 4px; - } - QComboBox::down-arrow { - image: none; - border: 5px solid transparent; - border-top: 6px solid #cdd6f4; - margin-right: 8px; - } - QFormLayout QLabel { - font-weight: 600; - margin-bottom: 4px; - color: #f9e2af; - } - QTextEdit { - line-height: 1.4; - } - /* Custom accent colors for different UI states */ - .success { - color: #a6e3a1; - } - .warning { - color: #f9e2af; - } - .error { - color: #f38ba8; - } - .info { - color: #89b4fa; - } -""" - - -# 1. 修改节点类,只使用简单的create_property -# 更新节点类,添加业务相关属性 -class ModelNode(BaseNode): - """Model node for ML inference""" - - __identifier__ = 'com.cluster.model_node' - NODE_NAME = 'Model Node' - - def __init__(self): - super(ModelNode, self).__init__() - - self.add_input('input', multi_input=False, color=(255, 140, 0)) - self.add_output('output', color=(0, 255, 0)) - self.set_color(65, 84, 102) - - # 业务属性 - self.create_property('model_path', '') - self.create_property('dongle_series', '520') - self.create_property('num_dongles', 1) - self.create_property('port_id', '') - - # 属性选项和验证规则 - self._property_options = { - 'dongle_series': ['520', '720', '1080', 'Custom'], - 'num_dongles': {'min': 1, 'max': 16}, - 'model_path': {'type': 'file_path', 'filter': 'Model files (*.onnx *.tflite *.pb)'}, - 'port_id': {'placeholder': 'e.g., 8080 or auto'} - } - -class PreprocessNode(BaseNode): - """Preprocessing node""" - - __identifier__ = 'com.cluster.preprocess_node' - NODE_NAME = 'Preprocess Node' - - def __init__(self): - super(PreprocessNode, self).__init__() - - self.add_input('input', multi_input=False, color=(255, 140, 0)) - self.add_output('output', color=(0, 255, 0)) - self.set_color(45, 126, 72) - - # 预处理业务属性 - self.create_property('resize_width', 640) - self.create_property('resize_height', 480) - self.create_property('normalize', True) - self.create_property('crop_enabled', False) - self.create_property('operations', 'resize,normalize') - - self._property_options = { - 'resize_width': {'min': 64, 'max': 4096}, - 'resize_height': {'min': 64, 'max': 4096}, - 'operations': {'placeholder': 'comma-separated: resize,normalize,crop'} - } - -class PostprocessNode(BaseNode): - """Postprocessing node""" - - __identifier__ = 'com.cluster.postprocess_node' - NODE_NAME = 'Postprocess Node' - - def __init__(self): - super(PostprocessNode, self).__init__() - - self.add_input('input', multi_input=False, color=(255, 140, 0)) - self.add_output('output', color=(0, 255, 0)) - self.set_color(153, 51, 51) - - # 后处理业务属性 - self.create_property('output_format', 'JSON') - self.create_property('confidence_threshold', 0.5) - self.create_property('nms_threshold', 0.4) - self.create_property('max_detections', 100) - - self._property_options = { - 'output_format': ['JSON', 'XML', 'CSV', 'Binary'], - 'confidence_threshold': {'min': 0.0, 'max': 1.0, 'step': 0.1}, - 'nms_threshold': {'min': 0.0, 'max': 1.0, 'step': 0.1}, - 'max_detections': {'min': 1, 'max': 1000} - } - -class InputNode(BaseNode): - """Input data source node""" - - __identifier__ = 'com.cluster.input_node' - NODE_NAME = 'Input Node' - - def __init__(self): - super(InputNode, self).__init__() - - self.add_output('output', color=(0, 255, 0)) - self.set_color(83, 133, 204) - - # 输入源业务属性 - self.create_property('source_type', 'Camera') - self.create_property('device_id', 0) - self.create_property('source_path', '') - self.create_property('resolution', '1920x1080') - self.create_property('fps', 30) - - self._property_options = { - 'source_type': ['Camera', 'Microphone', 'File', 'RTSP Stream', 'HTTP Stream'], - 'device_id': {'min': 0, 'max': 10}, - 'resolution': ['640x480', '1280x720', '1920x1080', '3840x2160', 'Custom'], - 'fps': {'min': 1, 'max': 120}, - 'source_path': {'type': 'file_path', 'filter': 'Media files (*.mp4 *.avi *.mov *.mkv *.wav *.mp3)'} - } - -class OutputNode(BaseNode): - """Output data sink node""" - - __identifier__ = 'com.cluster.output_node' - NODE_NAME = 'Output Node' - - def __init__(self): - super(OutputNode, self).__init__() - - self.add_input('input', multi_input=False, color=(255, 140, 0)) - self.set_color(255, 140, 0) - - # 输出业务属性 - self.create_property('output_type', 'File') - self.create_property('destination', '') - self.create_property('format', 'JSON') - self.create_property('save_interval', 1.0) - - self._property_options = { - 'output_type': ['File', 'API Endpoint', 'Database', 'Display', 'MQTT'], - 'format': ['JSON', 'XML', 'CSV', 'Binary'], - 'destination': {'type': 'file_path', 'filter': 'Output files (*.json *.xml *.csv *.txt)'}, - 'save_interval': {'min': 0.1, 'max': 60.0, 'step': 0.1} - } - -# 修复后的CustomPropertiesWidget -class CustomPropertiesWidget(QWidget): - def __init__(self, graph): - super().__init__() - self.graph = graph - self.current_node = None - self.property_widgets = {} - self.original_values = {} # Store original values for reset functionality - - self.setup_ui() - print("Connecting node selection changed signal...") - self.graph.node_selection_changed.connect(self.on_selection_changed) - print("Signal connected successfully") - - def setup_ui(self): - layout = QVBoxLayout(self) - - # 标题 - self.title = QLabel("Business Properties") - self.title.setFont(QFont("Arial", 12, QFont.Bold)) - self.title.setStyleSheet("color: #343a40; padding: 10px; background-color: #e9ecef; border-radius: 4px;") - layout.addWidget(self.title) - - # 调试信息标签 - self.debug_label = QLabel("No node selected") - self.debug_label.setStyleSheet("color: #6c757d; font-size: 10px; padding: 5px;") - layout.addWidget(self.debug_label) - - # 滚动区域 - self.scroll_area = QScrollArea() - self.properties_container = QWidget() - self.properties_layout = QFormLayout(self.properties_container) - self.properties_layout.setSpacing(8) - - self.scroll_area.setWidget(self.properties_container) - self.scroll_area.setWidgetResizable(True) - layout.addWidget(self.scroll_area) - - # 底部按钮 - self.button_layout = QHBoxLayout() - self.apply_btn = QPushButton("Apply Changes") - self.apply_btn.clicked.connect(self.apply_changes) - self.reset_btn = QPushButton("Reset") - self.reset_btn.clicked.connect(self.reset_properties) - - self.button_layout.addWidget(self.apply_btn) - self.button_layout.addWidget(self.reset_btn) - layout.addLayout(self.button_layout) - - def on_selection_changed(self): - print("Selection changed event triggered") - selected = self.graph.selected_nodes() - print(f"Selected nodes: {[node.name() for node in selected]}") - - if selected: - self.load_node_properties(selected[0]) - else: - self.clear_properties() - - def clear_properties(self): - print("Clearing properties...") - for widget in self.property_widgets.values(): - widget.deleteLater() - self.property_widgets.clear() - - for i in reversed(range(self.properties_layout.count())): - item = self.properties_layout.itemAt(i) - if item and item.widget(): - item.widget().deleteLater() - - self.title.setText("Business Properties") - self.debug_label.setText("No node selected") - - def load_node_properties(self, node): - print(f"Loading properties for node: {node.name()}") - - self.clear_properties() - self.current_node = node - - self.title.setText(f"Business Properties - {node.name()}") - - # 从custom字典中获取业务属性 - try: - all_properties = node.properties() - print(f"All properties: {all_properties}") - - # 检查是否有custom字典 - if 'custom' in all_properties: - custom_properties = all_properties['custom'] - print(f"Custom properties found: {custom_properties}") - - # Store original values for reset functionality - self.original_values = custom_properties.copy() - - self.debug_label.setText(f"Found {len(custom_properties)} business properties") - - if not custom_properties: - no_props_label = QLabel("No business properties found for this node type.") - no_props_label.setStyleSheet("color: #dc3545; font-style: italic; padding: 10px;") - self.properties_layout.addRow(no_props_label) - return - - for prop_name, value in custom_properties.items(): - try: - print(f"Property {prop_name} = {value}") - - widget = self.create_property_widget(prop_name, value, node) - if widget: - self.property_widgets[prop_name] = widget - label = QLabel(prop_name.replace('_', ' ').title() + ":") - label.setStyleSheet("font-weight: bold; color: #495057;") - self.properties_layout.addRow(label, widget) - print(f"Added widget for {prop_name}") - except Exception as e: - print(f"Error loading property {prop_name}: {e}") - import traceback - traceback.print_exc() - # 添加错误显示但不停止加载其他属性 - error_label = QLabel(f"Error loading {prop_name}") - error_label.setStyleSheet("color: #dc3545; font-style: italic;") - self.properties_layout.addRow(f"{prop_name}:", error_label) - else: - print("No custom properties found") - self.debug_label.setText("No custom properties found") - self.original_values = {} - no_props_label = QLabel("This node type has no configurable properties.") - no_props_label.setStyleSheet("color: #6c757d; font-style: italic; padding: 10px;") - self.properties_layout.addRow(no_props_label) - - except Exception as e: - print(f"Error accessing node properties: {e}") - import traceback - traceback.print_exc() - self.debug_label.setText("Error loading properties") - - def create_property_widget(self, prop_name, value, node): - """根据属性类型和选项创建对应的输入控件""" - print(f"Creating widget for {prop_name}, value: {value}") - - options = None - if hasattr(node, '_property_options') and prop_name in node._property_options: - options = node._property_options[prop_name] - print(f"Found options for {prop_name}: {options}") - - # 文件路径选择器 - if isinstance(options, dict) and options.get('type') == 'file_path': - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - - line_edit = QLineEdit() - line_edit.setText(str(value)) - line_edit.setPlaceholderText(options.get('placeholder', 'Select file...')) - - browse_btn = QPushButton("Browse") - browse_btn.setMaximumWidth(70) - browse_btn.clicked.connect(lambda: self.browse_file(line_edit, options.get('filter', 'All files (*.*)'))) - - layout.addWidget(line_edit) - layout.addWidget(browse_btn) - - # 绑定变化事件 - 使用直接属性更新 - line_edit.textChanged.connect(lambda text: self.update_node_property(node, prop_name, text)) - return container - - # 下拉选择 - elif isinstance(options, list): - widget = QComboBox() - widget.addItems(options) - if str(value) in options: - widget.setCurrentText(str(value)) - widget.currentTextChanged.connect(lambda text: self.update_node_property(node, prop_name, text)) - return widget - - # 数值范围控件 - 修复类型转换 - elif isinstance(options, dict) and 'min' in options and 'max' in options: - if isinstance(value, float) or options.get('step'): - widget = QDoubleSpinBox() - widget.setDecimals(1) - widget.setSingleStep(options.get('step', 0.1)) - widget.setValue(float(value) if isinstance(value, (int, float)) else float(options['min'])) - else: - widget = QSpinBox() - # 修复: 确保传入int类型 - widget.setValue(int(value) if isinstance(value, (int, float)) else int(options['min'])) - - widget.setRange(options['min'], options['max']) - widget.valueChanged.connect(lambda val: self.update_node_property(node, prop_name, val)) - return widget - - # 布尔值 - elif isinstance(value, bool): - widget = QCheckBox() - widget.setChecked(value) - widget.toggled.connect(lambda checked: self.update_node_property(node, prop_name, checked)) - return widget - - # 普通文本输入 - else: - widget = QLineEdit() - widget.setText(str(value)) - if isinstance(options, dict) and 'placeholder' in options: - widget.setPlaceholderText(options['placeholder']) - widget.textChanged.connect(lambda text: self.update_node_property(node, prop_name, text)) - return widget - - def update_node_property(self, node, prop_name, value): - """更新节点属性 - 使用直接属性更新方式""" - try: - print(f"Updating {prop_name} = {value}") - - # 尝试直接设置属性(如果NodeGraphQt支持) - try: - node.set_property(prop_name, value) - print(f"Successfully updated {prop_name} using set_property") - return - except Exception as e: - print(f"set_property failed: {e}") - - # 如果直接设置失败,尝试通过节点内部更新 - # 直接修改节点的_property_changed属性(如果存在) - if hasattr(node, '_model') and hasattr(node._model, '_custom_properties'): - if not hasattr(node._model, '_custom_properties'): - node._model._custom_properties = {} - node._model._custom_properties[prop_name] = value - print(f"Updated via _custom_properties: {prop_name} = {value}") - return - - # 最后的备用方案:直接修改properties字典(可能不持久化) - properties = node.properties() - if 'custom' in properties: - properties['custom'][prop_name] = value - print(f"Updated via properties dict: {prop_name} = {value}") - - except Exception as e: - print(f"Error updating property {prop_name}: {e}") - import traceback - traceback.print_exc() - - # 显示错误但不崩溃 - QMessageBox.warning(self, "Property Update Error", - f"Failed to update {prop_name}: {str(e)}\n\n" - f"Value will be reset when node is reselected.") - - def browse_file(self, line_edit, file_filter): - """打开文件浏览对话框""" - filename, _ = QFileDialog.getOpenFileName(self, "Select File", "", file_filter) - if filename: - line_edit.setText(filename) - - def apply_changes(self): - """应用所有更改""" - if self.current_node: - # 尝试保存当前所有属性值 - try: - saved_count = 0 - for prop_name, widget in self.property_widgets.items(): - # 根据widget类型获取当前值 - if isinstance(widget, QLineEdit): - value = widget.text() - elif isinstance(widget, QComboBox): - value = widget.currentText() - elif isinstance(widget, (QSpinBox, QDoubleSpinBox)): - value = widget.value() - elif isinstance(widget, QCheckBox): - value = widget.isChecked() - elif isinstance(widget, QWidget): # 文件路径容器 - line_edit = widget.findChild(QLineEdit) - value = line_edit.text() if line_edit else "" - else: - continue - - self.update_node_property(self.current_node, prop_name, value) - saved_count += 1 - - QMessageBox.information(self, "Applied", - f"Applied {saved_count} properties to {self.current_node.name()}") - except Exception as e: - QMessageBox.warning(self, "Apply Error", f"Error applying changes: {str(e)}") - - def reset_properties(self): - """Reset properties to original values when node was first loaded""" - if self.current_node: - reply = QMessageBox.question(self, "Reset Properties", - "Reset all properties to their original values?", - QMessageBox.Yes | QMessageBox.No) - if reply == QMessageBox.Yes: - try: - # Use original values if available, otherwise fall back to defaults - values_to_restore = self.original_values if self.original_values else self.get_default_values_for_node(self.current_node) - - if not values_to_restore: - QMessageBox.information(self, "No Reset Available", - "No original or default values available to reset.") - return - - # Reset each property to its original/default value - reset_count = 0 - for prop_name, original_value in values_to_restore.items(): - try: - self.update_node_property(self.current_node, prop_name, original_value) - reset_count += 1 - print(f"Reset {prop_name} to {original_value}") - except Exception as e: - print(f"Error resetting property {prop_name}: {e}") - - # Update the UI widgets to show the reset values - self.update_ui_widgets_with_values(values_to_restore) - - source_type = "original" if self.original_values else "default" - QMessageBox.information(self, "Reset Complete", - f"Reset {reset_count} properties to {source_type} values.") - - except Exception as e: - QMessageBox.warning(self, "Reset Error", f"Error resetting properties: {str(e)}") - - def update_ui_widgets_with_values(self, values_dict): - """Update the UI widgets to display the specified values""" - for prop_name, value in values_dict.items(): - if prop_name in self.property_widgets: - widget = self.property_widgets[prop_name] - try: - # Update widget based on type - if isinstance(widget, QLineEdit): - widget.setText(str(value)) - elif isinstance(widget, QComboBox): - widget.setCurrentText(str(value)) - elif isinstance(widget, (QSpinBox, QDoubleSpinBox)): - widget.setValue(value) - elif isinstance(widget, QCheckBox): - widget.setChecked(bool(value)) - elif isinstance(widget, QWidget): # File path container - line_edit = widget.findChild(QLineEdit) - if line_edit: - line_edit.setText(str(value)) - except Exception as e: - print(f"Error updating widget for {prop_name}: {e}") - - def get_default_values_for_node(self, node): - """Get the default property values for a specific node type""" - # Define default values for each node type - defaults = { - 'ModelNode': { - 'model_path': '', - 'dongle_series': '520', - 'num_dongles': 1, - 'port_id': '' - }, - 'PreprocessNode': { - 'resize_width': 640, - 'resize_height': 480, - 'normalize': True, - 'crop_enabled': False, - 'operations': 'resize,normalize' - }, - 'PostprocessNode': { - 'output_format': 'JSON', - 'confidence_threshold': 0.5, - 'nms_threshold': 0.4, - 'max_detections': 100 - }, - 'InputNode': { - 'source_type': 'Camera', - 'device_id': 0, - 'source_path': '', - 'resolution': '1920x1080', - 'fps': 30 - }, - 'OutputNode': { - 'output_type': 'File', - 'destination': '', - 'format': 'JSON', - 'save_interval': 1.0 - } - } - - # Get the node class name - node_class_name = node.__class__.__name__ - - # Return the defaults for this node type, or empty dict if not found - return defaults.get(node_class_name, {}) - -class CreatePipelineDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Create New Pipeline") - self.setMinimumWidth(450) - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - - self.projectNameInput = QLineEdit(self) - self.descriptionInput = QTextEdit(self) - self.descriptionInput.setPlaceholderText("Optional (briefly describe the pipeline's purpose)") - self.descriptionInput.setFixedHeight(80) - - formLayout = QFormLayout() - formLayout.setSpacing(10) - formLayout.addRow("Project Name:", self.projectNameInput) - formLayout.addRow("Description:", self.descriptionInput) - - self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.buttonBox.accepted.connect(self.handle_ok) - self.buttonBox.rejected.connect(self.reject) - - mainLayout = QVBoxLayout(self) - mainLayout.setContentsMargins(15, 15, 15, 15) - mainLayout.addLayout(formLayout) - mainLayout.addSpacing(10) - mainLayout.addWidget(self.buttonBox) - - def handle_ok(self): - if not self.projectNameInput.text().strip(): - QMessageBox.warning(self, "Input Error", "Project Name cannot be empty.") - return - self.accept() - - def get_data(self): - return { - "project_name": self.projectNameInput.text().strip(), - "description": self.descriptionInput.toPlainText().strip() - } - -class SimplePropertiesDialog(QDialog): # Corrected and placed before PipelineEditor - """Simple properties dialog as fallback""" - - def __init__(self, node, parent=None): - super().__init__(parent) - self.node = node - self.setWindowTitle(f"Properties - {node.name()}") - self.setMinimumWidth(400) - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - - layout = QVBoxLayout(self) - - # Node info - info_label = QLabel(f"Node: {node.name()}\nType: {node.type_}") - info_label.setFont(QFont("Arial", 12, QFont.Bold)) - layout.addWidget(info_label) - - # Properties - form_layout = QFormLayout() - self.property_widgets = {} - - try: - if hasattr(node, 'properties'): - for prop_name in node.properties(): - try: - current_value = node.get_property(prop_name) - - # Create simple text input for all properties - widget = QLineEdit() - widget.setText(str(current_value)) - - self.property_widgets[prop_name] = widget - form_layout.addRow(f"{prop_name}:", widget) - - except Exception as e: - print(f"Error loading property {prop_name} for node {node.name()}: {e}") - # Optionally add a label to the dialog indicating error for this property - error_prop_label = QLabel(f"(Error loading {prop_name})") - form_layout.addRow(f"{prop_name}:", error_prop_label) - - except Exception as e: - print(f"Error loading properties for node {node.name()}: {e}") - error_label = QLabel(f"Error loading properties: {e}") - layout.addWidget(error_label) - - layout.addLayout(form_layout) - - # Buttons - button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - button_box.accepted.connect(self.save_properties) - button_box.rejected.connect(self.reject) - layout.addWidget(button_box) - - def save_properties(self): - """Save property changes""" - try: - for prop_name, widget in self.property_widgets.items(): - if not isinstance(widget, QLineEdit): # Skip if widget is not an input (e.g. error label) - continue - text_value = widget.text() - - # Try to convert to appropriate type - try: - # Get original value to determine type - original_value = self.node.get_property(prop_name) # Assuming this doesn't error again - if isinstance(original_value, bool): - value = text_value.lower() in ('true', '1', 'yes', 'on') - elif isinstance(original_value, int): - value = int(text_value) - elif isinstance(original_value, float): - value = float(text_value) - else: - value = text_value - except (ValueError, TypeError): # Fallback if conversion fails - value = text_value - except Exception as e_get_prop: # Handle case where get_property failed during save_properties' type check - print(f"Could not get original property type for {prop_name} during save. Saving as string. Error: {e_get_prop}") - value = text_value # Save as string if original type unknown - - # Set the property - self.node.set_property(prop_name, value) - - self.accept() - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to save properties: {str(e)}") - -class NodePalette(QWidget): - """Node palette for adding nodes""" - def __init__(self, graph): - super().__init__() - self.graph = graph - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - - title = QLabel("Node Palette") - title.setFont(QFont("Arial", 12, QFont.Bold)) - layout.addWidget(title) - - # Node creation buttons - nodes = [ - ("Input", InputNode), - ("Model", ModelNode), - ("Preprocess", PreprocessNode), - ("Postprocess", PostprocessNode), - ("Output", OutputNode) - ] - - for name, node_class in nodes: - btn = QPushButton(name) - btn.clicked.connect(lambda checked, cls=node_class: self.create_node(cls)) - layout.addWidget(btn) - - # Instructions - instructions = QLabel() - instructions.setText( - "📍 Click buttons to add nodes\n" - "⌨️ Or press TAB in graph area to search nodes\n" - "🔗 Drag from 🟢GREEN output to 🟠ORANGE input\n" - "⚙️ Double-click nodes to see properties\n" - "🗑️ Select + Delete key to remove\n" - "💾 Ctrl+S to save\n" - "🎯 Right-click for context menu\n" - "📋 View menu → Properties Panel\n" - "🔍 Debug menu → Show Registered Nodes" - ) - instructions.setWordWrap(True) - instructions.setStyleSheet("color: #6c757d; font-size: 10px; margin: 10px 0; padding: 10px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px;") - layout.addWidget(instructions) - - layout.addStretch() - - def create_node(self, node_class): - """Create a new node and add to graph""" - try: - # Use class name instead of NODE_NAME for identifier - node_id = f"{node_class.__identifier__}.{node_class.__name__}" - # print(f"Creating node: {node_id}") # Optional: for debugging - node = self.graph.create_node(node_id) - if node: # Check if node was created - node.set_pos(100, 100) # Position new nodes at visible location - # print(f"Successfully created node: {node_id}") # Optional: for debugging - else: - # This case might happen if registration was incomplete or ID is wrong. - raise Exception(f"Graph returned None for create_node with ID: {node_id}") - - except Exception as e: - print(f"Failed to create node {node_class.__name__}: {e}") - QMessageBox.warning(self, "Node Creation Error", - f"Failed to create {node_class.__name__}: {str(e)}\n\n" - f"Try using Tab key to search for nodes, or check console for registration errors.") - -class IntegratedPipelineDashboard(QMainWindow): - """Integrated dashboard combining pipeline editor, stage configuration, and performance estimation""" - - def __init__(self, project_name="", description="", filename=None): - super().__init__() - self.project_name = project_name - self.description = description - self.current_file = filename - self.is_modified = False - - # Initialize attributes that will be used by methods - self.allocation_layout = None - self.stage_configs_layout = None - self.stages_spinbox = None - self.dongles_list = None - self.fps_label = None - self.latency_label = None - self.memory_label = None - self.suggestions_text = None - self.props_instructions = None - self.node_props_container = None - self.node_props_layout = None - - # Initialize node graph - self.graph = NodeGraph(properties_bin_class=None) - - # Register custom nodes - nodes_to_register = [InputNode, ModelNode, PreprocessNode, PostprocessNode, OutputNode] - for node_class in nodes_to_register: - try: - self.graph.register_node(node_class) - # Uncomment for debugging: print(f"✅ Registered {node_class.__name__}") - except Exception as e: - print(f"❌ Failed to register {node_class.__name__}: {e}") - - # Connect signals - self.graph.node_created.connect(self.mark_modified) - self.graph.nodes_deleted.connect(self.mark_modified) - self.graph.property_changed.connect(self.mark_modified) - - if hasattr(self.graph, 'port_connected'): - self.graph.port_connected.connect(self.on_port_connected) - if hasattr(self.graph, 'port_disconnected'): - self.graph.port_disconnected.connect(self.on_port_disconnected) - - self.setup_integrated_ui() - self.setup_menu() - - # Add keyboard shortcut for delete - self.delete_shortcut = QAction("Delete", self) - self.delete_shortcut.setShortcut('Delete') - self.delete_shortcut.triggered.connect(self.delete_selected_nodes) - self.addAction(self.delete_shortcut) - - self.update_window_title() - self.setGeometry(50, 50, 2000, 1200) # Wider window for 3-panel layout - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - - def setup_integrated_ui(self): - """Setup the integrated UI with node templates, pipeline editor and configuration panels""" - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # Main horizontal splitter with 3 panels - main_splitter = QSplitter(Qt.Horizontal) - main_splitter.setStyleSheet(""" - QSplitter::handle { - background-color: #45475a; - width: 3px; - } - QSplitter::handle:hover { - background-color: #89b4fa; - } - """) - - # Left side: Node Template Panel (20% width) - left_panel = self.create_node_template_panel() - left_panel.setMinimumWidth(250) - left_panel.setMaximumWidth(350) - - # Middle: Pipeline Editor (50% width) - editor_widget = QWidget() - editor_layout = QVBoxLayout(editor_widget) - editor_layout.setContentsMargins(5, 5, 5, 5) - - # Add pipeline editor title - editor_title = QLabel("Pipeline Editor") - editor_title.setStyleSheet("color: #f9e2af; font-size: 16px; font-weight: bold; padding: 10px;") - editor_layout.addWidget(editor_title) - - # Add the node graph widget - graph_widget = self.graph.widget - graph_widget.setMinimumHeight(400) - editor_layout.addWidget(graph_widget) - - # Right side: Configuration panels (30% width) - right_panel = QWidget() - right_panel.setMinimumWidth(350) - right_panel.setMaximumWidth(450) - right_layout = QVBoxLayout(right_panel) - right_layout.setContentsMargins(5, 5, 5, 5) - right_layout.setSpacing(10) - - # Create tabs for different configuration sections - config_tabs = QTabWidget() - config_tabs.setStyleSheet(""" - QTabWidget::pane { - border: 2px solid #45475a; - border-radius: 8px; - background-color: #313244; - } - QTabWidget::tab-bar { - alignment: center; - } - QTabBar::tab { - background-color: #45475a; - color: #cdd6f4; - padding: 6px 12px; - margin: 1px; - border-radius: 4px; - font-size: 11px; - } - QTabBar::tab:selected { - background-color: #89b4fa; - color: #1e1e2e; - font-weight: bold; - } - QTabBar::tab:hover { - background-color: #585b70; - } - """) - - # Node Properties Tab (most important for editing) - node_props_panel = self.create_node_properties_panel() - config_tabs.addTab(node_props_panel, "📝 Properties") - - # Stage Configuration Tab - stage_config = self.create_stage_config_panel() - config_tabs.addTab(stage_config, "⚙️ Stages") - - # Performance Estimation Tab - performance_panel = self.create_performance_panel() - config_tabs.addTab(performance_panel, "📊 Performance") - - # Dongle Management Tab - dongle_panel = self.create_dongle_panel() - config_tabs.addTab(dongle_panel, "🔌 Dongles") - - right_layout.addWidget(config_tabs) - - # Add widgets to splitter - main_splitter.addWidget(left_panel) - main_splitter.addWidget(editor_widget) - main_splitter.addWidget(right_panel) - main_splitter.setSizes([300, 800, 400]) # 20-50-30 split - - # Set main layout - main_layout = QVBoxLayout(central_widget) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(main_splitter) - - def create_node_template_panel(self): - """Create left panel with node templates""" - panel = QWidget() - layout = QVBoxLayout(panel) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(10) - - # Header - header = QLabel("Node Templates") - header.setStyleSheet("color: #f9e2af; font-size: 16px; font-weight: bold; padding: 10px;") - layout.addWidget(header) - - # Node template buttons - nodes_info = [ - ("🎯 Input Node", "Data input source", InputNode), - ("🧠 Model Node", "AI inference model", ModelNode), - ("⚙️ Preprocess Node", "Data preprocessing", PreprocessNode), - ("🔧 Postprocess Node", "Output processing", PostprocessNode), - ("📤 Output Node", "Final output", OutputNode) - ] - - for name, description, node_class in nodes_info: - # Create container for each node type - node_container = QFrame() - node_container.setStyleSheet(""" - QFrame { - background-color: #313244; - border: 2px solid #45475a; - border-radius: 8px; - padding: 5px; - } - QFrame:hover { - border-color: #89b4fa; - background-color: #383a59; - } - """) - - container_layout = QVBoxLayout(node_container) - container_layout.setContentsMargins(8, 8, 8, 8) - container_layout.setSpacing(4) - - # Node name - name_label = QLabel(name) - name_label.setStyleSheet("color: #cdd6f4; font-weight: bold; font-size: 12px;") - container_layout.addWidget(name_label) - - # Description - desc_label = QLabel(description) - desc_label.setStyleSheet("color: #a6adc8; font-size: 10px;") - desc_label.setWordWrap(True) - container_layout.addWidget(desc_label) - - # Add button - add_btn = QPushButton("+ Add") - add_btn.setStyleSheet(""" - QPushButton { - background-color: #89b4fa; - color: #1e1e2e; - border: none; - padding: 4px 8px; - border-radius: 4px; - font-size: 10px; - font-weight: bold; - } - QPushButton:hover { - background-color: #a6c8ff; - } - QPushButton:pressed { - background-color: #7287fd; - } - """) - add_btn.clicked.connect(lambda checked, nc=node_class: self.add_node_to_graph(nc)) - container_layout.addWidget(add_btn) - - layout.addWidget(node_container) - - # Add stretch to push everything to top - layout.addStretch() - - # Instructions - instructions = QLabel("💡 Click 'Add' to insert nodes into the pipeline editor") - instructions.setStyleSheet(""" - color: #f9e2af; - font-size: 10px; - padding: 10px; - background-color: #313244; - border-radius: 6px; - border-left: 3px solid #89b4fa; - """) - instructions.setWordWrap(True) - layout.addWidget(instructions) - - return panel - - def create_node_properties_panel(self): - """Create node properties editing panel""" - widget = QScrollArea() - content = QWidget() - layout = QVBoxLayout(content) - - # Header - header = QLabel("Node Properties") - header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;") - layout.addWidget(header) - - # Instructions when no node selected - self.props_instructions = QLabel("Select a node in the pipeline editor to view and edit its properties") - self.props_instructions.setStyleSheet(""" - color: #a6adc8; - font-size: 12px; - padding: 20px; - background-color: #313244; - border-radius: 8px; - border: 2px dashed #45475a; - """) - self.props_instructions.setWordWrap(True) - self.props_instructions.setAlignment(Qt.AlignCenter) - layout.addWidget(self.props_instructions) - - # Container for dynamic properties (will be populated when node is selected) - self.node_props_container = QWidget() - self.node_props_layout = QVBoxLayout(self.node_props_container) - layout.addWidget(self.node_props_container) - - # Initially hide the container - self.node_props_container.setVisible(False) - - layout.addStretch() - widget.setWidget(content) - widget.setWidgetResizable(True) - - # Connect to node selection changes - self.graph.node_selection_changed.connect(self.update_node_properties_panel) - - return widget - - def add_node_to_graph(self, node_class): - """Add a new node to the graph""" - try: - # Create node instance directly and add to graph - node = node_class() - self.graph.add_node(node) - - # Position it in a reasonable location (center with some randomness) - import random - x = random.randint(-100, 100) - y = random.randint(-100, 100) - node.set_pos(x, y) - - print(f"✅ Added {node_class.__name__} to graph at position ({x}, {y})") - self.mark_modified() - - # Auto-update performance estimation - self.update_performance_estimation() - - except Exception as e: - print(f"❌ Failed to add {node_class.__name__}: {e}") - import traceback - traceback.print_exc() - QMessageBox.warning(self, "Error", f"Failed to add {node_class.__name__}:\n{str(e)}") - - def update_node_properties_panel(self): - """Update the node properties panel based on selected node""" - try: - # Clear existing properties - for i in reversed(range(self.node_props_layout.count())): - child = self.node_props_layout.itemAt(i).widget() - if child: - child.setParent(None) - - selected_nodes = self.graph.selected_nodes() - - if not selected_nodes: - # Show instructions when no node selected - self.props_instructions.setVisible(True) - self.node_props_container.setVisible(False) - return - - # Hide instructions and show properties - self.props_instructions.setVisible(False) - self.node_props_container.setVisible(True) - - # Get first selected node - node = selected_nodes[0] - - # Create properties form for the node - self.create_node_properties_form(node) - - except Exception as e: - print(f"Error updating node properties panel: {e}") - - def create_node_properties_form(self, node): - """Create properties form for a specific node""" - try: - # Get node name safely - try: - node_name = node.name() if callable(node.name) else str(node.name) - except: - node_name = "Unknown Node" - - # Get node type safely with clean display names - try: - raw_type = node.type_() if callable(node.type_) else str(getattr(node, 'type_', 'Unknown')) - - # Check if it has a clean NODE_NAME attribute first - if hasattr(node, 'NODE_NAME'): - node_type = node.NODE_NAME - else: - # Extract clean name from full identifier or class name - if 'com.cluster.' in raw_type: - # Extract from full identifier like com.cluster.input_node.ExactInputNode - if raw_type.endswith('.ExactInputNode'): - node_type = 'Input Node' - elif raw_type.endswith('.ExactModelNode'): - node_type = 'Model Node' - elif raw_type.endswith('.ExactPreprocessNode'): - node_type = 'Preprocess Node' - elif raw_type.endswith('.ExactPostprocessNode'): - node_type = 'Postprocess Node' - elif raw_type.endswith('.ExactOutputNode'): - node_type = 'Output Node' - else: - # Fallback: extract base name - parts = raw_type.split('.') - if len(parts) >= 3: - base_name = parts[2].replace('_', ' ').title() + ' Node' - node_type = base_name - else: - node_type = raw_type - else: - # Extract from class name like ExactInputNode - class_name = node.__class__.__name__ - if class_name.startswith('Exact') and class_name.endswith('Node'): - # Remove 'Exact' prefix and add space before 'Node' - clean_name = class_name[5:] # Remove 'Exact' - if clean_name == 'InputNode': - node_type = 'Input Node' - elif clean_name == 'ModelNode': - node_type = 'Model Node' - elif clean_name == 'PreprocessNode': - node_type = 'Preprocess Node' - elif clean_name == 'PostprocessNode': - node_type = 'Postprocess Node' - elif clean_name == 'OutputNode': - node_type = 'Output Node' - else: - node_type = clean_name - else: - node_type = class_name - except: - node_type = "Unknown Type" - - # Node info header - info_group = QGroupBox(f"{node_name} Properties") - info_layout = QFormLayout(info_group) - - # Node name (editable) - name_edit = QLineEdit(node_name) - def update_node_name(text): - try: - if hasattr(node, 'set_name') and callable(node.set_name): - node.set_name(text) - self.mark_modified() - except Exception as e: - print(f"Error updating node name: {e}") - - name_edit.textChanged.connect(update_node_name) - info_layout.addRow("Name:", name_edit) - - # Node type (read-only) - type_label = QLabel(node_type) - type_label.setStyleSheet("color: #a6adc8;") - info_layout.addRow("Type:", type_label) - - self.node_props_layout.addWidget(info_group) - - # Get node properties - NodeGraphQt uses different property access methods - custom_props = {} - - # Method 0: Try to get business properties first (highest priority) - try: - if hasattr(node, 'get_business_properties') and callable(node.get_business_properties): - business_props = node.get_business_properties() - if business_props: - custom_props = business_props - print(f"Found properties via get_business_properties(): {list(custom_props.keys())}") - elif hasattr(node, '_business_properties') and node._business_properties: - custom_props = node._business_properties.copy() - print(f"Found properties via _business_properties: {list(custom_props.keys())}") - - # Check if node has a custom display properties method - if hasattr(node, 'get_display_properties') and callable(node.get_display_properties): - display_props = node.get_display_properties() - if display_props and custom_props: - # Filter to only show the specified display properties - filtered_props = {k: v for k, v in custom_props.items() if k in display_props} - if filtered_props: - custom_props = filtered_props - print(f"Filtered to display properties: {list(custom_props.keys())}") - - except Exception as e: - print(f"Method 0 - get_business_properties() failed: {e}") - - # Method 1: Try to get properties from NodeGraphQt node (only if no business properties found) - if not custom_props: - try: - if hasattr(node, 'properties'): - # Get all properties from the node - all_props = node.properties() - print(f"All node properties: {list(all_props.keys())}") - - # Check if there's a 'custom' property that contains our properties - if 'custom' in all_props: - custom_value = all_props['custom'] - print(f"Custom property value type: {type(custom_value)}, value: {custom_value}") - - # If custom property contains a dict, use it - if isinstance(custom_value, dict): - custom_props = custom_value - # If custom property is accessible via get_property, try that - elif hasattr(node, 'get_property'): - try: - custom_from_get = node.get_property('custom') - if isinstance(custom_from_get, dict): - custom_props = custom_from_get - except: - pass - - # Also include other potentially useful properties - useful_props = {} - for k, v in all_props.items(): - if k not in {'name', 'id', 'selected', 'disabled', 'visible', 'pos', 'color', - 'type_', 'icon', 'border_color', 'text_color', 'width', 'height', - 'layout_direction', 'port_deletion_allowed', 'subgraph_session'}: - useful_props[k] = v - - # Merge custom_props with other useful properties - if useful_props: - custom_props.update(useful_props) - - print(f"Found properties via node.properties(): {list(custom_props.keys())}") - except Exception as e: - print(f"Method 1 - node.properties() failed: {e}") - - # Method 2: Try to access properties via get_property (for NodeGraphQt created properties) - # This should work for properties created with create_property() - if not custom_props: - try: - # Get all properties defined for this node type - if hasattr(node, 'get_property'): - # Define properties for different node types - node_type_properties = { - 'ModelNode': ['model_path', 'dongle_series', 'num_dongles', 'port_id'], - 'ExactModelNode': ['model_path', 'dongle_series', 'num_dongles', 'port_id'], - 'InputNode': ['input_path', 'source_type', 'fps', 'source_path'], - 'ExactInputNode': ['source_type', 'device_id', 'source_path', 'resolution', 'fps'], - 'OutputNode': ['output_path', 'output_format', 'save_results'], - 'ExactOutputNode': ['output_type', 'destination', 'format', 'save_interval'], - 'PreprocessNode': ['resize_width', 'resize_height', 'operations'], - 'ExactPreprocessNode': ['resize_width', 'resize_height', 'normalize', 'crop_enabled', 'operations'], - 'PostprocessNode': ['confidence_threshold', 'nms_threshold', 'max_detections'], - 'ExactPostprocessNode': ['output_format', 'confidence_threshold', 'nms_threshold', 'max_detections'] - } - - # Try to determine node type - node_class_name = node.__class__.__name__ - properties_to_check = [] - - if node_class_name in node_type_properties: - properties_to_check = node_type_properties[node_class_name] - else: - # Try all known properties if we can't determine the type - properties_to_check = [] - for props_list in node_type_properties.values(): - properties_to_check.extend(props_list) - - print(f"Checking properties for {node_class_name}: {properties_to_check}") - - for prop in properties_to_check: - try: - value = node.get_property(prop) - # Only add if the property actually exists (not None and not raising exception) - custom_props[prop] = value - print(f" Found {prop}: {value}") - except Exception as prop_error: - # Property doesn't exist for this node, which is expected - pass - - print(f"Found properties via get_property(): {list(custom_props.keys())}") - except Exception as e: - print(f"Method 2 - get_property() failed: {e}") - - # Method 3: Try to access custom attribute if it exists - if not custom_props: - try: - if hasattr(node, 'custom'): - custom_attr = node.custom if not callable(node.custom) else node.custom() - if isinstance(custom_attr, dict): - custom_props = custom_attr - print(f"Found properties via custom attribute: {list(custom_props.keys())}") - except Exception as e: - print(f"Method 3 - custom attribute failed: {e}") - - if custom_props: - custom_group = QGroupBox("Custom Properties") - custom_layout = QFormLayout(custom_group) - - for prop_name, prop_value in custom_props.items(): - if prop_name in ['model_path', 'input_path', 'output_path']: - # File path property - path_layout = QHBoxLayout() - path_edit = QLineEdit(str(prop_value)) - browse_btn = QPushButton("Browse...") - browse_btn.clicked.connect(lambda checked, pe=path_edit, pn=prop_name: self.browse_file_for_property(pe, pn)) - - path_layout.addWidget(path_edit) - path_layout.addWidget(browse_btn) - - path_widget = QWidget() - path_widget.setLayout(path_layout) - custom_layout.addRow(prop_name.replace("_", " ").title() + ":", path_widget) - - # Connect to update node property - path_edit.textChanged.connect(lambda text, pn=prop_name: self.update_node_property(node, pn, text)) - - elif isinstance(prop_value, (int, float)): - # Numeric property - if isinstance(prop_value, int): - spin_box = QSpinBox() - spin_box.setRange(-999999, 999999) - spin_box.setValue(prop_value) - spin_box.valueChanged.connect(lambda val, pn=prop_name: self.update_node_property(node, pn, val)) - else: - spin_box = QDoubleSpinBox() - spin_box.setRange(-999999.0, 999999.0) - spin_box.setValue(prop_value) - spin_box.valueChanged.connect(lambda val, pn=prop_name: self.update_node_property(node, pn, val)) - - custom_layout.addRow(prop_name.replace("_", " ").title() + ":", spin_box) - - elif isinstance(prop_value, bool): - # Boolean property - check_box = QCheckBox() - check_box.setChecked(prop_value) - check_box.toggled.connect(lambda checked, pn=prop_name: self.update_node_property(node, pn, checked)) - custom_layout.addRow(prop_name.replace("_", " ").title() + ":", check_box) - - else: - # String property - text_edit = QLineEdit(str(prop_value)) - text_edit.textChanged.connect(lambda text, pn=prop_name: self.update_node_property(node, pn, text)) - custom_layout.addRow(prop_name.replace("_", " ").title() + ":", text_edit) - - self.node_props_layout.addWidget(custom_group) - - # Position info (read-only) - pos_group = QGroupBox("Position") - pos_layout = QFormLayout(pos_group) - - # Get position safely - try: - pos = node.pos() if callable(node.pos) else node.pos - if isinstance(pos, (list, tuple)) and len(pos) >= 2: - x, y = pos[0], pos[1] - else: - x, y = 0.0, 0.0 - except Exception as e: - print(f"Error getting node position: {e}") - x, y = 0.0, 0.0 - - x_label = QLabel(f"{x:.1f}") - y_label = QLabel(f"{y:.1f}") - x_label.setStyleSheet("color: #a6adc8;") - y_label.setStyleSheet("color: #a6adc8;") - - pos_layout.addRow("X:", x_label) - pos_layout.addRow("Y:", y_label) - - self.node_props_layout.addWidget(pos_group) - - except Exception as e: - print(f"Error creating properties form: {e}") - error_label = QLabel(f"Error loading properties: {str(e)}") - error_label.setStyleSheet("color: #f38ba8;") - self.node_props_layout.addWidget(error_label) - - def update_node_property(self, node, property_name, value): - """Update a node's custom property""" - try: - success = False - - # Method 1: Try NodeGraphQt set_property - if hasattr(node, 'set_property') and callable(node.set_property): - try: - node.set_property(property_name, value) - success = True - print(f"✅ Updated via set_property: {property_name} = {value}") - except Exception as e: - print(f"set_property failed: {e}") - - # Method 2: Try custom attribute - if not success and hasattr(node, 'custom'): - try: - if not callable(node.custom): - node.custom[property_name] = value - success = True - print(f"✅ Updated via custom attribute: {property_name} = {value}") - else: - print(f"Warning: Cannot update property {property_name} - custom is a method") - except Exception as e: - print(f"custom attribute update failed: {e}") - - # Method 3: Try direct attribute setting - if not success: - try: - setattr(node, property_name, value) - success = True - print(f"✅ Updated via setattr: {property_name} = {value}") - except Exception as e: - print(f"setattr failed: {e}") - - if success: - self.mark_modified() - - # Get node name safely for logging - try: - node_name = node.name() if callable(node.name) else str(node.name) - except: - node_name = "Unknown Node" - - print(f"✅ Successfully updated {node_name}.{property_name} = {value}") - else: - print(f"❌ Failed to update property {property_name} with all methods") - - except Exception as e: - print(f"❌ Error updating node property {property_name}: {e}") - import traceback - traceback.print_exc() - - def browse_file_for_property(self, line_edit, property_name): - """Open file browser for file path properties""" - try: - if 'model' in property_name.lower(): - file_filter = "Model files (*.nef *.onnx *.tflite);;All files (*.*)" - title = "Select Model File" - elif 'input' in property_name.lower(): - file_filter = "Media files (*.mp4 *.avi *.jpg *.png *.bmp);;All files (*.*)" - title = "Select Input File" - else: - file_filter = "All files (*.*)" - title = "Select File" - - filename, _ = QFileDialog.getOpenFileName(self, title, "", file_filter) - if filename: - line_edit.setText(filename) - - except Exception as e: - print(f"Error browsing file: {e}") - - def create_stage_config_panel(self): - """Create stage configuration panel""" - widget = QScrollArea() - content = QWidget() - layout = QVBoxLayout(content) - - # Header - header = QLabel("Stage Configuration") - header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;") - layout.addWidget(header) - - # Number of stages - stages_layout = QHBoxLayout() - stages_layout.addWidget(QLabel("Number of Stages:")) - self.stages_spinbox = QSpinBox() - self.stages_spinbox.setRange(1, 10) - self.stages_spinbox.setValue(2) - self.stages_spinbox.valueChanged.connect(self.update_stage_configs) - stages_layout.addWidget(self.stages_spinbox) - layout.addLayout(stages_layout) - - # Container for dynamic stage configurations - self.stage_configs_container = QWidget() - self.stage_configs_layout = QVBoxLayout(self.stage_configs_container) - layout.addWidget(self.stage_configs_container) - - # Initialize with default stages - self.update_stage_configs() - - layout.addStretch() - widget.setWidget(content) - widget.setWidgetResizable(True) - return widget - - def create_performance_panel(self): - """Create performance estimation panel""" - widget = QScrollArea() - content = QWidget() - layout = QVBoxLayout(content) - - # Header - header = QLabel("Performance Estimation") - header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;") - layout.addWidget(header) - - # Performance metrics - metrics_group = QGroupBox("Pipeline Metrics") - metrics_layout = QGridLayout(metrics_group) - - # FPS estimation - metrics_layout.addWidget(QLabel("Estimated FPS:"), 0, 0) - self.fps_label = QLabel("0.0") - self.fps_label.setStyleSheet("color: #a6e3a1; font-weight: bold;") - metrics_layout.addWidget(self.fps_label, 0, 1) - - # Latency estimation - metrics_layout.addWidget(QLabel("Pipeline Latency:"), 1, 0) - self.latency_label = QLabel("0.0 ms") - self.latency_label.setStyleSheet("color: #fab387; font-weight: bold;") - metrics_layout.addWidget(self.latency_label, 1, 1) - - # Memory usage - metrics_layout.addWidget(QLabel("Memory Usage:"), 2, 0) - self.memory_label = QLabel("0 MB") - self.memory_label.setStyleSheet("color: #f38ba8; font-weight: bold;") - metrics_layout.addWidget(self.memory_label, 2, 1) - - layout.addWidget(metrics_group) - - # Performance optimization suggestions - suggestions_group = QGroupBox("Optimization Suggestions") - suggestions_layout = QVBoxLayout(suggestions_group) - - self.suggestions_text = QTextBrowser() - self.suggestions_text.setMaximumHeight(150) - self.suggestions_text.setText("• Connect nodes to see performance analysis\n• Add stages to see optimization suggestions") - suggestions_layout.addWidget(self.suggestions_text) - - layout.addWidget(suggestions_group) - - # Update button - update_btn = QPushButton("🔄 Update Performance") - update_btn.clicked.connect(self.update_performance_estimation) - layout.addWidget(update_btn) - - layout.addStretch() - widget.setWidget(content) - widget.setWidgetResizable(True) - return widget - - def create_dongle_panel(self): - """Create dongle management panel""" - widget = QScrollArea() - content = QWidget() - layout = QVBoxLayout(content) - - # Header - header = QLabel("Dongle Management") - header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;") - layout.addWidget(header) - - # Available dongles - available_group = QGroupBox("Available Dongles") - available_layout = QVBoxLayout(available_group) - - # Auto-detect button - detect_btn = QPushButton("🔍 Auto-Detect Dongles") - detect_btn.clicked.connect(self.detect_dongles) - available_layout.addWidget(detect_btn) - - # Dongles list - self.dongles_list = QListWidget() - self.dongles_list.setMaximumHeight(120) - available_layout.addWidget(self.dongles_list) - - layout.addWidget(available_group) - - # Dongle allocation - allocation_group = QGroupBox("Stage Allocation") - allocation_layout = QVBoxLayout(allocation_group) - - # Container for allocation widgets - self.allocation_container = QWidget() - self.allocation_layout = QVBoxLayout(self.allocation_container) - allocation_layout.addWidget(self.allocation_container) - - layout.addWidget(allocation_group) - - # Initialize dongle detection - self.detect_dongles() - - layout.addStretch() - widget.setWidget(content) - widget.setWidgetResizable(True) - return widget - - def update_stage_configs(self): - """Update stage configuration widgets based on number of stages""" - if not self.stage_configs_layout or not self.stages_spinbox: - return - - # Clear existing configs - for i in reversed(range(self.stage_configs_layout.count())): - self.stage_configs_layout.itemAt(i).widget().setParent(None) - - # Add new stage configs - num_stages = self.stages_spinbox.value() - for i in range(num_stages): - stage_group = QGroupBox(f"Stage {i+1}") - stage_layout = QFormLayout(stage_group) - - # Model path - model_edit = QLineEdit() - model_edit.setPlaceholderText("model.nef") - stage_layout.addRow("Model Path:", model_edit) - - # Port IDs - ports_edit = QLineEdit() - ports_edit.setPlaceholderText("28,32") - stage_layout.addRow("Port IDs:", ports_edit) - - # Queue size - queue_spin = QSpinBox() - queue_spin.setRange(1, 100) - queue_spin.setValue(10) - stage_layout.addRow("Queue Size:", queue_spin) - - self.stage_configs_layout.addWidget(stage_group) - - # Update allocation panel - self.update_allocation_panel() - - def update_allocation_panel(self): - """Update dongle allocation panel""" - if not self.allocation_layout or not self.stages_spinbox or not self.dongles_list: - return - - # Clear existing allocations - for i in reversed(range(self.allocation_layout.count())): - self.allocation_layout.itemAt(i).widget().setParent(None) - - # Add allocation widgets for each stage - num_stages = self.stages_spinbox.value() - for i in range(num_stages): - alloc_layout = QHBoxLayout() - alloc_layout.addWidget(QLabel(f"Stage {i+1}:")) - - dongle_combo = QComboBox() - dongle_combo.addItem("Auto-assign") - # Add detected dongles - for j in range(self.dongles_list.count()): - dongle_combo.addItem(self.dongles_list.item(j).text()) - - alloc_layout.addWidget(dongle_combo) - - alloc_widget = QWidget() - alloc_widget.setLayout(alloc_layout) - self.allocation_layout.addWidget(alloc_widget) - - def detect_dongles(self): - """Simulate dongle detection""" - if not self.dongles_list: - return - - self.dongles_list.clear() - # Simulate detected dongles - dongles = ["KL520 Dongle (Port 28)", "KL520 Dongle (Port 32)", "KL720 Dongle (Port 36)"] - for dongle in dongles: - self.dongles_list.addItem(f"🔌 {dongle}") - - def update_performance_estimation(self): - """Update performance metrics based on current pipeline""" - if not all([self.fps_label, self.latency_label, self.memory_label, self.suggestions_text, self.stages_spinbox]): - return - - # Simulate performance calculation - num_nodes = len(self.graph.all_nodes()) - num_stages = self.stages_spinbox.value() - - # Simple estimation logic - base_fps = 30.0 - fps = base_fps / max(1, num_stages * 0.5) - latency = num_stages * 15 + num_nodes * 5 - memory = num_stages * 50 + num_nodes * 20 - - self.fps_label.setText(f"{fps:.1f}") - self.latency_label.setText(f"{latency:.1f} ms") - self.memory_label.setText(f"{memory} MB") - - # Generate suggestions - suggestions = [] - if num_stages > 3: - suggestions.append("• Consider reducing stages for better performance") - if num_nodes > 5: - suggestions.append("• Complex pipelines may benefit from optimization") - if fps < 15: - suggestions.append("• Low FPS detected - check dongle allocation") - - if not suggestions: - suggestions = ["• Pipeline looks optimized!", "• Consider adding more stages for complex workflows"] - - self.suggestions_text.setText("\n".join(suggestions)) - - def update_window_title(self): - title = f"Pipeline Dashboard - {self.project_name or 'Untitled'}" - if self.current_file: - title += f" [{os.path.basename(self.current_file)}]" - if self.is_modified: - title += "*" - self.setWindowTitle(title) - - def mark_modified(self, *args, **kwargs): - if not self.is_modified: - self.is_modified = True - self.update_window_title() - - def mark_saved(self): - if self.is_modified: - self.is_modified = False - self.update_window_title() - - def on_port_connected(self, *args, **kwargs): - """Handle port connection - update performance automatically""" - self.update_performance_estimation() - - def on_port_disconnected(self, *args, **kwargs): - """Handle port disconnection - update performance automatically""" - self.update_performance_estimation() - - def delete_selected_nodes(self): - """Delete selected nodes""" - selected_nodes = self.graph.selected_nodes() - for node in selected_nodes: - self.graph.delete_node(node) - - def setup_menu(self): - """Setup menu bar""" - menubar = self.menuBar() - - # File menu - file_menu = menubar.addMenu('File') - - save_action = QAction('Save', self) - save_action.setShortcut('Ctrl+S') - save_action.triggered.connect(self.save_pipeline) - file_menu.addAction(save_action) - - save_as_action = QAction('Save As...', self) - save_as_action.setShortcut('Ctrl+Shift+S') - save_as_action.triggered.connect(self.save_pipeline_as) - file_menu.addAction(save_as_action) - - file_menu.addSeparator() - - export_action = QAction('Export Configuration', self) - export_action.triggered.connect(self.export_configuration) - file_menu.addAction(export_action) - - def save_pipeline(self): - """Save pipeline to current file""" - if self.current_file: - self.save_to_file(self.current_file) - else: - self.save_pipeline_as() - - def save_pipeline_as(self): - """Save pipeline with file dialog""" - filename, _ = QFileDialog.getSaveFileName( - self, "Save Pipeline As", f"{self.project_name}.mflow", - "MFlow files (*.mflow);;All files (*.*)") - - if filename: - self.current_file = filename - self.save_to_file(filename) - - def save_to_file(self, filename): - """Save pipeline data to file""" - try: - performance_metrics = {} - if all([self.fps_label, self.latency_label, self.memory_label]): - performance_metrics = { - "fps": self.fps_label.text(), - "latency": self.latency_label.text(), - "memory": self.memory_label.text() - } - - pipeline_data = { - "project_name": self.project_name, - "description": self.description, - "graph_data": self.graph.serialize_session(), - "stage_configs": self.get_stage_configs(), - "performance_metrics": performance_metrics, - "metadata": {"version": "1.0", "editor": "IntegratedDashboard"} - } - - with open(filename, 'w') as f: - json.dump(pipeline_data, f, indent=2) - - self.mark_saved() - QMessageBox.information(self, "Success", f"Pipeline saved to {os.path.basename(filename)}") - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to save pipeline: {str(e)}") - - def get_stage_configs(self): - """Extract stage configurations from UI""" - if not self.stage_configs_layout: - return [] - - configs = [] - for i in range(self.stage_configs_layout.count()): - widget = self.stage_configs_layout.itemAt(i).widget() - if isinstance(widget, QGroupBox): - layout = widget.layout() - config = {} - for j in range(0, layout.rowCount()): - label_item = layout.itemAt(j, QFormLayout.LabelRole) - field_item = layout.itemAt(j, QFormLayout.FieldRole) - if label_item and field_item: - label = label_item.widget().text().replace(":", "") - field = field_item.widget() - if isinstance(field, QLineEdit): - config[label.lower().replace(" ", "_")] = field.text() - elif isinstance(field, QSpinBox): - config[label.lower().replace(" ", "_")] = field.value() - configs.append(config) - return configs - - def load_pipeline_file(self, filename): - """Load pipeline from file""" - try: - with open(filename, 'r') as f: - data = json.load(f) - - # Load graph data - if "graph_data" in data: - self.graph.deserialize_session(data["graph_data"]) - - # Load stage configs if available - if "stage_configs" in data: - self.load_stage_configs(data["stage_configs"]) - - # Load performance metrics if available - if "performance_metrics" in data: - metrics = data["performance_metrics"] - self.fps_label.setText(metrics.get("fps", "0.0")) - self.latency_label.setText(metrics.get("latency", "0.0 ms")) - self.memory_label.setText(metrics.get("memory", "0 MB")) - - self.mark_saved() - self.update_performance_estimation() - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to load pipeline: {str(e)}") - - def load_stage_configs(self, configs): - """Load stage configurations into UI""" - if not self.stages_spinbox or not self.stage_configs_layout or not configs: - return - - self.stages_spinbox.setValue(len(configs)) - # The update_stage_configs will be called automatically - # Then we can populate the fields - for i, config in enumerate(configs): - if i < self.stage_configs_layout.count(): - widget = self.stage_configs_layout.itemAt(i).widget() - if isinstance(widget, QGroupBox): - layout = widget.layout() - for j in range(0, layout.rowCount()): - label_item = layout.itemAt(j, QFormLayout.LabelRole) - field_item = layout.itemAt(j, QFormLayout.FieldRole) - if label_item and field_item: - label = label_item.widget().text().replace(":", "") - field = field_item.widget() - key = label.lower().replace(" ", "_") - if key in config: - if isinstance(field, QLineEdit): - field.setText(str(config[key])) - elif isinstance(field, QSpinBox): - field.setValue(int(config[key])) - - def export_configuration(self): - """Export pipeline configuration for deployment""" - try: - dongles = [] - if self.dongles_list: - dongles = [self.dongles_list.item(i).text() for i in range(self.dongles_list.count())] - - performance_estimate = {} - if all([self.fps_label, self.latency_label, self.memory_label]): - performance_estimate = { - "fps": self.fps_label.text(), - "latency": self.latency_label.text(), - "memory": self.memory_label.text() - } - - config_data = { - "pipeline_name": self.project_name, - "stages": self.get_stage_configs(), - "performance_estimate": performance_estimate, - "dongles": dongles, - "export_timestamp": json.dumps({"timestamp": "2024-01-01T00:00:00Z"}) - } - - filename, _ = QFileDialog.getSaveFileName( - self, "Export Configuration", f"{self.project_name}_config.json", - "JSON files (*.json);;All files (*.*)") - - if filename: - with open(filename, 'w') as f: - json.dump(config_data, f, indent=2) - - QMessageBox.information(self, "Success", f"Configuration exported to {os.path.basename(filename)}") - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to export configuration: {str(e)}") - -class PipelineEditor(QMainWindow): - """Main pipeline editor using NodeGraphQt""" - - def __init__(self, project_name="", description="", filename=None): - super().__init__() - self.project_name = project_name - self.description = description - self.current_file = filename - self.is_modified = False - - # Initialize node graph - self.graph = NodeGraph(properties_bin_class=None) - - # Register custom nodes - nodes_to_register = [InputNode, ModelNode, PreprocessNode, PostprocessNode, OutputNode] - - for node_class in nodes_to_register: - try: - self.graph.register_node(node_class) - except Exception as e: - print(f"Failed to register {node_class.__name__}: {e}") - - # --- MODIFICATION START --- - # Create properties bin widget to be docked in the main window. - try: - # 完全禁用原来的PropertiesBinWidget,使用我们的自定义面板 - print("Creating CustomPropertiesWidget...") # 调试信息 - self.properties_bin = CustomPropertiesWidget(self.graph) - self.properties_bin.setMinimumWidth(300) - print("CustomPropertiesWidget created successfully") # 调试信息 - except Exception as e: - print(f"Failed to create CustomPropertiesWidget: {e}") - import traceback - traceback.print_exc() - self.properties_bin = None - # --- MODIFICATION END --- - - # Connect signals - # We no longer need to connect node_double_clicked to show properties. - # The PropertiesBinWidget handles selection changes automatically. - self.graph.node_created.connect(self.mark_modified) # type: ignore - self.graph.nodes_deleted.connect(self.mark_modified) # type: ignore - self.graph.property_changed.connect(self.mark_modified) # type: ignore - - if hasattr(self.graph, 'port_connected'): - self.graph.port_connected.connect(self.on_port_connected) - if hasattr(self.graph, 'port_disconnected'): - self.graph.port_disconnected.connect(self.on_port_disconnected) - - self.setup_ui() - self.setup_menu() - - # Add keyboard shortcut for delete - self.delete_shortcut = QAction("Delete", self) - self.delete_shortcut.setShortcut('Delete') - self.delete_shortcut.triggered.connect(self.delete_selected_nodes) - self.addAction(self.delete_shortcut) - - self.update_window_title() - self.setGeometry(100, 100, 1600, 900) # Increased width for the new panel - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - - def update_window_title(self): - title = f"Pipeline Editor - {self.project_name or 'Untitled'}" - if self.current_file: - title += f" [{os.path.basename(self.current_file)}]" - if self.is_modified: - title += "*" - self.setWindowTitle(title) - - def mark_modified(self, *args, **kwargs): - if not self.is_modified: - self.is_modified = True - self.update_window_title() - - def mark_saved(self): - if self.is_modified: - self.is_modified = False - self.update_window_title() - - # This method is no longer needed as the primary way to show properties. - # Kept for the fallback dialog. - def display_properties_bin(self, node): - if not self.properties_bin: - self.show_simple_properties_dialog(node) - # If the properties bin exists, it updates automatically. - # Double-clicking can still be used to ensure it's visible. - elif self.properties_bin and not self.properties_bin.isVisible(): - self.properties_bin.show() - - def show_simple_properties_dialog(self, node): - try: - dialog = SimplePropertiesDialog(node, self) - if dialog.exec_() == QDialog.Accepted: - self.mark_modified() - - except Exception as e: - print(f"Error with interactive SimplePropertiesDialog for {node.name()}: {e}") - QMessageBox.warning(self, "Properties Error", - f"Could not display interactive properties for {node.name()}: {str(e)}") - - def on_port_connected(self, input_port, output_port): - self.mark_modified() - - def on_port_disconnected(self, input_port, output_port): - self.mark_modified() - - def show_connections(self): - all_nodes = self.graph.all_nodes() - connections = [] - - for node in all_nodes: - for output_port in node.output_ports(): - for connected_input_port in output_port.connected_ports(): - connections.append(f"{node.name()}.{output_port.name()} → {connected_input_port.node().name()}.{connected_input_port.name()}") - - QMessageBox.information(self, "Graph Connections", "Current connections:\n" + "\n".join(connections) if connections else "No connections found.") - - def show_registered_nodes(self): - try: - registered_info = [] - if hasattr(self.graph, 'all_registered_nodes'): - nodes_dict = self.graph.all_registered_nodes() - registered_info.append(f"all_registered_nodes(): {list(nodes_dict.keys())}") - - msg = "Registered node information:\n\n" + "\n\n".join(registered_info) - QMessageBox.information(self, "Registered Nodes Debug", msg) - - except Exception as e: - QMessageBox.critical(self, "Debug Error", f"Error getting registered nodes: {e}") - - def setup_ui(self): - """Setup main UI with a docked properties panel.""" - central_widget = QWidget() - self.setCentralWidget(central_widget) - - layout = QHBoxLayout(central_widget) - - # --- MODIFICATION START --- - # The main splitter will now hold three widgets: Palette, Graph, Properties. - main_splitter = QSplitter(Qt.Horizontal) - - # 1. Left Panel: Node Palette - self.palette = NodePalette(self.graph) - self.palette.setMaximumWidth(250) - main_splitter.addWidget(self.palette) - - # 2. Center Panel: Node Graph - graph_widget = self.graph.widget - graph_widget.setFocusPolicy(Qt.StrongFocus) - main_splitter.addWidget(graph_widget) - - # 3. Right Panel: Properties Bin - if self.properties_bin: - main_splitter.addWidget(self.properties_bin) - # Set initial sizes for the three panels - main_splitter.setSizes([220, 1080, 300]) - else: - # Fallback if properties bin failed to create - main_splitter.setSizes([250, 1350]) - # --- MODIFICATION END --- - - layout.addWidget(main_splitter) - graph_widget.setFocus() - - def setup_menu(self): - menubar = self.menuBar() - - file_menu = menubar.addMenu('File') - # ... (File menu remains the same) - save_action = file_menu.addAction('Save') - save_action.setShortcut('Ctrl+S') - save_action.triggered.connect(self.save_pipeline) - save_as_action = file_menu.addAction('Save As...') - save_as_action.triggered.connect(self.save_pipeline_as) - open_action = file_menu.addAction('Open...') - open_action.setShortcut('Ctrl+O') - open_action.triggered.connect(self.open_pipeline) - file_menu.addSeparator() - close_action = file_menu.addAction('Close') - close_action.triggered.connect(self.close) - - edit_menu = menubar.addMenu('Edit') - # ... (Edit menu remains the same) - undo_action = edit_menu.addAction('Undo') - undo_action.setShortcut('Ctrl+Z') - undo_action.triggered.connect(self.graph.undo_stack().undo) - redo_action = edit_menu.addAction('Redo') - redo_action.setShortcut('Ctrl+Y') - redo_action.triggered.connect(self.graph.undo_stack().redo) - edit_menu.addSeparator() - delete_action = edit_menu.addAction('Delete Selected') - delete_action.setShortcut('Delete') - delete_action.triggered.connect(self.delete_selected_nodes) - self.addAction(delete_action) - select_all_action = edit_menu.addAction('Select All') - select_all_action.setShortcut('Ctrl+A') - select_all_action.triggered.connect(self.graph.select_all) - clear_selection_action = edit_menu.addAction('Clear Selection') - clear_selection_action.setShortcut('Ctrl+D') - clear_selection_action.triggered.connect(self.graph.clear_selection) - - view_menu = menubar.addMenu('View') - - # --- MODIFICATION START --- - # Change the action to be a checkable toggle for the properties panel. - if self.properties_bin: - properties_action = view_menu.addAction('Toggle Properties Panel') - properties_action.setCheckable(True) - properties_action.setChecked(True) # Start with the panel visible. - properties_action.triggered.connect(self.toggle_properties_panel) - # --- MODIFICATION END --- - - # Pipeline menu - pipeline_menu = menubar.addMenu('Pipeline') - configure_stages_action = pipeline_menu.addAction('Configure Stages') - configure_stages_action.triggered.connect(self.show_stage_configuration_from_editor) - performance_action = pipeline_menu.addAction('Performance Analysis') - performance_action.triggered.connect(self.show_performance_analysis) - deploy_action = pipeline_menu.addAction('Deploy Pipeline') - deploy_action.triggered.connect(self.show_deploy_dialog) - - view_menu.addSeparator() - fit_action = view_menu.addAction('Fit to Selection') - fit_action.setShortcut('F') - fit_action.triggered.connect(self.graph.fit_to_selection) - auto_layout_action = view_menu.addAction('Auto Layout All') - auto_layout_action.triggered.connect(self.graph.auto_layout_nodes) # type: ignore - - debug_menu = menubar.addMenu('Debug') - # ... (Debug menu remains the same) - show_connections_action = debug_menu.addAction('Show All Connections') - show_connections_action.triggered.connect(self.show_connections) - show_registered_action = debug_menu.addAction('Show Registered Nodes') - show_registered_action.triggered.connect(self.show_registered_nodes) - - # --- NEW METHOD START --- - def toggle_properties_panel(self, checked): - """Show or hide the properties panel.""" - if self.properties_bin: - self.properties_bin.setVisible(checked) - # --- NEW METHOD END --- - - # This method is now replaced by toggle_properties_panel - def show_properties_panel(self): - """Show properties panel safely (Legacy, now used for fallback).""" - if self.properties_bin: - if not self.properties_bin.isVisible(): - self.properties_bin.show() - self.properties_bin.raise_() - self.properties_bin.activateWindow() - else: - selected_nodes = self.graph.selected_nodes() - if selected_nodes: - self.show_simple_properties_dialog(selected_nodes[0]) - else: - QMessageBox.information(self, "Properties Panel", "Properties panel is not available.") - - def delete_selected_nodes(self): - selected_nodes = self.graph.selected_nodes() - if selected_nodes: - try: - self.graph.delete_nodes(selected_nodes) - except Exception as e: - print(f"Error deleting nodes: {e}") - QMessageBox.warning(self, "Delete Error", f"Failed to delete nodes: {e}") - - def save_pipeline(self): - if self.current_file: - return self._save_to_file(self.current_file) - else: - return self.save_pipeline_as() - - def save_pipeline_as(self): - default_name = f"{self.project_name or 'untitled'}.mflow" - filename, _ = QFileDialog.getSaveFileName( - self, "Save Pipeline", default_name, - "MFlow files (*.mflow);;All files (*.*)") - - if filename: - if self._save_to_file(filename): - self.current_file = filename - self.project_name = os.path.splitext(os.path.basename(filename))[0] - self.mark_saved() - return True - return False - - def _save_to_file(self, filename): - try: - graph_data = self.graph.serialize_session() - pipeline_data = { - "project_name": self.project_name, - "description": self.description, - "graph_data": graph_data, - "metadata": { "version": "1.0", "editor": "NodeGraphQt" } - } - with open(filename, 'w') as f: - json.dump(pipeline_data, f, indent=2) - - self.mark_saved() - QMessageBox.information(self, "Saved", f"Pipeline saved to {os.path.basename(filename)}") - return True - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to save to {filename}: {str(e)}") - return False - - def open_pipeline(self): - if self.is_modified: - reply = QMessageBox.question(self, 'Save Changes', - "Current pipeline has unsaved changes. Save before opening a new one?", - QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, - QMessageBox.Save) - if reply == QMessageBox.Save: - if not self.save_pipeline(): - return - elif reply == QMessageBox.Cancel: - return - - filename, _ = QFileDialog.getOpenFileName( - self, "Open Pipeline", "", - "MFlow files (*.mflow);;All files (*.*)") - - if filename: - self.load_pipeline_file(filename) - - def load_pipeline_file(self, filename): - try: - with open(filename, 'r') as f: - data = json.load(f) - - self.project_name = data.get("project_name", os.path.splitext(os.path.basename(filename))[0]) - self.description = data.get("description", "") - - self.graph.clear_session() - if "graph_data" in data: - self.graph.deserialize_session(data["graph_data"]) - - self.current_file = filename - self.mark_saved() - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to open {filename}: {str(e)}") - self.project_name = "Untitled" - self.description = "" - self.current_file = None - self.graph.clear_session() - self.mark_saved() - - - def show_stage_configuration_from_editor(self): - """Show stage configuration from editor""" - # Get current pipeline data - current_data = { - "graph_data": self.graph.serialize_session(), - "project_name": self.project_name, - "description": self.description - } - - config_dialog = StageConfigurationDialog(current_data, self) - config_dialog.exec_() - - def show_performance_analysis(self): - """Show performance analysis for current pipeline""" - # Generate sample stage configs from current pipeline - stage_configs = [ - {"name": "Input Processing", "dongles": 2, "port_ids": "28,30", "model_path": ""}, - {"name": "Main Inference", "dongles": 4, "port_ids": "32,34,36,38", "model_path": ""}, - {"name": "Post Processing", "dongles": 2, "port_ids": "40,42", "model_path": ""} - ] - - perf_panel = PerformanceEstimationPanel(stage_configs, self) - perf_panel.exec_() - - def show_deploy_dialog(self): - """Show deployment dialog for current pipeline""" - # Generate sample stage configs from current pipeline - stage_configs = [ - {"name": "Input Processing", "dongles": 2, "port_ids": "28,30", "model_path": ""}, - {"name": "Main Inference", "dongles": 4, "port_ids": "32,34,36,38", "model_path": ""}, - {"name": "Post Processing", "dongles": 2, "port_ids": "40,42", "model_path": ""} - ] - - deploy_dialog = SaveDeployDialog(stage_configs, self) - deploy_dialog.exec_() - - def closeEvent(self, event): - if self.is_modified: - reply = QMessageBox.question(self, 'Save Changes', - "The pipeline has unsaved changes. Do you want to save them?", - QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, - QMessageBox.Save) - - if reply == QMessageBox.Save: - if self.save_pipeline(): - event.accept() - else: - event.ignore() - elif reply == QMessageBox.Discard: - event.accept() - else: - event.ignore() - else: - event.accept() - - -class DashboardLogin(QWidget): - def __init__(self): - super().__init__() - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - self.pipeline_editor = None # Keep track of the editor window - self.recent_files = self.load_recent_files() - # Store current project settings - self.current_project_name = "" - self.current_description = "" - self.current_pipeline_data = {} - self.initUI() - - def initUI(self): - self.setWindowTitle("Cluster Dashboard - ML Pipeline Builder") - self.setGeometry(300, 300, 700, 500) - - # Create main scroll area to ensure all content is viewable - scrollArea = QScrollArea(self) - scrollContent = QWidget() - scrollArea.setWidget(scrollContent) - scrollArea.setWidgetResizable(True) - scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - mainLayout = QVBoxLayout(scrollContent) - mainLayout.setContentsMargins(40, 40, 40, 40) - mainLayout.setSpacing(25) - - # Header section with title and subtitle - headerLayout = QVBoxLayout() - headerLayout.setSpacing(8) - - titleLabel = QLabel("Cluster") - titleFont = QFont("Segoe UI", 32, QFont.Bold) - titleLabel.setFont(titleFont) - titleLabel.setAlignment(Qt.AlignCenter) - titleLabel.setStyleSheet("color: #89b4fa; margin-bottom: 5px;") - - subtitleLabel = QLabel("AI Pipeline Builder & Execution Platform") - subtitleFont = QFont("Inter", 14) - subtitleLabel.setFont(subtitleFont) - subtitleLabel.setAlignment(Qt.AlignCenter) - subtitleLabel.setStyleSheet("color: #a6adc8; margin-bottom: 20px;") - - headerLayout.addWidget(titleLabel) - headerLayout.addWidget(subtitleLabel) - - # Action buttons section - buttonLayout = QVBoxLayout() - buttonLayout.setSpacing(15) - - # Create new pipeline button (primary action) - self.createNewPipelineButton = QPushButton("🚀 Create New Pipeline") - self.createNewPipelineButton.setMinimumHeight(55) - self.createNewPipelineButton.clicked.connect(self.create_new_pipeline_dialog) - self.createNewPipelineButton.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - border: none; - padding: 15px 25px; - border-radius: 12px; - font-weight: 700; - font-size: 16px; - text-align: left; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #a6c8ff, stop:1 #89dceb); - transform: translateY(-2px); - } - """) - - # Edit existing pipeline button - self.editPipelineButton = QPushButton("📁 Edit Previous Pipeline") - self.editPipelineButton.setMinimumHeight(55) - self.editPipelineButton.clicked.connect(self.edit_previous_pipeline) - self.editPipelineButton.setStyleSheet(""" - QPushButton { - background-color: #313244; - color: #cdd6f4; - border: 2px solid #89b4fa; - padding: 15px 25px; - border-radius: 12px; - font-weight: 600; - font-size: 16px; - text-align: left; - } - QPushButton:hover { - background-color: #383a59; - border-color: #a6c8ff; - color: #ffffff; - } - """) - - # Recent files section - recentLabel = QLabel("Recent Pipelines") - recentLabel.setFont(QFont("Segoe UI", 12, QFont.Bold)) - recentLabel.setStyleSheet("color: #f9e2af; margin-top: 10px;") - - self.recentFilesList = QListWidget() - self.recentFilesList.setMaximumHeight(120) - self.recentFilesList.setStyleSheet(""" - QListWidget { - background-color: #313244; - border: 2px solid #45475a; - border-radius: 12px; - padding: 8px; - } - QListWidget::item { - padding: 10px 14px; - border-radius: 6px; - margin: 2px; - color: #cdd6f4; - } - QListWidget::item:hover { - background-color: #383a59; - } - QListWidget::item:selected { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - } - """) - self.populate_recent_files() - self.recentFilesList.itemDoubleClicked.connect(self.open_recent_file) - - # Help/Info section - infoLabel = QLabel("💡 Quick Start Guide") - infoLabel.setFont(QFont("Segoe UI", 12, QFont.Bold)) - infoLabel.setStyleSheet("color: #f9e2af; margin-top: 15px;") - - helpText = QLabel( - "• Create a new pipeline to build ML workflows visually\n" - "• Edit existing .mflow files to continue work\n" - "• Drag and connect nodes to create processing chains\n" - "• Configure dongle properties in the integrated panel\n" - "• Monitor performance and deploy your pipelines" - ) - helpText.setStyleSheet(""" - color: #a6adc8; - font-size: 12px; - line-height: 1.6; - padding: 16px; - background-color: #313244; - border-radius: 12px; - border-left: 4px solid #89b4fa; - """) - helpText.setWordWrap(True) - - # Add all sections to main layout - buttonLayout.addWidget(self.createNewPipelineButton) - buttonLayout.addWidget(self.editPipelineButton) - - mainLayout.addLayout(headerLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonLayout) - mainLayout.addSpacing(15) - mainLayout.addWidget(recentLabel) - mainLayout.addWidget(self.recentFilesList) - mainLayout.addSpacing(10) - mainLayout.addWidget(infoLabel) - mainLayout.addWidget(helpText) - mainLayout.addStretch(1) - - # Set the scroll area as the main widget - containerLayout = QVBoxLayout(self) - containerLayout.setContentsMargins(0, 0, 0, 0) - containerLayout.addWidget(scrollArea) - - def load_recent_files(self): - """Load recent files from settings or return empty list""" - try: - recent_files_path = os.path.expanduser("~/.cluster_recent_files.json") - if os.path.exists(recent_files_path): - with open(recent_files_path, 'r') as f: - return json.load(f) - except Exception as e: - print(f"Error loading recent files: {e}") - return [] - - def save_recent_files(self): - """Save recent files to settings""" - try: - recent_files_path = os.path.expanduser("~/.cluster_recent_files.json") - with open(recent_files_path, 'w') as f: - json.dump(self.recent_files, f, indent=2) - except Exception as e: - print(f"Error saving recent files: {e}") - - def add_recent_file(self, filepath): - """Add file to recent files list""" - if filepath in self.recent_files: - self.recent_files.remove(filepath) - self.recent_files.insert(0, filepath) - # Keep only last 5 files - self.recent_files = self.recent_files[:5] - self.save_recent_files() - self.populate_recent_files() - - def populate_recent_files(self): - """Populate the recent files list widget""" - self.recentFilesList.clear() - for filepath in self.recent_files: - if os.path.exists(filepath): - filename = os.path.basename(filepath) - self.recentFilesList.addItem(f"📄 {filename}") - else: - # Remove non-existent files - self.recent_files.remove(filepath) - - if not self.recentFilesList.count(): - self.recentFilesList.addItem("No recent files") - - def open_recent_file(self, item): - """Open a recent file when double-clicked""" - if item.text() == "No recent files": - return - - filename = item.text().replace("📄 ", "") - for filepath in self.recent_files: - if os.path.basename(filepath) == filename: - try: - with open(filepath, 'r') as f: - data = json.load(f) - project_name = data.get("project_name", os.path.splitext(filename)[0]) - description = data.get("description", "") - - # Store loaded pipeline data in dashboard - self.current_project_name = project_name - self.current_description = description - self.current_pipeline_data = data - - self._open_editor_window(project_name, description, filepath) - break - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to open {filename}: {str(e)}") - - def _open_editor_window(self, project_name, description, filename): - """Helper to ensure only one editor is managed by dashboard, or handle existing.""" - if self.pipeline_editor and self.pipeline_editor.isVisible(): - # Ask if user wants to close current editor or if it's okay - # For simplicity here, we just bring it to front. - # A more robust solution might involve managing multiple editors or prompting user. - self.pipeline_editor.raise_() - self.pipeline_editor.activateWindow() - QMessageBox.information(self, "Editor Open", "An editor window is already open.") - # Optionally, load the new data into the existing editor if desired, after prompting. - return - - # Create the integrated dashboard instead of separate pipeline editor - self.pipeline_editor = IntegratedPipelineDashboard(project_name, description, filename) - if filename and os.path.exists(filename): # If filename is provided and exists, load it - self.pipeline_editor.load_pipeline_file(filename) - self.pipeline_editor.show() - - - def edit_previous_pipeline(self): - """Open existing pipeline""" - filename, _ = QFileDialog.getOpenFileName( - self, "Open Pipeline", "", - "MFlow files (*.mflow);;All files (*.*)") - - if filename: - try: - # Basic check, more validation could be in PipelineEditor.load_pipeline_file - with open(filename, 'r') as f: - data = json.load(f) - project_name = data.get("project_name", os.path.splitext(os.path.basename(filename))[0]) - description = data.get("description", "") - - # Store loaded pipeline data in dashboard - self.current_project_name = project_name - self.current_description = description - self.current_pipeline_data = data - - self._open_editor_window(project_name, description, filename) - self.add_recent_file(filename) - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to open pipeline {filename}: {str(e)}") - - def show_stage_configuration(self): - """Show stage configuration dialog for quick pipeline setup""" - # Use current project data if available, otherwise create minimal pipeline - if self.current_pipeline_data: - pipeline_data = self.current_pipeline_data.copy() - else: - # Create a minimal pipeline with current project settings - pipeline_data = { - "project_name": self.current_project_name or "Untitled Pipeline", - "description": self.current_description or "Pipeline configuration", - "graph_data": { - "nodes": { - "node1": {"type_": "ModelNode", "custom": {"model_path": ""}}, - "node2": {"type_": "ModelNode", "custom": {"model_path": ""}} - } - }, - "metadata": {"version": "1.0", "editor": "Dashboard"} - } - - config_dialog = StageConfigurationDialog(pipeline_data, self) - if config_dialog.exec_(): - # Update stored pipeline data with stage configurations - self.current_pipeline_data = pipeline_data - self.current_pipeline_data["stage_configs"] = config_dialog.get_stage_configs() - - def create_new_pipeline_dialog(self): - """Create new pipeline""" - dialog = CreatePipelineDialog(self) - if dialog.exec_(): - data = dialog.get_data() - - # Store project settings in dashboard - self.current_project_name = data['project_name'] - self.current_description = data['description'] - - # Prompt for initial save location - save_filename, _ = QFileDialog.getSaveFileName( - self, "Save New Pipeline As", f"{data['project_name']}.mflow", - "MFlow files (*.mflow);;All files (*.*)") - - if save_filename: - try: - # Create an empty pipeline structure to save - empty_graph_data = NodeGraph().serialize_session() # Get empty session data - pipeline_data = { - "project_name": data['project_name'], - "description": data['description'], - "graph_data": empty_graph_data, # Start with an empty graph - "metadata": { "version": "1.0", "editor": "NodeGraphQt" } - } - - # Store current pipeline data in dashboard - self.current_pipeline_data = pipeline_data - - with open(save_filename, 'w') as f: - json.dump(pipeline_data, f, indent=2) - - QMessageBox.information( - self, "Pipeline Created", - f"New pipeline '{data['project_name']}' has been created and saved as {os.path.basename(save_filename)}." - ) - - # Open editor with this new, saved pipeline - self._open_editor_window(data['project_name'], data['description'], save_filename) - self.add_recent_file(save_filename) - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to create and save new pipeline: {str(e)}") - - -class StageConfigurationDialog(QDialog): - """Dialog for configuring pipeline stages and dongle allocation""" - - def __init__(self, pipeline_data=None, parent=None): - super().__init__(parent) - self.pipeline_data = pipeline_data or {} - self.stage_configs = [] - self.total_dongles_available = 16 # Default max dongles - self.setWindowTitle("Stage Configuration & Dongle Allocation") - self.setMinimumSize(800, 600) - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - self.setup_ui() - self.load_stages_from_pipeline() - - def setup_ui(self): - layout = QVBoxLayout(self) - - # Header with project information - project_name = self.pipeline_data.get("project_name", "Untitled Pipeline") - header = QLabel(f"Configure Pipeline Stages - {project_name}") - header.setFont(QFont("Arial", 16, QFont.Bold)) - header.setStyleSheet("color: #89b4fa; margin-bottom: 5px;") - layout.addWidget(header) - - # Project description if available - description = self.pipeline_data.get("description", "") - if description: - desc_label = QLabel(f"Description: {description}") - desc_label.setStyleSheet("color: #a6adc8; margin-bottom: 10px; font-style: italic;") - desc_label.setWordWrap(True) - layout.addWidget(desc_label) - - # Instructions - instructions = QLabel( - "Break your pipeline into sections (A → B → C...) and assign dongles for optimal performance" - ) - instructions.setStyleSheet("color: #a6adc8; margin-bottom: 15px;") - instructions.setWordWrap(True) - layout.addWidget(instructions) - - # Main content area - content_splitter = QSplitter(Qt.Horizontal) - - # Left side: Stage list and controls - left_widget = QWidget() - left_layout = QVBoxLayout(left_widget) - - # Stage list - stages_group = QGroupBox("Pipeline Stages") - stages_layout = QVBoxLayout(stages_group) - - self.stages_list = QListWidget() - self.stages_list.currentItemChanged.connect(self.on_stage_selected) - stages_layout.addWidget(self.stages_list) - - # Stage controls - stage_controls = QHBoxLayout() - self.add_stage_btn = QPushButton("➕ Add Stage") - self.add_stage_btn.clicked.connect(self.add_stage) - self.remove_stage_btn = QPushButton("➖ Remove Stage") - self.remove_stage_btn.clicked.connect(self.remove_stage) - self.auto_balance_btn = QPushButton("⚖️ Auto-Balance") - self.auto_balance_btn.clicked.connect(self.auto_balance_dongles) - - stage_controls.addWidget(self.add_stage_btn) - stage_controls.addWidget(self.remove_stage_btn) - stage_controls.addWidget(self.auto_balance_btn) - stages_layout.addLayout(stage_controls) - - left_layout.addWidget(stages_group) - - # Right side: Stage configuration - right_widget = QWidget() - right_layout = QVBoxLayout(right_widget) - - # Stage details - details_group = QGroupBox("Stage Configuration") - details_layout = QFormLayout(details_group) - - self.stage_name_input = QLineEdit() - self.stage_name_input.textChanged.connect(self.update_current_stage) - details_layout.addRow("Stage Name:", self.stage_name_input) - - # Dongle allocation - dongle_layout = QHBoxLayout() - self.dongle_count_slider = QSlider(Qt.Horizontal) - self.dongle_count_slider.setRange(1, 8) - self.dongle_count_slider.setValue(2) - self.dongle_count_slider.valueChanged.connect(self.update_dongle_count) - - self.dongle_count_label = QLabel("2 dongles") - dongle_layout.addWidget(self.dongle_count_slider) - dongle_layout.addWidget(self.dongle_count_label) - details_layout.addRow("Dongles Assigned:", dongle_layout) - - # Port IDs - self.port_ids_input = QLineEdit() - self.port_ids_input.setPlaceholderText("e.g., 28,30,32 or auto") - self.port_ids_input.textChanged.connect(self.update_current_stage) - details_layout.addRow("Port IDs:", self.port_ids_input) - - # Model selection - self.model_path_input = QLineEdit() - model_browse_layout = QHBoxLayout() - model_browse_btn = QPushButton("Browse") - model_browse_btn.clicked.connect(self.browse_model_file) - model_browse_layout.addWidget(self.model_path_input) - model_browse_layout.addWidget(model_browse_btn) - details_layout.addRow("Model File:", model_browse_layout) - - # Performance estimates - perf_group = QGroupBox("Performance Estimation") - perf_layout = QFormLayout(perf_group) - - self.estimated_fps_label = QLabel("--") - self.estimated_latency_label = QLabel("--") - self.throughput_label = QLabel("--") - - perf_layout.addRow("Estimated FPS:", self.estimated_fps_label) - perf_layout.addRow("Latency:", self.estimated_latency_label) - perf_layout.addRow("Throughput:", self.throughput_label) - - right_layout.addWidget(details_group) - right_layout.addWidget(perf_group) - - # Resource summary - resource_group = QGroupBox("Resource Summary") - resource_layout = QFormLayout(resource_group) - - self.total_dongles_label = QLabel(f"0 / {self.total_dongles_available}") - self.pipeline_fps_label = QLabel("--") - resource_layout.addRow("Total Dongles Used:", self.total_dongles_label) - resource_layout.addRow("Pipeline FPS:", self.pipeline_fps_label) - - right_layout.addWidget(resource_group) - right_layout.addStretch() - - content_splitter.addWidget(left_widget) - content_splitter.addWidget(right_widget) - content_splitter.setSizes([400, 400]) - layout.addWidget(content_splitter) - - # Buttons - button_layout = QHBoxLayout() - self.next_btn = QPushButton("Next: Performance Estimation") - self.next_btn.clicked.connect(self.show_performance_panel) - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.clicked.connect(self.reject) - - button_layout.addStretch() - button_layout.addWidget(self.cancel_btn) - button_layout.addWidget(self.next_btn) - layout.addLayout(button_layout) - - def load_stages_from_pipeline(self): - """Extract stages from pipeline data""" - if not self.pipeline_data.get('graph_data'): - # Add default stage if no pipeline data - self.add_stage() - return - - # Parse graph data to identify stages - # This is a simplified implementation - nodes = self.pipeline_data.get('graph_data', {}).get('nodes', {}) - model_nodes = [node for node in nodes.values() if 'Model' in node.get('type_', '')] - - if not model_nodes: - self.add_stage() - return - - for i, node in enumerate(model_nodes): - stage_config = { - 'name': f"Stage {i+1}", - 'dongles': 2, - 'port_ids': 'auto', - 'model_path': node.get('custom', {}).get('model_path', ''), - 'node_id': node.get('id', '') - } - self.stage_configs.append(stage_config) - - self.update_stages_list() - - def add_stage(self): - """Add a new stage""" - stage_num = len(self.stage_configs) + 1 - stage_config = { - 'name': f"Stage {stage_num}", - 'dongles': 2, - 'port_ids': 'auto', - 'model_path': '', - 'node_id': '' - } - self.stage_configs.append(stage_config) - self.update_stages_list() - - # Select the new stage - self.stages_list.setCurrentRow(len(self.stage_configs) - 1) - - def remove_stage(self): - """Remove selected stage""" - current_row = self.stages_list.currentRow() - if current_row >= 0 and self.stage_configs: - self.stage_configs.pop(current_row) - self.update_stages_list() - - # Select previous stage or first stage - if self.stage_configs: - new_row = min(current_row, len(self.stage_configs) - 1) - self.stages_list.setCurrentRow(new_row) - - def update_stages_list(self): - """Update the stages list widget""" - self.stages_list.clear() - for i, config in enumerate(self.stage_configs): - dongles = config['dongles'] - item_text = f"{config['name']} ({dongles} dongles)" - self.stages_list.addItem(item_text) - - self.update_resource_summary() - - def on_stage_selected(self, current, previous): - """Handle stage selection""" - row = self.stages_list.currentRow() - if row >= 0 and row < len(self.stage_configs): - config = self.stage_configs[row] - - # Update UI with selected stage data - self.stage_name_input.setText(config['name']) - self.dongle_count_slider.setValue(config['dongles']) - self.port_ids_input.setText(config['port_ids']) - self.model_path_input.setText(config['model_path']) - - # Update performance estimates - self.update_performance_estimates(config) - - def update_current_stage(self): - """Update current stage configuration""" - row = self.stages_list.currentRow() - if row >= 0 and row < len(self.stage_configs): - config = self.stage_configs[row] - config['name'] = self.stage_name_input.text() - config['port_ids'] = self.port_ids_input.text() - config['model_path'] = self.model_path_input.text() - - self.update_stages_list() - self.stages_list.setCurrentRow(row) # Maintain selection - - def update_dongle_count(self, value): - """Update dongle count for current stage""" - self.dongle_count_label.setText(f"{value} dongles") - - row = self.stages_list.currentRow() - if row >= 0 and row < len(self.stage_configs): - self.stage_configs[row]['dongles'] = value - self.update_stages_list() - self.stages_list.setCurrentRow(row) - - # Update performance estimates - self.update_performance_estimates(self.stage_configs[row]) - - def auto_balance_dongles(self): - """Automatically balance dongles across stages""" - if not self.stage_configs: - return - - num_stages = len(self.stage_configs) - dongles_per_stage = max(1, self.total_dongles_available // num_stages) - remaining = self.total_dongles_available % num_stages - - for i, config in enumerate(self.stage_configs): - config['dongles'] = dongles_per_stage + (1 if i < remaining else 0) - - self.update_stages_list() - # Refresh current stage display - current_row = self.stages_list.currentRow() - if current_row >= 0: - self.on_stage_selected(None, None) - - def browse_model_file(self): - """Browse for model file""" - filename, _ = QFileDialog.getOpenFileName( - self, "Select Model File", "", - "Model files (*.nef *.onnx *.tflite);;All files (*.*)" - ) - if filename: - self.model_path_input.setText(filename) - self.update_current_stage() - - def update_performance_estimates(self, config): - """Calculate and display performance estimates""" - dongles = config['dongles'] - - # Simple performance estimation based on dongle count - base_fps = 30 # Base FPS per dongle - estimated_fps = dongles * base_fps - latency = 1000 / max(estimated_fps, 1) # ms - throughput = estimated_fps * 1 # frames per second - - self.estimated_fps_label.setText(f"{estimated_fps:.1f} FPS") - self.estimated_latency_label.setText(f"{latency:.1f} ms") - self.throughput_label.setText(f"{throughput:.1f} frames/sec") - - def update_resource_summary(self): - """Update resource usage summary""" - total_dongles = sum(config['dongles'] for config in self.stage_configs) - self.total_dongles_label.setText(f"{total_dongles} / {self.total_dongles_available}") - - # Calculate overall pipeline FPS (limited by slowest stage) - if self.stage_configs: - min_fps = min(config['dongles'] * 30 for config in self.stage_configs) - self.pipeline_fps_label.setText(f"{min_fps:.1f} FPS") - else: - self.pipeline_fps_label.setText("--") - - def show_performance_panel(self): - """Show performance estimation panel""" - if not self.stage_configs: - QMessageBox.warning(self, "No Stages", "Please add at least one stage before proceeding.") - return - - # Validate stage configurations - for config in self.stage_configs: - if not config['name'].strip(): - QMessageBox.warning(self, "Invalid Configuration", "All stages must have names.") - return - - self.accept() - - # Show performance panel - perf_panel = PerformanceEstimationPanel(self.stage_configs, self.parent()) - perf_panel.exec_() - - def get_stage_configs(self): - """Get configured stages""" - return self.stage_configs - - -# Simplified versions of the other dialogs for now -class PerformanceEstimationPanel(QDialog): - """Performance estimation and tweaking panel""" - - def __init__(self, stage_configs, parent=None): - super().__init__(parent) - self.stage_configs = stage_configs - self.setWindowTitle("Performance Estimation & Optimization") - self.setMinimumSize(600, 400) - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - - # Header - header = QLabel("Pipeline Performance Analysis") - header.setFont(QFont("Arial", 16, QFont.Bold)) - header.setStyleSheet("color: #89b4fa; margin-bottom: 10px;") - layout.addWidget(header) - - # Performance summary - summary_text = f"Analyzing {len(self.stage_configs)} stages:\n" - total_dongles = sum(config['dongles'] for config in self.stage_configs) - min_fps = min(config['dongles'] * 30 for config in self.stage_configs) if self.stage_configs else 0 - - summary_text += f"• Total dongles: {total_dongles}\n" - summary_text += f"• Pipeline FPS: {min_fps:.1f}\n" - summary_text += f"• Estimated latency: {sum(1000/(config['dongles']*30) for config in self.stage_configs):.1f} ms" - - summary_label = QLabel(summary_text) - summary_label.setStyleSheet("padding: 20px; background-color: #313244; border-radius: 8px;") - layout.addWidget(summary_label) - - # Buttons - button_layout = QHBoxLayout() - self.deploy_btn = QPushButton("Next: Save & Deploy") - self.deploy_btn.clicked.connect(self.show_deploy_dialog) - self.back_btn = QPushButton("← Back") - self.back_btn.clicked.connect(self.reject) - - button_layout.addWidget(self.back_btn) - button_layout.addStretch() - button_layout.addWidget(self.deploy_btn) - layout.addLayout(button_layout) - - def show_deploy_dialog(self): - """Show deployment dialog""" - self.accept() - deploy_dialog = SaveDeployDialog(self.stage_configs, self.parent()) - deploy_dialog.exec_() - - -class SaveDeployDialog(QDialog): - """Save and Deploy dialog with cost estimation""" - - def __init__(self, stage_configs, parent=None): - super().__init__(parent) - self.stage_configs = stage_configs - self.setWindowTitle("Save & Deploy Pipeline") - self.setMinimumSize(600, 400) - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - - # Header - header = QLabel("Save & Deploy Configuration") - header.setFont(QFont("Arial", 16, QFont.Bold)) - header.setStyleSheet("color: #89b4fa; margin-bottom: 10px;") - layout.addWidget(header) - - # Export options - export_group = QGroupBox("Export Configuration") - export_layout = QFormLayout(export_group) - - self.config_name_input = QLineEdit() - self.config_name_input.setText(f"pipeline_config_{time.strftime('%Y%m%d_%H%M%S')}") - export_layout.addRow("Configuration Name:", self.config_name_input) - - self.export_format_combo = QComboBox() - self.export_format_combo.addItems(["Python Script", "JSON Config", "YAML Pipeline"]) - export_layout.addRow("Export Format:", self.export_format_combo) - - self.export_btn = QPushButton("💾 Export Configuration") - self.export_btn.clicked.connect(self.export_configuration) - export_layout.addRow("", self.export_btn) - - layout.addWidget(export_group) - - # Cost estimation - total_dongles = sum(config['dongles'] for config in self.stage_configs) - cost_text = f"Cost Estimation:\n" - cost_text += f"• Hardware: {total_dongles} dongles (~${total_dongles * 299:,})\n" - cost_text += f"• Power: {total_dongles * 5}W continuous\n" - cost_text += f"• Monthly operating: ~${total_dongles * 15:.2f}" - - cost_label = QLabel(cost_text) - cost_label.setStyleSheet("padding: 20px; background-color: #313244; border-radius: 8px;") - layout.addWidget(cost_label) - - # Buttons - button_layout = QHBoxLayout() - self.deploy_btn = QPushButton("🚀 Deploy Pipeline") - self.deploy_btn.clicked.connect(self.deploy_pipeline) - self.back_btn = QPushButton("← Back") - self.back_btn.clicked.connect(self.reject) - - button_layout.addWidget(self.back_btn) - button_layout.addStretch() - button_layout.addWidget(self.deploy_btn) - layout.addLayout(button_layout) - - def export_configuration(self): - """Export configuration to file""" - format_type = self.export_format_combo.currentText() - config_name = self.config_name_input.text().strip() - - if not config_name: - QMessageBox.warning(self, "Invalid Name", "Please enter a configuration name.") - return - - # Generate content based on format - if format_type == "Python Script": - content = self.generate_python_script() - ext = ".py" - elif format_type == "JSON Config": - content = json.dumps({ - "stages": [{"name": c['name'], "dongles": c['dongles'], "port_ids": c['port_ids']} - for c in self.stage_configs] - }, indent=2) - ext = ".json" - else: # YAML - content = "stages:\n" - for config in self.stage_configs: - content += f" - name: {config['name']}\n dongles: {config['dongles']}\n port_ids: {config['port_ids']}\n" - ext = ".yaml" - - # Save file - filename, _ = QFileDialog.getSaveFileName( - self, f"Save {format_type}", f"{config_name}{ext}", - f"{format_type} files (*{ext});;All files (*.*)" - ) - - if filename: - try: - with open(filename, 'w') as f: - f.write(content) - QMessageBox.information(self, "Exported", f"Configuration exported to {os.path.basename(filename)}") - except Exception as e: - QMessageBox.critical(self, "Export Error", f"Failed to export: {str(e)}") - - def generate_python_script(self): - """Generate Python script""" - script = '''#!/usr/bin/env python3 -""" -Generated Pipeline Configuration -""" - -from src.cluster4npu.InferencePipeline import InferencePipeline, StageConfig - -def main(): - stage_configs = [ -''' - - for config in self.stage_configs: - port_ids = config['port_ids'].split(',') if ',' in config['port_ids'] else [28, 30] - script += f''' StageConfig( - stage_id="{config['name'].lower().replace(' ', '_')}", - port_ids={port_ids}, - scpu_fw_path="fw_scpu.bin", - ncpu_fw_path="fw_ncpu.bin", - model_path="{config.get('model_path', 'model.nef')}", - upload_fw=True - ), -''' - - script += ''' ] - - pipeline = InferencePipeline(stage_configs) - pipeline.initialize() - pipeline.start() - - try: - import time - while True: - time.sleep(1) - except KeyboardInterrupt: - pipeline.stop() - -if __name__ == "__main__": - main() -''' - return script - - def deploy_pipeline(self): - """Deploy the pipeline""" - QMessageBox.information( - self, "Deployment Started", - "Pipeline deployment initiated!\n\nThis would start the actual hardware deployment process." - ) - self.accept() - - -if __name__ == '__main__': - app.setFont(QFont("Arial", 9)) - dashboard = DashboardLogin() - dashboard.show() - sys.exit(app.exec_()) \ No newline at end of file diff --git a/check_dependencies.py b/check_dependencies.py deleted file mode 100644 index b8c9aad..0000000 --- a/check_dependencies.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -""" -Check dependencies and node setup without creating Qt widgets. -""" - -import sys -import os - -# Add the project root to Python path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -def check_dependencies(): - """Check if required dependencies are available.""" - print("Checking dependencies...") - - dependencies = [ - ('PyQt5', 'PyQt5.QtWidgets'), - ('NodeGraphQt', 'NodeGraphQt'), - ] - - results = {} - - for dep_name, import_path in dependencies: - try: - __import__(import_path) - print(f"✓ {dep_name} is available") - results[dep_name] = True - except ImportError as e: - print(f"✗ {dep_name} is missing: {e}") - results[dep_name] = False - - return results - -def check_node_classes(): - """Check if node classes are properly defined.""" - print("\nChecking node classes...") - - try: - from cluster4npu_ui.core.nodes.simple_input_node import ( - SimpleInputNode, SimpleModelNode, SimplePreprocessNode, - SimplePostprocessNode, SimpleOutputNode, SIMPLE_NODE_TYPES - ) - - print("✓ Simple nodes imported successfully") - - # Check node identifiers - for node_name, node_class in SIMPLE_NODE_TYPES.items(): - identifier = getattr(node_class, '__identifier__', 'MISSING') - node_display_name = getattr(node_class, 'NODE_NAME', 'MISSING') - print(f" {node_name}: {identifier} ({node_display_name})") - - return True - - except Exception as e: - print(f"✗ Failed to import nodes: {e}") - import traceback - traceback.print_exc() - return False - -def check_nodegraph_import(): - """Check if NodeGraphQt can be imported and used.""" - print("\nChecking NodeGraphQt functionality...") - - try: - from NodeGraphQt import NodeGraph, BaseNode - print("✓ NodeGraphQt classes imported successfully") - - # Check if we can create a basic node class - class TestNode(BaseNode): - __identifier__ = 'test.node' - NODE_NAME = 'Test Node' - - print("✓ Can create BaseNode subclass") - return True - - except ImportError as e: - print(f"✗ NodeGraphQt import failed: {e}") - return False - except Exception as e: - print(f"✗ NodeGraphQt functionality test failed: {e}") - return False - -def provide_solution(): - """Provide solution steps.""" - print("\n" + "=" * 50) - print("SOLUTION STEPS") - print("=" * 50) - - print("\n1. Install missing dependencies:") - print(" pip install NodeGraphQt") - print(" pip install PyQt5") - - print("\n2. Verify installation:") - print(" python -c \"import NodeGraphQt; print('NodeGraphQt OK')\"") - print(" python -c \"import PyQt5; print('PyQt5 OK')\"") - - print("\n3. The node registration issue is likely due to:") - print(" - Missing NodeGraphQt dependency") - print(" - Incorrect node identifier format") - print(" - Node class not properly inheriting from BaseNode") - - print("\n4. After installing dependencies, restart the application") - print(" The 'Can't find node' error should be resolved.") - -def main(): - """Run all checks.""" - print("CLUSTER4NPU NODE DEPENDENCY CHECK") - print("=" * 50) - - # Check dependencies - deps = check_dependencies() - - # Check node classes - nodes_ok = check_node_classes() - - # Check NodeGraphQt functionality - nodegraph_ok = check_nodegraph_import() - - print("\n" + "=" * 50) - print("SUMMARY") - print("=" * 50) - - if deps.get('NodeGraphQt', False) and deps.get('PyQt5', False) and nodes_ok and nodegraph_ok: - print("✓ ALL CHECKS PASSED") - print("The node registration should work correctly.") - print("If you're still getting errors, try restarting the application.") - else: - print("✗ SOME CHECKS FAILED") - provide_solution() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/cluster4npu_ui/.gitignore b/cluster4npu_ui/.gitignore deleted file mode 100644 index 2f25853..0000000 --- a/cluster4npu_ui/.gitignore +++ /dev/null @@ -1 +0,0 @@ -../dist.zip \ No newline at end of file diff --git a/cluster4npu_ui/CLAUDE.md b/cluster4npu_ui/CLAUDE.md deleted file mode 100644 index 2502efd..0000000 --- a/cluster4npu_ui/CLAUDE.md +++ /dev/null @@ -1,75 +0,0 @@ -Always follow the instructions in plan.md. When I say "go", find the next unmarked test in plan.md, implement the test, then implement only enough code to make that test pass. - -# ROLE AND EXPERTISE - -You are a senior software engineer who follows Mason Huang's Test-Driven Development (TDD) and Tidy First principles. Your purpose is to guide development following these methodologies precisely. - -# CORE DEVELOPMENT PRINCIPLES - -- Always follow the TDD cycle: Red → Green → Refactor -- Write the simplest failing test first -- Implement the minimum code needed to make tests pass -- Refactor only after tests are passing -- Follow Beck's "Tidy First" approach by separating structural changes from behavioral changes -- Maintain high code quality throughout development - -# TDD METHODOLOGY GUIDANCE - -- Start by writing a failing test that defines a small increment of functionality -- Use meaningful test names that describe behavior (e.g., "shouldSumTwoPositiveNumbers") -- Make test failures clear and informative -- Write just enough code to make the test pass - no more -- Once tests pass, consider if refactoring is needed -- Repeat the cycle for new functionality -- When fixing a defect, first write an API-level failing test then write the smallest possible test that replicates the problem then get both tests to pass. - -# TIDY FIRST APPROACH - -- Separate all changes into two distinct types: - 1. STRUCTURAL CHANGES: Rearranging code without changing behavior (renaming, extracting methods, moving code) - 2. BEHAVIORAL CHANGES: Adding or modifying actual functionality -- Never mix structural and behavioral changes in the same commit -- Always make structural changes first when both are needed -- Validate structural changes do not alter behavior by running tests before and after - -# COMMIT DISCIPLINE - -- Only commit when: - 1. ALL tests are passing - 2. ALL compiler/linter warnings have been resolved - 3. The change represents a single logical unit of work - 4. Commit messages clearly state whether the commit contains structural or behavioral changes -- Use small, frequent commits rather than large, infrequent ones - -# CODE QUALITY STANDARDS - -- Eliminate duplication ruthlessly -- Express intent clearly through naming and structure -- Make dependencies explicit -- Keep methods small and focused on a single responsibility -- Minimize state and side effects -- Use the simplest solution that could possibly work - -# REFACTORING GUIDELINES - -- Refactor only when tests are passing (in the "Green" phase) -- Use established refactoring patterns with their proper names -- Make one refactoring change at a time -- Run tests after each refactoring step -- Prioritize refactorings that remove duplication or improve clarity - -# EXAMPLE WORKFLOW - -When approaching a new feature: - -1. Write a simple failing test for a small part of the feature -2. Implement the bare minimum to make it pass -3. Run tests to confirm they pass (Green) -4. Make any necessary structural changes (Tidy First), running tests after each change -5. Commit structural changes separately -6. Add another test for the next small increment of functionality -7. Repeat until the feature is complete, committing behavioral changes separately from structural ones - -Follow this process precisely, always prioritizing clean, well-tested code over quick implementation. - -Always write one test at a time, make it run, then improve structure. Always run all the tests (except long-running tests) each time. \ No newline at end of file diff --git a/cluster4npu_ui/DEVELOPMENT_ROADMAP.md b/cluster4npu_ui/DEVELOPMENT_ROADMAP.md deleted file mode 100644 index 7f7cc78..0000000 --- a/cluster4npu_ui/DEVELOPMENT_ROADMAP.md +++ /dev/null @@ -1,131 +0,0 @@ -# Development Roadmap - -## Mission -Create an intuitive visual pipeline designer that demonstrates clear speedup benefits of parallel NPU processing through real-time performance visualization and automated optimization. - -## 🎯 Core Development Goals - -### 1. Performance Visualization (Critical) -- **Speedup Metrics**: Clear display of 2x, 3x, 4x performance improvements -- **Before/After Comparison**: Visual proof of parallel processing benefits -- **Device Utilization**: Real-time visualization of NPU usage -- **Execution Flow**: Visual representation of parallel processing paths - -### 2. Benchmarking System (Critical) -- **Automated Testing**: One-click performance measurement -- **Comparison Charts**: Single vs multi-device performance analysis -- **Regression Testing**: Track performance over time -- **Optimization Suggestions**: Automated recommendations - -### 3. Device Management (High Priority) -- **Visual Dashboard**: Device status and health monitoring -- **Manual Allocation**: Drag-and-drop device assignment -- **Load Balancing**: Optimal distribution across available NPUs -- **Performance Profiling**: Individual device performance tracking - -### 4. Real-time Monitoring (High Priority) -- **Live Charts**: FPS, latency, and throughput graphs -- **Resource Monitoring**: CPU, memory, and NPU utilization -- **Bottleneck Detection**: Automated identification of performance issues -- **Alert System**: Warnings for performance degradation - -## 📋 Implementation Plan - -### Phase 1: Performance Visualization (Weeks 1-2) - -**Core Components:** -- `PerformanceBenchmarker` class for automated testing -- `PerformanceDashboard` widget with live charts -- Speedup calculation and display widgets -- Integration with existing pipeline editor - -**Deliverables:** -- Single vs multi-device benchmark comparison -- Real-time FPS and latency monitoring -- Visual speedup indicators (e.g., "3.2x FASTER") -- Performance history tracking - -### Phase 2: Device Management (Weeks 3-4) - -**Core Components:** -- `DeviceManager` with enhanced NPU control -- `DeviceManagementPanel` for visual allocation -- Device health monitoring and profiling -- Load balancing optimization algorithms - -**Deliverables:** -- Visual device status dashboard -- Drag-and-drop device assignment interface -- Device performance profiling and history -- Automatic load balancing recommendations - -### Phase 3: Advanced Features (Weeks 5-6) - -**Core Components:** -- `OptimizationEngine` for automated suggestions -- Pipeline analysis and bottleneck detection -- Configuration templates and presets -- Performance prediction algorithms - -**Deliverables:** -- Automated pipeline optimization suggestions -- Configuration templates for common use cases -- Performance prediction before execution -- Bottleneck identification and resolution - -### Phase 4: Professional Polish (Weeks 7-8) - -**Core Components:** -- Advanced visualization and reporting -- Export and documentation features -- Performance analytics and insights -- User experience refinements - -**Deliverables:** -- Professional performance reports -- Advanced analytics and trending -- Export capabilities for results -- Comprehensive user documentation - -## 🎨 Target User Experience - -### Ideal Workflow -1. **Design** (< 5 minutes): Drag-and-drop pipeline creation -2. **Configure**: Automatic device detection and optimal allocation -3. **Benchmark**: One-click performance measurement -4. **Monitor**: Real-time speedup visualization during execution -5. **Optimize**: Automated suggestions for performance improvements - -### Success Metrics -- **Speedup Visibility**: Clear before/after performance comparison -- **Ease of Use**: Intuitive interface requiring minimal training -- **Performance Gains**: Measurable improvements from optimization -- **Professional Quality**: Enterprise-ready monitoring and reporting - -## 🛠 Technical Approach - -### Extend Current Architecture -- Build on existing `InferencePipeline` and `Multidongle` classes -- Enhance UI with new performance panels and dashboards -- Integrate visualization libraries (matplotlib/pyqtgraph) -- Add benchmarking automation and result storage - -### Key Technical Components -- **Performance Engine**: Automated benchmarking and comparison -- **Visualization Layer**: Real-time charts and progress indicators -- **Device Abstraction**: Enhanced NPU management and allocation -- **Optimization Logic**: Automated analysis and suggestions - -## 📈 Expected Impact - -### For Users -- **Simplified Setup**: No coding required for parallel processing -- **Clear Benefits**: Visual proof of performance improvements -- **Optimal Performance**: Automated hardware utilization -- **Professional Tools**: Enterprise-grade monitoring and analytics - -### For Platform -- **Competitive Advantage**: Unique visual approach to parallel AI inference -- **Market Expansion**: Lower barrier to entry for non-technical users -- **Performance Leadership**: Systematic optimization of NPU utilization -- **Enterprise Ready**: Foundation for advanced features and scaling \ No newline at end of file diff --git a/cluster4npu_ui/Flowchart.jpg b/cluster4npu_ui/Flowchart.jpg deleted file mode 100644 index 3c27e39491ae0cd01dba18554427592db18697b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 214832 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 z%0D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
  • 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 diff --git a/cluster4npu_ui/PROJECT_SUMMARY.md b/cluster4npu_ui/PROJECT_SUMMARY.md deleted file mode 100644 index ddc1069..0000000 --- a/cluster4npu_ui/PROJECT_SUMMARY.md +++ /dev/null @@ -1,138 +0,0 @@ -# Cluster4NPU UI - Project Summary - -## Vision - -Create an intuitive visual tool that enables users to design parallel AI inference pipelines for Kneron NPU dongles without coding knowledge, with clear visualization of performance benefits and hardware utilization. - -## Current System Status - -### ✅ Current Capabilities - -**Visual Pipeline Designer:** -- Drag-and-drop node-based interface using NodeGraphQt -- 5 node types: Input, Model, Preprocess, Postprocess, Output -- Real-time pipeline validation and stage counting -- Property configuration panels with type-aware widgets -- Pipeline persistence in .mflow JSON format - -**Professional UI:** -- Three-panel layout (templates, editor, configuration) -- Global status bar with live statistics -- Real-time connection analysis and error detection -- Integrated project management and recent files - -**Inference Engine:** -- Multi-stage pipeline orchestration with threading -- Kneron NPU dongle integration (KL520, KL720, KL1080) -- Hardware auto-detection and device management -- Real-time performance monitoring (FPS, latency) - -### 🎯 Core Use Cases - -**Pipeline Flow:** -``` -Input → Preprocess → Model → Postprocess → Output - ↓ ↓ ↓ ↓ ↓ -Camera Resize NPU Inference Format Display -``` - -**Supported Sources:** -- USB cameras with configurable resolution/FPS -- Video files (MP4, AVI, MOV) with frame processing -- Image files (JPG, PNG, BMP) for batch processing -- RTSP streams for live video (basic support) - -## Development Priorities - -### Immediate Goals -1. **Performance Visualization**: Show clear speedup benefits of parallel processing -2. **Device Management**: Enhanced control over NPU dongle allocation -3. **Benchmarking System**: Automated performance testing and comparison -4. **Real-time Dashboard**: Live monitoring of pipeline execution - -## 🚨 Key Missing Features - -### Performance Visualization -- Parallel vs sequential execution comparison -- Visual device allocation and load balancing -- Speedup calculation and metrics display -- Performance improvement charts - -### Advanced Monitoring -- Live performance graphs (FPS, latency, throughput) -- Resource utilization visualization -- Bottleneck identification and alerts -- Historical performance tracking - -### Device Management -- Visual device status dashboard -- Manual device assignment interface -- Device health monitoring and profiling -- Optimal allocation recommendations - -### Pipeline Optimization -- Automated benchmark execution -- Performance prediction before deployment -- Configuration templates for common use cases -- Optimization suggestions based on analysis - -## 🛠 Technical Architecture - -### Current Foundation -- **Core Processing**: `InferencePipeline` with multi-stage orchestration -- **Hardware Integration**: `Multidongle` with NPU auto-detection -- **UI Framework**: PyQt5 with NodeGraphQt visual editor -- **Pipeline Analysis**: Real-time validation and stage detection - -### Key Components Needed -1. **PerformanceBenchmarker**: Automated speedup measurement -2. **DeviceManager**: Advanced NPU allocation and monitoring -3. **VisualizationDashboard**: Live performance charts and metrics -4. **OptimizationEngine**: Automated configuration suggestions - -## 🎯 Implementation Roadmap - -### Phase 1: Performance Visualization -- Implement parallel vs sequential benchmarking -- Add speedup calculation and display -- Create performance comparison charts -- Build real-time monitoring dashboard - -### Phase 2: Device Management -- Visual device allocation interface -- Device health monitoring and profiling -- Manual assignment capabilities -- Load balancing optimization - -### Phase 3: Advanced Features -- Pipeline optimization suggestions -- Configuration templates -- Performance prediction -- Advanced analytics and reporting - -## 🎨 User Experience Goals - -### Target Workflow -1. **Design**: Drag-and-drop pipeline creation (< 5 minutes) -2. **Configure**: Automatic device detection and allocation -3. **Preview**: Performance prediction before execution -4. **Monitor**: Real-time speedup visualization -5. **Optimize**: Automated suggestions for improvements - -### Success Metrics -- Clear visualization of parallel processing benefits -- Intuitive interface requiring minimal training -- Measurable performance improvements from optimization -- Professional-grade monitoring and analytics - -## 📈 Business Value - -**For Users:** -- No-code parallel processing setup -- Clear ROI demonstration through speedup metrics -- Optimal hardware utilization without expert knowledge - -**For Platform:** -- Unique visual approach to AI inference optimization -- Lower barrier to entry for complex parallel processing -- Scalable foundation for enterprise features \ No newline at end of file diff --git a/cluster4npu_ui/README.md b/cluster4npu_ui/README.md deleted file mode 100644 index ff5e996..0000000 --- a/cluster4npu_ui/README.md +++ /dev/null @@ -1,259 +0,0 @@ -# Cluster4NPU UI - Visual Pipeline Designer - -A visual pipeline designer for creating parallel AI inference workflows using Kneron NPU dongles. Build complex multi-stage inference pipelines through an intuitive drag-and-drop interface without coding knowledge. - -## Features - -- **Visual Pipeline Design**: Drag-and-drop node-based interface using NodeGraphQt -- **Multi-Stage Pipelines**: Chain multiple AI models for complex workflows -- **Real-time Performance Monitoring**: Live FPS, latency, and throughput tracking -- **Hardware Integration**: Automatic Kneron NPU dongle detection and management -- **Professional UI**: Three-panel layout with integrated configuration and monitoring -- **Pipeline Validation**: Real-time pipeline structure analysis and error detection - -## 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 - -**Python Dependencies:** -- PyQt5 (GUI framework) -- NodeGraphQt (visual node editor) -- OpenCV (image processing) -- NumPy (array operations) -- Kneron KP SDK (NPU communication) - -**Hardware Requirements:** -- Kneron NPU dongles (KL520, KL720, KL1080) -- USB 3.0 ports for device connections -- Compatible firmware files (`fw_scpu.bin`, `fw_ncpu.bin`) -- Trained model files (`.nef` format) - -## Quick Start - -### Launching the Application - -```bash -# Activate virtual environment -source .venv/bin/activate # On Windows: .venv\Scripts\activate - -# Launch the visual pipeline designer -python main.py -``` - -### Creating Your First Pipeline - -1. **Start the Application**: Launch `main.py` to open the login/project manager -2. **Create New Project**: Click "Create New Pipeline" or load an existing `.mflow` file -3. **Design Pipeline**: Use the 3-panel interface: - - **Left Panel**: Drag nodes from the template palette - - **Middle Panel**: Connect nodes to build your pipeline flow - - **Right Panel**: Configure node properties and monitor performance - -### Basic Pipeline Structure - -``` -Input Node → Preprocess Node → Model Node → Postprocess Node → Output Node -``` - -**Node Types:** -- **Input Node**: Camera, video file, or image source -- **Preprocess Node**: Data transformation (resize, normalize, format conversion) -- **Model Node**: AI inference on Kneron NPU dongles -- **Postprocess Node**: Result processing (classification, detection formatting) -- **Output Node**: Display, file output, or network streaming - -### Visual Pipeline Design Workflow - -1. **Node Placement**: Drag nodes from the left template palette -2. **Connection**: Connect nodes by dragging from output to input ports -3. **Configuration**: Select nodes and configure properties in the right panel -4. **Validation**: Real-time pipeline validation with stage counting -5. **Deployment**: Export configured pipeline for execution - -## User Interface - -### Three-Panel Layout - -The main dashboard provides an integrated development environment with three main panels: - -**Left Panel (25% width):** -- **Node Templates**: Drag-and-drop node palette - - Input Node (camera, video, image sources) - - Model Node (AI inference on NPU dongles) - - Preprocess Node (data transformation) - - Postprocess Node (result processing) - - Output Node (display, file, stream output) -- **Pipeline Operations**: Validation and management tools -- **Instructions**: Context-sensitive help - -**Middle Panel (50% width):** -- **Visual Pipeline Editor**: NodeGraphQt-based visual editor -- **Real-time Validation**: Instant pipeline structure analysis -- **Node Connection**: Drag from output to input ports to connect nodes -- **Global Status Bar**: Shows stage count and pipeline statistics - -**Right Panel (25% width):** -- **Properties Tab**: Node-specific configuration panels -- **Performance Tab**: Real-time performance monitoring and estimation -- **Dongles Tab**: Hardware device management and allocation - -### Project Management - -**Login/Startup Window:** -- Recent projects list with quick access -- Create new pipeline projects -- Load existing `.mflow` pipeline files -- Project location management - -### Real-time Feedback - -- **Stage Counting**: Automatic detection of pipeline stages -- **Connection Analysis**: Real-time validation of node connections -- **Error Highlighting**: Visual indicators for configuration issues -- **Performance Metrics**: Live FPS, latency, and throughput display - -## Architecture - -### Core Components - -**Pipeline Analysis Engine (`core/pipeline.py`):** -- Automatic stage detection and validation -- Connection path analysis between nodes -- Real-time pipeline structure summarization -- Configuration export for deployment - -**Node System (`core/nodes/`):** -- Extensible node architecture with type-specific properties -- Business logic separation from UI presentation -- Dynamic property validation and configuration panels - -**Inference Engine (`core/functions/InferencePipeline.py`):** -- Multi-stage pipeline orchestration with thread-based processing -- Real-time performance monitoring and FPS calculation -- Inter-stage data flow and result aggregation - -**Hardware Abstraction (`core/functions/Multidongle.py`):** -- Kneron NPU dongle management and auto-detection -- Multi-device support with load balancing -- Async inference processing with result queuing - -### Data Flow - -1. **Design Phase**: Visual pipeline creation using drag-and-drop interface -2. **Validation Phase**: Real-time analysis of pipeline structure and configuration -3. **Export Phase**: Generate executable configuration from visual design -4. **Execution Phase**: Deploy pipeline to hardware with performance monitoring - -## File Formats - -### Pipeline Files (`.mflow`) - -JSON-based format storing: -- Node definitions and properties -- Connection relationships -- Stage configurations -- Export settings - -### Hardware Configuration - -- Firmware files: `fw_scpu.bin`, `fw_ncpu.bin` -- Model files: `.nef` format for Kneron NPUs -- Device mapping: USB port assignment to pipeline stages - -## Performance Monitoring - -### Real-time Metrics - -- **FPS (Frames Per Second)**: Processing throughput -- **Latency**: End-to-end processing time -- **Stage Performance**: Per-stage processing statistics -- **Device Utilization**: NPU dongle usage monitoring - -### Statistics Collection - -- Pipeline input/output counts -- Processing time distributions -- Error rates and failure analysis -- Resource utilization tracking - -## Testing and Validation - -Run the test suite to verify functionality: - -```bash -# Test core pipeline functionality -python tests/test_pipeline_editor.py - -# Test UI components -python tests/test_ui_fixes.py - -# Test integration -python tests/test_integration.py -``` - -## Troubleshooting - -### Common Issues - -**Node creation fails:** -- Verify NodeGraphQt installation and compatibility -- Check node template definitions in `core/nodes/` - -**Pipeline validation errors:** -- Ensure all model nodes are connected between input and output -- Verify node property configurations are complete - -**Hardware detection issues:** -- Check USB connections and dongles power -- Verify firmware files are accessible -- Ensure proper Kneron SDK installation - -**Performance issues:** -- Monitor device utilization in Dongles tab -- Adjust queue sizes for throughput vs. latency tradeoffs -- Check for processing bottlenecks in stage statistics - -## Development - -### Project Structure - -``` -cluster4npu_ui/ -├── main.py # Application entry point -├── config/ # Configuration and theming -├── core/ # Core processing engine -│ ├── functions/ # Inference and hardware abstraction -│ ├── nodes/ # Node type definitions -│ └── pipeline.py # Pipeline analysis and validation -├── ui/ # User interface components -│ ├── windows/ # Main windows (login, dashboard) -│ ├── components/ # Reusable UI widgets -│ └── dialogs/ # Modal dialogs -├── tests/ # Test suite -└── resources/ # Assets and styling -``` - -### Contributing - -1. Follow the TDD workflow defined in `CLAUDE.md` -2. Run tests before committing changes -3. Maintain the three-panel UI architecture -4. Document new node types and their properties - -## License - -This project is part of the Cluster4NPU ecosystem for parallel AI inference on Kneron NPU hardware. \ No newline at end of file diff --git a/cluster4npu_ui/__init__.py b/cluster4npu_ui/__init__.py deleted file mode 100644 index b51f946..0000000 --- a/cluster4npu_ui/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Cluster4NPU UI - Modular PyQt5 Application for ML Pipeline Design - -This package provides a comprehensive, modular user interface for designing, -configuring, and deploying high-performance ML inference pipelines optimized -for Kneron NPU dongles. - -Main Modules: - - config: Theme and settings management - - core: Business logic and node implementations - - ui: User interface components and windows - - utils: Utility functions and helpers - - resources: Static resources and assets - -Key Features: - - Visual node-based pipeline designer - - Multi-stage inference workflow support - - Hardware-aware resource allocation - - Real-time performance estimation - - Export to multiple deployment formats - -Usage: - # Run the application - from cluster4npu_ui.main import main - main() - - # Or use individual components - from cluster4npu_ui.core.nodes import ModelNode, InputNode - from cluster4npu_ui.config.theme import apply_theme - -Author: Cluster4NPU Team -Version: 1.0.0 -License: MIT -""" - -__version__ = "1.0.0" -__author__ = "Cluster4NPU Team" -__email__ = "team@cluster4npu.com" -__license__ = "MIT" - -# Package metadata -__title__ = "Cluster4NPU UI" -__description__ = "Modular PyQt5 Application for ML Pipeline Design" -__url__ = "https://github.com/cluster4npu/ui" - -# Import main components for convenience -from .main import main - -__all__ = [ - "main", - "__version__", - "__author__", - "__title__", - "__description__" -] \ No newline at end of file diff --git a/cluster4npu_ui/config/__init__.py b/cluster4npu_ui/config/__init__.py deleted file mode 100644 index 7f70c75..0000000 --- a/cluster4npu_ui/config/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Configuration management for the Cluster4NPU UI application. - -This module provides centralized configuration management including themes, -settings, user preferences, and application state persistence. - -Available Components: - - theme: QSS styling and color constants - - settings: Application settings and preferences management - -Usage: - from cluster4npu_ui.config import apply_theme, get_settings - - # Apply theme to application - apply_theme(app) - - # Access settings - settings = get_settings() - recent_files = settings.get_recent_files() -""" - -from .theme import apply_theme, Colors, HARMONIOUS_THEME_STYLESHEET -from .settings import get_settings, Settings - -__all__ = [ - "apply_theme", - "Colors", - "HARMONIOUS_THEME_STYLESHEET", - "get_settings", - "Settings" -] \ No newline at end of file diff --git a/cluster4npu_ui/config/settings.py b/cluster4npu_ui/config/settings.py deleted file mode 100644 index 774f80c..0000000 --- a/cluster4npu_ui/config/settings.py +++ /dev/null @@ -1,321 +0,0 @@ -""" -Application settings and configuration management. - -This module handles application-wide settings, preferences, and configuration -data. It provides a centralized location for managing user preferences, -default values, and application state. - -Main Components: - - Settings class for configuration management - - Default configuration values - - Settings persistence and loading - - Configuration validation - -Usage: - from cluster4npu_ui.config.settings import Settings - - settings = Settings() - recent_files = settings.get_recent_files() - settings.add_recent_file('/path/to/pipeline.mflow') -""" - -import json -import os -from typing import Dict, Any, List, Optional -from pathlib import Path - - -class Settings: - """ - Application settings and configuration management. - - Handles loading, saving, and managing application settings including - user preferences, recent files, and default configurations. - """ - - def __init__(self, config_file: Optional[str] = None): - """ - Initialize settings manager. - - Args: - config_file: Optional path to configuration file - """ - self.config_file = config_file or self._get_default_config_path() - self._settings = self._load_default_settings() - self.load() - - def _get_default_config_path(self) -> str: - """Get the default configuration file path.""" - home_dir = Path.home() - config_dir = home_dir / '.cluster4npu' - config_dir.mkdir(exist_ok=True) - return str(config_dir / 'settings.json') - - def _load_default_settings(self) -> Dict[str, Any]: - """Load default application settings.""" - return { - 'general': { - 'auto_save': True, - 'auto_save_interval': 300, # seconds - 'check_for_updates': True, - 'theme': 'harmonious_dark', - 'language': 'en' - }, - 'recent_files': [], - 'window': { - 'main_window_geometry': None, - 'main_window_state': None, - 'splitter_sizes': None, - 'recent_window_size': [1200, 800] - }, - 'pipeline': { - 'default_project_location': str(Path.home() / 'Documents' / 'Cluster4NPU'), - 'auto_layout': True, - 'show_grid': True, - 'snap_to_grid': False, - 'grid_size': 20, - 'auto_connect': True, - 'validate_on_save': True - }, - 'performance': { - 'max_undo_steps': 50, - 'render_quality': 'high', - 'enable_animations': True, - 'cache_size_mb': 100 - }, - 'hardware': { - 'auto_detect_dongles': True, - 'preferred_dongle_series': '720', - 'max_dongles_per_stage': 4, - 'power_management': 'balanced' - }, - 'export': { - 'default_format': 'JSON', - 'include_metadata': True, - 'compress_exports': False, - 'export_location': str(Path.home() / 'Downloads') - }, - 'debugging': { - 'log_level': 'INFO', - 'enable_profiling': False, - 'save_debug_logs': False, - 'max_log_files': 10 - } - } - - def load(self) -> bool: - """ - Load settings from file. - - Returns: - True if settings were loaded successfully, False otherwise - """ - try: - if os.path.exists(self.config_file): - with open(self.config_file, 'r', encoding='utf-8') as f: - saved_settings = json.load(f) - self._merge_settings(saved_settings) - return True - except Exception as e: - print(f"Error loading settings: {e}") - return False - - def save(self) -> bool: - """ - Save current settings to file. - - Returns: - True if settings were saved successfully, False otherwise - """ - try: - os.makedirs(os.path.dirname(self.config_file), exist_ok=True) - with open(self.config_file, 'w', encoding='utf-8') as f: - json.dump(self._settings, f, indent=2, ensure_ascii=False) - return True - except Exception as e: - print(f"Error saving settings: {e}") - return False - - def _merge_settings(self, saved_settings: Dict[str, Any]): - """Merge saved settings with defaults.""" - def merge_dict(default: dict, saved: dict) -> dict: - result = default.copy() - for key, value in saved.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = merge_dict(result[key], value) - else: - result[key] = value - return result - - self._settings = merge_dict(self._settings, saved_settings) - - def get(self, key: str, default: Any = None) -> Any: - """ - Get a setting value using dot notation. - - Args: - key: Setting key (e.g., 'general.auto_save') - default: Default value if key not found - - Returns: - Setting value or default - """ - keys = key.split('.') - value = self._settings - - try: - for k in keys: - value = value[k] - return value - except (KeyError, TypeError): - return default - - def set(self, key: str, value: Any): - """ - Set a setting value using dot notation. - - Args: - key: Setting key (e.g., 'general.auto_save') - value: Value to set - """ - keys = key.split('.') - setting = self._settings - - # Navigate to the parent dictionary - for k in keys[:-1]: - if k not in setting: - setting[k] = {} - setting = setting[k] - - # Set the final value - setting[keys[-1]] = value - - def get_recent_files(self) -> List[str]: - """Get list of recent files.""" - return self.get('recent_files', []) - - def add_recent_file(self, file_path: str, max_files: int = 10): - """ - Add a file to recent files list. - - Args: - file_path: Path to the file - max_files: Maximum number of recent files to keep - """ - recent_files = self.get_recent_files() - - # Remove if already exists - if file_path in recent_files: - recent_files.remove(file_path) - - # Add to beginning - recent_files.insert(0, file_path) - - # Limit list size - recent_files = recent_files[:max_files] - - self.set('recent_files', recent_files) - self.save() - - def remove_recent_file(self, file_path: str): - """Remove a file from recent files list.""" - recent_files = self.get_recent_files() - if file_path in recent_files: - recent_files.remove(file_path) - self.set('recent_files', recent_files) - self.save() - - def clear_recent_files(self): - """Clear all recent files.""" - self.set('recent_files', []) - self.save() - - def get_default_project_location(self) -> str: - """Get default project location.""" - return self.get('pipeline.default_project_location', str(Path.home() / 'Documents' / 'Cluster4NPU')) - - def set_window_geometry(self, geometry: bytes): - """Save window geometry.""" - # Convert bytes to base64 string for JSON serialization - import base64 - geometry_str = base64.b64encode(geometry).decode('utf-8') - self.set('window.main_window_geometry', geometry_str) - self.save() - - def get_window_geometry(self) -> Optional[bytes]: - """Get saved window geometry.""" - geometry_str = self.get('window.main_window_geometry') - if geometry_str: - import base64 - return base64.b64decode(geometry_str.encode('utf-8')) - return None - - def set_window_state(self, state: bytes): - """Save window state.""" - import base64 - state_str = base64.b64encode(state).decode('utf-8') - self.set('window.main_window_state', state_str) - self.save() - - def get_window_state(self) -> Optional[bytes]: - """Get saved window state.""" - state_str = self.get('window.main_window_state') - if state_str: - import base64 - return base64.b64decode(state_str.encode('utf-8')) - return None - - def reset_to_defaults(self): - """Reset all settings to default values.""" - self._settings = self._load_default_settings() - self.save() - - def export_settings(self, file_path: str) -> bool: - """ - Export settings to a file. - - Args: - file_path: Path to export file - - Returns: - True if export was successful, False otherwise - """ - try: - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(self._settings, f, indent=2, ensure_ascii=False) - return True - except Exception as e: - print(f"Error exporting settings: {e}") - return False - - def import_settings(self, file_path: str) -> bool: - """ - Import settings from a file. - - Args: - file_path: Path to import file - - Returns: - True if import was successful, False otherwise - """ - try: - with open(file_path, 'r', encoding='utf-8') as f: - imported_settings = json.load(f) - self._merge_settings(imported_settings) - self.save() - return True - except Exception as e: - print(f"Error importing settings: {e}") - return False - - -# Global settings instance -_settings_instance = None - - -def get_settings() -> Settings: - """Get the global settings instance.""" - global _settings_instance - if _settings_instance is None: - _settings_instance = Settings() - return _settings_instance \ No newline at end of file diff --git a/cluster4npu_ui/config/theme.py b/cluster4npu_ui/config/theme.py deleted file mode 100644 index a0fcb49..0000000 --- a/cluster4npu_ui/config/theme.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -Theme and styling configuration for the Cluster4NPU UI application. - -This module contains the complete QSS (Qt Style Sheets) theme definitions and color -constants used throughout the application. It provides a harmonious dark theme with -complementary color palette optimized for professional ML pipeline development. - -Main Components: - - HARMONIOUS_THEME_STYLESHEET: Complete QSS dark theme definition - - Color constants and theme utilities - - Consistent styling for all UI components - -Usage: - from cluster4npu_ui.config.theme import HARMONIOUS_THEME_STYLESHEET - - app.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) -""" - -# 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; - } - 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); - } - 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; - } -""" - -# Color constants for programmatic use -class Colors: - """Color constants used throughout the application.""" - - # Background colors - BACKGROUND_MAIN = "#1e1e2e" - BACKGROUND_WINDOW = "#181825" - BACKGROUND_WIDGET = "#313244" - BACKGROUND_HOVER = "#383a59" - - # Text colors - TEXT_PRIMARY = "#cdd6f4" - TEXT_SECONDARY = "#f9e2af" - TEXT_DISABLED = "#6c7086" - - # Accent colors - ACCENT_PRIMARY = "#89b4fa" - ACCENT_SECONDARY = "#74c7ec" - ACCENT_HOVER = "#a6c8ff" - - # State colors - SUCCESS = "#a6e3a1" - WARNING = "#f9e2af" - ERROR = "#f38ba8" - INFO = "#89b4fa" - - # Border colors - BORDER_NORMAL = "#45475a" - BORDER_HOVER = "#585b70" - BORDER_FOCUS = "#89b4fa" - - -def apply_theme(app): - """Apply the harmonious theme to the application.""" - app.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) \ No newline at end of file diff --git a/cluster4npu_ui/core/__init__.py b/cluster4npu_ui/core/__init__.py deleted file mode 100644 index 99aefce..0000000 --- a/cluster4npu_ui/core/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Core business logic for the Cluster4NPU pipeline system. - -This module contains the fundamental business logic, node implementations, -and pipeline management functionality that drives the application. - -Available Components: - - nodes: All node implementations for pipeline design - - pipeline: Pipeline management and orchestration (future) - -Usage: - from cluster4npu_ui.core.nodes import ModelNode, InputNode, OutputNode - from cluster4npu_ui.core.nodes import NODE_TYPES, NODE_CATEGORIES - - # Create nodes - input_node = InputNode() - model_node = ModelNode() - output_node = OutputNode() - - # Access available node types - available_nodes = NODE_TYPES.keys() -""" - -from . import nodes - -__all__ = [ - "nodes" -] \ No newline at end of file diff --git a/cluster4npu_ui/core/functions/InferencePipeline.py b/cluster4npu_ui/core/functions/InferencePipeline.py deleted file mode 100644 index f8dbc40..0000000 --- a/cluster4npu_ui/core/functions/InferencePipeline.py +++ /dev/null @@ -1,686 +0,0 @@ -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 - 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, - auto_detect=config.auto_detect if hasattr(config, 'auto_detect') else False, - max_queue_size=config.max_queue_size - ) - - # Store preprocessor and postprocessor for later use - self.stage_preprocessor = config.stage_preprocessor - self.stage_postprocessor = config.stage_postprocessor - self.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=1.0) - if pipeline_data is None: # Sentinel value - continue - except queue.Empty: - if self._stop_event.is_set(): - break - continue - - start_time = time.time() - - # Process data through this stage - processed_data = self._process_data(pipeline_data) - - # Only count and record timing for actual inference results - if processed_data and self._has_inference_result(processed_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 _has_inference_result(self, processed_data) -> bool: - """Check if processed_data contains a valid inference result (like standalone code)""" - if not processed_data: - return False - - try: - # Check if it's a PipelineData with stage results - if hasattr(processed_data, 'stage_results') and processed_data.stage_results: - stage_result = processed_data.stage_results.get(self.stage_id) - if stage_result: - # Check for tuple result (prob, result_str) - like standalone code - if isinstance(stage_result, tuple) and len(stage_result) == 2: - prob, result_str = stage_result - return prob is not None and result_str is not None - # Check for dict result with actual inference data (not status messages) - elif isinstance(stage_result, dict): - # Don't count "Processing" or "async" status as real results - if stage_result.get("status") in ["processing", "async"]: - return False - # Don't count empty results - if not stage_result or stage_result.get("result") == "Processing": - return False - return True - else: - return stage_result is not None - except Exception: - pass - - return False - - def _process_data(self, pipeline_data: PipelineData) -> PipelineData: - """Process data through this stage""" - try: - current_data = pipeline_data.data - - # Step 1: Input preprocessing (inter-stage) - if self.input_preprocessor: - if isinstance(current_data, np.ndarray): - current_data = self.input_preprocessor.process( - current_data, - self.multidongle.model_input_shape, - 'BGR565' # Default format - ) - - # 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 - processed_data = self.multidongle.preprocess_frame(current_data, 'BGR565') - - # 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 - processed_data = current_data - else: - processed_data = current_data - - # Step 3: MultiDongle inference - if isinstance(processed_data, np.ndarray): - self.multidongle.put_input(processed_data, 'BGR565') - - # Get inference result (non-blocking, async pattern like standalone code) - result = self.multidongle.get_latest_inference_result() - - # Process result if available - only count actual inference results for FPS - inference_result = None - - if result is not None: - if isinstance(result, tuple) and len(result) == 2: - # Handle tuple results like (probability, result_string) - prob, result_str = result - if prob is not None and result_str is not None: - # Avoid duplicate logging - handled by GUI callback formatting - # print(f"[Stage {self.stage_id}] ✅ Inference result: prob={prob:.3f}, result={result_str}") - inference_result = result - elif isinstance(result, dict) and result: # Non-empty dict - # Avoid duplicate logging - handled by GUI callback formatting - # print(f"[Stage {self.stage_id}] Dict result: {result}") - inference_result = result - else: - inference_result = result - - # If no result, use default (don't spam logs) - if not inference_result: - inference_result = {'probability': 0.0, 'result': 'Processing', 'status': 'async'} - - # Step 4: Update pipeline data - pipeline_data.stage_results[self.stage_id] = inference_result - pipeline_data.data = inference_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 - - # FPS calculation based on output queue throughput (cumulative approach) - self.fps_start_time = None # Start time for FPS calculation - self.fps_lock = threading.Lock() # Thread safety for FPS calculation - - 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 _record_output_timestamp(self): - """Record timestamp when output is generated for FPS calculation""" - with self.fps_lock: - # Set start time only when we have our first completed result - if self.fps_start_time is None and self.completed_counter == 1: - self.fps_start_time = time.time() - - def get_current_fps(self) -> float: - """Calculate current FPS based on output queue throughput (cumulative approach like example.py)""" - with self.fps_lock: - if self.fps_start_time is None or self.completed_counter == 0: - return 0.0 - - elapsed_time = time.time() - self.fps_start_time - if elapsed_time > 0: - return self.completed_counter / elapsed_time - - return 0.0 - - def _has_valid_inference_result(self, pipeline_data) -> bool: - """Check if pipeline data contains valid inference results (not async/processing status)""" - for stage_id, stage_result in pipeline_data.stage_results.items(): - if stage_result: - # Check for tuple result (prob, result_str) - if isinstance(stage_result, tuple) and len(stage_result) == 2: - prob, result_str = stage_result - if prob is not None and result_str not in ['Processing']: - return True - # Check for dict result with actual inference data - elif isinstance(stage_result, dict): - # Don't count "Processing" or "async" status as real results - if stage_result.get("status") in ["processing", "async"]: - continue - # Don't count empty results - if stage_result.get("result") == "Processing": - continue - # If we have a meaningful result, count it - return True - return False - - def start(self): - """Start the pipeline""" - # Clear previous FPS data when starting - with self.fps_lock: - self.fps_start_time = None - - 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 - but only if it's a real inference result, not async - if success: - # Check if we have valid inference results (not async/processing status) - has_valid_inference = self._has_valid_inference_result(current_data) - - current_data.metadata['end_timestamp'] = time.time() - current_data.metadata['total_processing_time'] = ( - current_data.metadata['end_timestamp'] - - current_data.metadata['start_timestamp'] - ) - - # Only put valid inference results into output queue - if has_valid_inference: - # Manage output queue size - maintain fixed upper limit for memory management - MAX_OUTPUT_QUEUE_SIZE = 50 # Set maximum output queue size - - # If queue is getting full, remove old results to make space - while self.pipeline_output_queue.qsize() >= MAX_OUTPUT_QUEUE_SIZE: - try: - dropped_result = self.pipeline_output_queue.get_nowait() - # Track dropped results for debugging - if not hasattr(self, '_dropped_results_count'): - self._dropped_results_count = 0 - self._dropped_results_count += 1 - except queue.Empty: - break - - try: - self.pipeline_output_queue.put(current_data, block=False) - self.completed_counter += 1 - # Record output timestamp for FPS calculation - self._record_output_timestamp() - - # Debug: Log pipeline activity every 10 results - if self.completed_counter % 10 == 0: - print(f"[{self.pipeline_name}] Processed {self.completed_counter} results") - print(f"[{self.pipeline_name}] Queue sizes - Input: {self.pipeline_input_queue.qsize()}, Output: {self.pipeline_output_queue.qsize()}") - # Show dropped results info if any - if hasattr(self, '_dropped_results_count') and self._dropped_results_count > 0: - print(f"[{self.pipeline_name}] Dropped {self._dropped_results_count} old results for memory management") - - # Call result callback for valid inference results - if self.result_callback: - self.result_callback(current_data) - - except queue.Full: - # Fallback: should rarely happen due to pre-emptive cleaning above - print(f"[{self.pipeline_name}] Warning: Output queue still full after cleanup") - 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 with memory management""" - try: - self.pipeline_input_queue.put(data, timeout=timeout) - return True - except queue.Full: - # Drop oldest frames to make space for new ones (for real-time processing) - try: - dropped_data = self.pipeline_input_queue.get_nowait() - self.pipeline_input_queue.put(data, block=False) - - # Track dropped frames for debugging - if not hasattr(self, '_dropped_frames_count'): - self._dropped_frames_count = 0 - self._dropped_frames_count += 1 - - # Log occasionally to show frame dropping (every 50 drops) - if self._dropped_frames_count % 50 == 0: - print(f"[{self.pipeline_name}] Dropped {self._dropped_frames_count} input frames for real-time processing") - - return True - except queue.Empty: - # Rare case: queue became empty between full check and get - try: - self.pipeline_input_queue.put(data, block=False) - 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(), - 'current_fps': self.get_current_fps(), # Add real-time FPS - '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/cluster4npu_ui/core/functions/Multidongle.py b/cluster4npu_ui/core/functions/Multidongle.py deleted file mode 100644 index ad68057..0000000 --- a/cluster4npu_ui/core/functions/Multidongle.py +++ /dev/null @@ -1,796 +0,0 @@ -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 DataProcessor(ABC): - """Abstract base class for data processors in the pipeline""" - - @abstractmethod - def process(self, data: Any, *args, **kwargs) -> Any: - """Process data and return result""" - pass - - -class PreProcessor(DataProcessor): - 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 PostProcessor(DataProcessor): - """Post-processor for handling output data from inference stages""" - - def __init__(self, process_fn: Optional[Callable] = None): - self.process_fn = process_fn or self._default_process - - def process(self, data: Any, *args, **kwargs) -> Any: - """Process inference output data""" - return self.process_fn(data, *args, **kwargs) - - def _default_process(self, data: Any, *args, **kwargs) -> Any: - """Default post-processing - returns data unchanged""" - return data - - -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, - } - - DongleModelMap = { - "0x100": "KL520", - "0x720": "KL720", - "0x630": "KL630", - "0x730": "KL730", - "0x540": "KL540", - } - - @staticmethod - def scan_devices(): - """ - Scan for available Kneron devices and return their information. - - Returns: - List[Dict]: List of device information containing port_id, series, and device_descriptor - """ - try: - print('[Scanning Devices]') - device_descriptors = kp.core.scan_devices() - - print(device_descriptors) - - if not device_descriptors or device_descriptors.device_descriptor_number == 0: - print(' - No devices found') - return [] - - devices_info = [] - - # Access the actual device list from the DeviceDescriptorList object - devices = device_descriptors.device_descriptor_list - - print(f' - Found {len(devices)} device(s):') - - for i, device_desc in enumerate(devices): - try: - product_id_hex = hex(device_desc.product_id).strip().lower() - dongle_model = MultiDongle.DongleModelMap.get(product_id_hex, "Unknown") - - device_info = { - 'port_id': device_desc.usb_port_id, - 'product_id': product_id_hex, - 'kn_number': device_desc.kn_number, - 'dongle': dongle_model, - 'series': dongle_model, # Assuming series is the same as dongle model - 'device_descriptor': device_desc - } - devices_info.append(device_info) - - print(f' [{i+1}] Port ID: {device_info["port_id"]}, Series: {device_info["series"]}, Product ID: {device_info["product_id"]}, KN Number: {device_info["kn_number"]}') - - except Exception as e: - print(f"Error processing device: {e}") - - return devices_info - - except kp.ApiKPException as exception: - print(f'Error: scan devices fail, error msg: [{str(exception)}]') - return [] - - @staticmethod - def _get_device_series(device_descriptor): - """ - Extract device series from device descriptor using product_id. - - Args: - device_descriptor: Device descriptor from scan_devices() - can be dict or object - - Returns: - str: Device series (e.g., 'KL520', 'KL720', etc.) - """ - try: - # Handle dict format (from JSON) - if isinstance(device_descriptor, dict): - product_id = device_descriptor.get('product_id', '') - if product_id in MultiDongle.DongleModelMap: - return MultiDongle.DongleModelMap[product_id] - return f'Unknown ({product_id})' - - # Handle object format (from SDK) - if hasattr(device_descriptor, 'product_id'): - product_id = device_descriptor.product_id - if isinstance(product_id, int): - product_id = hex(product_id) - if product_id in MultiDongle.DongleModelMap: - return MultiDongle.DongleModelMap[product_id] - return f'Unknown ({product_id})' - - # Legacy chip-based detection (fallback) - if hasattr(device_descriptor, 'chip'): - chip = device_descriptor.chip - if chip == kp.ModelNefDescriptor.KP_CHIP_KL520: - return 'KL520' - elif chip == kp.ModelNefDescriptor.KP_CHIP_KL720: - return 'KL720' - elif chip == kp.ModelNefDescriptor.KP_CHIP_KL630: - return 'KL630' - elif chip == kp.ModelNefDescriptor.KP_CHIP_KL730: - return 'KL730' - elif chip == kp.ModelNefDescriptor.KP_CHIP_KL540: - return 'KL540' - - # Final fallback - return 'Unknown' - - except Exception as e: - print(f'Warning: Unable to determine device series: {str(e)}') - return 'Unknown' - - @staticmethod - def connect_auto_detected_devices(device_count: int = None): - """ - Auto-detect and connect to available Kneron devices. - - Args: - device_count: Number of devices to connect. If None, connect to all available devices. - - Returns: - Tuple[kp.DeviceGroup, List[Dict]]: Device group and list of connected device info - """ - devices_info = MultiDongle.scan_devices() - - if not devices_info: - raise Exception("No Kneron devices found") - - # Determine how many devices to connect - if device_count is None: - device_count = len(devices_info) - else: - device_count = min(device_count, len(devices_info)) - - # Get port IDs for connection - port_ids = [devices_info[i]['port_id'] for i in range(device_count)] - - try: - print(f'[Connecting to {device_count} device(s)]') - device_group = kp.core.connect_devices(usb_port_ids=port_ids) - print(' - Success') - - connected_devices = devices_info[:device_count] - return device_group, connected_devices - - except kp.ApiKPException as exception: - raise Exception(f'Failed to connect devices: {str(exception)}') - - def __init__(self, port_id: list = None, scpu_fw_path: str = None, ncpu_fw_path: str = None, model_path: str = None, upload_fw: bool = False, auto_detect: bool = False, max_queue_size: int = 0): - """ - Initialize the MultiDongle class. - :param port_id: List of USB port IDs for the same layer's devices. If None and auto_detect=True, will auto-detect 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. - :param auto_detect: Flag to auto-detect and connect to available devices. - :param max_queue_size: Maximum size for internal queues. If 0, unlimited queues are used. - """ - self.auto_detect = auto_detect - self.connected_devices_info = [] - - if auto_detect: - # Auto-detect devices - devices_info = self.scan_devices() - if devices_info: - self.port_id = [device['port_id'] for device in devices_info] - self.connected_devices_info = devices_info - else: - raise Exception("No Kneron devices found for auto-detection") - else: - self.port_id = port_id or [] - - self.upload_fw = upload_fw - - # Always store firmware paths when provided - 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 - if max_queue_size > 0: - self._input_queue = queue.Queue(maxsize=max_queue_size) - self._output_queue = queue.Queue(maxsize=max_queue_size) - else: - self._input_queue = queue.Queue() - 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 - # Note: Timeout setting removed as it causes crashes when camera is connected - print('[Set Device Timeout]') - print(' - Skipped (prevents camera connection crashes)') - - 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 len(inf_node_output_list) > 0: - # 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.") - send_count = 0 - 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 - send_count += 1 - - # Debug: Log send activity every 100 images - if send_count % 100 == 0: - print(f"[MultiDongle] Sent {send_count} images to inference") - - 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) - # 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.") - receive_count = 0 - 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) - receive_count += 1 - - # Debug: Log receive activity every 100 results - if receive_count % 100 == 0: - print(f"[MultiDongle] Received {receive_count} inference results") - 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") - - print("Disconnecting device group...") - if self.device_group: - try: - kp.core.disconnect_devices(device_group=self.device_group) - print("Device group disconnected successfully.") - except kp.ApiKPException as e: - print(f"Error disconnecting device group: {e}") - self.device_group = None - - 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 get_device_info(self): - """ - Get information about connected devices including port IDs and series. - - Returns: - List[Dict]: List of device information with port_id and series - """ - if self.auto_detect and self.connected_devices_info: - return self.connected_devices_info - - # If not auto-detected, try to get info from device group - if self.device_group: - try: - device_info_list = [] - - # Get device group content - device_group_content = self.device_group.content - - # Iterate through devices in the group - for i, port_id in enumerate(self.port_id): - device_info = { - 'port_id': port_id, - 'series': 'Unknown', # We'll try to determine this - 'device_descriptor': None - } - - # Try to get device series from device group - try: - # This is a simplified approach - you might need to adjust - # based on the actual device group structure - if hasattr(device_group_content, 'devices') and i < len(device_group_content.devices): - device = device_group_content.devices[i] - if hasattr(device, 'chip_id'): - device_info['series'] = self._chip_id_to_series(device.chip_id) - except: - # If we can't get series info, keep as 'Unknown' - pass - - device_info_list.append(device_info) - - return device_info_list - - except Exception as e: - print(f"Warning: Could not get device info from device group: {str(e)}") - - # Fallback: return basic info based on port_id - return [{'port_id': port_id, 'series': 'Unknown', 'device_descriptor': None} for port_id in self.port_id] - - def _chip_id_to_series(self, chip_id): - """ - Convert chip ID to series name. - - Args: - chip_id: Chip ID from device - - Returns: - str: Device series name - """ - chip_mapping = { - 'kl520': 'KL520', - 'kl720': 'KL720', - 'kl630': 'KL630', - 'kl730': 'KL730', - 'kl540': 'KL540', - } - - if isinstance(chip_id, str): - return chip_mapping.get(chip_id.lower(), 'Unknown') - - return 'Unknown' - - def print_device_info(self): - """ - Print detailed information about connected devices. - """ - devices_info = self.get_device_info() - - if not devices_info: - print("No device information available") - return - - print(f"\n[Connected Devices - {len(devices_info)} device(s)]") - for i, device_info in enumerate(devices_info): - print(f" [{i+1}] Port ID: {device_info['port_id']}, Series: {device_info['series']}") - - 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 is not None 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/cluster4npu_ui/core/functions/camera_source.py b/cluster4npu_ui/core/functions/camera_source.py deleted file mode 100644 index b4093ad..0000000 --- a/cluster4npu_ui/core/functions/camera_source.py +++ /dev/null @@ -1,151 +0,0 @@ - -import cv2 -import threading -import time -from typing import Optional, Callable - -class CameraSource: - """ - A class to handle camera input using cv2.VideoCapture. - It captures frames in a separate thread and can send them to a pipeline. - """ - def __init__(self, - camera_index: int = 0, - resolution: Optional[tuple[int, int]] = None, - fps: Optional[int] = None, - data_callback: Optional[Callable[[object], None]] = None, - frame_callback: Optional[Callable[[object], None]] = None): - """ - Initializes the CameraSource. - - Args: - camera_index (int): The index of the camera to use. - resolution (Optional[tuple[int, int]]): The desired resolution (width, height). - fps (Optional[int]): The desired frames per second. - data_callback (Optional[Callable[[object], None]]): A callback function to send data to the pipeline. - frame_callback (Optional[Callable[[object], None]]): A callback function for raw frame updates. - """ - self.camera_index = camera_index - self.resolution = resolution - self.fps = fps - self.data_callback = data_callback - self.frame_callback = frame_callback - - self.cap = None - self.running = False - self.thread = None - self._stop_event = threading.Event() - - def initialize(self) -> bool: - """ - Initializes the camera capture. - - Returns: - bool: True if initialization is successful, False otherwise. - """ - print(f"Initializing camera at index {self.camera_index}...") - self.cap = cv2.VideoCapture(self.camera_index) - if not self.cap.isOpened(): - print(f"Error: Could not open camera at index {self.camera_index}.") - return False - - if self.resolution: - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.resolution[1]) - - if self.fps: - self.cap.set(cv2.CAP_PROP_FPS, self.fps) - - print("Camera initialized successfully.") - return True - - def start(self): - """ - Starts the frame capture thread. - """ - if self.running: - print("Camera source is already running.") - return - - if not self.cap or not self.cap.isOpened(): - if not self.initialize(): - return - - self.running = True - self._stop_event.clear() - self.thread = threading.Thread(target=self._capture_loop, daemon=True) - self.thread.start() - print("Camera capture thread started.") - - def stop(self): - """ - Stops the frame capture thread. - """ - self.running = False - if self.thread and self.thread.is_alive(): - self._stop_event.set() - self.thread.join(timeout=2) - - if self.cap and self.cap.isOpened(): - self.cap.release() - self.cap = None - print("Camera source stopped.") - - def _capture_loop(self): - """ - The main loop for capturing frames from the camera. - """ - frame_count = 0 - while self.running and not self._stop_event.is_set(): - ret, frame = self.cap.read() - if not ret: - print("Error: Could not read frame from camera. Reconnecting...") - self.cap.release() - time.sleep(1) - self.initialize() - continue - - frame_count += 1 - # Debug: Log camera activity every 100 frames - if frame_count % 100 == 0: - print(f"[Camera] Captured {frame_count} frames") - - if self.data_callback: - try: - # Assuming the callback is thread-safe or handles its own locking - self.data_callback(frame) - except Exception as e: - print(f"Error in data_callback: {e}") - # If callback fails repeatedly, camera might need to stop - if frame_count > 10: # Allow some initial failures - print("Too many callback failures, stopping camera") - break - - if self.frame_callback: - try: - self.frame_callback(frame) - except Exception as e: - print(f"Error in frame_callback: {e}") - - # Control frame rate if FPS is set - if self.fps: - time.sleep(1.0 / self.fps) - - def set_data_callback(self, callback: Callable[[object], None]): - """ - Sets the data callback function. - """ - self.data_callback = callback - - def get_frame(self) -> Optional[object]: - """ - Gets a single frame from the camera. Not recommended for continuous capture. - """ - if not self.cap or not self.cap.isOpened(): - if not self.initialize(): - return None - - ret, frame = self.cap.read() - if not ret: - return None - return frame diff --git a/cluster4npu_ui/core/functions/demo_topology_clean.py b/cluster4npu_ui/core/functions/demo_topology_clean.py deleted file mode 100644 index 21b533b..0000000 --- a/cluster4npu_ui/core/functions/demo_topology_clean.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env python3 -""" -智慧拓撲排序算法演示 (獨立版本) - -不依賴外部模組,純粹展示拓撲排序算法的核心功能 -""" - -import json -from typing import List, Dict, Any, Tuple -from collections import deque - -class TopologyDemo: - """演示拓撲排序算法的類別""" - - def __init__(self): - self.stage_order = [] - - def analyze_pipeline(self, pipeline_data: Dict[str, Any]): - """分析pipeline並執行拓撲排序""" - print("Starting intelligent pipeline topology analysis...") - - # 提取模型節點 - model_nodes = [node for node in pipeline_data.get('nodes', []) - if 'model' in node.get('type', '').lower()] - connections = pipeline_data.get('connections', []) - - if not model_nodes: - print(" Warning: No model nodes found!") - return [] - - # 建立依賴圖 - dependency_graph = self._build_dependency_graph(model_nodes, connections) - - # 檢測循環 - cycles = self._detect_cycles(dependency_graph) - if cycles: - print(f" Warning: Found {len(cycles)} cycles!") - dependency_graph = self._resolve_cycles(dependency_graph, cycles) - - # 執行拓撲排序 - sorted_stages = self._topological_sort_with_optimization(dependency_graph, model_nodes) - - # 計算指標 - metrics = self._calculate_pipeline_metrics(sorted_stages, dependency_graph) - self._display_pipeline_analysis(sorted_stages, metrics) - - return sorted_stages - - def _build_dependency_graph(self, model_nodes: List[Dict], connections: List[Dict]) -> Dict[str, Dict]: - """建立依賴圖""" - print(" Building dependency graph...") - - graph = {} - for node in model_nodes: - graph[node['id']] = { - 'node': node, - 'dependencies': set(), - 'dependents': set(), - 'depth': 0 - } - - # 分析連接 - for conn in connections: - output_node_id = conn.get('output_node') - input_node_id = conn.get('input_node') - - if output_node_id in graph and input_node_id in graph: - graph[input_node_id]['dependencies'].add(output_node_id) - graph[output_node_id]['dependents'].add(input_node_id) - - dep_count = sum(len(data['dependencies']) for data in graph.values()) - print(f" Graph built: {len(graph)} nodes, {dep_count} dependencies") - return graph - - def _detect_cycles(self, graph: Dict[str, Dict]) -> List[List[str]]: - """檢測循環""" - print(" Checking for dependency cycles...") - - cycles = [] - visited = set() - rec_stack = set() - - def dfs_cycle_detect(node_id, path): - if node_id in rec_stack: - cycle_start = path.index(node_id) - cycle = path[cycle_start:] + [node_id] - cycles.append(cycle) - return True - - if node_id in visited: - return False - - visited.add(node_id) - rec_stack.add(node_id) - path.append(node_id) - - for dependent in graph[node_id]['dependents']: - if dfs_cycle_detect(dependent, path): - return True - - path.pop() - rec_stack.remove(node_id) - return False - - for node_id in graph: - if node_id not in visited: - dfs_cycle_detect(node_id, []) - - if cycles: - print(f" Warning: Found {len(cycles)} cycles") - else: - print(" No cycles detected") - - return cycles - - def _resolve_cycles(self, graph: Dict[str, Dict], cycles: List[List[str]]) -> Dict[str, Dict]: - """解決循環""" - print(" Resolving dependency cycles...") - - for cycle in cycles: - node_names = [graph[nid]['node']['name'] for nid in cycle] - print(f" Breaking cycle: {' → '.join(node_names)}") - - if len(cycle) >= 2: - node_to_break = cycle[-2] - dependent_to_break = cycle[-1] - - graph[dependent_to_break]['dependencies'].discard(node_to_break) - graph[node_to_break]['dependents'].discard(dependent_to_break) - - print(f" Broke dependency: {graph[node_to_break]['node']['name']} → {graph[dependent_to_break]['node']['name']}") - - return graph - - def _topological_sort_with_optimization(self, graph: Dict[str, Dict], model_nodes: List[Dict]) -> List[Dict]: - """執行優化的拓撲排序""" - print(" Performing optimized topological sort...") - - # 計算深度層級 - self._calculate_depth_levels(graph) - - # 按深度分組 - depth_groups = self._group_by_depth(graph) - - # 排序 - sorted_nodes = [] - for depth in sorted(depth_groups.keys()): - group_nodes = depth_groups[depth] - - group_nodes.sort(key=lambda nid: ( - len(graph[nid]['dependencies']), - -len(graph[nid]['dependents']), - graph[nid]['node']['name'] - )) - - for node_id in group_nodes: - sorted_nodes.append(graph[node_id]['node']) - - print(f" Sorted {len(sorted_nodes)} stages into {len(depth_groups)} execution levels") - return sorted_nodes - - def _calculate_depth_levels(self, graph: Dict[str, Dict]): - """計算深度層級""" - print(" Calculating execution depth levels...") - - no_deps = [nid for nid, data in graph.items() if not data['dependencies']] - queue = deque([(nid, 0) for nid in no_deps]) - - while queue: - node_id, depth = queue.popleft() - - if graph[node_id]['depth'] < depth: - graph[node_id]['depth'] = depth - - for dependent in graph[node_id]['dependents']: - queue.append((dependent, depth + 1)) - - def _group_by_depth(self, graph: Dict[str, Dict]) -> Dict[int, List[str]]: - """按深度分組""" - depth_groups = {} - - for node_id, data in graph.items(): - depth = data['depth'] - if depth not in depth_groups: - depth_groups[depth] = [] - depth_groups[depth].append(node_id) - - return depth_groups - - def _calculate_pipeline_metrics(self, sorted_stages: List[Dict], graph: Dict[str, Dict]) -> Dict[str, Any]: - """計算指標""" - print(" Calculating pipeline metrics...") - - total_stages = len(sorted_stages) - max_depth = max([data['depth'] for data in graph.values()]) + 1 if graph else 1 - - depth_distribution = {} - for data in graph.values(): - depth = data['depth'] - depth_distribution[depth] = depth_distribution.get(depth, 0) + 1 - - max_parallel = max(depth_distribution.values()) if depth_distribution else 1 - critical_path = self._find_critical_path(graph) - - return { - 'total_stages': total_stages, - 'pipeline_depth': max_depth, - 'max_parallel_stages': max_parallel, - 'parallelization_efficiency': (total_stages / max_depth) if max_depth > 0 else 1.0, - 'critical_path_length': len(critical_path), - 'critical_path': critical_path - } - - def _find_critical_path(self, graph: Dict[str, Dict]) -> List[str]: - """找出關鍵路徑""" - longest_path = [] - - def dfs_longest_path(node_id, current_path): - nonlocal longest_path - - current_path.append(node_id) - - if not graph[node_id]['dependents']: - if len(current_path) > len(longest_path): - longest_path = current_path.copy() - else: - for dependent in graph[node_id]['dependents']: - dfs_longest_path(dependent, current_path) - - current_path.pop() - - for node_id, data in graph.items(): - if not data['dependencies']: - dfs_longest_path(node_id, []) - - return longest_path - - def _display_pipeline_analysis(self, sorted_stages: List[Dict], metrics: Dict[str, Any]): - """顯示分析結果""" - print("\n" + "="*60) - print("INTELLIGENT PIPELINE TOPOLOGY ANALYSIS COMPLETE") - print("="*60) - - print(f"Pipeline Metrics:") - print(f" Total Stages: {metrics['total_stages']}") - print(f" Pipeline Depth: {metrics['pipeline_depth']} levels") - print(f" Max Parallel Stages: {metrics['max_parallel_stages']}") - print(f" Parallelization Efficiency: {metrics['parallelization_efficiency']:.1%}") - - print(f"\nOptimized Execution Order:") - for i, stage in enumerate(sorted_stages, 1): - print(f" {i:2d}. {stage['name']} (ID: {stage['id'][:8]}...)") - - if metrics['critical_path']: - print(f"\nCritical Path ({metrics['critical_path_length']} stages):") - critical_names = [] - for node_id in metrics['critical_path']: - node_name = next((stage['name'] for stage in sorted_stages if stage['id'] == node_id), 'Unknown') - critical_names.append(node_name) - print(f" {' → '.join(critical_names)}") - - print(f"\nPerformance Insights:") - if metrics['parallelization_efficiency'] > 0.8: - print(" Excellent parallelization potential!") - elif metrics['parallelization_efficiency'] > 0.6: - print(" Good parallelization opportunities available") - else: - print(" Limited parallelization - consider pipeline redesign") - - if metrics['pipeline_depth'] <= 3: - print(" Low latency pipeline - great for real-time applications") - elif metrics['pipeline_depth'] <= 6: - print(" Balanced pipeline depth - good throughput/latency trade-off") - else: - print(" Deep pipeline - optimized for maximum throughput") - - print("="*60 + "\n") - -def create_demo_pipelines(): - """創建演示用的pipeline""" - - # Demo 1: 簡單線性pipeline - simple_pipeline = { - "project_name": "Simple Linear Pipeline", - "nodes": [ - {"id": "model_001", "name": "Object Detection", "type": "ExactModelNode"}, - {"id": "model_002", "name": "Fire Classification", "type": "ExactModelNode"}, - {"id": "model_003", "name": "Result Verification", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_002"}, - {"output_node": "model_002", "input_node": "model_003"} - ] - } - - # Demo 2: 並行pipeline - parallel_pipeline = { - "project_name": "Parallel Processing Pipeline", - "nodes": [ - {"id": "model_001", "name": "RGB Processor", "type": "ExactModelNode"}, - {"id": "model_002", "name": "IR Processor", "type": "ExactModelNode"}, - {"id": "model_003", "name": "Depth Processor", "type": "ExactModelNode"}, - {"id": "model_004", "name": "Fusion Engine", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_004"}, - {"output_node": "model_002", "input_node": "model_004"}, - {"output_node": "model_003", "input_node": "model_004"} - ] - } - - # Demo 3: 複雜多層pipeline - complex_pipeline = { - "project_name": "Advanced Multi-Stage Fire Detection Pipeline", - "nodes": [ - {"id": "model_rgb_001", "name": "RGB Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_edge_002", "name": "Edge Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_thermal_003", "name": "Thermal Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_fusion_004", "name": "Feature Fusion", "type": "ExactModelNode"}, - {"id": "model_attention_005", "name": "Attention Mechanism", "type": "ExactModelNode"}, - {"id": "model_classifier_006", "name": "Fire Classifier", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_rgb_001", "input_node": "model_fusion_004"}, - {"output_node": "model_edge_002", "input_node": "model_fusion_004"}, - {"output_node": "model_thermal_003", "input_node": "model_attention_005"}, - {"output_node": "model_fusion_004", "input_node": "model_classifier_006"}, - {"output_node": "model_attention_005", "input_node": "model_classifier_006"} - ] - } - - # Demo 4: 有循環的pipeline (測試循環檢測) - cycle_pipeline = { - "project_name": "Pipeline with Cycles (Testing)", - "nodes": [ - {"id": "model_A", "name": "Model A", "type": "ExactModelNode"}, - {"id": "model_B", "name": "Model B", "type": "ExactModelNode"}, - {"id": "model_C", "name": "Model C", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_A", "input_node": "model_B"}, - {"output_node": "model_B", "input_node": "model_C"}, - {"output_node": "model_C", "input_node": "model_A"} # 創建循環! - ] - } - - return [simple_pipeline, parallel_pipeline, complex_pipeline, cycle_pipeline] - -def main(): - """主演示函數""" - print("INTELLIGENT PIPELINE TOPOLOGY SORTING DEMONSTRATION") - print("="*60) - print("This demo showcases our advanced pipeline analysis capabilities:") - print("• Automatic dependency resolution") - print("• Parallel execution optimization") - print("• Cycle detection and prevention") - print("• Critical path analysis") - print("• Performance metrics calculation") - print("="*60 + "\n") - - demo = TopologyDemo() - pipelines = create_demo_pipelines() - demo_names = ["Simple Linear", "Parallel Processing", "Complex Multi-Stage", "Cycle Detection"] - - for i, (pipeline, name) in enumerate(zip(pipelines, demo_names), 1): - print(f"DEMO {i}: {name} Pipeline") - print("="*50) - demo.analyze_pipeline(pipeline) - print("\n") - - print("ALL DEMONSTRATIONS COMPLETED SUCCESSFULLY!") - print("Ready for production deployment and progress reporting!") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/cluster4npu_ui/core/functions/mflow_converter.py b/cluster4npu_ui/core/functions/mflow_converter.py deleted file mode 100644 index 246f301..0000000 --- a/cluster4npu_ui/core/functions/mflow_converter.py +++ /dev/null @@ -1,697 +0,0 @@ -""" -MFlow to API Converter - -This module converts .mflow pipeline files from the UI app into the API format -required by MultiDongle and InferencePipeline components. - -Key Features: -- Parse .mflow JSON files -- Convert UI node properties to API configurations -- Generate StageConfig objects for InferencePipeline -- Handle pipeline topology and stage ordering -- Validate configurations and provide helpful error messages - -Usage: - from mflow_converter import MFlowConverter - - converter = MFlowConverter() - pipeline_config = converter.load_and_convert("pipeline.mflow") - - # Use with InferencePipeline - inference_pipeline = InferencePipeline(pipeline_config.stage_configs) -""" - -import json -import os -from typing import List, Dict, Any, Tuple -from dataclasses import dataclass - -from InferencePipeline import StageConfig, InferencePipeline - - -class DefaultProcessors: - """Default preprocessing and postprocessing functions""" - - @staticmethod - def resize_and_normalize(frame, target_size=(640, 480), normalize=True): - """Default resize and normalize function""" - import cv2 - import numpy as np - - # Resize - resized = cv2.resize(frame, target_size) - - # Normalize if requested - if normalize: - resized = resized.astype(np.float32) / 255.0 - - return resized - - @staticmethod - def bgr_to_rgb(frame): - """Convert BGR to RGB""" - import cv2 - return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - - @staticmethod - def format_detection_output(results, confidence_threshold=0.5): - """Format detection results""" - formatted = [] - for result in results: - if result.get('confidence', 0) >= confidence_threshold: - formatted.append({ - 'class': result.get('class', 'unknown'), - 'confidence': result.get('confidence', 0), - 'bbox': result.get('bbox', [0, 0, 0, 0]) - }) - return formatted - - -@dataclass -class PipelineConfig: - """Complete pipeline configuration ready for API use""" - stage_configs: List[StageConfig] - pipeline_name: str - description: str - input_config: Dict[str, Any] - output_config: Dict[str, Any] - preprocessing_configs: List[Dict[str, Any]] - postprocessing_configs: List[Dict[str, Any]] - - -class MFlowConverter: - """Convert .mflow files to API configurations""" - - def __init__(self, default_fw_path: str = "./firmware"): - """ - Initialize converter - - Args: - default_fw_path: Default path for firmware files if not specified - """ - self.default_fw_path = default_fw_path - self.node_id_map = {} # Map node IDs to node objects - self.stage_order = [] # Ordered list of model nodes (stages) - - def load_and_convert(self, mflow_file_path: str) -> PipelineConfig: - """ - Load .mflow file and convert to API configuration - - Args: - mflow_file_path: Path to .mflow file - - Returns: - PipelineConfig object ready for API use - - Raises: - FileNotFoundError: If .mflow file doesn't exist - ValueError: If .mflow format is invalid - RuntimeError: If conversion fails - """ - if not os.path.exists(mflow_file_path): - raise FileNotFoundError(f"MFlow file not found: {mflow_file_path}") - - with open(mflow_file_path, 'r', encoding='utf-8') as f: - mflow_data = json.load(f) - - return self._convert_mflow_to_config(mflow_data) - - def _convert_mflow_to_config(self, mflow_data: Dict[str, Any]) -> PipelineConfig: - """Convert loaded .mflow data to PipelineConfig""" - - # Extract basic metadata - pipeline_name = mflow_data.get('project_name', 'Converted Pipeline') - description = mflow_data.get('description', '') - nodes = mflow_data.get('nodes', []) - connections = mflow_data.get('connections', []) - - # Build node lookup and categorize nodes - self._build_node_map(nodes) - model_nodes, input_nodes, output_nodes, preprocess_nodes, postprocess_nodes = self._categorize_nodes() - - # Determine stage order based on connections - self._determine_stage_order(model_nodes, connections) - - # Convert to StageConfig objects - stage_configs = self._create_stage_configs(model_nodes, preprocess_nodes, postprocess_nodes, connections) - - # Extract input/output configurations - input_config = self._extract_input_config(input_nodes) - output_config = self._extract_output_config(output_nodes) - - # Extract preprocessing/postprocessing configurations - preprocessing_configs = self._extract_preprocessing_configs(preprocess_nodes) - postprocessing_configs = self._extract_postprocessing_configs(postprocess_nodes) - - return PipelineConfig( - stage_configs=stage_configs, - pipeline_name=pipeline_name, - description=description, - input_config=input_config, - output_config=output_config, - preprocessing_configs=preprocessing_configs, - postprocessing_configs=postprocessing_configs - ) - - def _build_node_map(self, nodes: List[Dict[str, Any]]): - """Build lookup map for nodes by ID""" - self.node_id_map = {node['id']: node for node in nodes} - - def _categorize_nodes(self) -> Tuple[List[Dict], List[Dict], List[Dict], List[Dict], List[Dict]]: - """Categorize nodes by type""" - model_nodes = [] - input_nodes = [] - output_nodes = [] - preprocess_nodes = [] - postprocess_nodes = [] - - for node in self.node_id_map.values(): - node_type = node.get('type', '').lower() - - if 'model' in node_type: - model_nodes.append(node) - elif 'input' in node_type: - input_nodes.append(node) - elif 'output' in node_type: - output_nodes.append(node) - elif 'preprocess' in node_type: - preprocess_nodes.append(node) - elif 'postprocess' in node_type: - postprocess_nodes.append(node) - - return model_nodes, input_nodes, output_nodes, preprocess_nodes, postprocess_nodes - - def _determine_stage_order(self, model_nodes: List[Dict], connections: List[Dict]): - """ - Advanced Topological Sorting Algorithm - - Analyzes connection dependencies to determine optimal pipeline execution order. - Features: - - Cycle detection and prevention - - Parallel stage identification - - Dependency depth analysis - - Pipeline efficiency optimization - """ - print("Starting intelligent pipeline topology analysis...") - - # Build dependency graph - dependency_graph = self._build_dependency_graph(model_nodes, connections) - - # Detect and handle cycles - cycles = self._detect_cycles(dependency_graph) - if cycles: - print(f"Warning: Detected {len(cycles)} dependency cycles!") - dependency_graph = self._resolve_cycles(dependency_graph, cycles) - - # Perform topological sort with parallel optimization - sorted_stages = self._topological_sort_with_optimization(dependency_graph, model_nodes) - - # Calculate and display pipeline metrics - metrics = self._calculate_pipeline_metrics(sorted_stages, dependency_graph) - self._display_pipeline_analysis(sorted_stages, metrics) - - self.stage_order = sorted_stages - - def _build_dependency_graph(self, model_nodes: List[Dict], connections: List[Dict]) -> Dict[str, Dict]: - """Build dependency graph from connections""" - print(" Building dependency graph...") - - # Initialize graph with all model nodes - graph = {} - node_id_to_model = {node['id']: node for node in model_nodes} - - for node in model_nodes: - graph[node['id']] = { - 'node': node, - 'dependencies': set(), # What this node depends on - 'dependents': set(), # What depends on this node - 'depth': 0, # Distance from input - 'parallel_group': 0 # For parallel execution grouping - } - - # Analyze connections to build dependencies - for conn in connections: - output_node_id = conn.get('output_node') - input_node_id = conn.get('input_node') - - # Only consider connections between model nodes - if output_node_id in graph and input_node_id in graph: - graph[input_node_id]['dependencies'].add(output_node_id) - graph[output_node_id]['dependents'].add(input_node_id) - - print(f" Graph built: {len(graph)} model nodes, {len([c for c in connections if c.get('output_node') in graph and c.get('input_node') in graph])} dependencies") - return graph - - def _detect_cycles(self, graph: Dict[str, Dict]) -> List[List[str]]: - """Detect dependency cycles using DFS""" - print(" Checking for dependency cycles...") - - cycles = [] - visited = set() - rec_stack = set() - - def dfs_cycle_detect(node_id, path): - if node_id in rec_stack: - # Found cycle - extract the cycle from path - cycle_start = path.index(node_id) - cycle = path[cycle_start:] + [node_id] - cycles.append(cycle) - return True - - if node_id in visited: - return False - - visited.add(node_id) - rec_stack.add(node_id) - path.append(node_id) - - for dependent in graph[node_id]['dependents']: - if dfs_cycle_detect(dependent, path): - return True - - path.pop() - rec_stack.remove(node_id) - return False - - for node_id in graph: - if node_id not in visited: - dfs_cycle_detect(node_id, []) - - if cycles: - print(f" Warning: Found {len(cycles)} cycles") - else: - print(" No cycles detected") - - return cycles - - def _resolve_cycles(self, graph: Dict[str, Dict], cycles: List[List[str]]) -> Dict[str, Dict]: - """Resolve dependency cycles by breaking weakest links""" - print(" Resolving dependency cycles...") - - for cycle in cycles: - print(f" Breaking cycle: {' → '.join([graph[nid]['node']['name'] for nid in cycle])}") - - # Find the "weakest" dependency to break (arbitrary for now) - # In a real implementation, this could be based on model complexity, processing time, etc. - if len(cycle) >= 2: - node_to_break = cycle[-2] # Break the last dependency - dependent_to_break = cycle[-1] - - graph[dependent_to_break]['dependencies'].discard(node_to_break) - graph[node_to_break]['dependents'].discard(dependent_to_break) - - print(f" Broke dependency: {graph[node_to_break]['node']['name']} → {graph[dependent_to_break]['node']['name']}") - - return graph - - def _topological_sort_with_optimization(self, graph: Dict[str, Dict], model_nodes: List[Dict]) -> List[Dict]: - """Advanced topological sort with parallel optimization""" - print(" Performing optimized topological sort...") - - # Calculate depth levels for each node - self._calculate_depth_levels(graph) - - # Group nodes by depth for parallel execution - depth_groups = self._group_by_depth(graph) - - # Sort within each depth group by optimization criteria - sorted_nodes = [] - for depth in sorted(depth_groups.keys()): - group_nodes = depth_groups[depth] - - # Sort by complexity/priority within the same depth - group_nodes.sort(key=lambda nid: ( - len(graph[nid]['dependencies']), # Fewer dependencies first - -len(graph[nid]['dependents']), # More dependents first (critical path) - graph[nid]['node']['name'] # Stable sort by name - )) - - for node_id in group_nodes: - sorted_nodes.append(graph[node_id]['node']) - - print(f" Sorted {len(sorted_nodes)} stages into {len(depth_groups)} execution levels") - return sorted_nodes - - def _calculate_depth_levels(self, graph: Dict[str, Dict]): - """Calculate depth levels using dynamic programming""" - print(" Calculating execution depth levels...") - - # Find nodes with no dependencies (starting points) - no_deps = [nid for nid, data in graph.items() if not data['dependencies']] - - # BFS to calculate depths - from collections import deque - queue = deque([(nid, 0) for nid in no_deps]) - - while queue: - node_id, depth = queue.popleft() - - if graph[node_id]['depth'] < depth: - graph[node_id]['depth'] = depth - - # Update dependents - for dependent in graph[node_id]['dependents']: - queue.append((dependent, depth + 1)) - - def _group_by_depth(self, graph: Dict[str, Dict]) -> Dict[int, List[str]]: - """Group nodes by execution depth for parallel processing""" - depth_groups = {} - - for node_id, data in graph.items(): - depth = data['depth'] - if depth not in depth_groups: - depth_groups[depth] = [] - depth_groups[depth].append(node_id) - - return depth_groups - - def _calculate_pipeline_metrics(self, sorted_stages: List[Dict], graph: Dict[str, Dict]) -> Dict[str, Any]: - """Calculate pipeline performance metrics""" - print(" Calculating pipeline metrics...") - - total_stages = len(sorted_stages) - max_depth = max([data['depth'] for data in graph.values()]) + 1 if graph else 1 - - # Calculate parallelization potential - depth_distribution = {} - for data in graph.values(): - depth = data['depth'] - depth_distribution[depth] = depth_distribution.get(depth, 0) + 1 - - max_parallel = max(depth_distribution.values()) if depth_distribution else 1 - avg_parallel = sum(depth_distribution.values()) / len(depth_distribution) if depth_distribution else 1 - - # Calculate critical path - critical_path = self._find_critical_path(graph) - - metrics = { - 'total_stages': total_stages, - 'pipeline_depth': max_depth, - 'max_parallel_stages': max_parallel, - 'avg_parallel_stages': avg_parallel, - 'parallelization_efficiency': (total_stages / max_depth) if max_depth > 0 else 1.0, - 'critical_path_length': len(critical_path), - 'critical_path': critical_path - } - - return metrics - - def _find_critical_path(self, graph: Dict[str, Dict]) -> List[str]: - """Find the critical path (longest dependency chain)""" - longest_path = [] - - def dfs_longest_path(node_id, current_path): - nonlocal longest_path - - current_path.append(node_id) - - if not graph[node_id]['dependents']: - # Leaf node - check if this is the longest path - if len(current_path) > len(longest_path): - longest_path = current_path.copy() - else: - for dependent in graph[node_id]['dependents']: - dfs_longest_path(dependent, current_path) - - current_path.pop() - - # Start from nodes with no dependencies - for node_id, data in graph.items(): - if not data['dependencies']: - dfs_longest_path(node_id, []) - - return longest_path - - def _display_pipeline_analysis(self, sorted_stages: List[Dict], metrics: Dict[str, Any]): - """Display pipeline analysis results""" - print("\n" + "="*60) - print("INTELLIGENT PIPELINE TOPOLOGY ANALYSIS COMPLETE") - print("="*60) - - print(f"Pipeline Metrics:") - print(f" Total Stages: {metrics['total_stages']}") - print(f" Pipeline Depth: {metrics['pipeline_depth']} levels") - print(f" Max Parallel Stages: {metrics['max_parallel_stages']}") - print(f" Parallelization Efficiency: {metrics['parallelization_efficiency']:.1%}") - - print(f"\nOptimized Execution Order:") - for i, stage in enumerate(sorted_stages, 1): - print(f" {i:2d}. {stage['name']} (ID: {stage['id'][:8]}...)") - - if metrics['critical_path']: - print(f"\nCritical Path ({metrics['critical_path_length']} stages):") - critical_names = [] - for node_id in metrics['critical_path']: - node_name = next((stage['name'] for stage in sorted_stages if stage['id'] == node_id), 'Unknown') - critical_names.append(node_name) - print(f" {' → '.join(critical_names)}") - - print(f"\nPerformance Insights:") - if metrics['parallelization_efficiency'] > 0.8: - print(" Excellent parallelization potential!") - elif metrics['parallelization_efficiency'] > 0.6: - print(" Good parallelization opportunities available") - else: - print(" Limited parallelization - consider pipeline redesign") - - if metrics['pipeline_depth'] <= 3: - print(" Low latency pipeline - great for real-time applications") - elif metrics['pipeline_depth'] <= 6: - print(" Balanced pipeline depth - good throughput/latency trade-off") - else: - print(" Deep pipeline - optimized for maximum throughput") - - print("="*60 + "\n") - - def _create_stage_configs(self, model_nodes: List[Dict], preprocess_nodes: List[Dict], - postprocess_nodes: List[Dict], connections: List[Dict]) -> List[StageConfig]: - """Create StageConfig objects for each model node""" - # Note: preprocess_nodes, postprocess_nodes, connections reserved for future enhanced processing - stage_configs = [] - - for i, model_node in enumerate(self.stage_order): - properties = model_node.get('properties', {}) - - # Extract configuration from UI properties - stage_id = f"stage_{i+1}_{model_node.get('name', 'unknown').replace(' ', '_')}" - - # Convert port_id to list format - port_id_str = properties.get('port_id', '').strip() - if port_id_str: - try: - # Handle comma-separated port IDs - port_ids = [int(p.strip()) for p in port_id_str.split(',') if p.strip()] - except ValueError: - print(f"Warning: Invalid port_id format '{port_id_str}', using default [28]") - port_ids = [32] # Default port - else: - port_ids = [32] # Default port - - # Model path - model_path = properties.get('model_path', '') - if not model_path: - print(f"Warning: No model_path specified for {model_node.get('name')}") - - # Firmware paths from UI properties - scpu_fw_path = properties.get('scpu_fw_path', os.path.join(self.default_fw_path, 'fw_scpu.bin')) - ncpu_fw_path = properties.get('ncpu_fw_path', os.path.join(self.default_fw_path, 'fw_ncpu.bin')) - - # Upload firmware flag - upload_fw = properties.get('upload_fw', False) - - # Queue size - max_queue_size = properties.get('max_queue_size', 50) - - # Create StageConfig - stage_config = StageConfig( - stage_id=stage_id, - port_ids=port_ids, - scpu_fw_path=scpu_fw_path, - ncpu_fw_path=ncpu_fw_path, - model_path=model_path, - upload_fw=upload_fw, - max_queue_size=max_queue_size - ) - - stage_configs.append(stage_config) - - return stage_configs - - def _extract_input_config(self, input_nodes: List[Dict]) -> Dict[str, Any]: - """Extract input configuration from input nodes""" - if not input_nodes: - return {} - - # Use the first input node - input_node = input_nodes[0] - properties = input_node.get('properties', {}) - - return { - 'source_type': properties.get('source_type', 'Camera'), - 'device_id': properties.get('device_id', 0), - 'source_path': properties.get('source_path', ''), - 'resolution': properties.get('resolution', '1920x1080'), - 'fps': properties.get('fps', 30) - } - - def _extract_output_config(self, output_nodes: List[Dict]) -> Dict[str, Any]: - """Extract output configuration from output nodes""" - if not output_nodes: - return {} - - # Use the first output node - output_node = output_nodes[0] - properties = output_node.get('properties', {}) - - return { - 'output_type': properties.get('output_type', 'File'), - 'format': properties.get('format', 'JSON'), - 'destination': properties.get('destination', ''), - 'save_interval': properties.get('save_interval', 1.0) - } - - def _extract_preprocessing_configs(self, preprocess_nodes: List[Dict]) -> List[Dict[str, Any]]: - """Extract preprocessing configurations""" - configs = [] - - for node in preprocess_nodes: - properties = node.get('properties', {}) - config = { - 'resize_width': properties.get('resize_width', 640), - 'resize_height': properties.get('resize_height', 480), - 'normalize': properties.get('normalize', True), - 'crop_enabled': properties.get('crop_enabled', False), - 'operations': properties.get('operations', 'resize,normalize') - } - configs.append(config) - - return configs - - def _extract_postprocessing_configs(self, postprocess_nodes: List[Dict]) -> List[Dict[str, Any]]: - """Extract postprocessing configurations""" - configs = [] - - for node in postprocess_nodes: - properties = node.get('properties', {}) - config = { - 'output_format': properties.get('output_format', 'JSON'), - 'confidence_threshold': properties.get('confidence_threshold', 0.5), - 'nms_threshold': properties.get('nms_threshold', 0.4), - 'max_detections': properties.get('max_detections', 100) - } - configs.append(config) - - return configs - - def create_inference_pipeline(self, config: PipelineConfig) -> InferencePipeline: - """ - Create InferencePipeline instance from PipelineConfig - - Args: - config: PipelineConfig object - - Returns: - Configured InferencePipeline instance - """ - return InferencePipeline( - stage_configs=config.stage_configs, - pipeline_name=config.pipeline_name - ) - - def validate_config(self, config: PipelineConfig) -> Tuple[bool, List[str]]: - """ - Validate pipeline configuration - - Args: - config: PipelineConfig to validate - - Returns: - (is_valid, error_messages) - """ - errors = [] - - # Check if we have at least one stage - if not config.stage_configs: - errors.append("Pipeline must have at least one stage (model node)") - - # Validate each stage config - for i, stage_config in enumerate(config.stage_configs): - stage_errors = self._validate_stage_config(stage_config, i+1) - errors.extend(stage_errors) - - return len(errors) == 0, errors - - def _validate_stage_config(self, stage_config: StageConfig, stage_num: int) -> List[str]: - """Validate individual stage configuration""" - errors = [] - - # Check model path - if not stage_config.model_path: - errors.append(f"Stage {stage_num}: Model path is required") - elif not os.path.exists(stage_config.model_path): - errors.append(f"Stage {stage_num}: Model file not found: {stage_config.model_path}") - - # Check firmware paths if upload_fw is True - if stage_config.upload_fw: - if not os.path.exists(stage_config.scpu_fw_path): - errors.append(f"Stage {stage_num}: SCPU firmware not found: {stage_config.scpu_fw_path}") - if not os.path.exists(stage_config.ncpu_fw_path): - errors.append(f"Stage {stage_num}: NCPU firmware not found: {stage_config.ncpu_fw_path}") - - # Check port IDs - if not stage_config.port_ids: - errors.append(f"Stage {stage_num}: At least one port ID is required") - - return errors - - -def convert_mflow_file(mflow_path: str, firmware_path: str = "./firmware") -> PipelineConfig: - """ - Convenience function to convert a .mflow file - - Args: - mflow_path: Path to .mflow file - firmware_path: Path to firmware directory - - Returns: - PipelineConfig ready for API use - """ - converter = MFlowConverter(default_fw_path=firmware_path) - return converter.load_and_convert(mflow_path) - - -if __name__ == "__main__": - # Example usage - import sys - - if len(sys.argv) < 2: - print("Usage: python mflow_converter.py [firmware_path]") - sys.exit(1) - - mflow_file = sys.argv[1] - firmware_path = sys.argv[2] if len(sys.argv) > 2 else "./firmware" - - try: - converter = MFlowConverter(default_fw_path=firmware_path) - config = converter.load_and_convert(mflow_file) - - print(f"Converted pipeline: {config.pipeline_name}") - print(f"Stages: {len(config.stage_configs)}") - - # Validate configuration - is_valid, errors = converter.validate_config(config) - if is_valid: - print("✓ Configuration is valid") - - # Create pipeline instance - pipeline = converter.create_inference_pipeline(config) - print(f"✓ InferencePipeline created: {pipeline.pipeline_name}") - - else: - print("✗ Configuration has errors:") - for error in errors: - print(f" - {error}") - - except Exception as e: - print(f"Error: {e}") - sys.exit(1) \ No newline at end of file diff --git a/cluster4npu_ui/core/functions/result_handler.py b/cluster4npu_ui/core/functions/result_handler.py deleted file mode 100644 index 4d98b53..0000000 --- a/cluster4npu_ui/core/functions/result_handler.py +++ /dev/null @@ -1,97 +0,0 @@ - -import json -import csv -import os -import time -from typing import Any, Dict, List - -class ResultSerializer: - """ - Serializes inference results into various formats. - """ - def to_json(self, data: Dict[str, Any]) -> str: - """ - Serializes data to a JSON string. - """ - return json.dumps(data, indent=2) - - def to_csv(self, data: List[Dict[str, Any]], fieldnames: List[str]) -> str: - """ - Serializes data to a CSV string. - """ - import io - output = io.StringIO() - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - writer.writerows(data) - return output.getvalue() - -class FileOutputManager: - """ - Manages writing results to files with timestamped names and directory organization. - """ - def __init__(self, base_path: str = "./output"): - """ - Initializes the FileOutputManager. - - Args: - base_path (str): The base directory to save output files. - """ - self.base_path = base_path - self.serializer = ResultSerializer() - - def save_result(self, result_data: Dict[str, Any], pipeline_name: str, format: str = 'json'): - """ - Saves a single result to a file. - - Args: - result_data (Dict[str, Any]): The result data to save. - pipeline_name (str): The name of the pipeline that generated the result. - format (str): The format to save the result in ('json' or 'csv'). - """ - try: - # Sanitize pipeline_name to be a valid directory name - sanitized_pipeline_name = "".join(c for c in pipeline_name if c.isalnum() or c in (' ', '_')).rstrip() - if not sanitized_pipeline_name: - sanitized_pipeline_name = "default_pipeline" - - # Ensure base_path is valid - if not self.base_path or not isinstance(self.base_path, str): - self.base_path = "./output" - - # Create directory structure - today = time.strftime("%Y-%m-%d") - output_dir = os.path.join(self.base_path, sanitized_pipeline_name, today) - os.makedirs(output_dir, exist_ok=True) - - # Create filename - timestamp = time.strftime("%Y%m%d_%H%M%S") - filename = f"{timestamp}_{result_data.get('pipeline_id', 'result')}.{format}" - file_path = os.path.join(output_dir, filename) - - # Serialize and save - if format == 'json': - content = self.serializer.to_json(result_data) - with open(file_path, 'w') as f: - f.write(content) - elif format == 'csv': - # For CSV, we expect a list of dicts. If it's a single dict, wrap it. - data_to_save = result_data if isinstance(result_data, list) else [result_data] - if data_to_save: - # Ensure all items in the list are dictionaries - if all(isinstance(item, dict) for item in data_to_save): - fieldnames = list(data_to_save[0].keys()) - content = self.serializer.to_csv(data_to_save, fieldnames) - with open(file_path, 'w') as f: - f.write(content) - else: - print(f"Error: CSV data must be a list of dictionaries.") - return - else: - print(f"Error: Unsupported format '{format}'") - return - - print(f"Result saved to {file_path}") - - except Exception as e: - print(f"Error saving result: {e}") diff --git a/cluster4npu_ui/core/functions/test.py b/cluster4npu_ui/core/functions/test.py deleted file mode 100644 index bf5682e..0000000 --- a/cluster4npu_ui/core/functions/test.py +++ /dev/null @@ -1,407 +0,0 @@ -""" -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/cluster4npu_ui/core/functions/video_source.py b/cluster4npu_ui/core/functions/video_source.py deleted file mode 100644 index ff77915..0000000 --- a/cluster4npu_ui/core/functions/video_source.py +++ /dev/null @@ -1,138 +0,0 @@ - -import cv2 -import threading -import time -from typing import Optional, Callable - -class VideoFileSource: - """ - A class to handle video file input using cv2.VideoCapture. - It reads frames from a video file and can send them to a pipeline. - """ - def __init__(self, - file_path: str, - data_callback: Optional[Callable[[object], None]] = None, - frame_callback: Optional[Callable[[object], None]] = None, - loop: bool = False): - """ - Initializes the VideoFileSource. - - Args: - file_path (str): The path to the video file. - data_callback (Optional[Callable[[object], None]]): A callback function to send data to the pipeline. - frame_callback (Optional[Callable[[object], None]]): A callback function for raw frame updates. - loop (bool): Whether to loop the video when it ends. - """ - self.file_path = file_path - self.data_callback = data_callback - self.frame_callback = frame_callback - self.loop = loop - - self.cap = None - self.running = False - self.thread = None - self._stop_event = threading.Event() - self.fps = 0 - - def initialize(self) -> bool: - """ - Initializes the video capture from the file. - - Returns: - bool: True if initialization is successful, False otherwise. - """ - print(f"Initializing video source from {self.file_path}...") - self.cap = cv2.VideoCapture(self.file_path) - if not self.cap.isOpened(): - print(f"Error: Could not open video file {self.file_path}.") - return False - - self.fps = self.cap.get(cv2.CAP_PROP_FPS) - if self.fps == 0: - print("Warning: Could not determine video FPS. Defaulting to 30.") - self.fps = 30 - - print(f"Video source initialized successfully. FPS: {self.fps}") - return True - - def start(self): - """ - Starts the frame reading thread. - """ - if self.running: - print("Video source is already running.") - return - - if not self.cap or not self.cap.isOpened(): - if not self.initialize(): - return - - self.running = True - self._stop_event.clear() - self.thread = threading.Thread(target=self._capture_loop, daemon=True) - self.thread.start() - print("Video capture thread started.") - - def stop(self): - """ - Stops the frame reading thread. - """ - self.running = False - if self.thread and self.thread.is_alive(): - self._stop_event.set() - self.thread.join(timeout=2) - - if self.cap and self.cap.isOpened(): - self.cap.release() - self.cap = None - print("Video source stopped.") - - def _capture_loop(self): - """ - The main loop for reading frames from the video file. - """ - while self.running and not self._stop_event.is_set(): - ret, frame = self.cap.read() - if not ret: - if self.loop: - print("Video ended, looping...") - self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0) - continue - else: - print("Video ended.") - self.running = False - break - - if self.data_callback: - try: - self.data_callback(frame) - except Exception as e: - print(f"Error in data_callback: {e}") - - if self.frame_callback: - try: - self.frame_callback(frame) - except Exception as e: - print(f"Error in frame_callback: {e}") - - # Control frame rate - time.sleep(1.0 / self.fps) - - def set_data_callback(self, callback: Callable[[object], None]): - """ - Sets the data callback function. - """ - self.data_callback = callback - - def get_frame(self) -> Optional[object]: - """ - Gets a single frame from the video. Not recommended for continuous capture. - """ - if not self.cap or not self.cap.isOpened(): - if not self.initialize(): - return None - - ret, frame = self.cap.read() - if not ret: - return None - return frame diff --git a/cluster4npu_ui/core/functions/workflow_orchestrator.py b/cluster4npu_ui/core/functions/workflow_orchestrator.py deleted file mode 100644 index 4bdafe7..0000000 --- a/cluster4npu_ui/core/functions/workflow_orchestrator.py +++ /dev/null @@ -1,197 +0,0 @@ - -import threading -import time -from typing import Any, Dict, Optional - -from .InferencePipeline import InferencePipeline, PipelineData -from .camera_source import CameraSource -from .video_source import VideoFileSource -from .result_handler import FileOutputManager -# Import other data sources as they are created - -class WorkflowOrchestrator: - """ - Coordinates the entire data flow from input source to the inference pipeline - and handles the results. - """ - def __init__(self, pipeline: InferencePipeline, input_config: Dict[str, Any], output_config: Dict[str, Any]): - """ - Initializes the WorkflowOrchestrator. - - Args: - pipeline (InferencePipeline): The configured inference pipeline. - input_config (Dict[str, Any]): The configuration for the input source. - output_config (Dict[str, Any]): The configuration for the output. - """ - self.pipeline = pipeline - self.input_config = input_config - self.output_config = output_config - self.data_source = None - self.result_handler = None - self.running = False - self._stop_event = threading.Event() - self.frame_callback = None - self.result_callback = None - - def start(self): - """ - Starts the workflow, including the data source and the pipeline. - """ - if self.running: - print("Workflow is already running.") - return - - print("Starting workflow orchestrator...") - self.running = True - self._stop_event.clear() - - # Create the result handler - self.result_handler = self._create_result_handler() - - # Create and start the data source - self.data_source = self._create_data_source() - if not self.data_source: - print("Error: Could not create data source. Aborting workflow.") - self.running = False - return - - # Set the pipeline's put_data method as the callback - self.data_source.set_data_callback(self.pipeline.put_data) - - # Set the result callback on the pipeline - always set this regardless of result_handler - # The handle_result method will decide what to do with the results - self.pipeline.set_result_callback(self.handle_result) - - # Start the pipeline - self.pipeline.initialize() - self.pipeline.start() - - # Start the data source - self.data_source.start() - - print("🚀 Workflow orchestrator started successfully.") - print(f"📊 Pipeline: {self.pipeline.pipeline_name}") - print(f"🎥 Input: {self.input_config.get('source_type', 'Unknown')} source") - print(f"💾 Output: {self.output_config.get('output_type', 'Unknown')} destination") - print("🔄 Inference pipeline is now processing data...") - print("📡 Inference results will appear below:") - print("="*60) - - def stop(self): - """ - Stops the workflow gracefully. - """ - if not self.running: - return - - print("🛑 Stopping workflow orchestrator...") - self.running = False - self._stop_event.set() - - if self.data_source: - self.data_source.stop() - print("📹 Data source stopped") - - if self.pipeline: - self.pipeline.stop() - print("⚙️ Inference pipeline stopped") - - print("✅ Workflow orchestrator stopped successfully.") - print("="*60) - - def set_frame_callback(self, callback): - """ - Sets the callback function for frame updates. - """ - self.frame_callback = callback - - def set_result_callback(self, callback): - """ - Sets the callback function for inference results. - """ - self.result_callback = callback - - def _create_data_source(self) -> Optional[Any]: - """ - Creates the appropriate data source based on the input configuration. - """ - source_type = self.input_config.get('source_type', '').lower() - print(f"Creating data source of type: {source_type}") - - if source_type == 'camera': - return CameraSource( - camera_index=self.input_config.get('device_id', 0), - resolution=self._parse_resolution(self.input_config.get('resolution')), - fps=self.input_config.get('fps', 30), - data_callback=self.pipeline.put_data, - frame_callback=self.frame_callback - ) - elif source_type == 'file': - # Assuming 'file' means video file for now - return VideoFileSource( - file_path=self.input_config.get('source_path', ''), - loop=True, # Or get from config if available - data_callback=self.pipeline.put_data, - frame_callback=self.frame_callback - ) - # Add other source types here (e.g., 'rtsp stream', 'image file') - else: - print(f"Error: Unsupported source type '{source_type}'") - return None - - def _create_result_handler(self) -> Optional[Any]: - """ - Creates the appropriate result handler based on the output configuration. - """ - output_type = self.output_config.get('output_type', '').lower() - print(f"Creating result handler of type: {output_type}") - - if output_type == 'file': - return FileOutputManager( - base_path=self.output_config.get('destination', './output') - ) - # Add other result handlers here - else: - print(f"Warning: Unsupported output type '{output_type}'. No results will be saved.") - return None - - def handle_result(self, result_data: PipelineData): - """ - Callback function to handle results from the pipeline. - """ - try: - # Convert PipelineData to a dictionary for serialization - result_dict = { - "pipeline_id": result_data.pipeline_id, - "timestamp": result_data.timestamp, - "metadata": result_data.metadata, - "stage_results": result_data.stage_results - } - - # Save to file if result handler exists - if self.result_handler: - self.result_handler.save_result( - result_dict, - self.pipeline.pipeline_name, - format=self.output_config.get('format', 'json').lower() - ) - - # Always call the result callback if set (for GUI updates) - if self.result_callback: - self.result_callback(result_dict) - - except Exception as e: - print(f"Error handling result: {e}") - - def _parse_resolution(self, resolution_str: Optional[str]) -> Optional[tuple[int, int]]: - """ - Parses a resolution string (e.g., '1920x1080') into a tuple. - """ - if not resolution_str: - return None - try: - width, height = map(int, resolution_str.lower().split('x')) - return (width, height) - except ValueError: - print(f"Warning: Invalid resolution format '{resolution_str}'. Using default.") - return None diff --git a/cluster4npu_ui/core/nodes/__init__.py b/cluster4npu_ui/core/nodes/__init__.py deleted file mode 100644 index 46e91a1..0000000 --- a/cluster4npu_ui/core/nodes/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Node definitions for the Cluster4NPU pipeline system. - -This package contains all node implementations for the ML pipeline system, -including input sources, preprocessing, model inference, postprocessing, -and output destinations. - -Available Nodes: - - InputNode: Data source node (cameras, files, streams) - - PreprocessNode: Data preprocessing and transformation - - ModelNode: AI model inference operations - - PostprocessNode: Output processing and filtering - - OutputNode: Data sink and export operations - -Usage: - from cluster4npu_ui.core.nodes import InputNode, ModelNode, OutputNode - - # Create a simple pipeline - input_node = InputNode() - model_node = ModelNode() - output_node = OutputNode() -""" - -from .base_node import BaseNodeWithProperties, create_node_property_widget -from .input_node import InputNode -from .preprocess_node import PreprocessNode -from .model_node import ModelNode -from .postprocess_node import PostprocessNode -from .output_node import OutputNode - -# Available node types for UI registration -NODE_TYPES = { - 'Input Node': InputNode, - 'Preprocess Node': PreprocessNode, - 'Model Node': ModelNode, - 'Postprocess Node': PostprocessNode, - 'Output Node': OutputNode -} - -# Node categories for UI organization -NODE_CATEGORIES = { - 'Data Sources': [InputNode], - 'Processing': [PreprocessNode, PostprocessNode], - 'Inference': [ModelNode], - 'Output': [OutputNode] -} - -__all__ = [ - 'BaseNodeWithProperties', - 'create_node_property_widget', - 'InputNode', - 'PreprocessNode', - 'ModelNode', - 'PostprocessNode', - 'OutputNode', - 'NODE_TYPES', - 'NODE_CATEGORIES' -] \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/base_node.py b/cluster4npu_ui/core/nodes/base_node.py deleted file mode 100644 index 0bef9fd..0000000 --- a/cluster4npu_ui/core/nodes/base_node.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -Base node functionality for the Cluster4NPU pipeline system. - -This module provides the common base functionality for all pipeline nodes, -including property management, validation, and common node operations. - -Main Components: - - BaseNodeWithProperties: Enhanced base node with business property support - - Property validation and management utilities - - Common node operations and interfaces - -Usage: - from cluster4npu_ui.core.nodes.base_node import BaseNodeWithProperties - - class MyNode(BaseNodeWithProperties): - def __init__(self): - super().__init__() - self.setup_properties() -""" - -try: - from NodeGraphQt import BaseNode - NODEGRAPH_AVAILABLE = True -except ImportError: - # Fallback if NodeGraphQt is not available - class BaseNode: - def __init__(self): - pass - def create_property(self, name, value): - pass - def set_property(self, name, value): - pass - def get_property(self, name): - return None - NODEGRAPH_AVAILABLE = False - -from typing import Dict, Any, Optional, Union, List - - -class BaseNodeWithProperties(BaseNode): - """ - Enhanced base node with business property support. - - This class extends the NodeGraphQt BaseNode to provide enhanced property - management capabilities specifically for ML pipeline nodes. - """ - - def __init__(self): - super().__init__() - self._property_options: Dict[str, Any] = {} - self._property_validators: Dict[str, callable] = {} - self._business_properties: Dict[str, Any] = {} - - def setup_properties(self): - """Setup node-specific properties. Override in subclasses.""" - pass - - def create_business_property(self, name: str, default_value: Any, - options: Optional[Dict[str, Any]] = None): - """ - Create a business property with validation options. - - Args: - name: Property name - default_value: Default value for the property - options: Validation and UI options dictionary - """ - self.create_property(name, default_value) - self._business_properties[name] = default_value - - if options: - self._property_options[name] = options - - def set_property_validator(self, name: str, validator: callable): - """Set a custom validator for a property.""" - self._property_validators[name] = validator - - def validate_property(self, name: str, value: Any) -> bool: - """Validate a property value.""" - if name in self._property_validators: - return self._property_validators[name](value) - - # Default validation based on options - if name in self._property_options: - options = self._property_options[name] - - # Numeric range validation - if 'min' in options and isinstance(value, (int, float)): - if value < options['min']: - return False - - if 'max' in options and isinstance(value, (int, float)): - if value > options['max']: - return False - - # Choice validation - if isinstance(options, list) and value not in options: - return False - - return True - - def get_property_options(self, name: str) -> Optional[Dict[str, Any]]: - """Get property options for UI generation.""" - return self._property_options.get(name) - - def get_business_properties(self) -> Dict[str, Any]: - """Get all business properties.""" - return self._business_properties.copy() - - def update_business_property(self, name: str, value: Any) -> bool: - """Update a business property with validation.""" - if self.validate_property(name, value): - self._business_properties[name] = value - self.set_property(name, value) - return True - return False - - def get_node_config(self) -> Dict[str, Any]: - """Get node configuration for serialization.""" - return { - 'type': self.__class__.__name__, - 'name': self.name(), - 'properties': self.get_business_properties(), - 'position': self.pos() - } - - def load_node_config(self, config: Dict[str, Any]): - """Load node configuration from serialized data.""" - if 'name' in config: - self.set_name(config['name']) - - if 'properties' in config: - for name, value in config['properties'].items(): - if name in self._business_properties: - self.update_business_property(name, value) - - if 'position' in config: - self.set_pos(*config['position']) - - -def create_node_property_widget(node: BaseNodeWithProperties, prop_name: str, - prop_value: Any, options: Optional[Dict[str, Any]] = None): - """ - Create appropriate widget for a node property. - - This function analyzes the property type and options to create the most - appropriate Qt widget for editing the property value. - - Args: - node: The node instance - prop_name: Property name - prop_value: Current property value - options: Property options dictionary - - Returns: - Appropriate Qt widget for editing the property - """ - from PyQt5.QtWidgets import (QLineEdit, QSpinBox, QDoubleSpinBox, - QComboBox, QCheckBox, QFileDialog, QPushButton) - - if options is None: - options = {} - - # File path property - if options.get('type') == 'file_path': - widget = QPushButton(str(prop_value) if prop_value else 'Select File...') - - def select_file(): - file_filter = options.get('filter', 'All Files (*)') - file_path, _ = QFileDialog.getOpenFileName(None, f'Select {prop_name}', - str(prop_value) if prop_value else '', - file_filter) - if file_path: - widget.setText(file_path) - node.update_business_property(prop_name, file_path) - - widget.clicked.connect(select_file) - return widget - - # Boolean property - elif isinstance(prop_value, bool): - widget = QCheckBox() - widget.setChecked(prop_value) - widget.stateChanged.connect( - lambda state: node.update_business_property(prop_name, state == 2) - ) - return widget - - # Choice property - elif isinstance(options, list): - widget = QComboBox() - widget.addItems(options) - if prop_value in options: - widget.setCurrentText(str(prop_value)) - widget.currentTextChanged.connect( - lambda text: node.update_business_property(prop_name, text) - ) - return widget - - # Numeric properties - elif isinstance(prop_value, int): - widget = QSpinBox() - widget.setMinimum(options.get('min', -999999)) - widget.setMaximum(options.get('max', 999999)) - widget.setValue(prop_value) - widget.valueChanged.connect( - lambda value: node.update_business_property(prop_name, value) - ) - return widget - - elif isinstance(prop_value, float): - widget = QDoubleSpinBox() - widget.setMinimum(options.get('min', -999999.0)) - widget.setMaximum(options.get('max', 999999.0)) - widget.setDecimals(options.get('decimals', 2)) - widget.setSingleStep(options.get('step', 0.1)) - widget.setValue(prop_value) - widget.valueChanged.connect( - lambda value: node.update_business_property(prop_name, value) - ) - return widget - - # String property (default) - else: - widget = QLineEdit() - widget.setText(str(prop_value)) - widget.setPlaceholderText(options.get('placeholder', '')) - widget.textChanged.connect( - lambda text: node.update_business_property(prop_name, text) - ) - return widget \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/exact_nodes.py b/cluster4npu_ui/core/nodes/exact_nodes.py deleted file mode 100644 index 9df208c..0000000 --- a/cluster4npu_ui/core/nodes/exact_nodes.py +++ /dev/null @@ -1,383 +0,0 @@ -""" -Exact node implementations matching the original UI.py properties. - -This module provides node implementations that exactly match the original -properties and behavior from the monolithic UI.py file. -""" - -try: - from NodeGraphQt import BaseNode - NODEGRAPH_AVAILABLE = True -except ImportError: - NODEGRAPH_AVAILABLE = False - # Create a mock base class - class BaseNode: - def __init__(self): - pass - - -class ExactInputNode(BaseNode): - """Input data source node - exact match to original.""" - - __identifier__ = 'com.cluster.input_node.ExactInputNode' - NODE_NAME = 'Input Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - exact match - self.add_output('output', color=(0, 255, 0)) - self.set_color(83, 133, 204) - - # Original properties - exact match - 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) - - # Original property options - exact match - 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)'} - } - - # Create custom properties dictionary for UI compatibility - self._populate_custom_properties() - - def _populate_custom_properties(self): - """Populate the custom properties dictionary for UI compatibility.""" - if not NODEGRAPH_AVAILABLE: - return - - # Get all business properties defined in _property_options - business_props = list(self._property_options.keys()) - - # Create custom dictionary containing current property values - custom_dict = {} - for prop_name in business_props: - try: - # Skip 'custom' property to avoid infinite recursion - if prop_name != 'custom': - custom_dict[prop_name] = self.get_property(prop_name) - except: - # If property doesn't exist, skip it - pass - - # Create the custom property that contains all business properties - self.create_property('custom', custom_dict) - - def get_business_properties(self): - """Get all business properties for serialization.""" - if not NODEGRAPH_AVAILABLE: - return {} - - properties = {} - for prop_name in self._property_options.keys(): - try: - properties[prop_name] = self.get_property(prop_name) - except: - pass - return properties - - def get_display_properties(self): - """Return properties that should be displayed in the UI panel.""" - # Customize which properties appear in the properties panel - # You can reorder, filter, or modify this list - return ['source_type', 'resolution', 'fps'] # Only show these 3 properties - - -class ExactModelNode(BaseNode): - """Model node for ML inference - exact match to original.""" - - __identifier__ = 'com.cluster.model_node.ExactModelNode' - NODE_NAME = 'Model Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - exact match - 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) - - # Original properties - exact match - self.create_property('model_path', '') - self.create_property('scpu_fw_path', '') - self.create_property('ncpu_fw_path', '') - self.create_property('dongle_series', '520') - self.create_property('num_dongles', 1) - self.create_property('port_id', '') - self.create_property('upload_fw', True) - - # Original property options - exact match - self._property_options = { - 'dongle_series': ['520', '720', '1080', 'Custom'], - 'num_dongles': {'min': 1, 'max': 16}, - 'model_path': {'type': 'file_path', 'filter': 'NEF Model files (*.nef)'}, - 'scpu_fw_path': {'type': 'file_path', 'filter': 'SCPU Firmware files (*.bin)'}, - 'ncpu_fw_path': {'type': 'file_path', 'filter': 'NCPU Firmware files (*.bin)'}, - 'port_id': {'placeholder': 'e.g., 8080 or auto'}, - 'upload_fw': {'type': 'bool', 'default': True, 'description': 'Upload firmware to dongle if needed'} - } - - # Create custom properties dictionary for UI compatibility - self._populate_custom_properties() - - def _populate_custom_properties(self): - """Populate the custom properties dictionary for UI compatibility.""" - if not NODEGRAPH_AVAILABLE: - return - - # Get all business properties defined in _property_options - business_props = list(self._property_options.keys()) - - # Create custom dictionary containing current property values - custom_dict = {} - for prop_name in business_props: - try: - # Skip 'custom' property to avoid infinite recursion - if prop_name != 'custom': - custom_dict[prop_name] = self.get_property(prop_name) - except: - # If property doesn't exist, skip it - pass - - # Create the custom property that contains all business properties - self.create_property('custom', custom_dict) - - def get_business_properties(self): - """Get all business properties for serialization.""" - if not NODEGRAPH_AVAILABLE: - return {} - - properties = {} - for prop_name in self._property_options.keys(): - try: - properties[prop_name] = self.get_property(prop_name) - except: - pass - return properties - - def get_display_properties(self): - """Return properties that should be displayed in the UI panel.""" - # Customize which properties appear for Model nodes - return ['model_path', 'scpu_fw_path', 'ncpu_fw_path', 'dongle_series', 'num_dongles', 'port_id', 'upload_fw'] - - -class ExactPreprocessNode(BaseNode): - """Preprocessing node - exact match to original.""" - - __identifier__ = 'com.cluster.preprocess_node.ExactPreprocessNode' - NODE_NAME = 'Preprocess Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - exact match - 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) - - # Original properties - exact match - 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') - - # Original property options - exact match - self._property_options = { - 'resize_width': {'min': 64, 'max': 4096}, - 'resize_height': {'min': 64, 'max': 4096}, - 'operations': {'placeholder': 'comma-separated: resize,normalize,crop'} - } - - # Create custom properties dictionary for UI compatibility - self._populate_custom_properties() - - def _populate_custom_properties(self): - """Populate the custom properties dictionary for UI compatibility.""" - if not NODEGRAPH_AVAILABLE: - return - - # Get all business properties defined in _property_options - business_props = list(self._property_options.keys()) - - # Create custom dictionary containing current property values - custom_dict = {} - for prop_name in business_props: - try: - # Skip 'custom' property to avoid infinite recursion - if prop_name != 'custom': - custom_dict[prop_name] = self.get_property(prop_name) - except: - # If property doesn't exist, skip it - pass - - # Create the custom property that contains all business properties - self.create_property('custom', custom_dict) - - def get_business_properties(self): - """Get all business properties for serialization.""" - if not NODEGRAPH_AVAILABLE: - return {} - - properties = {} - for prop_name in self._property_options.keys(): - try: - properties[prop_name] = self.get_property(prop_name) - except: - pass - return properties - - -class ExactPostprocessNode(BaseNode): - """Postprocessing node - exact match to original.""" - - __identifier__ = 'com.cluster.postprocess_node.ExactPostprocessNode' - NODE_NAME = 'Postprocess Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - exact match - 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) - - # Original properties - exact match - 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) - - # Original property options - exact match - 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} - } - - # Create custom properties dictionary for UI compatibility - self._populate_custom_properties() - - def _populate_custom_properties(self): - """Populate the custom properties dictionary for UI compatibility.""" - if not NODEGRAPH_AVAILABLE: - return - - # Get all business properties defined in _property_options - business_props = list(self._property_options.keys()) - - # Create custom dictionary containing current property values - custom_dict = {} - for prop_name in business_props: - try: - # Skip 'custom' property to avoid infinite recursion - if prop_name != 'custom': - custom_dict[prop_name] = self.get_property(prop_name) - except: - # If property doesn't exist, skip it - pass - - # Create the custom property that contains all business properties - self.create_property('custom', custom_dict) - - def get_business_properties(self): - """Get all business properties for serialization.""" - if not NODEGRAPH_AVAILABLE: - return {} - - properties = {} - for prop_name in self._property_options.keys(): - try: - properties[prop_name] = self.get_property(prop_name) - except: - pass - return properties - - -class ExactOutputNode(BaseNode): - """Output data sink node - exact match to original.""" - - __identifier__ = 'com.cluster.output_node.ExactOutputNode' - NODE_NAME = 'Output Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - exact match - self.add_input('input', multi_input=False, color=(255, 140, 0)) - self.set_color(255, 140, 0) - - # Original properties - exact match - self.create_property('output_type', 'File') - self.create_property('destination', '') - self.create_property('format', 'JSON') - self.create_property('save_interval', 1.0) - - # Original property options - exact match - 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} - } - - # Create custom properties dictionary for UI compatibility - self._populate_custom_properties() - - def _populate_custom_properties(self): - """Populate the custom properties dictionary for UI compatibility.""" - if not NODEGRAPH_AVAILABLE: - return - - # Get all business properties defined in _property_options - business_props = list(self._property_options.keys()) - - # Create custom dictionary containing current property values - custom_dict = {} - for prop_name in business_props: - try: - # Skip 'custom' property to avoid infinite recursion - if prop_name != 'custom': - custom_dict[prop_name] = self.get_property(prop_name) - except: - # If property doesn't exist, skip it - pass - - # Create the custom property that contains all business properties - self.create_property('custom', custom_dict) - - def get_business_properties(self): - """Get all business properties for serialization.""" - if not NODEGRAPH_AVAILABLE: - return {} - - properties = {} - for prop_name in self._property_options.keys(): - try: - properties[prop_name] = self.get_property(prop_name) - except: - pass - return properties - - -# Export the exact nodes -EXACT_NODE_TYPES = { - 'Input Node': ExactInputNode, - 'Model Node': ExactModelNode, - 'Preprocess Node': ExactPreprocessNode, - 'Postprocess Node': ExactPostprocessNode, - 'Output Node': ExactOutputNode -} \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/input_node.py b/cluster4npu_ui/core/nodes/input_node.py deleted file mode 100644 index e5b3b2f..0000000 --- a/cluster4npu_ui/core/nodes/input_node.py +++ /dev/null @@ -1,290 +0,0 @@ -""" -Input node implementation for data source operations. - -This module provides the InputNode class which handles various input data sources -including cameras, files, streams, and other media sources for the pipeline. - -Main Components: - - InputNode: Core input data source node implementation - - Media source configuration and validation - - Stream management and configuration - -Usage: - from cluster4npu_ui.core.nodes.input_node import InputNode - - node = InputNode() - node.set_property('source_type', 'Camera') - node.set_property('device_id', 0) -""" - -from .base_node import BaseNodeWithProperties - - -class InputNode(BaseNodeWithProperties): - """ - Input data source node for pipeline data ingestion. - - This node handles various input data sources including cameras, files, - RTSP streams, and other media sources for the processing pipeline. - """ - - __identifier__ = 'com.cluster.input_node' - NODE_NAME = 'Input Node' - - def __init__(self): - super().__init__() - - # Setup node connections (only output) - self.add_output('output', color=(0, 255, 0)) - self.set_color(83, 133, 204) - - # Initialize properties - self.setup_properties() - - def setup_properties(self): - """Initialize input source-specific properties.""" - # Source type configuration - self.create_business_property('source_type', 'Camera', [ - 'Camera', 'Microphone', 'File', 'RTSP Stream', 'HTTP Stream', 'WebCam', 'Screen Capture' - ]) - - # Device configuration - self.create_business_property('device_id', 0, { - 'min': 0, - 'max': 10, - 'description': 'Device ID for camera or microphone' - }) - - self.create_business_property('source_path', '', { - 'type': 'file_path', - 'filter': 'Media files (*.mp4 *.avi *.mov *.mkv *.wav *.mp3 *.jpg *.png *.bmp)', - 'description': 'Path to media file or stream URL' - }) - - # Video configuration - self.create_business_property('resolution', '1920x1080', [ - '640x480', '1280x720', '1920x1080', '2560x1440', '3840x2160', 'Custom' - ]) - - self.create_business_property('custom_width', 1920, { - 'min': 320, - 'max': 7680, - 'description': 'Custom resolution width' - }) - - self.create_business_property('custom_height', 1080, { - 'min': 240, - 'max': 4320, - 'description': 'Custom resolution height' - }) - - self.create_business_property('fps', 30, { - 'min': 1, - 'max': 120, - 'description': 'Frames per second' - }) - - # Stream configuration - self.create_business_property('stream_url', '', { - 'placeholder': 'rtsp://user:pass@host:port/path', - 'description': 'RTSP or HTTP stream URL' - }) - - self.create_business_property('stream_timeout', 10, { - 'min': 1, - 'max': 60, - 'description': 'Stream connection timeout in seconds' - }) - - self.create_business_property('stream_buffer_size', 1, { - 'min': 1, - 'max': 10, - 'description': 'Stream buffer size in frames' - }) - - # Audio configuration - self.create_business_property('audio_sample_rate', 44100, [ - 16000, 22050, 44100, 48000, 96000 - ]) - - self.create_business_property('audio_channels', 2, { - 'min': 1, - 'max': 8, - 'description': 'Number of audio channels' - }) - - # Advanced options - self.create_business_property('enable_loop', False, { - 'description': 'Loop playback for file sources' - }) - - self.create_business_property('start_time', 0.0, { - 'min': 0.0, - 'max': 3600.0, - 'step': 0.1, - 'description': 'Start time in seconds for file sources' - }) - - self.create_business_property('duration', 0.0, { - 'min': 0.0, - 'max': 3600.0, - 'step': 0.1, - 'description': 'Duration in seconds (0 = entire file)' - }) - - # Color space and format - self.create_business_property('color_format', 'RGB', [ - 'RGB', 'BGR', 'YUV', 'GRAY' - ]) - - self.create_business_property('bit_depth', 8, [ - 8, 10, 12, 16 - ]) - - def validate_configuration(self) -> tuple[bool, str]: - """ - Validate the current node configuration. - - Returns: - Tuple of (is_valid, error_message) - """ - source_type = self.get_property('source_type') - - # Validate based on source type - if source_type in ['Camera', 'WebCam']: - device_id = self.get_property('device_id') - if not isinstance(device_id, int) or device_id < 0: - return False, "Device ID must be a non-negative integer" - - elif source_type == 'File': - source_path = self.get_property('source_path') - if not source_path: - return False, "Source path is required for file input" - - elif source_type in ['RTSP Stream', 'HTTP Stream']: - stream_url = self.get_property('stream_url') - if not stream_url: - return False, "Stream URL is required for stream input" - - # Basic URL validation - if not (stream_url.startswith('rtsp://') or stream_url.startswith('http://') or stream_url.startswith('https://')): - return False, "Invalid stream URL format" - - # Validate resolution - resolution = self.get_property('resolution') - if resolution == 'Custom': - width = self.get_property('custom_width') - height = self.get_property('custom_height') - - if not isinstance(width, int) or width < 320: - return False, "Custom width must be at least 320 pixels" - - if not isinstance(height, int) or height < 240: - return False, "Custom height must be at least 240 pixels" - - # Validate FPS - fps = self.get_property('fps') - if not isinstance(fps, int) or fps < 1: - return False, "FPS must be at least 1" - - return True, "" - - def get_input_config(self) -> dict: - """ - Get input configuration for pipeline execution. - - Returns: - Dictionary containing input configuration - """ - config = { - 'node_id': self.id, - 'node_name': self.name(), - 'source_type': self.get_property('source_type'), - 'device_id': self.get_property('device_id'), - 'source_path': self.get_property('source_path'), - 'resolution': self.get_property('resolution'), - 'fps': self.get_property('fps'), - 'stream_url': self.get_property('stream_url'), - 'stream_timeout': self.get_property('stream_timeout'), - 'stream_buffer_size': self.get_property('stream_buffer_size'), - 'audio_sample_rate': self.get_property('audio_sample_rate'), - 'audio_channels': self.get_property('audio_channels'), - 'enable_loop': self.get_property('enable_loop'), - 'start_time': self.get_property('start_time'), - 'duration': self.get_property('duration'), - 'color_format': self.get_property('color_format'), - 'bit_depth': self.get_property('bit_depth') - } - - # Add custom resolution if applicable - if self.get_property('resolution') == 'Custom': - config['custom_width'] = self.get_property('custom_width') - config['custom_height'] = self.get_property('custom_height') - - return config - - def get_resolution_tuple(self) -> tuple[int, int]: - """ - Get resolution as (width, height) tuple. - - Returns: - Tuple of (width, height) - """ - resolution = self.get_property('resolution') - - if resolution == 'Custom': - return (self.get_property('custom_width'), self.get_property('custom_height')) - - resolution_map = { - '640x480': (640, 480), - '1280x720': (1280, 720), - '1920x1080': (1920, 1080), - '2560x1440': (2560, 1440), - '3840x2160': (3840, 2160) - } - - return resolution_map.get(resolution, (1920, 1080)) - - def get_estimated_bandwidth(self) -> dict: - """ - Estimate bandwidth requirements for the input source. - - Returns: - Dictionary with bandwidth information - """ - width, height = self.get_resolution_tuple() - fps = self.get_property('fps') - bit_depth = self.get_property('bit_depth') - color_format = self.get_property('color_format') - - # Calculate bits per pixel - if color_format == 'GRAY': - bits_per_pixel = bit_depth - else: - bits_per_pixel = bit_depth * 3 # RGB/BGR/YUV - - # Raw bandwidth (bits per second) - raw_bandwidth = width * height * fps * bits_per_pixel - - # Estimated compressed bandwidth (assuming 10:1 compression) - compressed_bandwidth = raw_bandwidth / 10 - - return { - 'raw_bps': raw_bandwidth, - 'compressed_bps': compressed_bandwidth, - 'raw_mbps': raw_bandwidth / 1000000, - 'compressed_mbps': compressed_bandwidth / 1000000, - 'resolution': (width, height), - 'fps': fps, - 'bit_depth': bit_depth - } - - def supports_audio(self) -> bool: - """Check if the current source type supports audio.""" - source_type = self.get_property('source_type') - return source_type in ['Microphone', 'File', 'RTSP Stream', 'HTTP Stream'] - - def is_real_time(self) -> bool: - """Check if the current source is real-time.""" - source_type = self.get_property('source_type') - return source_type in ['Camera', 'WebCam', 'Microphone', 'RTSP Stream', 'HTTP Stream', 'Screen Capture'] \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/model_node.py b/cluster4npu_ui/core/nodes/model_node.py deleted file mode 100644 index ef1429c..0000000 --- a/cluster4npu_ui/core/nodes/model_node.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Model node implementation for ML inference operations. - -This module provides the ModelNode class which represents AI model inference -nodes in the pipeline. It handles model loading, hardware allocation, and -inference configuration for various NPU dongles. - -Main Components: - - ModelNode: Core model inference node implementation - - Model configuration and validation - - Hardware dongle management - -Usage: - from cluster4npu_ui.core.nodes.model_node import ModelNode - - node = ModelNode() - node.set_property('model_path', '/path/to/model.onnx') - node.set_property('dongle_series', '720') -""" - -from .base_node import BaseNodeWithProperties - - -class ModelNode(BaseNodeWithProperties): - """ - Model node for ML inference operations. - - This node represents an AI model inference stage in the pipeline, handling - model loading, hardware allocation, and inference configuration. - """ - - __identifier__ = 'com.cluster.model_node' - NODE_NAME = 'Model Node' - - def __init__(self): - super().__init__() - - # Setup node connections - 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) - - # Initialize properties - self.setup_properties() - - def setup_properties(self): - """Initialize model-specific properties.""" - # Model configuration - self.create_business_property('model_path', '', { - 'type': 'file_path', - 'filter': 'Model files (*.onnx *.tflite *.pb *.nef)', - 'description': 'Path to the model file' - }) - - # Hardware configuration - self.create_business_property('dongle_series', '520', [ - '520', '720', '1080', 'Custom' - ]) - - self.create_business_property('num_dongles', 1, { - 'min': 1, - 'max': 16, - 'description': 'Number of dongles to use for this model' - }) - - self.create_business_property('port_id', '', { - 'placeholder': 'e.g., 8080 or auto', - 'description': 'Port ID for dongle communication' - }) - - # Performance configuration - self.create_business_property('batch_size', 1, { - 'min': 1, - 'max': 32, - 'description': 'Inference batch size' - }) - - self.create_business_property('max_queue_size', 10, { - 'min': 1, - 'max': 100, - 'description': 'Maximum input queue size' - }) - - # Advanced options - self.create_business_property('enable_preprocessing', True, { - 'description': 'Enable built-in preprocessing' - }) - - self.create_business_property('enable_postprocessing', True, { - 'description': 'Enable built-in postprocessing' - }) - - def validate_configuration(self) -> tuple[bool, str]: - """ - Validate the current node configuration. - - Returns: - Tuple of (is_valid, error_message) - """ - # Check model path - model_path = self.get_property('model_path') - if not model_path: - return False, "Model path is required" - - # Check dongle series - dongle_series = self.get_property('dongle_series') - if dongle_series not in ['520', '720', '1080', 'Custom']: - return False, f"Invalid dongle series: {dongle_series}" - - # Check number of dongles - num_dongles = self.get_property('num_dongles') - if not isinstance(num_dongles, int) or num_dongles < 1: - return False, "Number of dongles must be at least 1" - - return True, "" - - def get_inference_config(self) -> dict: - """ - Get inference configuration for pipeline execution. - - Returns: - Dictionary containing inference configuration - """ - return { - 'node_id': self.id, - 'node_name': self.name(), - 'model_path': self.get_property('model_path'), - 'dongle_series': self.get_property('dongle_series'), - 'num_dongles': self.get_property('num_dongles'), - 'port_id': self.get_property('port_id'), - 'batch_size': self.get_property('batch_size'), - 'max_queue_size': self.get_property('max_queue_size'), - 'enable_preprocessing': self.get_property('enable_preprocessing'), - 'enable_postprocessing': self.get_property('enable_postprocessing') - } - - def get_hardware_requirements(self) -> dict: - """ - Get hardware requirements for this model node. - - Returns: - Dictionary containing hardware requirements - """ - return { - 'dongle_series': self.get_property('dongle_series'), - 'num_dongles': self.get_property('num_dongles'), - 'port_id': self.get_property('port_id'), - 'estimated_memory': self._estimate_memory_usage(), - 'estimated_power': self._estimate_power_usage() - } - - def _estimate_memory_usage(self) -> float: - """Estimate memory usage in MB.""" - # Simple estimation based on batch size and number of dongles - base_memory = 512 # Base memory in MB - batch_factor = self.get_property('batch_size') * 50 - dongle_factor = self.get_property('num_dongles') * 100 - - return base_memory + batch_factor + dongle_factor - - def _estimate_power_usage(self) -> float: - """Estimate power usage in Watts.""" - # Simple estimation based on dongle series and count - dongle_series = self.get_property('dongle_series') - num_dongles = self.get_property('num_dongles') - - power_per_dongle = { - '520': 2.5, - '720': 3.5, - '1080': 5.0, - 'Custom': 4.0 - } - - return power_per_dongle.get(dongle_series, 4.0) * num_dongles \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/output_node.py b/cluster4npu_ui/core/nodes/output_node.py deleted file mode 100644 index 65a32c9..0000000 --- a/cluster4npu_ui/core/nodes/output_node.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -Output node implementation for data sink operations. - -This module provides the OutputNode class which handles various output destinations -including files, databases, APIs, and display systems for pipeline results. - -Main Components: - - OutputNode: Core output data sink node implementation - - Output destination configuration and validation - - Format conversion and export functionality - -Usage: - from cluster4npu_ui.core.nodes.output_node import OutputNode - - node = OutputNode() - node.set_property('output_type', 'File') - node.set_property('destination', '/path/to/output.json') -""" - -from .base_node import BaseNodeWithProperties - - -class OutputNode(BaseNodeWithProperties): - """ - Output data sink node for pipeline result export. - - This node handles various output destinations including files, databases, - API endpoints, and display systems for processed pipeline results. - """ - - __identifier__ = 'com.cluster.output_node' - NODE_NAME = 'Output Node' - - def __init__(self): - super().__init__() - - # Setup node connections (only input) - self.add_input('input', multi_input=False, color=(255, 140, 0)) - self.set_color(255, 140, 0) - - # Initialize properties - self.setup_properties() - - def setup_properties(self): - """Initialize output destination-specific properties.""" - # Output type configuration - self.create_business_property('output_type', 'File', [ - 'File', 'API Endpoint', 'Database', 'Display', 'MQTT', 'WebSocket', 'Console' - ]) - - # File output configuration - self.create_business_property('destination', '', { - 'type': 'file_path', - 'filter': 'Output files (*.json *.xml *.csv *.txt *.log)', - 'description': 'Output file path or URL' - }) - - self.create_business_property('format', 'JSON', [ - 'JSON', 'XML', 'CSV', 'Binary', 'MessagePack', 'YAML', 'Parquet' - ]) - - self.create_business_property('save_interval', 1.0, { - 'min': 0.1, - 'max': 60.0, - 'step': 0.1, - 'description': 'Save interval in seconds' - }) - - # File management - self.create_business_property('enable_rotation', False, { - 'description': 'Enable file rotation based on size or time' - }) - - self.create_business_property('rotation_type', 'size', [ - 'size', 'time', 'count' - ]) - - self.create_business_property('rotation_size_mb', 100, { - 'min': 1, - 'max': 1000, - 'description': 'Rotation size in MB' - }) - - self.create_business_property('rotation_time_hours', 24, { - 'min': 1, - 'max': 168, - 'description': 'Rotation time in hours' - }) - - # API endpoint configuration - self.create_business_property('api_url', '', { - 'placeholder': 'https://api.example.com/data', - 'description': 'API endpoint URL' - }) - - self.create_business_property('api_method', 'POST', [ - 'POST', 'PUT', 'PATCH' - ]) - - self.create_business_property('api_headers', '', { - 'placeholder': 'Authorization: Bearer token\\nContent-Type: application/json', - 'description': 'API headers (one per line)' - }) - - self.create_business_property('api_timeout', 30, { - 'min': 1, - 'max': 300, - 'description': 'API request timeout in seconds' - }) - - # Database configuration - self.create_business_property('db_connection_string', '', { - 'placeholder': 'postgresql://user:pass@host:port/db', - 'description': 'Database connection string' - }) - - self.create_business_property('db_table', '', { - 'placeholder': 'results', - 'description': 'Database table name' - }) - - self.create_business_property('db_batch_size', 100, { - 'min': 1, - 'max': 1000, - 'description': 'Batch size for database inserts' - }) - - # MQTT configuration - self.create_business_property('mqtt_broker', '', { - 'placeholder': 'mqtt://broker.example.com:1883', - 'description': 'MQTT broker URL' - }) - - self.create_business_property('mqtt_topic', '', { - 'placeholder': 'cluster4npu/results', - 'description': 'MQTT topic for publishing' - }) - - self.create_business_property('mqtt_qos', 0, [ - 0, 1, 2 - ]) - - # Display configuration - self.create_business_property('display_type', 'console', [ - 'console', 'window', 'overlay', 'web' - ]) - - self.create_business_property('display_format', 'pretty', [ - 'pretty', 'compact', 'raw' - ]) - - # Buffer and queuing - self.create_business_property('enable_buffering', True, { - 'description': 'Enable output buffering' - }) - - self.create_business_property('buffer_size', 1000, { - 'min': 1, - 'max': 10000, - 'description': 'Buffer size in number of results' - }) - - self.create_business_property('flush_interval', 5.0, { - 'min': 0.1, - 'max': 60.0, - 'step': 0.1, - 'description': 'Buffer flush interval in seconds' - }) - - # Error handling - self.create_business_property('retry_on_error', True, { - 'description': 'Retry on output errors' - }) - - self.create_business_property('max_retries', 3, { - 'min': 0, - 'max': 10, - 'description': 'Maximum number of retries' - }) - - self.create_business_property('retry_delay', 1.0, { - 'min': 0.1, - 'max': 10.0, - 'step': 0.1, - 'description': 'Delay between retries in seconds' - }) - - def validate_configuration(self) -> tuple[bool, str]: - """ - Validate the current node configuration. - - Returns: - Tuple of (is_valid, error_message) - """ - output_type = self.get_property('output_type') - - # Validate based on output type - if output_type == 'File': - destination = self.get_property('destination') - if not destination: - return False, "Destination path is required for file output" - - elif output_type == 'API Endpoint': - api_url = self.get_property('api_url') - if not api_url: - return False, "API URL is required for API endpoint output" - - # Basic URL validation - if not (api_url.startswith('http://') or api_url.startswith('https://')): - return False, "Invalid API URL format" - - elif output_type == 'Database': - db_connection = self.get_property('db_connection_string') - if not db_connection: - return False, "Database connection string is required" - - db_table = self.get_property('db_table') - if not db_table: - return False, "Database table name is required" - - elif output_type == 'MQTT': - mqtt_broker = self.get_property('mqtt_broker') - if not mqtt_broker: - return False, "MQTT broker URL is required" - - mqtt_topic = self.get_property('mqtt_topic') - if not mqtt_topic: - return False, "MQTT topic is required" - - # Validate save interval - save_interval = self.get_property('save_interval') - if not isinstance(save_interval, (int, float)) or save_interval <= 0: - return False, "Save interval must be greater than 0" - - return True, "" - - def get_output_config(self) -> dict: - """ - Get output configuration for pipeline execution. - - Returns: - Dictionary containing output configuration - """ - return { - 'node_id': self.id, - 'node_name': self.name(), - 'output_type': self.get_property('output_type'), - 'destination': self.get_property('destination'), - 'format': self.get_property('format'), - 'save_interval': self.get_property('save_interval'), - 'enable_rotation': self.get_property('enable_rotation'), - 'rotation_type': self.get_property('rotation_type'), - 'rotation_size_mb': self.get_property('rotation_size_mb'), - 'rotation_time_hours': self.get_property('rotation_time_hours'), - 'api_url': self.get_property('api_url'), - 'api_method': self.get_property('api_method'), - 'api_headers': self._parse_headers(self.get_property('api_headers')), - 'api_timeout': self.get_property('api_timeout'), - 'db_connection_string': self.get_property('db_connection_string'), - 'db_table': self.get_property('db_table'), - 'db_batch_size': self.get_property('db_batch_size'), - 'mqtt_broker': self.get_property('mqtt_broker'), - 'mqtt_topic': self.get_property('mqtt_topic'), - 'mqtt_qos': self.get_property('mqtt_qos'), - 'display_type': self.get_property('display_type'), - 'display_format': self.get_property('display_format'), - 'enable_buffering': self.get_property('enable_buffering'), - 'buffer_size': self.get_property('buffer_size'), - 'flush_interval': self.get_property('flush_interval'), - 'retry_on_error': self.get_property('retry_on_error'), - 'max_retries': self.get_property('max_retries'), - 'retry_delay': self.get_property('retry_delay') - } - - def _parse_headers(self, headers_str: str) -> dict: - """Parse API headers from string format.""" - headers = {} - if not headers_str: - return headers - - for line in headers_str.split('\\n'): - line = line.strip() - if ':' in line: - key, value = line.split(':', 1) - headers[key.strip()] = value.strip() - - return headers - - def get_supported_formats(self) -> list[str]: - """Get list of supported output formats.""" - return ['JSON', 'XML', 'CSV', 'Binary', 'MessagePack', 'YAML', 'Parquet'] - - def get_estimated_throughput(self) -> dict: - """ - Estimate output throughput capabilities. - - Returns: - Dictionary with throughput information - """ - output_type = self.get_property('output_type') - format_type = self.get_property('format') - - # Estimated throughput (items per second) for different output types - throughput_map = { - 'File': { - 'JSON': 1000, - 'XML': 800, - 'CSV': 2000, - 'Binary': 5000, - 'MessagePack': 3000, - 'YAML': 600, - 'Parquet': 1500 - }, - 'API Endpoint': { - 'JSON': 100, - 'XML': 80, - 'CSV': 120, - 'Binary': 150 - }, - 'Database': { - 'JSON': 500, - 'XML': 400, - 'CSV': 800, - 'Binary': 1200 - }, - 'MQTT': { - 'JSON': 2000, - 'XML': 1500, - 'CSV': 3000, - 'Binary': 5000 - }, - 'Display': { - 'JSON': 100, - 'XML': 80, - 'CSV': 120, - 'Binary': 150 - }, - 'Console': { - 'JSON': 50, - 'XML': 40, - 'CSV': 60, - 'Binary': 80 - } - } - - base_throughput = throughput_map.get(output_type, {}).get(format_type, 100) - - # Adjust for buffering - if self.get_property('enable_buffering'): - buffer_multiplier = 1.5 - else: - buffer_multiplier = 1.0 - - return { - 'estimated_throughput': base_throughput * buffer_multiplier, - 'output_type': output_type, - 'format': format_type, - 'buffering_enabled': self.get_property('enable_buffering'), - 'buffer_size': self.get_property('buffer_size') - } - - def requires_network(self) -> bool: - """Check if the current output type requires network connectivity.""" - output_type = self.get_property('output_type') - return output_type in ['API Endpoint', 'Database', 'MQTT', 'WebSocket'] - - def supports_real_time(self) -> bool: - """Check if the current output type supports real-time output.""" - output_type = self.get_property('output_type') - return output_type in ['Display', 'Console', 'MQTT', 'WebSocket', 'API Endpoint'] \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/postprocess_node.py b/cluster4npu_ui/core/nodes/postprocess_node.py deleted file mode 100644 index 55929f0..0000000 --- a/cluster4npu_ui/core/nodes/postprocess_node.py +++ /dev/null @@ -1,286 +0,0 @@ -""" -Postprocessing node implementation for output transformation operations. - -This module provides the PostprocessNode class which handles output postprocessing -operations in the pipeline, including result filtering, format conversion, and -output validation. - -Main Components: - - PostprocessNode: Core postprocessing node implementation - - Result filtering and validation - - Output format conversion - -Usage: - from cluster4npu_ui.core.nodes.postprocess_node import PostprocessNode - - node = PostprocessNode() - node.set_property('output_format', 'JSON') - node.set_property('confidence_threshold', 0.5) -""" - -from .base_node import BaseNodeWithProperties - - -class PostprocessNode(BaseNodeWithProperties): - """ - Postprocessing node for output transformation operations. - - This node handles various postprocessing operations including result filtering, - format conversion, confidence thresholding, and output validation. - """ - - __identifier__ = 'com.cluster.postprocess_node' - NODE_NAME = 'Postprocess Node' - - def __init__(self): - super().__init__() - - # Setup node connections - 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) - - # Initialize properties - self.setup_properties() - - def setup_properties(self): - """Initialize postprocessing-specific properties.""" - # Output format - self.create_business_property('output_format', 'JSON', [ - 'JSON', 'XML', 'CSV', 'Binary', 'MessagePack', 'YAML' - ]) - - # Confidence filtering - self.create_business_property('confidence_threshold', 0.5, { - 'min': 0.0, - 'max': 1.0, - 'step': 0.01, - 'description': 'Minimum confidence threshold for results' - }) - - self.create_business_property('enable_confidence_filter', True, { - 'description': 'Enable confidence-based filtering' - }) - - # NMS (Non-Maximum Suppression) - self.create_business_property('nms_threshold', 0.4, { - 'min': 0.0, - 'max': 1.0, - 'step': 0.01, - 'description': 'NMS threshold for overlapping detections' - }) - - self.create_business_property('enable_nms', True, { - 'description': 'Enable Non-Maximum Suppression' - }) - - # Result limiting - self.create_business_property('max_detections', 100, { - 'min': 1, - 'max': 1000, - 'description': 'Maximum number of detections to keep' - }) - - self.create_business_property('top_k_results', 10, { - 'min': 1, - 'max': 100, - 'description': 'Number of top results to return' - }) - - # Class filtering - self.create_business_property('enable_class_filter', False, { - 'description': 'Enable class-based filtering' - }) - - self.create_business_property('allowed_classes', '', { - 'placeholder': 'comma-separated class names or indices', - 'description': 'Allowed class names or indices' - }) - - self.create_business_property('blocked_classes', '', { - 'placeholder': 'comma-separated class names or indices', - 'description': 'Blocked class names or indices' - }) - - # Output validation - self.create_business_property('validate_output', True, { - 'description': 'Validate output format and structure' - }) - - self.create_business_property('output_schema', '', { - 'placeholder': 'JSON schema for output validation', - 'description': 'JSON schema for output validation' - }) - - # Coordinate transformation - self.create_business_property('coordinate_system', 'relative', [ - 'relative', # [0, 1] normalized coordinates - 'absolute', # Pixel coordinates - 'center', # Center-based coordinates - 'custom' # Custom transformation - ]) - - # Post-processing operations - self.create_business_property('operations', 'filter,nms,format', { - 'placeholder': 'comma-separated: filter,nms,format,validate,transform', - 'description': 'Ordered list of postprocessing operations' - }) - - # Advanced options - self.create_business_property('enable_tracking', False, { - 'description': 'Enable object tracking across frames' - }) - - self.create_business_property('tracking_method', 'simple', [ - 'simple', 'kalman', 'deep_sort', 'custom' - ]) - - self.create_business_property('enable_aggregation', False, { - 'description': 'Enable result aggregation across time' - }) - - self.create_business_property('aggregation_window', 5, { - 'min': 1, - 'max': 100, - 'description': 'Number of frames for aggregation' - }) - - def validate_configuration(self) -> tuple[bool, str]: - """ - Validate the current node configuration. - - Returns: - Tuple of (is_valid, error_message) - """ - # Check confidence threshold - confidence_threshold = self.get_property('confidence_threshold') - if not isinstance(confidence_threshold, (int, float)) or confidence_threshold < 0 or confidence_threshold > 1: - return False, "Confidence threshold must be between 0 and 1" - - # Check NMS threshold - nms_threshold = self.get_property('nms_threshold') - if not isinstance(nms_threshold, (int, float)) or nms_threshold < 0 or nms_threshold > 1: - return False, "NMS threshold must be between 0 and 1" - - # Check max detections - max_detections = self.get_property('max_detections') - if not isinstance(max_detections, int) or max_detections < 1: - return False, "Max detections must be at least 1" - - # Validate operations string - operations = self.get_property('operations') - valid_operations = ['filter', 'nms', 'format', 'validate', 'transform', 'track', 'aggregate'] - - if operations: - ops_list = [op.strip() for op in operations.split(',')] - invalid_ops = [op for op in ops_list if op not in valid_operations] - if invalid_ops: - return False, f"Invalid operations: {', '.join(invalid_ops)}" - - return True, "" - - def get_postprocessing_config(self) -> dict: - """ - Get postprocessing configuration for pipeline execution. - - Returns: - Dictionary containing postprocessing configuration - """ - return { - 'node_id': self.id, - 'node_name': self.name(), - 'output_format': self.get_property('output_format'), - 'confidence_threshold': self.get_property('confidence_threshold'), - 'enable_confidence_filter': self.get_property('enable_confidence_filter'), - 'nms_threshold': self.get_property('nms_threshold'), - 'enable_nms': self.get_property('enable_nms'), - 'max_detections': self.get_property('max_detections'), - 'top_k_results': self.get_property('top_k_results'), - 'enable_class_filter': self.get_property('enable_class_filter'), - 'allowed_classes': self._parse_class_list(self.get_property('allowed_classes')), - 'blocked_classes': self._parse_class_list(self.get_property('blocked_classes')), - 'validate_output': self.get_property('validate_output'), - 'output_schema': self.get_property('output_schema'), - 'coordinate_system': self.get_property('coordinate_system'), - 'operations': self._parse_operations_list(self.get_property('operations')), - 'enable_tracking': self.get_property('enable_tracking'), - 'tracking_method': self.get_property('tracking_method'), - 'enable_aggregation': self.get_property('enable_aggregation'), - 'aggregation_window': self.get_property('aggregation_window') - } - - def _parse_class_list(self, value_str: str) -> list[str]: - """Parse comma-separated class names or indices.""" - if not value_str: - return [] - return [x.strip() for x in value_str.split(',') if x.strip()] - - def _parse_operations_list(self, operations_str: str) -> list[str]: - """Parse comma-separated operations list.""" - if not operations_str: - return [] - return [op.strip() for op in operations_str.split(',') if op.strip()] - - def get_supported_formats(self) -> list[str]: - """Get list of supported output formats.""" - return ['JSON', 'XML', 'CSV', 'Binary', 'MessagePack', 'YAML'] - - def get_estimated_processing_time(self, num_detections: int = None) -> float: - """ - Estimate processing time for given number of detections. - - Args: - num_detections: Number of input detections - - Returns: - Estimated processing time in milliseconds - """ - if num_detections is None: - num_detections = self.get_property('max_detections') - - # Base processing time (ms per detection) - base_time = 0.1 - - # Operation-specific time factors - operations = self._parse_operations_list(self.get_property('operations')) - operation_factors = { - 'filter': 0.05, - 'nms': 0.5, - 'format': 0.1, - 'validate': 0.2, - 'transform': 0.1, - 'track': 1.0, - 'aggregate': 0.3 - } - - total_factor = sum(operation_factors.get(op, 0.1) for op in operations) - - return num_detections * base_time * total_factor - - def estimate_output_size(self, num_detections: int = None) -> dict: - """ - Estimate output data size for different formats. - - Args: - num_detections: Number of detections - - Returns: - Dictionary with estimated sizes in bytes for each format - """ - if num_detections is None: - num_detections = self.get_property('max_detections') - - # Estimated bytes per detection for each format - format_sizes = { - 'JSON': 150, # JSON with metadata - 'XML': 200, # XML with structure - 'CSV': 50, # Compact CSV - 'Binary': 30, # Binary format - 'MessagePack': 40, # MessagePack - 'YAML': 180 # YAML with structure - } - - return { - format_name: size * num_detections - for format_name, size in format_sizes.items() - } \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/preprocess_node.py b/cluster4npu_ui/core/nodes/preprocess_node.py deleted file mode 100644 index 6d69429..0000000 --- a/cluster4npu_ui/core/nodes/preprocess_node.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Preprocessing node implementation for data transformation operations. - -This module provides the PreprocessNode class which handles data preprocessing -operations in the pipeline, including image resizing, normalization, cropping, -and other transformation operations. - -Main Components: - - PreprocessNode: Core preprocessing node implementation - - Image and data transformation operations - - Preprocessing configuration and validation - -Usage: - from cluster4npu_ui.core.nodes.preprocess_node import PreprocessNode - - node = PreprocessNode() - node.set_property('resize_width', 640) - node.set_property('resize_height', 480) -""" - -from .base_node import BaseNodeWithProperties - - -class PreprocessNode(BaseNodeWithProperties): - """ - Preprocessing node for data transformation operations. - - This node handles various preprocessing operations including image resizing, - normalization, cropping, and other transformations required before model inference. - """ - - __identifier__ = 'com.cluster.preprocess_node' - NODE_NAME = 'Preprocess Node' - - def __init__(self): - super().__init__() - - # Setup node connections - 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) - - # Initialize properties - self.setup_properties() - - def setup_properties(self): - """Initialize preprocessing-specific properties.""" - # Image resizing - self.create_business_property('resize_width', 640, { - 'min': 64, - 'max': 4096, - 'description': 'Target width for image resizing' - }) - - self.create_business_property('resize_height', 480, { - 'min': 64, - 'max': 4096, - 'description': 'Target height for image resizing' - }) - - self.create_business_property('maintain_aspect_ratio', True, { - 'description': 'Maintain aspect ratio during resizing' - }) - - # Normalization - self.create_business_property('normalize', True, { - 'description': 'Apply normalization to input data' - }) - - self.create_business_property('normalization_type', 'zero_one', [ - 'zero_one', # [0, 1] - 'neg_one_one', # [-1, 1] - 'imagenet', # ImageNet mean/std - 'custom' # Custom mean/std - ]) - - self.create_business_property('custom_mean', '0.485,0.456,0.406', { - 'placeholder': 'comma-separated values for RGB channels', - 'description': 'Custom normalization mean values' - }) - - self.create_business_property('custom_std', '0.229,0.224,0.225', { - 'placeholder': 'comma-separated values for RGB channels', - 'description': 'Custom normalization std values' - }) - - # Cropping - self.create_business_property('crop_enabled', False, { - 'description': 'Enable image cropping' - }) - - self.create_business_property('crop_type', 'center', [ - 'center', # Center crop - 'random', # Random crop - 'custom' # Custom coordinates - ]) - - self.create_business_property('crop_width', 224, { - 'min': 32, - 'max': 2048, - 'description': 'Crop width in pixels' - }) - - self.create_business_property('crop_height', 224, { - 'min': 32, - 'max': 2048, - 'description': 'Crop height in pixels' - }) - - # Color space conversion - self.create_business_property('color_space', 'RGB', [ - 'RGB', 'BGR', 'HSV', 'LAB', 'YUV', 'GRAY' - ]) - - # Operations chain - self.create_business_property('operations', 'resize,normalize', { - 'placeholder': 'comma-separated: resize,normalize,crop,flip,rotate', - 'description': 'Ordered list of preprocessing operations' - }) - - # Advanced options - self.create_business_property('enable_augmentation', False, { - 'description': 'Enable data augmentation during preprocessing' - }) - - self.create_business_property('interpolation_method', 'bilinear', [ - 'nearest', 'bilinear', 'bicubic', 'lanczos' - ]) - - def validate_configuration(self) -> tuple[bool, str]: - """ - Validate the current node configuration. - - Returns: - Tuple of (is_valid, error_message) - """ - # Check resize dimensions - resize_width = self.get_property('resize_width') - resize_height = self.get_property('resize_height') - - if not isinstance(resize_width, int) or resize_width < 64: - return False, "Resize width must be at least 64 pixels" - - if not isinstance(resize_height, int) or resize_height < 64: - return False, "Resize height must be at least 64 pixels" - - # Check crop dimensions if cropping is enabled - if self.get_property('crop_enabled'): - crop_width = self.get_property('crop_width') - crop_height = self.get_property('crop_height') - - if crop_width > resize_width or crop_height > resize_height: - return False, "Crop dimensions cannot exceed resize dimensions" - - # Validate operations string - operations = self.get_property('operations') - valid_operations = ['resize', 'normalize', 'crop', 'flip', 'rotate', 'blur', 'sharpen'] - - if operations: - ops_list = [op.strip() for op in operations.split(',')] - invalid_ops = [op for op in ops_list if op not in valid_operations] - if invalid_ops: - return False, f"Invalid operations: {', '.join(invalid_ops)}" - - return True, "" - - def get_preprocessing_config(self) -> dict: - """ - Get preprocessing configuration for pipeline execution. - - Returns: - Dictionary containing preprocessing configuration - """ - return { - 'node_id': self.id, - 'node_name': self.name(), - 'resize_width': self.get_property('resize_width'), - 'resize_height': self.get_property('resize_height'), - 'maintain_aspect_ratio': self.get_property('maintain_aspect_ratio'), - 'normalize': self.get_property('normalize'), - 'normalization_type': self.get_property('normalization_type'), - 'custom_mean': self._parse_float_list(self.get_property('custom_mean')), - 'custom_std': self._parse_float_list(self.get_property('custom_std')), - 'crop_enabled': self.get_property('crop_enabled'), - 'crop_type': self.get_property('crop_type'), - 'crop_width': self.get_property('crop_width'), - 'crop_height': self.get_property('crop_height'), - 'color_space': self.get_property('color_space'), - 'operations': self._parse_operations_list(self.get_property('operations')), - 'enable_augmentation': self.get_property('enable_augmentation'), - 'interpolation_method': self.get_property('interpolation_method') - } - - def _parse_float_list(self, value_str: str) -> list[float]: - """Parse comma-separated float values.""" - try: - return [float(x.strip()) for x in value_str.split(',') if x.strip()] - except (ValueError, AttributeError): - return [] - - def _parse_operations_list(self, operations_str: str) -> list[str]: - """Parse comma-separated operations list.""" - if not operations_str: - return [] - return [op.strip() for op in operations_str.split(',') if op.strip()] - - def get_estimated_processing_time(self, input_size: tuple = None) -> float: - """ - Estimate processing time for given input size. - - Args: - input_size: Tuple of (width, height) for input image - - Returns: - Estimated processing time in milliseconds - """ - if input_size is None: - input_size = (1920, 1080) # Default HD resolution - - width, height = input_size - pixel_count = width * height - - # Base processing time (ms per megapixel) - base_time = 5.0 - - # Operation-specific time factors - operations = self._parse_operations_list(self.get_property('operations')) - operation_factors = { - 'resize': 1.0, - 'normalize': 0.5, - 'crop': 0.2, - 'flip': 0.1, - 'rotate': 1.5, - 'blur': 2.0, - 'sharpen': 2.0 - } - - total_factor = sum(operation_factors.get(op, 1.0) for op in operations) - - return (pixel_count / 1000000) * base_time * total_factor \ No newline at end of file diff --git a/cluster4npu_ui/core/nodes/simple_input_node.py b/cluster4npu_ui/core/nodes/simple_input_node.py deleted file mode 100644 index 8e334d9..0000000 --- a/cluster4npu_ui/core/nodes/simple_input_node.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -Simple Input node implementation compatible with NodeGraphQt. - -This is a simplified version that ensures compatibility with the NodeGraphQt -registration system. -""" - -try: - from NodeGraphQt import BaseNode - NODEGRAPH_AVAILABLE = True -except ImportError: - NODEGRAPH_AVAILABLE = False - # Create a mock base class - class BaseNode: - def __init__(self): - pass - - -class SimpleInputNode(BaseNode): - """Simple Input node for data sources.""" - - __identifier__ = 'com.cluster.input_node' - NODE_NAME = 'Input Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - self.add_output('output', color=(0, 255, 0)) - self.set_color(83, 133, 204) - - # Add basic properties - self.create_property('source_type', 'Camera') - self.create_property('device_id', 0) - self.create_property('resolution', '1920x1080') - self.create_property('fps', 30) - - -class SimpleModelNode(BaseNode): - """Simple Model node for AI inference.""" - - __identifier__ = 'com.cluster.model_node' - NODE_NAME = 'Model Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - 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) - - # Add basic properties - self.create_property('model_path', '') - self.create_property('dongle_series', '720') - self.create_property('num_dongles', 1) - - -class SimplePreprocessNode(BaseNode): - """Simple Preprocessing node.""" - - __identifier__ = 'com.cluster.preprocess_node' - NODE_NAME = 'Preprocess Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - 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) - - # Add basic properties - self.create_property('resize_width', 640) - self.create_property('resize_height', 480) - self.create_property('normalize', True) - - -class SimplePostprocessNode(BaseNode): - """Simple Postprocessing node.""" - - __identifier__ = 'com.cluster.postprocess_node' - NODE_NAME = 'Postprocess Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - 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) - - # Add basic properties - self.create_property('output_format', 'JSON') - self.create_property('confidence_threshold', 0.5) - - -class SimpleOutputNode(BaseNode): - """Simple Output node for data sinks.""" - - __identifier__ = 'com.cluster.output_node' - NODE_NAME = 'Output Node' - - def __init__(self): - super().__init__() - - if NODEGRAPH_AVAILABLE: - # Setup node connections - self.add_input('input', multi_input=False, color=(255, 140, 0)) - self.set_color(255, 140, 0) - - # Add basic properties - self.create_property('output_type', 'File') - self.create_property('destination', '') - self.create_property('format', 'JSON') - - -# Export the simple nodes -SIMPLE_NODE_TYPES = { - 'Input Node': SimpleInputNode, - 'Model Node': SimpleModelNode, - 'Preprocess Node': SimplePreprocessNode, - 'Postprocess Node': SimplePostprocessNode, - 'Output Node': SimpleOutputNode -} \ No newline at end of file diff --git a/cluster4npu_ui/core/pipeline.py b/cluster4npu_ui/core/pipeline.py deleted file mode 100644 index be57552..0000000 --- a/cluster4npu_ui/core/pipeline.py +++ /dev/null @@ -1,545 +0,0 @@ -""" -Pipeline stage analysis and management functionality. - -This module provides functions to analyze pipeline node connections and automatically -determine the number of stages in a pipeline. Each stage consists of a model node -with optional preprocessing and postprocessing nodes. - -Main Components: - - Stage detection and analysis - - Pipeline structure validation - - Stage configuration generation - - Connection path analysis - -Usage: - from cluster4npu_ui.core.pipeline import analyze_pipeline_stages, get_stage_count - - stage_count = get_stage_count(node_graph) - stages = analyze_pipeline_stages(node_graph) -""" - -from typing import List, Dict, Any, Optional, Tuple -from .nodes.model_node import ModelNode -from .nodes.preprocess_node import PreprocessNode -from .nodes.postprocess_node import PostprocessNode -from .nodes.input_node import InputNode -from .nodes.output_node import OutputNode - - -class PipelineStage: - """Represents a single stage in the pipeline.""" - - def __init__(self, stage_id: int, model_node: ModelNode): - self.stage_id = stage_id - self.model_node = model_node - self.preprocess_nodes: List[PreprocessNode] = [] - self.postprocess_nodes: List[PostprocessNode] = [] - self.input_connections = [] - self.output_connections = [] - - def add_preprocess_node(self, node: PreprocessNode): - """Add a preprocessing node to this stage.""" - self.preprocess_nodes.append(node) - - def add_postprocess_node(self, node: PostprocessNode): - """Add a postprocessing node to this stage.""" - self.postprocess_nodes.append(node) - - def get_stage_config(self) -> Dict[str, Any]: - """Get configuration for this stage.""" - # Get model config safely - model_config = {} - try: - if hasattr(self.model_node, 'get_inference_config'): - model_config = self.model_node.get_inference_config() - else: - model_config = {'node_name': getattr(self.model_node, 'NODE_NAME', 'Unknown Model')} - except: - model_config = {'node_name': 'Unknown Model'} - - # Get preprocess configs safely - preprocess_configs = [] - for node in self.preprocess_nodes: - try: - if hasattr(node, 'get_preprocessing_config'): - preprocess_configs.append(node.get_preprocessing_config()) - else: - preprocess_configs.append({'node_name': getattr(node, 'NODE_NAME', 'Unknown Preprocess')}) - except: - preprocess_configs.append({'node_name': 'Unknown Preprocess'}) - - # Get postprocess configs safely - postprocess_configs = [] - for node in self.postprocess_nodes: - try: - if hasattr(node, 'get_postprocessing_config'): - postprocess_configs.append(node.get_postprocessing_config()) - else: - postprocess_configs.append({'node_name': getattr(node, 'NODE_NAME', 'Unknown Postprocess')}) - except: - postprocess_configs.append({'node_name': 'Unknown Postprocess'}) - - config = { - 'stage_id': self.stage_id, - 'model_config': model_config, - 'preprocess_configs': preprocess_configs, - 'postprocess_configs': postprocess_configs - } - return config - - def validate_stage(self) -> Tuple[bool, str]: - """Validate this stage configuration.""" - # Validate model node - is_valid, error = self.model_node.validate_configuration() - if not is_valid: - return False, f"Stage {self.stage_id} model error: {error}" - - # Validate preprocessing nodes - for i, node in enumerate(self.preprocess_nodes): - is_valid, error = node.validate_configuration() - if not is_valid: - return False, f"Stage {self.stage_id} preprocess {i} error: {error}" - - # Validate postprocessing nodes - for i, node in enumerate(self.postprocess_nodes): - is_valid, error = node.validate_configuration() - if not is_valid: - return False, f"Stage {self.stage_id} postprocess {i} error: {error}" - - return True, "" - - -def find_connected_nodes(node, visited=None, direction='forward'): - """ - Find all nodes connected to a given node. - - Args: - node: Starting node - visited: Set of already visited nodes - direction: 'forward' for outputs, 'backward' for inputs - - Returns: - List of connected nodes - """ - if visited is None: - visited = set() - - if node in visited: - return [] - - visited.add(node) - connected = [] - - if direction == 'forward': - # Get connected output nodes - for output in node.outputs(): - for connected_input in output.connected_inputs(): - connected_node = connected_input.node() - if connected_node not in visited: - connected.append(connected_node) - connected.extend(find_connected_nodes(connected_node, visited, direction)) - else: - # Get connected input nodes - for input_port in node.inputs(): - for connected_output in input_port.connected_outputs(): - connected_node = connected_output.node() - if connected_node not in visited: - connected.append(connected_node) - connected.extend(find_connected_nodes(connected_node, visited, direction)) - - return connected - - -def analyze_pipeline_stages(node_graph) -> List[PipelineStage]: - """ - Analyze a node graph to identify pipeline stages. - - Each stage consists of: - 1. A model node (required) that is connected in the pipeline flow - 2. Optional preprocessing nodes (before model) - 3. Optional postprocessing nodes (after model) - - Args: - node_graph: NodeGraphQt graph object - - Returns: - List of PipelineStage objects - """ - stages = [] - all_nodes = node_graph.all_nodes() - - # Find all model nodes - these define the stages - model_nodes = [] - input_nodes = [] - output_nodes = [] - - for node in all_nodes: - # Detect model nodes - if is_model_node(node): - model_nodes.append(node) - - # Detect input nodes - elif is_input_node(node): - input_nodes.append(node) - - # Detect output nodes - elif is_output_node(node): - output_nodes.append(node) - - if not input_nodes or not output_nodes: - return [] # Invalid pipeline - must have input and output - - # Use all model nodes when we have valid input/output structure - # Simplified approach: if we have input and output nodes, count all model nodes as stages - connected_model_nodes = model_nodes # Use all model nodes - - # For nodes without connections, just create stages in the order they appear - try: - # Sort model nodes by their position in the pipeline - model_nodes_with_distance = [] - for model_node in connected_model_nodes: - # Calculate distance from input nodes - distance = calculate_distance_from_input(model_node, input_nodes) - model_nodes_with_distance.append((model_node, distance)) - - # Sort by distance from input (closest first) - model_nodes_with_distance.sort(key=lambda x: x[1]) - - # Create stages - for stage_id, (model_node, _) in enumerate(model_nodes_with_distance, 1): - stage = PipelineStage(stage_id, model_node) - - # Find preprocessing nodes (nodes that connect to this model but aren't models themselves) - preprocess_nodes = find_preprocess_nodes_for_model(model_node, all_nodes) - for preprocess_node in preprocess_nodes: - stage.add_preprocess_node(preprocess_node) - - # Find postprocessing nodes (nodes that this model connects to but aren't models) - postprocess_nodes = find_postprocess_nodes_for_model(model_node, all_nodes) - for postprocess_node in postprocess_nodes: - stage.add_postprocess_node(postprocess_node) - - stages.append(stage) - except Exception as e: - # Fallback: just create simple stages for all model nodes - print(f"Warning: Pipeline distance calculation failed ({e}), using simple stage creation") - for stage_id, model_node in enumerate(connected_model_nodes, 1): - stage = PipelineStage(stage_id, model_node) - stages.append(stage) - - return stages - - -def calculate_distance_from_input(target_node, input_nodes): - """Calculate the shortest distance from any input node to the target node.""" - min_distance = float('inf') - - for input_node in input_nodes: - distance = find_shortest_path_distance(input_node, target_node) - if distance < min_distance: - min_distance = distance - - return min_distance if min_distance != float('inf') else 0 - - -def find_shortest_path_distance(start_node, target_node, visited=None, distance=0): - """Find shortest path distance between two nodes.""" - if visited is None: - visited = set() - - if start_node == target_node: - return distance - - if start_node in visited: - return float('inf') - - visited.add(start_node) - min_distance = float('inf') - - # Check all connected nodes - handle nodes without proper connections - try: - if hasattr(start_node, 'outputs'): - for output in start_node.outputs(): - if hasattr(output, 'connected_inputs'): - for connected_input in output.connected_inputs(): - if hasattr(connected_input, 'node'): - connected_node = connected_input.node() - if connected_node not in visited: - path_distance = find_shortest_path_distance( - connected_node, target_node, visited.copy(), distance + 1 - ) - min_distance = min(min_distance, path_distance) - except: - # If there's any error in path finding, return a default distance - pass - - return min_distance - - -def find_preprocess_nodes_for_model(model_node, all_nodes): - """Find preprocessing nodes that connect to the given model node.""" - preprocess_nodes = [] - - # Get all nodes that connect to the model's inputs - for input_port in model_node.inputs(): - for connected_output in input_port.connected_outputs(): - connected_node = connected_output.node() - if isinstance(connected_node, PreprocessNode): - preprocess_nodes.append(connected_node) - - return preprocess_nodes - - -def find_postprocess_nodes_for_model(model_node, all_nodes): - """Find postprocessing nodes that the given model node connects to.""" - postprocess_nodes = [] - - # Get all nodes that the model connects to - for output in model_node.outputs(): - for connected_input in output.connected_inputs(): - connected_node = connected_input.node() - if isinstance(connected_node, PostprocessNode): - postprocess_nodes.append(connected_node) - - return postprocess_nodes - - -def is_model_node(node): - """Check if a node is a model node using multiple detection methods.""" - if hasattr(node, '__identifier__'): - identifier = node.__identifier__ - if 'model' in identifier.lower(): - return True - if hasattr(node, 'type_') and 'model' in str(node.type_).lower(): - return True - if hasattr(node, 'NODE_NAME') and 'model' in str(node.NODE_NAME).lower(): - return True - if 'model' in str(type(node)).lower(): - return True - # Check if it's our ModelNode class - if hasattr(node, 'get_inference_config'): - return True - # Check for ExactModelNode - if 'exactmodel' in str(type(node)).lower(): - return True - return False - - -def is_input_node(node): - """Check if a node is an input node using multiple detection methods.""" - if hasattr(node, '__identifier__'): - identifier = node.__identifier__ - if 'input' in identifier.lower(): - return True - if hasattr(node, 'type_') and 'input' in str(node.type_).lower(): - return True - if hasattr(node, 'NODE_NAME') and 'input' in str(node.NODE_NAME).lower(): - return True - if 'input' in str(type(node)).lower(): - return True - # Check if it's our InputNode class - if hasattr(node, 'get_input_config'): - return True - # Check for ExactInputNode - if 'exactinput' in str(type(node)).lower(): - return True - return False - - -def is_output_node(node): - """Check if a node is an output node using multiple detection methods.""" - if hasattr(node, '__identifier__'): - identifier = node.__identifier__ - if 'output' in identifier.lower(): - return True - if hasattr(node, 'type_') and 'output' in str(node.type_).lower(): - return True - if hasattr(node, 'NODE_NAME') and 'output' in str(node.NODE_NAME).lower(): - return True - if 'output' in str(type(node)).lower(): - return True - # Check if it's our OutputNode class - if hasattr(node, 'get_output_config'): - return True - # Check for ExactOutputNode - if 'exactoutput' in str(type(node)).lower(): - return True - return False - - -def get_stage_count(node_graph) -> int: - """ - Get the number of stages in a pipeline. - - Args: - node_graph: NodeGraphQt graph object - - Returns: - Number of stages (model nodes) in the pipeline - """ - if not node_graph: - return 0 - - all_nodes = node_graph.all_nodes() - - # Use robust detection for model nodes - model_nodes = [node for node in all_nodes if is_model_node(node)] - - return len(model_nodes) - - -def validate_pipeline_structure(node_graph) -> Tuple[bool, str]: - """ - Validate the overall pipeline structure. - - Args: - node_graph: NodeGraphQt graph object - - Returns: - Tuple of (is_valid, error_message) - """ - if not node_graph: - return False, "No pipeline graph provided" - - all_nodes = node_graph.all_nodes() - - # Check for required node types using our detection functions - input_nodes = [node for node in all_nodes if is_input_node(node)] - output_nodes = [node for node in all_nodes if is_output_node(node)] - model_nodes = [node for node in all_nodes if is_model_node(node)] - - if not input_nodes: - return False, "Pipeline must have at least one input node" - - if not output_nodes: - return False, "Pipeline must have at least one output node" - - if not model_nodes: - return False, "Pipeline must have at least one model node" - - # Skip connectivity checks for now since nodes may not have proper connections - # In a real NodeGraphQt environment, this would check actual connections - - return True, "" - - -def is_node_connected_to_pipeline(node, input_nodes, output_nodes): - """Check if a node is connected to both input and output sides of the pipeline.""" - # Check if there's a path from any input to this node - connected_to_input = any( - has_path_between_nodes(input_node, node) for input_node in input_nodes - ) - - # Check if there's a path from this node to any output - connected_to_output = any( - has_path_between_nodes(node, output_node) for output_node in output_nodes - ) - - return connected_to_input and connected_to_output - - -def has_path_between_nodes(start_node, end_node, visited=None): - """Check if there's a path between two nodes.""" - if visited is None: - visited = set() - - if start_node == end_node: - return True - - if start_node in visited: - return False - - visited.add(start_node) - - # Check all connected nodes - try: - if hasattr(start_node, 'outputs'): - for output in start_node.outputs(): - if hasattr(output, 'connected_inputs'): - for connected_input in output.connected_inputs(): - if hasattr(connected_input, 'node'): - connected_node = connected_input.node() - if has_path_between_nodes(connected_node, end_node, visited): - return True - elif hasattr(output, 'connected_ports'): - # Alternative connection method - for connected_port in output.connected_ports(): - if hasattr(connected_port, 'node'): - connected_node = connected_port.node() - if has_path_between_nodes(connected_node, end_node, visited): - return True - except Exception: - # If there's any error accessing connections, assume no path - pass - - return False - - -def get_pipeline_summary(node_graph) -> Dict[str, Any]: - """ - Get a summary of the pipeline structure. - - Args: - node_graph: NodeGraphQt graph object - - Returns: - Dictionary containing pipeline summary information - """ - if not node_graph: - return {'stage_count': 0, 'valid': False, 'error': 'No pipeline graph'} - - all_nodes = node_graph.all_nodes() - - # Count nodes by type using robust detection - input_count = 0 - output_count = 0 - model_count = 0 - preprocess_count = 0 - postprocess_count = 0 - - for node in all_nodes: - # Detect input nodes - if is_input_node(node): - input_count += 1 - - # Detect output nodes - elif is_output_node(node): - output_count += 1 - - # Detect model nodes - elif is_model_node(node): - model_count += 1 - - # Detect preprocess nodes - elif ((hasattr(node, '__identifier__') and 'preprocess' in node.__identifier__.lower()) or \ - (hasattr(node, 'type_') and 'preprocess' in str(node.type_).lower()) or \ - (hasattr(node, 'NODE_NAME') and 'preprocess' in str(node.NODE_NAME).lower()) or \ - ('preprocess' in str(type(node)).lower()) or \ - ('exactpreprocess' in str(type(node)).lower()) or \ - hasattr(node, 'get_preprocessing_config')): - preprocess_count += 1 - - # Detect postprocess nodes - elif ((hasattr(node, '__identifier__') and 'postprocess' in node.__identifier__.lower()) or \ - (hasattr(node, 'type_') and 'postprocess' in str(node.type_).lower()) or \ - (hasattr(node, 'NODE_NAME') and 'postprocess' in str(node.NODE_NAME).lower()) or \ - ('postprocess' in str(type(node)).lower()) or \ - ('exactpostprocess' in str(type(node)).lower()) or \ - hasattr(node, 'get_postprocessing_config')): - postprocess_count += 1 - - stages = analyze_pipeline_stages(node_graph) - is_valid, error = validate_pipeline_structure(node_graph) - - return { - 'stage_count': len(stages), - 'valid': is_valid, - 'error': error if not is_valid else None, - 'stages': [stage.get_stage_config() for stage in stages], - 'total_nodes': len(all_nodes), - 'input_nodes': input_count, - 'output_nodes': output_count, - 'model_nodes': model_count, - 'preprocess_nodes': preprocess_count, - 'postprocess_nodes': postprocess_count - } \ No newline at end of file diff --git a/cluster4npu_ui/example.py b/cluster4npu_ui/example.py deleted file mode 100644 index 6b73ded..0000000 --- a/cluster4npu_ui/example.py +++ /dev/null @@ -1,504 +0,0 @@ -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 len(inf_node_output_list) > 0: - # 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 is not None 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 = [32] - SCPU_FW = r'C:/Users/mason/Downloads/kneron_plus_v3.1.2/kneron_plus/res/firmware/KL520/fw_scpu.bin' - NCPU_FW = r'C:/Users/mason/Downloads/kneron_plus_v3.1.2/kneron_plus/res/firmware/KL520/fw_ncpu.bin' - MODEL_PATH = r'C:/Users/mason/AppData/Local/Kneron_Academy/utils/yolov5s/yolov5s/kl520_20005_yolov5-noupsample_w640h640.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/cluster4npu_ui/main.py b/cluster4npu_ui/main.py deleted file mode 100644 index cc62cb4..0000000 --- a/cluster4npu_ui/main.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Main application entry point for the Cluster4NPU UI application. - -This module initializes the PyQt5 application, applies the theme, and launches -the main dashboard window. It serves as the primary entry point for the -modularized UI application. - -Main Components: - - Application initialization and configuration - - Theme application and font setup - - Main window instantiation and display - - Application event loop management - -Usage: - python -m cluster4npu_ui.main - - # Or directly: - from cluster4npu_ui.main import main - main() -""" - -import sys -import os -from PyQt5.QtWidgets import QApplication -from PyQt5.QtGui import QFont -from PyQt5.QtCore import Qt - -# Add the parent directory to the path for imports -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from cluster4npu_ui.config.theme import apply_theme -from cluster4npu_ui.ui.windows.login import DashboardLogin - - -def setup_application(): - """Initialize and configure the QApplication.""" - # Enable high DPI support BEFORE creating QApplication - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) - - # Create QApplication if it doesn't exist - if not QApplication.instance(): - app = QApplication(sys.argv) - else: - app = QApplication.instance() - - # Set application properties - app.setApplicationName("Cluster4NPU") - app.setApplicationVersion("1.0.0") - app.setOrganizationName("Cluster4NPU Team") - - # Set application font - app.setFont(QFont("Arial", 9)) - - # Apply the harmonious theme - apply_theme(app) - - return app - - -def main(): - """Main application entry point.""" - try: - # Setup the application - app = setup_application() - - # Create and show the main dashboard login window - dashboard = DashboardLogin() - dashboard.show() - - # Start the application event loop - sys.exit(app.exec_()) - - except Exception as e: - print(f"Error starting application: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/cluster4npu_ui/release_note.md b/cluster4npu_ui/release_note.md deleted file mode 100644 index e3ad7ca..0000000 --- a/cluster4npu_ui/release_note.md +++ /dev/null @@ -1,20 +0,0 @@ -# Release Notes - Cluster4NPU - -## v0.0.2 - -**Release Date:** July 31, 2025 - -### Update -- App automatically cleans up old data to stay efficient -- Removed unnecessary text and confusing messages that cluttered the display -- Add option of "upload_fw" and connect to the inference process -- Update properties' UI prevent horizontal scroll bar - -### Fixgit -- Running inference more than one times will lead to error -- FPS visualization and calculation -- Inference output (used to be limited to 100 output) - -### Known Bugs -- Nodes' properties -- Output visualization including postprocessing \ No newline at end of file diff --git a/cluster4npu_ui/resources/__init__.py b/cluster4npu_ui/resources/__init__.py deleted file mode 100644 index 17af5d9..0000000 --- a/cluster4npu_ui/resources/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Static resources and assets for the Cluster4NPU application. - -This module manages static resources including icons, images, stylesheets, -and other assets used throughout the application. - -Available Resources: - - icons/: Application icons and graphics - - styles/: Additional stylesheet files - - assets/: Other static resources - -Usage: - from cluster4npu_ui.resources import get_icon_path, get_style_path - - icon_path = get_icon_path('node_model.png') - style_path = get_style_path('dark_theme.qss') -""" - -import os -from pathlib import Path - -def get_resource_path(resource_name: str) -> str: - """ - Get the full path to a resource file. - - Args: - resource_name: Name of the resource file - - Returns: - Full path to the resource file - """ - resources_dir = Path(__file__).parent - return str(resources_dir / resource_name) - -def get_icon_path(icon_name: str) -> str: - """ - Get the full path to an icon file. - - Args: - icon_name: Name of the icon file - - Returns: - Full path to the icon file - """ - return get_resource_path(f"icons/{icon_name}") - -def get_style_path(style_name: str) -> str: - """ - Get the full path to a stylesheet file. - - Args: - style_name: Name of the stylesheet file - - Returns: - Full path to the stylesheet file - """ - return get_resource_path(f"styles/{style_name}") - -__all__ = [ - "get_resource_path", - "get_icon_path", - "get_style_path" -] \ No newline at end of file diff --git a/cluster4npu_ui/test.mflow b/cluster4npu_ui/test.mflow deleted file mode 100644 index e50f4f1..0000000 --- a/cluster4npu_ui/test.mflow +++ /dev/null @@ -1,90 +0,0 @@ -{ - "project_name": "Untitled Pipeline", - "description": "", - "nodes": [ - { - "id": "0x17b4c1069d0", - "name": "Input Node", - "type": "ExactInputNode", - "pos": [ - 228.0, - 53.0 - ], - "properties": { - "source_type": "Camera", - "device_id": 0, - "resolution": "1920x1080", - "fps": 30, - "source_path": "" - } - }, - { - "id": "0x17b4c129f50", - "name": "Model Node", - "type": "ExactModelNode", - "pos": [ - 246.43484658813134, - 294.4905206877882 - ], - "properties": { - "dongle_series": "520", - "num_dongles": 3, - "model_path": "C:/Users/mason/AppData/Local/Kneron_Academy/utils/yolov5s/yolov5s/kl520_20005_yolov5-noupsample_w640h640.nef", - "scpu_fw_path": "C:/Users/mason/Downloads/kneron_plus_v3.1.2/kneron_plus/res/firmware/KL520/fw_scpu.bin", - "ncpu_fw_path": "C:/Users/mason/Downloads/kneron_plus_v3.1.2/kneron_plus/res/firmware/KL520/fw_ncpu.bin", - "port_id": "6, 32", - "upload_fw": true - } - }, - { - "id": "0x17b4c12bc50", - "name": "Output Node", - "type": "ExactOutputNode", - "pos": [ - 504.8299047169322, - 430.1696952829989 - ], - "properties": { - "output_type": "Display", - "format": "JSON", - "destination": "C:/Users/mason/Downloads/test.json", - "save_interval": 1.0 - } - }, - { - "id": "0x17b4c134f50", - "name": "Preprocess Node", - "type": "ExactPreprocessNode", - "pos": [ - 575.7830427160366, - 150.8128173029918 - ], - "properties": { - "resize_width": 144, - "resize_height": 144, - "operations": "resize,normalize" - } - } - ], - "connections": [ - { - "input_node": "0x17b4c134f50", - "input_port": "input", - "output_node": "0x17b4c1069d0", - "output_port": "output" - }, - { - "input_node": "0x17b4c12bc50", - "input_port": "input", - "output_node": "0x17b4c129f50", - "output_port": "output" - }, - { - "input_node": "0x17b4c129f50", - "input_port": "input", - "output_node": "0x17b4c134f50", - "output_port": "output" - } - ], - "version": "1.0" -} \ No newline at end of file diff --git a/cluster4npu_ui/tests/debug_deployment.py b/cluster4npu_ui/tests/debug_deployment.py deleted file mode 100644 index b75b594..0000000 --- a/cluster4npu_ui/tests/debug_deployment.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python3 -""" -Debug script to trace deployment pipeline data flow. -This script helps identify where data flow breaks during deployment. -""" - -import sys -import os -import json -from typing import Dict, Any - -# Add the project root to the Python path -project_root = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, project_root) -sys.path.insert(0, os.path.join(project_root, 'core', 'functions')) - -try: - from core.functions.mflow_converter import MFlowConverter - from core.functions.workflow_orchestrator import WorkflowOrchestrator - from core.functions.InferencePipeline import InferencePipeline - IMPORTS_AVAILABLE = True -except ImportError as e: - print(f"❌ Import error: {e}") - IMPORTS_AVAILABLE = False - -def create_test_pipeline_data() -> Dict[str, Any]: - """Create a minimal test pipeline that should work.""" - return { - 'project_name': 'Debug Test Pipeline', - 'description': 'Simple test pipeline for debugging data flow', - 'version': '1.0', - 'nodes': [ - { - 'id': 'input_1', - 'name': 'Camera Input', - 'type': 'ExactInputNode', - 'pos': [100, 100], - 'properties': { - 'source_type': 'camera', # lowercase to match WorkflowOrchestrator - 'device_id': 0, - 'resolution': '640x480', # smaller resolution for testing - 'fps': 10 # lower fps for testing - } - }, - { - 'id': 'model_1', - 'name': 'Test Model', - 'type': 'ExactModelNode', - 'pos': [300, 100], - 'properties': { - 'model_path': '/path/to/test.nef', - 'scpu_fw_path': 'fw_scpu.bin', - 'ncpu_fw_path': 'fw_ncpu.bin', - 'port_ids': [28, 32], - 'upload_fw': True - } - }, - { - 'id': 'output_1', - 'name': 'Debug Output', - 'type': 'ExactOutputNode', - 'pos': [500, 100], - 'properties': { - 'output_type': 'console', - 'destination': './debug_output' - } - } - ], - 'connections': [ - { - 'input_node': 'input_1', - 'input_port': 'output', - 'output_node': 'model_1', - 'output_port': 'input' - }, - { - 'input_node': 'model_1', - 'input_port': 'output', - 'output_node': 'output_1', - 'output_port': 'input' - } - ] - } - -def trace_pipeline_conversion(pipeline_data: Dict[str, Any]): - """Trace the conversion process step by step.""" - print("🔍 DEBUGGING PIPELINE CONVERSION") - print("=" * 60) - - if not IMPORTS_AVAILABLE: - print("❌ Cannot trace conversion - imports not available") - return None, None, None - - try: - print("1️⃣ Creating MFlowConverter...") - converter = MFlowConverter() - - print("2️⃣ Converting pipeline data to config...") - config = converter._convert_mflow_to_config(pipeline_data) - - print(f"✅ Conversion successful!") - print(f" Pipeline name: {config.pipeline_name}") - print(f" Total stages: {len(config.stage_configs)}") - - print("\n📊 INPUT CONFIG:") - print(json.dumps(config.input_config, indent=2)) - - print("\n📊 OUTPUT CONFIG:") - print(json.dumps(config.output_config, indent=2)) - - print("\n📊 STAGE CONFIGS:") - for i, stage_config in enumerate(config.stage_configs, 1): - print(f" Stage {i}: {stage_config.stage_id}") - print(f" Port IDs: {stage_config.port_ids}") - print(f" Model: {stage_config.model_path}") - - print("\n3️⃣ Validating configuration...") - is_valid, errors = converter.validate_config(config) - if is_valid: - print("✅ Configuration is valid") - else: - print("❌ Configuration validation failed:") - for error in errors: - print(f" - {error}") - - return converter, config, is_valid - - except Exception as e: - print(f"❌ Conversion failed: {e}") - import traceback - traceback.print_exc() - return None, None, False - -def trace_workflow_creation(converter, config): - """Trace the workflow orchestrator creation.""" - print("\n🔧 DEBUGGING WORKFLOW ORCHESTRATOR") - print("=" * 60) - - try: - print("1️⃣ Creating InferencePipeline...") - pipeline = converter.create_inference_pipeline(config) - print("✅ Pipeline created") - - print("2️⃣ Creating WorkflowOrchestrator...") - orchestrator = WorkflowOrchestrator(pipeline, config.input_config, config.output_config) - print("✅ Orchestrator created") - - print("3️⃣ Checking data source creation...") - data_source = orchestrator._create_data_source() - if data_source: - print(f"✅ Data source created: {type(data_source).__name__}") - - # Check if the data source can initialize - print("4️⃣ Testing data source initialization...") - if hasattr(data_source, 'initialize'): - init_result = data_source.initialize() - print(f" Initialization result: {init_result}") - else: - print(" Data source has no initialize method") - - else: - print("❌ Data source creation failed") - print(f" Source type: {config.input_config.get('source_type', 'MISSING')}") - - print("5️⃣ Checking result handler creation...") - result_handler = orchestrator._create_result_handler() - if result_handler: - print(f"✅ Result handler created: {type(result_handler).__name__}") - else: - print("⚠️ No result handler created (may be expected)") - - return orchestrator, data_source, result_handler - - except Exception as e: - print(f"❌ Workflow creation failed: {e}") - import traceback - traceback.print_exc() - return None, None, None - -def test_data_flow(orchestrator): - """Test the actual data flow without real dongles.""" - print("\n🌊 TESTING DATA FLOW") - print("=" * 60) - - # Set up result callback to track data - results_received = [] - - def debug_result_callback(result_dict): - print(f"🎯 RESULT RECEIVED: {result_dict}") - results_received.append(result_dict) - - def debug_frame_callback(frame): - print(f"📸 FRAME RECEIVED: {type(frame)} shape={getattr(frame, 'shape', 'N/A')}") - - try: - print("1️⃣ Setting up callbacks...") - orchestrator.set_result_callback(debug_result_callback) - orchestrator.set_frame_callback(debug_frame_callback) - - print("2️⃣ Starting orchestrator (this will fail with dongles, but should show data source activity)...") - orchestrator.start() - - print("3️⃣ Running for 5 seconds to capture any activity...") - import time - time.sleep(5) - - print("4️⃣ Stopping orchestrator...") - orchestrator.stop() - - print(f"📊 Results summary:") - print(f" Total results received: {len(results_received)}") - - return len(results_received) > 0 - - except Exception as e: - print(f"❌ Data flow test failed: {e}") - print(" This might be expected if dongles are not available") - return False - -def main(): - """Main debugging function.""" - print("🚀 CLUSTER4NPU DEPLOYMENT DEBUG TOOL") - print("=" * 60) - - # Create test pipeline data - pipeline_data = create_test_pipeline_data() - - # Trace conversion - converter, config, is_valid = trace_pipeline_conversion(pipeline_data) - - if not converter or not config or not is_valid: - print("\n❌ Cannot proceed - conversion failed or invalid") - return - - # Trace workflow creation - orchestrator, data_source, result_handler = trace_workflow_creation(converter, config) - - if not orchestrator: - print("\n❌ Cannot proceed - workflow creation failed") - return - - # Test data flow (this will likely fail with dongle connection, but shows data source behavior) - print("\n⚠️ Note: The following test will likely fail due to missing dongles,") - print(" but it will help us see if the data source is working correctly.") - - data_flowing = test_data_flow(orchestrator) - - print("\n📋 DEBUGGING SUMMARY") - print("=" * 60) - print(f"✅ Pipeline conversion: {'SUCCESS' if converter else 'FAILED'}") - print(f"✅ Configuration validation: {'SUCCESS' if is_valid else 'FAILED'}") - print(f"✅ Workflow orchestrator: {'SUCCESS' if orchestrator else 'FAILED'}") - print(f"✅ Data source creation: {'SUCCESS' if data_source else 'FAILED'}") - print(f"✅ Result handler creation: {'SUCCESS' if result_handler else 'N/A'}") - print(f"✅ Data flow test: {'SUCCESS' if data_flowing else 'FAILED (expected without dongles)'}") - - if data_source and not data_flowing: - print("\n🔍 DIAGNOSIS:") - print("The issue appears to be that:") - print("1. Pipeline configuration is working correctly") - print("2. Data source can be created") - print("3. BUT: Either the data source cannot initialize (camera not available)") - print(" OR: The pipeline cannot start (dongles not available)") - print(" OR: No data is being sent to the pipeline") - - print("\n💡 RECOMMENDATIONS:") - print("1. Check if a camera is connected at index 0") - print("2. Check if dongles are properly connected") - print("3. Add more detailed logging to WorkflowOrchestrator.start()") - print("4. Verify the pipeline.put_data() callback is being called") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/deploy_demo.py b/cluster4npu_ui/tests/deploy_demo.py deleted file mode 100644 index f13a2ec..0000000 --- a/cluster4npu_ui/tests/deploy_demo.py +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env python3 -""" -Deploy功能演示 - -此腳本展示deploy按鈕的完整工作流程,包括: -1. Pipeline驗證 -2. .mflow轉換 -3. 拓撲分析 -4. 配置生成 -5. 部署流程(模擬) -""" - -import json -import os - -def simulate_deploy_workflow(): - """模擬完整的deploy工作流程""" - - print("🚀 Pipeline Deploy功能演示") - print("=" * 60) - - # 模擬從UI導出的pipeline數據 - pipeline_data = { - "project_name": "Fire Detection Pipeline", - "description": "Real-time fire detection using Kneron NPU", - "nodes": [ - { - "id": "input_camera", - "name": "RGB Camera", - "type": "ExactInputNode", - "properties": { - "source_type": "Camera", - "device_id": 0, - "resolution": "1920x1080", - "fps": 30 - } - }, - { - "id": "model_fire_det", - "name": "Fire Detection Model", - "type": "ExactModelNode", - "properties": { - "model_path": "./models/fire_detection_520.nef", - "scpu_fw_path": "./firmware/fw_scpu.bin", - "ncpu_fw_path": "./firmware/fw_ncpu.bin", - "dongle_series": "520", - "port_id": "28,30", - "num_dongles": 2 - } - }, - { - "id": "model_verify", - "name": "Verification Model", - "type": "ExactModelNode", - "properties": { - "model_path": "./models/verification_520.nef", - "scpu_fw_path": "./firmware/fw_scpu.bin", - "ncpu_fw_path": "./firmware/fw_ncpu.bin", - "dongle_series": "520", - "port_id": "32,34", - "num_dongles": 2 - } - }, - { - "id": "output_alert", - "name": "Alert System", - "type": "ExactOutputNode", - "properties": { - "output_type": "Stream", - "format": "JSON", - "destination": "tcp://localhost:5555" - } - } - ], - "connections": [ - {"output_node": "input_camera", "input_node": "model_fire_det"}, - {"output_node": "model_fire_det", "input_node": "model_verify"}, - {"output_node": "model_verify", "input_node": "output_alert"} - ] - } - - print("📋 Step 1: Pipeline Validation") - print("-" * 30) - - # 驗證pipeline結構 - nodes = pipeline_data.get('nodes', []) - connections = pipeline_data.get('connections', []) - - input_nodes = [n for n in nodes if 'Input' in n['type']] - model_nodes = [n for n in nodes if 'Model' in n['type']] - output_nodes = [n for n in nodes if 'Output' in n['type']] - - print(f" Input nodes: {len(input_nodes)}") - print(f" Model nodes: {len(model_nodes)}") - print(f" Output nodes: {len(output_nodes)}") - print(f" Connections: {len(connections)}") - - if input_nodes and model_nodes and output_nodes: - print(" ✓ Pipeline structure is valid") - else: - print(" ✗ Pipeline structure is invalid") - return - - print("\n🔄 Step 2: MFlow Conversion & Topology Analysis") - print("-" * 30) - - # 模擬拓撲分析 - print(" Starting intelligent pipeline topology analysis...") - print(" Building dependency graph...") - print(f" Graph built: {len(model_nodes)} model nodes, {len(connections)} dependencies") - print(" Checking for dependency cycles...") - print(" No cycles detected") - print(" Performing optimized topological sort...") - print(" Calculating execution depth levels...") - print(f" Sorted {len(model_nodes)} stages into 2 execution levels") - print(" Calculating pipeline metrics...") - - print("\n INTELLIGENT PIPELINE TOPOLOGY ANALYSIS COMPLETE") - print(" " + "=" * 40) - print(" Pipeline Metrics:") - print(f" Total Stages: {len(model_nodes)}") - print(f" Pipeline Depth: 2 levels") - print(f" Max Parallel Stages: 1") - print(f" Parallelization Efficiency: 100.0%") - - print("\n Optimized Execution Order:") - for i, model in enumerate(model_nodes, 1): - print(f" {i:2d}. {model['name']}") - - print("\n Critical Path (2 stages):") - print(" Fire Detection Model → Verification Model") - - print("\n Performance Insights:") - print(" Excellent parallelization potential!") - print(" Low latency pipeline - great for real-time applications") - - print("\n⚙️ Step 3: Stage Configuration Generation") - print("-" * 30) - - for i, model_node in enumerate(model_nodes, 1): - props = model_node['properties'] - stage_id = f"stage_{i}_{model_node['name'].replace(' ', '_').lower()}" - - print(f" Stage {i}: {stage_id}") - print(f" Port IDs: {props.get('port_id', 'auto').split(',')}") - print(f" Model Path: {props.get('model_path', 'not_set')}") - print(f" SCPU Firmware: {props.get('scpu_fw_path', 'not_set')}") - print(f" NCPU Firmware: {props.get('ncpu_fw_path', 'not_set')}") - print(f" Upload Firmware: {props.get('upload_fw', False)}") - print(f" Queue Size: 50") - print() - - print("🔧 Step 4: Configuration Validation") - print("-" * 30) - - validation_errors = [] - - for model_node in model_nodes: - props = model_node['properties'] - name = model_node['name'] - - # 檢查模型路徑 - model_path = props.get('model_path', '') - if not model_path: - validation_errors.append(f"Model '{name}' missing model path") - elif not model_path.endswith('.nef'): - validation_errors.append(f"Model '{name}' must use .nef format") - - # 檢查固件路徑 - if not props.get('scpu_fw_path'): - validation_errors.append(f"Model '{name}' missing SCPU firmware") - if not props.get('ncpu_fw_path'): - validation_errors.append(f"Model '{name}' missing NCPU firmware") - - # 檢查端口ID - if not props.get('port_id'): - validation_errors.append(f"Model '{name}' missing port ID") - - if validation_errors: - print(" ✗ Validation failed with errors:") - for error in validation_errors: - print(f" - {error}") - print("\n Please fix these issues before deployment.") - return - else: - print(" ✓ All configurations are valid!") - - print("\n🚀 Step 5: Pipeline Deployment") - print("-" * 30) - - # 模擬部署過程 - deployment_steps = [ - (10, "Converting pipeline configuration..."), - (30, "Pipeline conversion completed"), - (40, "Validating pipeline configuration..."), - (60, "Configuration validation passed"), - (70, "Initializing inference pipeline..."), - (80, "Initializing dongle connections..."), - (85, "Uploading firmware to dongles..."), - (90, "Loading models to dongles..."), - (95, "Starting pipeline execution..."), - (100, "Pipeline deployed successfully!") - ] - - for progress, message in deployment_steps: - print(f" [{progress:3d}%] {message}") - - # 模擬一些具體的部署細節 - if "dongle connections" in message: - print(" Connecting to dongle on port 28...") - print(" Connecting to dongle on port 30...") - print(" Connecting to dongle on port 32...") - print(" Connecting to dongle on port 34...") - elif "firmware" in message: - print(" Uploading SCPU firmware...") - print(" Uploading NCPU firmware...") - elif "models" in message: - print(" Loading fire_detection_520.nef...") - print(" Loading verification_520.nef...") - - print("\n🎉 Deployment Complete!") - print("-" * 30) - print(f" ✓ Pipeline '{pipeline_data['project_name']}' deployed successfully") - print(f" ✓ {len(model_nodes)} stages running on {sum(len(m['properties'].get('port_id', '').split(',')) for m in model_nodes)} dongles") - print(" ✓ Real-time inference pipeline is now active") - - print("\n📊 Deployment Summary:") - print(" • Input: RGB Camera (1920x1080 @ 30fps)") - print(" • Stage 1: Fire Detection (Ports 28,30)") - print(" • Stage 2: Verification (Ports 32,34)") - print(" • Output: Alert System (TCP stream)") - print(" • Expected Latency: <50ms") - print(" • Expected Throughput: 25-30 FPS") - -def show_ui_integration(): - """展示如何在UI中使用deploy功能""" - - print("\n" + "=" * 60) - print("🖥️ UI Integration Guide") - print("=" * 60) - - print("\n在App中使用Deploy功能的步驟:") - print("\n1. 📝 創建Pipeline") - print(" • 拖拽Input、Model、Output節點到畫布") - print(" • 連接節點建立數據流") - print(" • 設置每個節點的屬性") - - print("\n2. ⚙️ 配置Model節點") - print(" • model_path: 設置.nef模型檔案路徑") - print(" • scpu_fw_path: 設置SCPU固件路徑(.bin)") - print(" • ncpu_fw_path: 設置NCPU固件路徑(.bin)") - print(" • port_id: 設置dongle端口ID (如: '28,30')") - print(" • dongle_series: 選擇dongle型號 (520/720等)") - - print("\n3. 🔄 驗證Pipeline") - print(" • 點擊 'Validate Pipeline' 檢查結構") - print(" • 確認stage count顯示正確") - print(" • 檢查所有連接是否正確") - - print("\n4. 🚀 部署Pipeline") - print(" • 點擊綠色的 'Deploy Pipeline' 按鈕") - print(" • 查看自動拓撲分析結果") - print(" • 檢查配置並確認部署") - print(" • 監控部署進度和狀態") - - print("\n5. 📊 監控運行狀態") - print(" • 查看dongle連接狀態") - print(" • 監控pipeline性能指標") - print(" • 檢查實時處理結果") - - print("\n💡 注意事項:") - print(" • 確保所有檔案路徑正確且存在") - print(" • 確認dongle硬體已連接") - print(" • 檢查USB端口權限") - print(" • 監控系統資源使用情況") - -if __name__ == "__main__": - simulate_deploy_workflow() - show_ui_integration() - - print("\n" + "=" * 60) - print("✅ Deploy功能已完整實現!") - print("\n🎯 主要特色:") - print(" • 一鍵部署 - 從UI直接部署到dongle") - print(" • 智慧拓撲分析 - 自動優化執行順序") - print(" • 完整驗證 - 部署前檢查所有配置") - print(" • 實時監控 - 部署進度和狀態追蹤") - print(" • 錯誤處理 - 詳細的錯誤信息和建議") - - print("\n🚀 準備就緒,可以進行進度報告!") \ No newline at end of file diff --git a/cluster4npu_ui/tests/deployment_terminal_example.py b/cluster4npu_ui/tests/deployment_terminal_example.py deleted file mode 100644 index 5ec185f..0000000 --- a/cluster4npu_ui/tests/deployment_terminal_example.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -Deployment Terminal Example -========================== - -This script demonstrates how to deploy modules on dongles with terminal result printing. -It shows how the enhanced deployment system now prints detailed inference results to the console. - -Usage: - python deployment_terminal_example.py - -Requirements: - - Dongles connected (or simulation mode) - - Pipeline configuration (.mflow file or manual config) -""" - -import sys -import os -import time -import threading -from datetime import datetime - -# Add core functions to path -sys.path.append(os.path.join(os.path.dirname(__file__), 'core', 'functions')) - -# Hardware dependencies not needed for simulation -COMPONENTS_AVAILABLE = False - -def simulate_terminal_results(): - """Simulate what terminal output looks like during deployment.""" - print("🚀 DEPLOYMENT TERMINAL OUTPUT SIMULATION") - print("="*60) - print() - - # Simulate pipeline start - print("🚀 Workflow orchestrator started successfully.") - print("📊 Pipeline: FireDetectionCascade") - print("🎥 Input: camera source") - print("💾 Output: file destination") - print("🔄 Inference pipeline is now processing data...") - print("📡 Inference results will appear below:") - print("="*60) - - # Simulate some inference results - sample_results = [ - { - "timestamp": time.time(), - "pipeline_id": "fire_cascade_001", - "stage_results": { - "object_detection": { - "result": "Fire Detected", - "probability": 0.85, - "confidence": "High" - }, - "fire_classification": { - "result": "Fire Confirmed", - "probability": 0.92, - "combined_probability": 0.88, - "confidence": "Very High" - } - }, - "metadata": { - "total_processing_time": 0.045, - "dongle_count": 4, - "stage_count": 2 - } - }, - { - "timestamp": time.time() + 1, - "pipeline_id": "fire_cascade_002", - "stage_results": { - "object_detection": { - "result": "No Fire", - "probability": 0.12, - "confidence": "Low" - } - }, - "metadata": { - "total_processing_time": 0.038 - } - }, - { - "timestamp": time.time() + 2, - "pipeline_id": "fire_cascade_003", - "stage_results": { - "rgb_analysis": ("Fire Detected", 0.75), - "edge_analysis": ("Fire Detected", 0.68), - "thermal_analysis": ("Fire Detected", 0.82), - "result_fusion": { - "result": "Fire Detected", - "fused_probability": 0.78, - "individual_probs": { - "rgb": 0.75, - "edge": 0.68, - "thermal": 0.82 - }, - "confidence": "High" - } - }, - "metadata": { - "total_processing_time": 0.067 - } - } - ] - - # Print each result with delay to simulate real-time - for i, result_dict in enumerate(sample_results): - time.sleep(2) # Simulate processing delay - print_terminal_results(result_dict) - - time.sleep(1) - print("🛑 Stopping workflow orchestrator...") - print("📹 Data source stopped") - print("⚙️ Inference pipeline stopped") - print("✅ Workflow orchestrator stopped successfully.") - print("="*60) - -def print_terminal_results(result_dict): - """Print inference results to terminal with detailed formatting.""" - try: - # Header with timestamp - timestamp = datetime.fromtimestamp(result_dict.get('timestamp', 0)).strftime("%H:%M:%S.%f")[:-3] - pipeline_id = result_dict.get('pipeline_id', 'Unknown') - - print(f"\n🔥 INFERENCE RESULT [{timestamp}]") - print(f" Pipeline ID: {pipeline_id}") - print(" " + "="*50) - - # Stage results - stage_results = result_dict.get('stage_results', {}) - if stage_results: - for stage_id, result in stage_results.items(): - print(f" 📊 Stage: {stage_id}") - - if isinstance(result, tuple) and len(result) == 2: - # Handle tuple results (result_string, probability) - result_string, probability = result - print(f" ✅ Result: {result_string}") - print(f" 📈 Probability: {probability:.3f}") - - # Add confidence level - if probability > 0.8: - confidence = "🟢 Very High" - elif probability > 0.6: - confidence = "🟡 High" - elif probability > 0.4: - confidence = "🟠 Medium" - else: - confidence = "🔴 Low" - print(f" 🎯 Confidence: {confidence}") - - elif isinstance(result, dict): - # Handle dict results - for key, value in result.items(): - if key == 'probability': - print(f" 📈 {key.title()}: {value:.3f}") - elif key == 'result': - print(f" ✅ {key.title()}: {value}") - elif key == 'confidence': - print(f" 🎯 {key.title()}: {value}") - elif key == 'fused_probability': - print(f" 🔀 Fused Probability: {value:.3f}") - elif key == 'individual_probs': - print(f" 📋 Individual Probabilities:") - for prob_key, prob_value in value.items(): - print(f" {prob_key}: {prob_value:.3f}") - else: - print(f" 📝 {key}: {value}") - else: - # Handle other result types - print(f" 📝 Raw Result: {result}") - - print() # Blank line between stages - else: - print(" ⚠️ No stage results available") - - # Processing time if available - metadata = result_dict.get('metadata', {}) - if 'total_processing_time' in metadata: - processing_time = metadata['total_processing_time'] - print(f" ⏱️ Processing Time: {processing_time:.3f}s") - - # Add FPS calculation - if processing_time > 0: - fps = 1.0 / processing_time - print(f" 🚄 Theoretical FPS: {fps:.2f}") - - # Additional metadata - if metadata: - interesting_keys = ['dongle_count', 'stage_count', 'queue_sizes', 'error_count'] - for key in interesting_keys: - if key in metadata: - print(f" 📋 {key.replace('_', ' ').title()}: {metadata[key]}") - - print(" " + "="*50) - - except Exception as e: - print(f"❌ Error printing terminal results: {e}") - -def main(): - """Main function to demonstrate terminal result printing.""" - print("Terminal Result Printing Demo") - print("============================") - print() - print("This script demonstrates how inference results are printed to the terminal") - print("when deploying modules on dongles using the enhanced deployment system.") - print() - - if COMPONENTS_AVAILABLE: - print("✅ All components available - ready for real deployment") - print("💡 To use with real deployment:") - print(" 1. Run the UI: python UI.py") - print(" 2. Create or load a pipeline") - print(" 3. Use Deploy Pipeline dialog") - print(" 4. Watch terminal for inference results") - else: - print("⚠️ Some components missing - running simulation only") - - print() - print("Running simulation of terminal output...") - print() - - try: - simulate_terminal_results() - except KeyboardInterrupt: - print("\n⏹️ Simulation stopped by user") - - print() - print("Demo completed!") - print() - print("Real deployment usage:") - print(" uv run python UI.py # Start the full UI application") - print(" # OR") - print(" uv run python core/functions/test.py --example single # Direct pipeline test") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/device_detection_example.py b/cluster4npu_ui/tests/device_detection_example.py deleted file mode 100644 index 96e7f88..0000000 --- a/cluster4npu_ui/tests/device_detection_example.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -""" -Example script demonstrating Kneron device auto-detection functionality. -This script shows how to scan for devices and connect to them automatically. -""" - -import sys -import os - -# Add the core functions path to sys.path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'core', 'functions')) - -def example_device_scan(): - """ - Example 1: Scan for available devices without connecting - """ - print("=== Example 1: Device Scanning ===") - - try: - from Multidongle import MultiDongle - - # Scan for available devices - devices = MultiDongle.scan_devices() - - if not devices: - print("No Kneron devices found") - return - - print(f"Found {len(devices)} device(s):") - for i, device in enumerate(devices): - desc = device['device_descriptor'] - product_id = desc.get('product_id', 'Unknown') if isinstance(desc, dict) else 'Unknown' - print(f" [{i+1}] Port ID: {device['port_id']}, Series: {device['series']}, Product ID: {product_id}") - - except Exception as e: - print(f"Error during device scan: {str(e)}") - -def example_auto_connect(): - """ - Example 2: Auto-connect to all available devices - """ - print("\n=== Example 2: Auto-Connect to Devices ===") - - try: - from Multidongle import MultiDongle - - # Connect to all available devices automatically - device_group, connected_devices = MultiDongle.connect_auto_detected_devices() - - print(f"Successfully connected to {len(connected_devices)} device(s):") - for i, device in enumerate(connected_devices): - desc = device['device_descriptor'] - product_id = desc.get('product_id', 'Unknown') if isinstance(desc, dict) else 'Unknown' - print(f" [{i+1}] Port ID: {device['port_id']}, Series: {device['series']}, Product ID: {product_id}") - - # Disconnect devices - import kp - kp.core.disconnect_devices(device_group=device_group) - print("Devices disconnected") - - except Exception as e: - print(f"Error during auto-connect: {str(e)}") - -def example_multidongle_with_auto_detect(): - """ - Example 3: Use MultiDongle with auto-detection - """ - print("\n=== Example 3: MultiDongle with Auto-Detection ===") - - try: - from Multidongle import MultiDongle - - # Create MultiDongle instance with auto-detection - # Note: You'll need to provide firmware and model paths for full initialization - multidongle = MultiDongle( - auto_detect=True, - scpu_fw_path="path/to/fw_scpu.bin", # Update with actual path - ncpu_fw_path="path/to/fw_ncpu.bin", # Update with actual path - model_path="path/to/model.nef", # Update with actual path - upload_fw=False # Set to True if you want to upload firmware - ) - - # Print device information - multidongle.print_device_info() - - # Get device info programmatically - device_info = multidongle.get_device_info() - - print("\nDevice details:") - for device in device_info: - print(f" Port ID: {device['port_id']}, Series: {device['series']}") - - except Exception as e: - print(f"Error during MultiDongle auto-detection: {str(e)}") - -def example_connect_specific_count(): - """ - Example 4: Connect to specific number of devices - """ - print("\n=== Example 4: Connect to Specific Number of Devices ===") - - try: - from Multidongle import MultiDongle - - # Connect to only 2 devices (or all available if less than 2) - device_group, connected_devices = MultiDongle.connect_auto_detected_devices(device_count=2) - - print(f"Connected to {len(connected_devices)} device(s):") - for i, device in enumerate(connected_devices): - print(f" [{i+1}] Port ID: {device['port_id']}, Series: {device['series']}") - - # Disconnect devices - import kp - kp.core.disconnect_devices(device_group=device_group) - print("Devices disconnected") - - except Exception as e: - print(f"Error during specific count connect: {str(e)}") - -if __name__ == "__main__": - print("Kneron Device Auto-Detection Examples") - print("=" * 50) - - # Run examples - example_device_scan() - example_auto_connect() - example_multidongle_with_auto_detect() - example_connect_specific_count() - - print("\n" + "=" * 50) - print("Examples completed!") - print("\nUsage Notes:") - print("- Make sure Kneron devices are connected via USB") - print("- Update firmware and model paths in example 3") - print("- The examples require the Kneron SDK to be properly installed") \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_deploy.py b/cluster4npu_ui/tests/test_deploy.py deleted file mode 100644 index 5cab943..0000000 --- a/cluster4npu_ui/tests/test_deploy.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for pipeline deployment functionality. - -This script demonstrates the deploy feature without requiring actual dongles. -""" - -import sys -import os -from PyQt5.QtWidgets import QApplication -from PyQt5.QtCore import Qt - -# Add the current directory to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ui.dialogs.deployment import DeploymentDialog - -def test_deployment_dialog(): - """Test the deployment dialog with sample pipeline data.""" - - # Sample pipeline data (similar to what would be exported from the UI) - sample_pipeline_data = { - "project_name": "Test Fire Detection Pipeline", - "description": "A test pipeline for demonstrating deployment functionality", - "nodes": [ - { - "id": "input_001", - "name": "Camera Input", - "type": "ExactInputNode", - "pos": [100, 200], - "properties": { - "source_type": "Camera", - "device_id": 0, - "resolution": "1920x1080", - "fps": 30, - "source_path": "" - } - }, - { - "id": "model_001", - "name": "Fire Detection Model", - "type": "ExactModelNode", - "pos": [300, 200], - "properties": { - "model_path": "./models/fire_detection.nef", - "scpu_fw_path": "./firmware/fw_scpu.bin", - "ncpu_fw_path": "./firmware/fw_ncpu.bin", - "dongle_series": "520", - "num_dongles": 1, - "port_id": "28" - } - }, - { - "id": "output_001", - "name": "Detection Output", - "type": "ExactOutputNode", - "pos": [500, 200], - "properties": { - "output_type": "Stream", - "format": "JSON", - "destination": "tcp://localhost:5555", - "save_interval": 1.0 - } - } - ], - "connections": [ - { - "output_node": "input_001", - "output_port": "output", - "input_node": "model_001", - "input_port": "input" - }, - { - "output_node": "model_001", - "output_port": "output", - "input_node": "output_001", - "input_port": "input" - } - ], - "version": "1.0" - } - - app = QApplication(sys.argv) - - # Enable high DPI support - app.setAttribute(Qt.AA_EnableHighDpiScaling, True) - app.setAttribute(Qt.AA_UseHighDpiPixmaps, True) - - # Create and show deployment dialog - dialog = DeploymentDialog(sample_pipeline_data) - dialog.show() - - print("Deployment dialog opened!") - print("You can:") - print("1. Click 'Analyze Pipeline' to see topology analysis") - print("2. Review the configuration in different tabs") - print("3. Click 'Deploy to Dongles' to test deployment process") - print("(Note: Actual dongle deployment will fail without hardware)") - - # Run the application - return app.exec_() - -if __name__ == "__main__": - sys.exit(test_deployment_dialog()) \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_deploy_simple.py b/cluster4npu_ui/tests/test_deploy_simple.py deleted file mode 100644 index 0f74625..0000000 --- a/cluster4npu_ui/tests/test_deploy_simple.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple test for deployment functionality without complex imports. -""" - -import sys -import os -import json - -# Add the current directory to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'core', 'functions')) - -def test_mflow_conversion(): - """Test the MFlow conversion functionality.""" - - print("Testing MFlow Pipeline Conversion") - print("=" * 50) - - # Sample pipeline data - sample_pipeline = { - "project_name": "Test Fire Detection Pipeline", - "description": "A test pipeline for demonstrating deployment functionality", - "nodes": [ - { - "id": "input_001", - "name": "Camera Input", - "type": "ExactInputNode", - "properties": { - "source_type": "Camera", - "device_id": 0, - "resolution": "1920x1080", - "fps": 30 - } - }, - { - "id": "model_001", - "name": "Fire Detection Model", - "type": "ExactModelNode", - "properties": { - "model_path": "./models/fire_detection.nef", - "scpu_fw_path": "./firmware/fw_scpu.bin", - "ncpu_fw_path": "./firmware/fw_ncpu.bin", - "dongle_series": "520", - "port_id": "28" - } - }, - { - "id": "output_001", - "name": "Detection Output", - "type": "ExactOutputNode", - "properties": { - "output_type": "Stream", - "format": "JSON", - "destination": "tcp://localhost:5555" - } - } - ], - "connections": [ - { - "output_node": "input_001", - "input_node": "model_001" - }, - { - "output_node": "model_001", - "input_node": "output_001" - } - ], - "version": "1.0" - } - - try: - # Test the converter without dongle dependencies - from mflow_converter import MFlowConverter - - print("1. Creating MFlow converter...") - converter = MFlowConverter() - - print("2. Converting pipeline data...") - config = converter._convert_mflow_to_config(sample_pipeline) - - print("3. Pipeline conversion results:") - print(f" Pipeline Name: {config.pipeline_name}") - print(f" Total Stages: {len(config.stage_configs)}") - print(f" Input Config: {config.input_config}") - print(f" Output Config: {config.output_config}") - - print("\n4. Stage Configurations:") - for i, stage_config in enumerate(config.stage_configs, 1): - print(f" Stage {i}: {stage_config.stage_id}") - print(f" Port IDs: {stage_config.port_ids}") - print(f" Model Path: {stage_config.model_path}") - print(f" SCPU Firmware: {stage_config.scpu_fw_path}") - print(f" NCPU Firmware: {stage_config.ncpu_fw_path}") - print(f" Upload Firmware: {stage_config.upload_fw}") - print(f" Queue Size: {stage_config.max_queue_size}") - - print("\n5. Validating configuration...") - is_valid, errors = converter.validate_config(config) - - if is_valid: - print(" ✓ Configuration is valid!") - else: - print(" ✗ Configuration has errors:") - for error in errors: - print(f" - {error}") - - print("\n6. Testing pipeline creation (without dongles)...") - try: - # This will fail due to missing kp module, but shows the process - pipeline = converter.create_inference_pipeline(config) - print(" ✓ Pipeline object created successfully!") - except Exception as e: - print(f" ⚠ Pipeline creation failed (expected): {e}") - print(" This is normal without dongle hardware/drivers installed.") - - print("\n" + "=" * 50) - print("✓ MFlow conversion test completed successfully!") - print("\nDeploy Button Functionality Summary:") - print("• Pipeline validation - Working ✓") - print("• MFlow conversion - Working ✓") - print("• Topology analysis - Working ✓") - print("• Configuration generation - Working ✓") - print("• Dongle deployment - Requires hardware") - - return True - - except ImportError as e: - print(f"Import error: {e}") - print("MFlow converter not available - this would show an error in the UI") - return False - except Exception as e: - print(f"Conversion error: {e}") - return False - -def test_deployment_validation(): - """Test deployment validation logic.""" - - print("\nTesting Deployment Validation") - print("=" * 50) - - # Test with invalid pipeline (missing paths) - invalid_pipeline = { - "project_name": "Invalid Pipeline", - "nodes": [ - { - "id": "model_001", - "name": "Invalid Model", - "type": "ExactModelNode", - "properties": { - "model_path": "", # Missing model path - "scpu_fw_path": "", # Missing firmware - "ncpu_fw_path": "", - "port_id": "" # Missing port - } - } - ], - "connections": [], - "version": "1.0" - } - - try: - from mflow_converter import MFlowConverter - - converter = MFlowConverter() - config = converter._convert_mflow_to_config(invalid_pipeline) - - print("Testing validation with invalid configuration...") - is_valid, errors = converter.validate_config(config) - - print(f"Validation result: {'Valid' if is_valid else 'Invalid'}") - if errors: - print("Validation errors found:") - for error in errors: - print(f" - {error}") - - print("✓ Validation system working correctly!") - - except Exception as e: - print(f"Validation test error: {e}") - -if __name__ == "__main__": - print("Pipeline Deployment System Test") - print("=" * 60) - - success1 = test_mflow_conversion() - test_deployment_validation() - - print("\n" + "=" * 60) - if success1: - print("🎉 Deploy functionality is working correctly!") - print("\nTo test in the UI:") - print("1. Run: python main.py") - print("2. Create a pipeline with Input → Model → Output nodes") - print("3. Configure model paths and firmware in Model node properties") - print("4. Click the 'Deploy Pipeline' button in the toolbar") - print("5. Follow the deployment wizard") - else: - print("⚠ Some components need to be checked") \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_exact_node_logging.py b/cluster4npu_ui/tests/test_exact_node_logging.py deleted file mode 100644 index eae3a78..0000000 --- a/cluster4npu_ui/tests/test_exact_node_logging.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify logging works with ExactNode identifiers. -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from core.pipeline import is_model_node, is_input_node, is_output_node, get_stage_count - - -class MockExactNode: - """Mock node that simulates the ExactNode behavior.""" - - def __init__(self, node_type, identifier): - self.node_type = node_type - self.__identifier__ = identifier - self.NODE_NAME = f"{node_type.capitalize()} Node" - - def __str__(self): - return f"<{self.__class__.__name__}({self.NODE_NAME})>" - - def __repr__(self): - return self.__str__() - - -class MockExactInputNode(MockExactNode): - def __init__(self): - super().__init__("Input", "com.cluster.input_node.ExactInputNode.ExactInputNode") - - -class MockExactModelNode(MockExactNode): - def __init__(self): - super().__init__("Model", "com.cluster.model_node.ExactModelNode.ExactModelNode") - - -class MockExactOutputNode(MockExactNode): - def __init__(self): - super().__init__("Output", "com.cluster.output_node.ExactOutputNode.ExactOutputNode") - - -class MockExactPreprocessNode(MockExactNode): - def __init__(self): - super().__init__("Preprocess", "com.cluster.preprocess_node.ExactPreprocessNode.ExactPreprocessNode") - - -class MockExactPostprocessNode(MockExactNode): - def __init__(self): - super().__init__("Postprocess", "com.cluster.postprocess_node.ExactPostprocessNode.ExactPostprocessNode") - - -class MockNodeGraph: - def __init__(self): - self.nodes = [] - - def all_nodes(self): - return self.nodes - - def add_node(self, node): - self.nodes.append(node) - - -def test_exact_node_detection(): - """Test that our detection methods work with ExactNode identifiers.""" - print("Testing ExactNode Detection...") - - # Create ExactNode instances - input_node = MockExactInputNode() - model_node = MockExactModelNode() - output_node = MockExactOutputNode() - preprocess_node = MockExactPreprocessNode() - postprocess_node = MockExactPostprocessNode() - - # Test detection - print(f"Input node: {input_node}") - print(f" Identifier: {input_node.__identifier__}") - print(f" is_input_node: {is_input_node(input_node)}") - print(f" is_model_node: {is_model_node(input_node)}") - print() - - print(f"Model node: {model_node}") - print(f" Identifier: {model_node.__identifier__}") - print(f" is_model_node: {is_model_node(model_node)}") - print(f" is_input_node: {is_input_node(model_node)}") - print() - - print(f"Output node: {output_node}") - print(f" Identifier: {output_node.__identifier__}") - print(f" is_output_node: {is_output_node(output_node)}") - print(f" is_model_node: {is_model_node(output_node)}") - print() - - # Test stage counting - graph = MockNodeGraph() - print("Testing stage counting with ExactNodes...") - - print(f"Empty graph: {get_stage_count(graph)} stages") - - graph.add_node(input_node) - print(f"After adding input: {get_stage_count(graph)} stages") - - graph.add_node(model_node) - print(f"After adding model: {get_stage_count(graph)} stages") - - graph.add_node(output_node) - print(f"After adding output: {get_stage_count(graph)} stages") - - model_node2 = MockExactModelNode() - graph.add_node(model_node2) - print(f"After adding second model: {get_stage_count(graph)} stages") - - print("\n✅ ExactNode detection tests completed!") - - -def simulate_pipeline_logging(): - """Simulate the pipeline logging that would occur in the actual editor.""" - print("\n" + "="*60) - print("Simulating Pipeline Editor Logging with ExactNodes") - print("="*60) - - class MockPipelineEditor: - def __init__(self): - self.previous_stage_count = 0 - self.nodes = [] - print("🚀 Pipeline Editor initialized") - self.analyze_pipeline() - - def add_node(self, node_type): - print(f"🔄 Adding {node_type} via toolbar...") - - if node_type == "Input": - node = MockExactInputNode() - elif node_type == "Model": - node = MockExactModelNode() - elif node_type == "Output": - node = MockExactOutputNode() - elif node_type == "Preprocess": - node = MockExactPreprocessNode() - elif node_type == "Postprocess": - node = MockExactPostprocessNode() - - self.nodes.append(node) - print(f"➕ Node added: {node.NODE_NAME}") - self.analyze_pipeline() - - def analyze_pipeline(self): - graph = MockNodeGraph() - for node in self.nodes: - graph.add_node(node) - - current_stage_count = get_stage_count(graph) - - # Print stage count changes - if current_stage_count != self.previous_stage_count: - if self.previous_stage_count == 0 and current_stage_count > 0: - print(f"🎯 Initial stage count: {current_stage_count}") - elif current_stage_count != self.previous_stage_count: - change = current_stage_count - self.previous_stage_count - if change > 0: - print(f"📈 Stage count increased: {self.previous_stage_count} → {current_stage_count} (+{change})") - else: - print(f"📉 Stage count decreased: {self.previous_stage_count} → {current_stage_count} ({change})") - - # Print current status - print(f"📊 Current Pipeline Status:") - print(f" • Stages: {current_stage_count}") - print(f" • Total Nodes: {len(self.nodes)}") - print("─" * 50) - - self.previous_stage_count = current_stage_count - - # Run simulation - editor = MockPipelineEditor() - - print("\n1. Adding Input Node:") - editor.add_node("Input") - - print("\n2. Adding Model Node:") - editor.add_node("Model") - - print("\n3. Adding Output Node:") - editor.add_node("Output") - - print("\n4. Adding Preprocess Node:") - editor.add_node("Preprocess") - - print("\n5. Adding Second Model Node:") - editor.add_node("Model") - - print("\n6. Adding Postprocess Node:") - editor.add_node("Postprocess") - - print("\n✅ Simulation completed!") - - -def main(): - """Run all tests.""" - try: - test_exact_node_detection() - simulate_pipeline_logging() - - print("\n" + "="*60) - print("🎉 All tests completed successfully!") - print("="*60) - print("\nWhat you observed:") - print("• The logs show stage count changes when you add/remove model nodes") - print("• 'Updating for X stages' messages indicate the stage count is working") - print("• The identifier fallback mechanism handles different node formats") - print("• The detection methods correctly identify ExactNode types") - print("\nThis is completely normal behavior! The logs demonstrate that:") - print("• Stage counting works correctly with your ExactNode identifiers") - print("• The pipeline editor properly detects and counts model nodes") - print("• Real-time logging shows stage changes as they happen") - - except Exception as e: - print(f"❌ Test failed: {e}") - import traceback - traceback.print_exc() - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_final_implementation.py b/cluster4npu_ui/tests/test_final_implementation.py deleted file mode 100644 index 7ea7651..0000000 --- a/cluster4npu_ui/tests/test_final_implementation.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -""" -Final test to verify the stage detection implementation works correctly. -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -# Set up Qt environment -os.environ['QT_QPA_PLATFORM'] = 'offscreen' - -from PyQt5.QtWidgets import QApplication -app = QApplication(sys.argv) - -from core.pipeline import ( - is_model_node, is_input_node, is_output_node, - get_stage_count, get_pipeline_summary -) -from core.nodes.model_node import ModelNode -from core.nodes.input_node import InputNode -from core.nodes.output_node import OutputNode -from core.nodes.preprocess_node import PreprocessNode -from core.nodes.postprocess_node import PostprocessNode - - -class MockNodeGraph: - """Mock node graph for testing.""" - def __init__(self): - self.nodes = [] - - def all_nodes(self): - return self.nodes - - def add_node(self, node): - self.nodes.append(node) - print(f"Added node: {node} (type: {type(node).__name__})") - - -def test_comprehensive_pipeline(): - """Test comprehensive pipeline functionality.""" - print("Testing Comprehensive Pipeline...") - - # Create mock graph - graph = MockNodeGraph() - - # Test 1: Empty pipeline - print("\n1. Empty pipeline:") - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 0, f"Expected 0 stages, got {stage_count}" - - # Test 2: Add input node - print("\n2. Add input node:") - input_node = InputNode() - graph.add_node(input_node) - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 0, f"Expected 0 stages, got {stage_count}" - - # Test 3: Add model node (should create 1 stage) - print("\n3. Add model node:") - model_node = ModelNode() - graph.add_node(model_node) - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 1, f"Expected 1 stage, got {stage_count}" - - # Test 4: Add output node - print("\n4. Add output node:") - output_node = OutputNode() - graph.add_node(output_node) - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 1, f"Expected 1 stage, got {stage_count}" - - # Test 5: Add preprocess node - print("\n5. Add preprocess node:") - preprocess_node = PreprocessNode() - graph.add_node(preprocess_node) - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 1, f"Expected 1 stage, got {stage_count}" - - # Test 6: Add postprocess node - print("\n6. Add postprocess node:") - postprocess_node = PostprocessNode() - graph.add_node(postprocess_node) - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 1, f"Expected 1 stage, got {stage_count}" - - # Test 7: Add second model node (should create 2 stages) - print("\n7. Add second model node:") - model_node2 = ModelNode() - graph.add_node(model_node2) - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 2, f"Expected 2 stages, got {stage_count}" - - # Test 8: Add third model node (should create 3 stages) - print("\n8. Add third model node:") - model_node3 = ModelNode() - graph.add_node(model_node3) - stage_count = get_stage_count(graph) - print(f" Stage count: {stage_count}") - assert stage_count == 3, f"Expected 3 stages, got {stage_count}" - - # Test 9: Get pipeline summary - print("\n9. Get pipeline summary:") - summary = get_pipeline_summary(graph) - print(f" Summary: {summary}") - - expected_fields = ['stage_count', 'valid', 'total_nodes', 'model_nodes', 'input_nodes', 'output_nodes'] - for field in expected_fields: - assert field in summary, f"Missing field '{field}' in summary" - - assert summary['stage_count'] == 3, f"Expected 3 stages in summary, got {summary['stage_count']}" - assert summary['model_nodes'] == 3, f"Expected 3 model nodes in summary, got {summary['model_nodes']}" - assert summary['input_nodes'] == 1, f"Expected 1 input node in summary, got {summary['input_nodes']}" - assert summary['output_nodes'] == 1, f"Expected 1 output node in summary, got {summary['output_nodes']}" - assert summary['total_nodes'] == 7, f"Expected 7 total nodes in summary, got {summary['total_nodes']}" - - print("✓ All comprehensive tests passed!") - - -def test_node_detection_robustness(): - """Test robustness of node detection.""" - print("\nTesting Node Detection Robustness...") - - # Test with actual node instances - model_node = ModelNode() - input_node = InputNode() - output_node = OutputNode() - preprocess_node = PreprocessNode() - postprocess_node = PostprocessNode() - - # Test detection methods - assert is_model_node(model_node), "Model node not detected correctly" - assert is_input_node(input_node), "Input node not detected correctly" - assert is_output_node(output_node), "Output node not detected correctly" - - # Test cross-detection (should be False) - assert not is_model_node(input_node), "Input node incorrectly detected as model" - assert not is_model_node(output_node), "Output node incorrectly detected as model" - assert not is_input_node(model_node), "Model node incorrectly detected as input" - assert not is_input_node(output_node), "Output node incorrectly detected as input" - assert not is_output_node(model_node), "Model node incorrectly detected as output" - assert not is_output_node(input_node), "Input node incorrectly detected as output" - - print("✓ Node detection robustness tests passed!") - - -def main(): - """Run all tests.""" - print("Running Final Implementation Tests...") - print("=" * 60) - - try: - test_node_detection_robustness() - test_comprehensive_pipeline() - - print("\n" + "=" * 60) - print("🎉 ALL TESTS PASSED! The stage detection implementation is working correctly.") - print("\nKey Features Verified:") - print("✓ Model node detection works correctly") - print("✓ Stage counting updates when model nodes are added") - print("✓ Pipeline summary provides accurate information") - print("✓ Node detection is robust and handles edge cases") - print("✓ Multiple stages are correctly counted") - - except Exception as e: - print(f"\n❌ Test failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_integration.py b/cluster4npu_ui/tests/test_integration.py deleted file mode 100644 index 83a3ca8..0000000 --- a/cluster4npu_ui/tests/test_integration.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for pipeline editor integration into dashboard. - -This script tests the integration of pipeline_editor.py functionality -into the dashboard.py file. -""" - -import sys -import os - -# Add parent directory to path -current_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(current_dir) -sys.path.insert(0, parent_dir) - -def test_imports(): - """Test that all required imports work.""" - print("🔍 Testing imports...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard, StageCountWidget - print("✅ Dashboard components imported successfully") - - # Test PyQt5 imports - from PyQt5.QtWidgets import QApplication, QWidget - from PyQt5.QtCore import QTimer - print("✅ PyQt5 components imported successfully") - - return True - except Exception as e: - print(f"❌ Import failed: {e}") - return False - -def test_stage_count_widget(): - """Test StageCountWidget functionality.""" - print("\n🔍 Testing StageCountWidget...") - - try: - from PyQt5.QtWidgets import QApplication - from cluster4npu_ui.ui.windows.dashboard import StageCountWidget - - # Create application if needed - app = QApplication.instance() - if app is None: - app = QApplication([]) - - # Create widget - widget = StageCountWidget() - print("✅ StageCountWidget created successfully") - - # Test stage count updates - widget.update_stage_count(0, True, "") - assert widget.stage_count == 0 - print("✅ Initial stage count test passed") - - widget.update_stage_count(3, True, "") - assert widget.stage_count == 3 - assert widget.pipeline_valid == True - print("✅ Valid pipeline test passed") - - widget.update_stage_count(1, False, "Test error") - assert widget.stage_count == 1 - assert widget.pipeline_valid == False - assert widget.pipeline_error == "Test error" - print("✅ Error state test passed") - - return True - except Exception as e: - print(f"❌ StageCountWidget test failed: {e}") - import traceback - traceback.print_exc() - return False - -def test_dashboard_methods(): - """Test that dashboard methods exist and are callable.""" - print("\n🔍 Testing Dashboard methods...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check critical methods exist - required_methods = [ - 'setup_analysis_timer', - 'schedule_analysis', - 'analyze_pipeline', - 'print_pipeline_analysis', - 'create_pipeline_toolbar', - 'clear_pipeline', - 'validate_pipeline' - ] - - for method_name in required_methods: - if hasattr(IntegratedPipelineDashboard, method_name): - method = getattr(IntegratedPipelineDashboard, method_name) - if callable(method): - print(f"✅ Method {method_name} exists and is callable") - else: - print(f"❌ Method {method_name} exists but is not callable") - return False - else: - print(f"❌ Method {method_name} does not exist") - return False - - print("✅ All required methods are present and callable") - return True - except Exception as e: - print(f"❌ Dashboard methods test failed: {e}") - return False - -def test_pipeline_analysis_functions(): - """Test pipeline analysis function imports.""" - print("\n🔍 Testing pipeline analysis functions...") - - try: - from cluster4npu_ui.ui.windows.dashboard import get_pipeline_summary, get_stage_count, analyze_pipeline_stages - print("✅ Pipeline analysis functions imported (or fallbacks created)") - - # Test fallback functions with None input - try: - result = get_pipeline_summary(None) - print(f"✅ get_pipeline_summary fallback works: {result}") - - count = get_stage_count(None) - print(f"✅ get_stage_count fallback works: {count}") - - stages = analyze_pipeline_stages(None) - print(f"✅ analyze_pipeline_stages fallback works: {stages}") - - except Exception as e: - print(f"⚠️ Fallback functions exist but may need graph input: {e}") - - return True - except Exception as e: - print(f"❌ Pipeline analysis functions test failed: {e}") - return False - -def run_all_tests(): - """Run all integration tests.""" - print("🚀 Starting pipeline editor integration tests...\n") - - tests = [ - test_imports, - test_stage_count_widget, - test_dashboard_methods, - test_pipeline_analysis_functions - ] - - passed = 0 - total = len(tests) - - for test_func in tests: - try: - if test_func(): - passed += 1 - else: - print(f"❌ Test {test_func.__name__} failed") - except Exception as e: - print(f"❌ Test {test_func.__name__} raised exception: {e}") - - print(f"\n📊 Test Results: {passed}/{total} tests passed") - - if passed == total: - print("🎉 All integration tests passed! Pipeline editor functionality has been successfully integrated into dashboard.") - return True - else: - print("❌ Some tests failed. Integration may have issues.") - return False - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_logging_demo.py b/cluster4npu_ui/tests/test_logging_demo.py deleted file mode 100644 index 0d40cdd..0000000 --- a/cluster4npu_ui/tests/test_logging_demo.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -""" -Demo script to test the logging functionality in the pipeline editor. -This simulates adding nodes and shows the terminal logging output. -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -# Set up Qt environment -os.environ['QT_QPA_PLATFORM'] = 'offscreen' - -from PyQt5.QtWidgets import QApplication -from PyQt5.QtCore import QTimer - -# Create Qt application -app = QApplication(sys.argv) - -# Mock the pipeline editor to test logging without full UI -from core.pipeline import get_pipeline_summary -from core.nodes.model_node import ModelNode -from core.nodes.input_node import InputNode -from core.nodes.output_node import OutputNode -from core.nodes.preprocess_node import PreprocessNode -from core.nodes.postprocess_node import PostprocessNode - - -class MockPipelineEditor: - """Mock pipeline editor to test logging functionality.""" - - def __init__(self): - self.nodes = [] - self.previous_stage_count = 0 - print("🚀 Pipeline Editor initialized") - self.analyze_pipeline() - - def add_node(self, node_type): - """Add a node and trigger analysis.""" - if node_type == 'input': - node = InputNode() - print("🔄 Adding Input Node via toolbar...") - elif node_type == 'model': - node = ModelNode() - print("🔄 Adding Model Node via toolbar...") - elif node_type == 'output': - node = OutputNode() - print("🔄 Adding Output Node via toolbar...") - elif node_type == 'preprocess': - node = PreprocessNode() - print("🔄 Adding Preprocess Node via toolbar...") - elif node_type == 'postprocess': - node = PostprocessNode() - print("🔄 Adding Postprocess Node via toolbar...") - - self.nodes.append(node) - print(f"➕ Node added: {node.NODE_NAME}") - self.analyze_pipeline() - - def remove_last_node(self): - """Remove the last node and trigger analysis.""" - if self.nodes: - node = self.nodes.pop() - print(f"➖ Node removed: {node.NODE_NAME}") - self.analyze_pipeline() - - def clear_pipeline(self): - """Clear all nodes.""" - print("🗑️ Clearing entire pipeline...") - self.nodes.clear() - self.analyze_pipeline() - - def analyze_pipeline(self): - """Analyze the pipeline and show logging.""" - # Create a mock node graph - class MockGraph: - def __init__(self, nodes): - self._nodes = nodes - def all_nodes(self): - return self._nodes - - graph = MockGraph(self.nodes) - - try: - # Get pipeline summary - summary = get_pipeline_summary(graph) - current_stage_count = summary['stage_count'] - - # Print detailed pipeline analysis - self.print_pipeline_analysis(summary, current_stage_count) - - # Update previous count for next comparison - self.previous_stage_count = current_stage_count - - except Exception as e: - print(f"❌ Pipeline analysis error: {str(e)}") - - def print_pipeline_analysis(self, summary, current_stage_count): - """Print detailed pipeline analysis to terminal.""" - # Check if stage count changed - if current_stage_count != self.previous_stage_count: - if self.previous_stage_count == 0: - print(f"🎯 Initial stage count: {current_stage_count}") - else: - change = current_stage_count - self.previous_stage_count - if change > 0: - print(f"📈 Stage count increased: {self.previous_stage_count} → {current_stage_count} (+{change})") - else: - print(f"📉 Stage count decreased: {self.previous_stage_count} → {current_stage_count} ({change})") - - # Print current pipeline status - print(f"📊 Current Pipeline Status:") - print(f" • Stages: {current_stage_count}") - print(f" • Total Nodes: {summary['total_nodes']}") - print(f" • Model Nodes: {summary['model_nodes']}") - print(f" • Input Nodes: {summary['input_nodes']}") - print(f" • Output Nodes: {summary['output_nodes']}") - print(f" • Preprocess Nodes: {summary['preprocess_nodes']}") - print(f" • Postprocess Nodes: {summary['postprocess_nodes']}") - print(f" • Valid: {'✅' if summary['valid'] else '❌'}") - - if not summary['valid'] and summary.get('error'): - print(f" • Error: {summary['error']}") - - # Print stage details if available - if summary.get('stages'): - print(f"📋 Stage Details:") - for i, stage in enumerate(summary['stages'], 1): - model_name = stage['model_config'].get('node_name', 'Unknown Model') - preprocess_count = len(stage['preprocess_configs']) - postprocess_count = len(stage['postprocess_configs']) - - stage_info = f" Stage {i}: {model_name}" - if preprocess_count > 0: - stage_info += f" (with {preprocess_count} preprocess)" - if postprocess_count > 0: - stage_info += f" (with {postprocess_count} postprocess)" - - print(stage_info) - - print("─" * 50) # Separator line - - -def demo_logging(): - """Demonstrate the logging functionality.""" - print("=" * 60) - print("🔊 PIPELINE LOGGING DEMO") - print("=" * 60) - - # Create mock editor - editor = MockPipelineEditor() - - # Demo sequence: Build a pipeline step by step - print("\n1. Adding Input Node:") - editor.add_node('input') - - print("\n2. Adding Model Node (creates first stage):") - editor.add_node('model') - - print("\n3. Adding Output Node:") - editor.add_node('output') - - print("\n4. Adding Preprocess Node:") - editor.add_node('preprocess') - - print("\n5. Adding second Model Node (creates second stage):") - editor.add_node('model') - - print("\n6. Adding Postprocess Node:") - editor.add_node('postprocess') - - print("\n7. Adding third Model Node (creates third stage):") - editor.add_node('model') - - print("\n8. Removing a Model Node (decreases stages):") - editor.remove_last_node() - - print("\n9. Clearing entire pipeline:") - editor.clear_pipeline() - - print("\n" + "=" * 60) - print("🎉 DEMO COMPLETED") - print("=" * 60) - print("\nAs you can see, the terminal logs show:") - print("• When nodes are added/removed") - print("• Stage count changes (increases/decreases)") - print("• Current pipeline status with detailed breakdown") - print("• Validation status and errors") - print("• Individual stage details") - - -def main(): - """Run the logging demo.""" - try: - demo_logging() - except Exception as e: - print(f"Demo failed: {e}") - import traceback - traceback.print_exc() - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_modifications.py b/cluster4npu_ui/tests/test_modifications.py deleted file mode 100644 index e227e17..0000000 --- a/cluster4npu_ui/tests/test_modifications.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify our modifications work correctly: -1. Model node properties panel shows upload_fw option -2. Terminal output appears in GUI instead of console -""" - -import sys -import os -from PyQt5.QtWidgets import QApplication - -# Add project paths -project_root = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, project_root) - -def test_model_node_properties(): - """Test that ExactModelNode has upload_fw property""" - print("🧪 Testing Model Node Properties") - print("=" * 40) - - try: - from core.nodes.exact_nodes import ExactModelNode - - # Create a mock node to test properties - class MockModelNode: - def __init__(self): - self._properties = { - 'model_path': '/path/to/model.nef', - 'scpu_fw_path': '/path/to/scpu.bin', - 'ncpu_fw_path': '/path/to/ncpu.bin', - 'dongle_series': '520', - 'num_dongles': 1, - 'port_id': '28,32', - 'upload_fw': True - } - - def get_property(self, prop_name): - return self._properties.get(prop_name) - - # Test that all required properties are present - mock_node = MockModelNode() - required_props = ['model_path', 'scpu_fw_path', 'ncpu_fw_path', 'dongle_series', 'num_dongles', 'port_id', 'upload_fw'] - - print("Checking required properties:") - for prop in required_props: - value = mock_node.get_property(prop) - print(f" ✅ {prop}: {value}") - - print("\n✅ Model Node Properties Test PASSED") - return True - - except Exception as e: - print(f"❌ Model Node Properties Test FAILED: {e}") - import traceback - traceback.print_exc() - return False - -def test_deployment_dialog_structure(): - """Test that DeploymentDialog has terminal output display""" - print("\n🧪 Testing Deployment Dialog Structure") - print("=" * 40) - - try: - from ui.dialogs.deployment import DeploymentDialog, DeploymentWorker - - # Test that DeploymentWorker has terminal_output signal - worker_signals = [signal for signal in dir(DeploymentWorker) if not signal.startswith('_')] - print("DeploymentWorker signals:") - for signal in worker_signals: - if 'signal' in signal.lower() or signal in ['terminal_output', 'frame_updated', 'result_updated']: - print(f" ✅ {signal}") - - # Check if terminal_output signal exists - if hasattr(DeploymentWorker, 'terminal_output'): - print(" ✅ terminal_output signal found") - else: - print(" ❌ terminal_output signal missing") - return False - - print("\n✅ Deployment Dialog Structure Test PASSED") - return True - - except Exception as e: - print(f"❌ Deployment Dialog Structure Test FAILED: {e}") - import traceback - traceback.print_exc() - return False - -def main(): - """Run all tests""" - print("🚀 TESTING MODIFICATIONS") - print("=" * 50) - - # Don't need GUI for these tests - results = [] - - # Test 1: Model node properties - results.append(test_model_node_properties()) - - # Test 2: Deployment dialog structure - results.append(test_deployment_dialog_structure()) - - # Summary - print("\n" + "=" * 50) - print("📊 TEST RESULTS SUMMARY") - print("=" * 50) - - if all(results): - print("🎉 ALL TESTS PASSED!") - print("\nModifications successfully implemented:") - print(" ✅ Model node properties panel now includes upload_fw option") - print(" ✅ Terminal output will be displayed in GUI instead of console") - print("\nTo see the changes in action:") - print(" 1. Run: python main.py") - print(" 2. Create a model node and check the Properties tab") - print(" 3. Deploy a pipeline and check the Deployment tab for terminal output") - return True - else: - print("SOME TESTS FAILED") - print("Please check the error messages above") - return False - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_node_detection.py b/cluster4npu_ui/tests/test_node_detection.py deleted file mode 100644 index 10b957f..0000000 --- a/cluster4npu_ui/tests/test_node_detection.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify node detection methods work correctly. -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -# Mock Qt application for testing -import os -os.environ['QT_QPA_PLATFORM'] = 'offscreen' - -# Create a minimal Qt application -from PyQt5.QtWidgets import QApplication -import sys -app = QApplication(sys.argv) - -from core.pipeline import is_model_node, is_input_node, is_output_node, get_stage_count -from core.nodes.model_node import ModelNode -from core.nodes.input_node import InputNode -from core.nodes.output_node import OutputNode -from core.nodes.preprocess_node import PreprocessNode -from core.nodes.postprocess_node import PostprocessNode - - -class MockNodeGraph: - """Mock node graph for testing.""" - def __init__(self): - self.nodes = [] - - def all_nodes(self): - return self.nodes - - def add_node(self, node): - self.nodes.append(node) - - -def test_node_detection(): - """Test node detection methods.""" - print("Testing Node Detection Methods...") - - # Create node instances - input_node = InputNode() - model_node = ModelNode() - output_node = OutputNode() - preprocess_node = PreprocessNode() - postprocess_node = PostprocessNode() - - # Test detection - print(f"Input node detection: {is_input_node(input_node)}") - print(f"Model node detection: {is_model_node(model_node)}") - print(f"Output node detection: {is_output_node(output_node)}") - - # Test cross-detection (should be False) - print(f"Model node detected as input: {is_input_node(model_node)}") - print(f"Input node detected as model: {is_model_node(input_node)}") - print(f"Output node detected as model: {is_model_node(output_node)}") - - # Test with mock graph - graph = MockNodeGraph() - graph.add_node(input_node) - graph.add_node(model_node) - graph.add_node(output_node) - - stage_count = get_stage_count(graph) - print(f"Stage count: {stage_count}") - - # Add another model node - model_node2 = ModelNode() - graph.add_node(model_node2) - - stage_count2 = get_stage_count(graph) - print(f"Stage count after adding second model: {stage_count2}") - - assert stage_count == 1, f"Expected 1 stage, got {stage_count}" - assert stage_count2 == 2, f"Expected 2 stages, got {stage_count2}" - - print("✓ Node detection tests passed") - - -def test_node_properties(): - """Test node properties for detection.""" - print("\nTesting Node Properties...") - - model_node = ModelNode() - print(f"Model node type: {type(model_node)}") - print(f"Model node identifier: {getattr(model_node, '__identifier__', 'None')}") - print(f"Model node NODE_NAME: {getattr(model_node, 'NODE_NAME', 'None')}") - print(f"Has get_inference_config: {hasattr(model_node, 'get_inference_config')}") - - input_node = InputNode() - print(f"Input node type: {type(input_node)}") - print(f"Input node identifier: {getattr(input_node, '__identifier__', 'None')}") - print(f"Input node NODE_NAME: {getattr(input_node, 'NODE_NAME', 'None')}") - print(f"Has get_input_config: {hasattr(input_node, 'get_input_config')}") - - output_node = OutputNode() - print(f"Output node type: {type(output_node)}") - print(f"Output node identifier: {getattr(output_node, '__identifier__', 'None')}") - print(f"Output node NODE_NAME: {getattr(output_node, 'NODE_NAME', 'None')}") - print(f"Has get_output_config: {hasattr(output_node, 'get_output_config')}") - - -def main(): - """Run all tests.""" - print("Running Node Detection Tests...") - print("=" * 50) - - try: - test_node_properties() - test_node_detection() - - print("\n" + "=" * 50) - print("All tests passed! ✓") - - except Exception as e: - print(f"\n❌ Test failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_pipeline_editor.py b/cluster4npu_ui/tests/test_pipeline_editor.py deleted file mode 100644 index 82be498..0000000 --- a/cluster4npu_ui/tests/test_pipeline_editor.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify the pipeline editor functionality. -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -# Set up Qt environment -os.environ['QT_QPA_PLATFORM'] = 'offscreen' - -from PyQt5.QtWidgets import QApplication -from PyQt5.QtCore import QTimer - -# Create Qt application -app = QApplication(sys.argv) - -# Import after Qt setup -from ui.windows.pipeline_editor import PipelineEditor - - -def test_pipeline_editor(): - """Test the pipeline editor functionality.""" - print("Testing Pipeline Editor...") - - # Create editor - editor = PipelineEditor() - - # Test initial state - initial_count = editor.get_current_stage_count() - print(f"Initial stage count: {initial_count}") - assert initial_count == 0, f"Expected 0 stages initially, got {initial_count}" - - # Test adding nodes (if NodeGraphQt is available) - if hasattr(editor, 'node_graph') and editor.node_graph: - print("NodeGraphQt is available, testing node addition...") - - # Add input node - editor.add_input_node() - - # Add model node - editor.add_model_node() - - # Add output node - editor.add_output_node() - - # Wait for analysis to complete - QTimer.singleShot(1000, lambda: check_final_count(editor)) - - # Run event loop briefly - QTimer.singleShot(1500, app.quit) - app.exec_() - - else: - print("NodeGraphQt not available, skipping node addition tests") - - print("✓ Pipeline editor test completed") - - -def check_final_count(editor): - """Check final stage count after adding nodes.""" - final_count = editor.get_current_stage_count() - print(f"Final stage count: {final_count}") - - if final_count == 1: - print("✓ Stage count correctly updated to 1") - else: - print(f"❌ Expected 1 stage, got {final_count}") - - # Get pipeline summary - summary = editor.get_pipeline_summary() - print(f"Pipeline summary: {summary}") - - -def main(): - """Run all tests.""" - print("Running Pipeline Editor Tests...") - print("=" * 50) - - try: - test_pipeline_editor() - - print("\n" + "=" * 50) - print("All tests completed! ✓") - - except Exception as e: - print(f"\n❌ Test failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_stage_function.py b/cluster4npu_ui/tests/test_stage_function.py deleted file mode 100644 index e6db422..0000000 --- a/cluster4npu_ui/tests/test_stage_function.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the stage function implementation. - -This script tests the stage detection and counting functionality without requiring -the full NodeGraphQt dependency. -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -# Test the core pipeline functions directly -def get_stage_count(node_graph): - """Mock version of get_stage_count for testing.""" - if not node_graph: - return 0 - - all_nodes = node_graph.all_nodes() - model_nodes = [node for node in all_nodes if 'model' in node.node_type] - - return len(model_nodes) - -def get_pipeline_summary(node_graph): - """Mock version of get_pipeline_summary for testing.""" - if not node_graph: - return {'stage_count': 0, 'valid': False, 'error': 'No pipeline graph'} - - all_nodes = node_graph.all_nodes() - model_nodes = [node for node in all_nodes if 'model' in node.node_type] - input_nodes = [node for node in all_nodes if 'input' in node.node_type] - output_nodes = [node for node in all_nodes if 'output' in node.node_type] - - # Basic validation - valid = len(input_nodes) > 0 and len(output_nodes) > 0 and len(model_nodes) > 0 - error = None - - if not input_nodes: - error = "No input nodes found" - elif not output_nodes: - error = "No output nodes found" - elif not model_nodes: - error = "No model nodes found" - - return { - 'stage_count': len(model_nodes), - 'valid': valid, - 'error': error, - 'total_nodes': len(all_nodes), - 'input_nodes': len(input_nodes), - 'output_nodes': len(output_nodes), - 'model_nodes': len(model_nodes), - 'preprocess_nodes': len([n for n in all_nodes if 'preprocess' in n.node_type]), - 'postprocess_nodes': len([n for n in all_nodes if 'postprocess' in n.node_type]), - 'stages': [] - } - - -class MockPort: - """Mock port for testing without NodeGraphQt.""" - def __init__(self, node, port_type): - self.node_ref = node - self.port_type = port_type - self.connections = [] - - def node(self): - return self.node_ref - - def connected_inputs(self): - return [conn for conn in self.connections if conn.port_type == 'input'] - - def connected_outputs(self): - return [conn for conn in self.connections if conn.port_type == 'output'] - - -class MockNode: - """Mock node for testing without NodeGraphQt.""" - def __init__(self, node_type): - self.node_type = node_type - self.input_ports = [] - self.output_ports = [] - self.node_name = f"{node_type}_node" - self.node_id = f"{node_type}_{id(self)}" - - def inputs(self): - return self.input_ports - - def outputs(self): - return self.output_ports - - def add_input(self, name): - port = MockPort(self, 'input') - self.input_ports.append(port) - return port - - def add_output(self, name): - port = MockPort(self, 'output') - self.output_ports.append(port) - return port - - def name(self): - return self.node_name - - -class MockNodeGraph: - """Mock node graph for testing without NodeGraphQt.""" - def __init__(self): - self.nodes = [] - - def all_nodes(self): - return self.nodes - - def add_node(self, node): - self.nodes.append(node) - - def connect_nodes(self, output_node, input_node): - """Connect output of first node to input of second node.""" - output_port = output_node.add_output('output') - input_port = input_node.add_input('input') - - # Create bidirectional connection - output_port.connections.append(input_port) - input_port.connections.append(output_port) - - -def create_mock_pipeline(): - """Create a mock pipeline for testing.""" - graph = MockNodeGraph() - - # Create nodes - input_node = MockNode('input') - preprocess_node = MockNode('preprocess') - model_node1 = MockNode('model') - postprocess_node1 = MockNode('postprocess') - model_node2 = MockNode('model') - postprocess_node2 = MockNode('postprocess') - output_node = MockNode('output') - - # Add nodes to graph - for node in [input_node, preprocess_node, model_node1, postprocess_node1, - model_node2, postprocess_node2, output_node]: - graph.add_node(node) - - # Connect nodes: input -> preprocess -> model1 -> postprocess1 -> model2 -> postprocess2 -> output - graph.connect_nodes(input_node, preprocess_node) - graph.connect_nodes(preprocess_node, model_node1) - graph.connect_nodes(model_node1, postprocess_node1) - graph.connect_nodes(postprocess_node1, model_node2) - graph.connect_nodes(model_node2, postprocess_node2) - graph.connect_nodes(postprocess_node2, output_node) - - return graph - - -def test_stage_count(): - """Test the stage counting functionality.""" - print("Testing Stage Count Function...") - - # Create mock pipeline - graph = create_mock_pipeline() - - # Count stages - should be 2 (2 model nodes) - stage_count = get_stage_count(graph) - print(f"Stage count: {stage_count}") - - # Expected: 2 stages (2 model nodes) - assert stage_count == 2, f"Expected 2 stages, got {stage_count}" - print("✓ Stage count test passed") - - -def test_empty_pipeline(): - """Test with empty pipeline.""" - print("\nTesting Empty Pipeline...") - - empty_graph = MockNodeGraph() - stage_count = get_stage_count(empty_graph) - print(f"Empty pipeline stage count: {stage_count}") - - assert stage_count == 0, f"Expected 0 stages, got {stage_count}" - print("✓ Empty pipeline test passed") - - -def test_single_stage(): - """Test with single stage pipeline.""" - print("\nTesting Single Stage Pipeline...") - - graph = MockNodeGraph() - - # Create simple pipeline: input -> model -> output - input_node = MockNode('input') - model_node = MockNode('model') - output_node = MockNode('output') - - graph.add_node(input_node) - graph.add_node(model_node) - graph.add_node(output_node) - - graph.connect_nodes(input_node, model_node) - graph.connect_nodes(model_node, output_node) - - stage_count = get_stage_count(graph) - print(f"Single stage pipeline count: {stage_count}") - - assert stage_count == 1, f"Expected 1 stage, got {stage_count}" - print("✓ Single stage test passed") - - -def test_pipeline_summary(): - """Test the pipeline summary function.""" - print("\nTesting Pipeline Summary...") - - graph = create_mock_pipeline() - - # Get summary - summary = get_pipeline_summary(graph) - - print(f"Pipeline summary: {summary}") - - # Check basic structure - assert 'stage_count' in summary, "Missing stage_count in summary" - assert 'valid' in summary, "Missing valid in summary" - assert 'total_nodes' in summary, "Missing total_nodes in summary" - - # Check values - assert summary['stage_count'] == 2, f"Expected 2 stages, got {summary['stage_count']}" - assert summary['total_nodes'] == 7, f"Expected 7 nodes, got {summary['total_nodes']}" - - print("✓ Pipeline summary test passed") - - -def main(): - """Run all tests.""" - print("Running Stage Function Tests...") - print("=" * 50) - - try: - test_stage_count() - test_empty_pipeline() - test_single_stage() - test_pipeline_summary() - - print("\n" + "=" * 50) - print("All tests passed! ✓") - - except Exception as e: - print(f"\n❌ Test failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_stage_improvements.py b/cluster4npu_ui/tests/test_stage_improvements.py deleted file mode 100644 index 7de70b4..0000000 --- a/cluster4npu_ui/tests/test_stage_improvements.py +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for stage calculation improvements and UI changes. - -Tests the improvements made to stage calculation logic and UI layout. -""" - -import sys -import os - -# Add parent directory to path -current_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(current_dir) -sys.path.insert(0, parent_dir) - -def test_stage_calculation_improvements(): - """Test the improved stage calculation logic.""" - print("🔍 Testing stage calculation improvements...") - - try: - from cluster4npu_ui.core.pipeline import analyze_pipeline_stages, is_node_connected_to_pipeline - print("✅ Pipeline analysis functions imported successfully") - - # Test that stage calculation functions exist - functions_to_test = [ - 'analyze_pipeline_stages', - 'is_node_connected_to_pipeline', - 'has_path_between_nodes' - ] - - import cluster4npu_ui.core.pipeline as pipeline_module - - for func_name in functions_to_test: - if hasattr(pipeline_module, func_name): - print(f"✅ Function {func_name} exists") - else: - print(f"❌ Function {func_name} missing") - return False - - return True - except Exception as e: - print(f"❌ Stage calculation test failed: {e}") - return False - -def test_ui_improvements(): - """Test UI layout improvements.""" - print("\n🔍 Testing UI improvements...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard, StageCountWidget - - # Test new methods exist - ui_methods = [ - 'create_status_bar_widget', - ] - - for method_name in ui_methods: - if hasattr(IntegratedPipelineDashboard, method_name): - print(f"✅ Method {method_name} exists") - else: - print(f"❌ Method {method_name} missing") - return False - - # Test StageCountWidget compact design - from PyQt5.QtWidgets import QApplication - app = QApplication.instance() - if app is None: - app = QApplication([]) - - widget = StageCountWidget() - print("✅ StageCountWidget created successfully") - - # Test compact size - size = widget.size() - print(f"✅ StageCountWidget size: {size.width()}x{size.height()}") - - # Test status updates with new styling - widget.update_stage_count(0, True, "") - print("✅ Zero stages test (warning state)") - - widget.update_stage_count(2, True, "") - print("✅ Valid stages test (success state)") - - widget.update_stage_count(1, False, "Test error") - print("✅ Error state test") - - return True - except Exception as e: - print(f"❌ UI improvements test failed: {e}") - import traceback - traceback.print_exc() - return False - -def test_removed_functionality(): - """Test that deprecated functionality has been properly removed.""" - print("\n🔍 Testing removed functionality...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # These methods should not exist anymore - removed_methods = [ - 'create_stage_config_panel', # Removed - stage info moved to status bar - 'update_stage_configs', # Removed - no longer needed - ] - - for method_name in removed_methods: - if hasattr(IntegratedPipelineDashboard, method_name): - print(f"⚠️ Method {method_name} still exists (may be OK if empty)") - else: - print(f"✅ Method {method_name} properly removed") - - return True - except Exception as e: - print(f"❌ Removed functionality test failed: {e}") - return False - -def test_new_status_bar(): - """Test the new status bar functionality.""" - print("\n🔍 Testing status bar functionality...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - from PyQt5.QtWidgets import QApplication - - app = QApplication.instance() - if app is None: - app = QApplication([]) - - # We can't easily test the full dashboard creation without NodeGraphQt - # But we can test that the methods exist - dashboard = IntegratedPipelineDashboard - - if hasattr(dashboard, 'create_status_bar_widget'): - print("✅ Status bar widget creation method exists") - else: - print("❌ Status bar widget creation method missing") - return False - - print("✅ Status bar functionality test passed") - return True - except Exception as e: - print(f"❌ Status bar test failed: {e}") - return False - -def run_all_tests(): - """Run all improvement tests.""" - print("🚀 Starting stage calculation and UI improvement tests...\n") - - tests = [ - test_stage_calculation_improvements, - test_ui_improvements, - test_removed_functionality, - test_new_status_bar - ] - - passed = 0 - total = len(tests) - - for test_func in tests: - try: - if test_func(): - passed += 1 - else: - print(f"❌ Test {test_func.__name__} failed") - except Exception as e: - print(f"❌ Test {test_func.__name__} raised exception: {e}") - - print(f"\n📊 Test Results: {passed}/{total} tests passed") - - if passed == total: - print("🎉 All improvement tests passed! Stage calculation and UI changes work correctly.") - print("\n📋 Summary of improvements:") - print(" ✅ Stage calculation now requires model nodes to be connected between input and output") - print(" ✅ Toolbar moved from top to left panel") - print(" ✅ Redundant stage information removed from right panel") - print(" ✅ Stage count moved to bottom status bar with compact design") - print(" ✅ Status bar shows both stage count and node statistics") - return True - else: - print("❌ Some improvement tests failed.") - return False - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_status_bar_fixes.py b/cluster4npu_ui/tests/test_status_bar_fixes.py deleted file mode 100644 index 0daddc1..0000000 --- a/cluster4npu_ui/tests/test_status_bar_fixes.py +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for status bar fixes: stage count display and UI cleanup. - -Tests the fixes for stage count visibility and NodeGraphQt UI cleanup. -""" - -import sys -import os - -# Add parent directory to path -current_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(current_dir) -sys.path.insert(0, parent_dir) - -def test_stage_count_visibility(): - """Test stage count widget visibility and updates.""" - print("🔍 Testing stage count widget visibility...") - - try: - from cluster4npu_ui.ui.windows.dashboard import StageCountWidget - from PyQt5.QtWidgets import QApplication - - app = QApplication.instance() - if app is None: - app = QApplication([]) - - # Create widget - widget = StageCountWidget() - print("✅ StageCountWidget created successfully") - - # Test visibility - if widget.isVisible(): - print("✅ Widget is visible") - else: - print("❌ Widget is not visible") - return False - - if widget.stage_label.isVisible(): - print("✅ Stage label is visible") - else: - print("❌ Stage label is not visible") - return False - - # Test size - size = widget.size() - if size.width() == 120 and size.height() == 22: - print(f"✅ Correct size: {size.width()}x{size.height()}") - else: - print(f"⚠️ Size: {size.width()}x{size.height()}") - - # Test font size - font = widget.stage_label.font() - if font.pointSize() == 10: - print(f"✅ Font size: {font.pointSize()}pt") - else: - print(f"⚠️ Font size: {font.pointSize()}pt") - - return True - except Exception as e: - print(f"❌ Stage count visibility test failed: {e}") - return False - -def test_stage_count_updates(): - """Test stage count widget updates with different states.""" - print("\n🔍 Testing stage count updates...") - - try: - from cluster4npu_ui.ui.windows.dashboard import StageCountWidget - from PyQt5.QtWidgets import QApplication - - app = QApplication.instance() - if app is None: - app = QApplication([]) - - widget = StageCountWidget() - - # Test zero stages (warning state) - widget.update_stage_count(0, True, "") - if "⚠️" in widget.stage_label.text(): - print("✅ Zero stages warning display") - else: - print(f"⚠️ Zero stages text: {widget.stage_label.text()}") - - # Test valid stages (success state) - widget.update_stage_count(2, True, "") - if "✅" in widget.stage_label.text() and "2" in widget.stage_label.text(): - print("✅ Valid stages success display") - else: - print(f"⚠️ Valid stages text: {widget.stage_label.text()}") - - # Test error state - widget.update_stage_count(1, False, "Test error") - if "❌" in widget.stage_label.text(): - print("✅ Error state display") - else: - print(f"⚠️ Error state text: {widget.stage_label.text()}") - - return True - except Exception as e: - print(f"❌ Stage count updates test failed: {e}") - return False - -def test_ui_cleanup_functionality(): - """Test UI cleanup functionality.""" - print("\n🔍 Testing UI cleanup functionality...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check if cleanup method exists - if hasattr(IntegratedPipelineDashboard, 'cleanup_node_graph_ui'): - print("✅ cleanup_node_graph_ui method exists") - else: - print("❌ cleanup_node_graph_ui method missing") - return False - - # Check if setup includes cleanup timer - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.__init__) - if 'ui_cleanup_timer' in source: - print("✅ UI cleanup timer setup found") - else: - print("⚠️ UI cleanup timer setup not found") - - # Check cleanup method implementation - source = inspect.getsource(IntegratedPipelineDashboard.cleanup_node_graph_ui) - if 'bottom-left' in source and 'setVisible(False)' in source: - print("✅ Cleanup method has bottom-left widget hiding logic") - else: - print("⚠️ Cleanup method logic may need verification") - - return True - except Exception as e: - print(f"❌ UI cleanup test failed: {e}") - return False - -def test_status_bar_integration(): - """Test status bar integration.""" - print("\n🔍 Testing status bar integration...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check if create_status_bar_widget exists - if hasattr(IntegratedPipelineDashboard, 'create_status_bar_widget'): - print("✅ create_status_bar_widget method exists") - else: - print("❌ create_status_bar_widget method missing") - return False - - # Check if setup_integrated_ui includes global status bar - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.setup_integrated_ui) - if 'global_status_bar' in source: - print("✅ Global status bar integration found") - else: - print("❌ Global status bar integration missing") - return False - - # Check if analyze_pipeline has debug output - source = inspect.getsource(IntegratedPipelineDashboard.analyze_pipeline) - if 'Updating stage count widget' in source: - print("✅ Debug output for stage count updates found") - else: - print("⚠️ Debug output not found") - - return True - except Exception as e: - print(f"❌ Status bar integration test failed: {e}") - return False - -def test_node_graph_configuration(): - """Test node graph configuration for UI cleanup.""" - print("\n🔍 Testing node graph configuration...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check if setup_node_graph has UI cleanup code - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.setup_node_graph) - - cleanup_checks = [ - 'set_logo_visible', - 'set_nav_widget_visible', - 'set_minimap_visible', - 'findChildren', - 'setVisible(False)' - ] - - found_cleanup = [] - for check in cleanup_checks: - if check in source: - found_cleanup.append(check) - - if len(found_cleanup) >= 3: - print(f"✅ UI cleanup code found: {', '.join(found_cleanup)}") - else: - print(f"⚠️ Limited cleanup code found: {', '.join(found_cleanup)}") - - return True - except Exception as e: - print(f"❌ Node graph configuration test failed: {e}") - return False - -def run_all_tests(): - """Run all status bar fix tests.""" - print("🚀 Starting status bar fixes tests...\n") - - tests = [ - test_stage_count_visibility, - test_stage_count_updates, - test_ui_cleanup_functionality, - test_status_bar_integration, - test_node_graph_configuration - ] - - passed = 0 - total = len(tests) - - for test_func in tests: - try: - if test_func(): - passed += 1 - else: - print(f"❌ Test {test_func.__name__} failed") - except Exception as e: - print(f"❌ Test {test_func.__name__} raised exception: {e}") - - print(f"\n📊 Test Results: {passed}/{total} tests passed") - - if passed == total: - print("🎉 All status bar fixes tests passed!") - print("\n📋 Summary of fixes:") - print(" ✅ Stage count widget visibility improved") - print(" ✅ Stage count updates with proper status icons") - print(" ✅ UI cleanup functionality for NodeGraphQt elements") - print(" ✅ Global status bar integration") - print(" ✅ Node graph configuration for UI cleanup") - print("\n💡 The fixes should resolve:") - print(" • Stage count not displaying in status bar") - print(" • Left-bottom corner horizontal bar visibility") - return True - else: - print("❌ Some status bar fixes tests failed.") - return False - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_topology.py b/cluster4npu_ui/tests/test_topology.py deleted file mode 100644 index f65fdfa..0000000 --- a/cluster4npu_ui/tests/test_topology.py +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env python3 -""" -🚀 智慧拓撲排序算法演示 - -這個演示展示了我們的進階pipeline拓撲分析和優化算法: -- 自動依賴關係分析 -- 循環檢測和解決 -- 並行執行優化 -- 關鍵路徑分析 -- 性能指標計算 - -適合進度報告展示! -""" - -import json -from mflow_converter import MFlowConverter - -def create_demo_pipeline() -> dict: - """創建一個複雜的多階段pipeline用於演示""" - return { - "project_name": "Advanced Multi-Stage Fire Detection Pipeline", - "description": "Demonstrates intelligent topology sorting with parallel stages", - "nodes": [ - # Input Node - { - "id": "input_001", - "name": "RGB Camera Input", - "type": "ExactInputNode", - "pos": [100, 200], - "properties": { - "source_type": "Camera", - "device_id": 0, - "resolution": "1920x1080", - "fps": 30 - } - }, - - # Parallel Feature Extraction Stages - { - "id": "model_rgb_001", - "name": "RGB Feature Extractor", - "type": "ExactModelNode", - "pos": [300, 100], - "properties": { - "model_path": "rgb_features.nef", - "scpu_fw_path": "fw_scpu.bin", - "ncpu_fw_path": "fw_ncpu.bin", - "dongle_series": "520", - "port_id": "28,30" - } - }, - - { - "id": "model_edge_002", - "name": "Edge Feature Extractor", - "type": "ExactModelNode", - "pos": [300, 200], - "properties": { - "model_path": "edge_features.nef", - "scpu_fw_path": "fw_scpu.bin", - "ncpu_fw_path": "fw_ncpu.bin", - "dongle_series": "520", - "port_id": "32,34" - } - }, - - { - "id": "model_thermal_003", - "name": "Thermal Feature Extractor", - "type": "ExactModelNode", - "pos": [300, 300], - "properties": { - "model_path": "thermal_features.nef", - "scpu_fw_path": "fw_scpu.bin", - "ncpu_fw_path": "fw_ncpu.bin", - "dongle_series": "520", - "port_id": "36,38" - } - }, - - # Intermediate Processing Stages - { - "id": "model_fusion_004", - "name": "Feature Fusion", - "type": "ExactModelNode", - "pos": [500, 150], - "properties": { - "model_path": "feature_fusion.nef", - "scpu_fw_path": "fw_scpu.bin", - "ncpu_fw_path": "fw_ncpu.bin", - "dongle_series": "720", - "port_id": "40,42" - } - }, - - { - "id": "model_attention_005", - "name": "Attention Mechanism", - "type": "ExactModelNode", - "pos": [500, 250], - "properties": { - "model_path": "attention.nef", - "scpu_fw_path": "fw_scpu.bin", - "ncpu_fw_path": "fw_ncpu.bin", - "dongle_series": "720", - "port_id": "44,46" - } - }, - - # Final Classification Stage - { - "id": "model_classifier_006", - "name": "Fire Classifier", - "type": "ExactModelNode", - "pos": [700, 200], - "properties": { - "model_path": "fire_classifier.nef", - "scpu_fw_path": "fw_scpu.bin", - "ncpu_fw_path": "fw_ncpu.bin", - "dongle_series": "720", - "port_id": "48,50" - } - }, - - # Output Node - { - "id": "output_007", - "name": "Detection Output", - "type": "ExactOutputNode", - "pos": [900, 200], - "properties": { - "output_type": "Stream", - "format": "JSON", - "destination": "tcp://localhost:5555" - } - } - ], - - "connections": [ - # Input to parallel feature extractors - {"output_node": "input_001", "output_port": "output", "input_node": "model_rgb_001", "input_port": "input"}, - {"output_node": "input_001", "output_port": "output", "input_node": "model_edge_002", "input_port": "input"}, - {"output_node": "input_001", "output_port": "output", "input_node": "model_thermal_003", "input_port": "input"}, - - # Feature extractors to fusion - {"output_node": "model_rgb_001", "output_port": "output", "input_node": "model_fusion_004", "input_port": "input"}, - {"output_node": "model_edge_002", "output_port": "output", "input_node": "model_fusion_004", "input_port": "input"}, - {"output_node": "model_thermal_003", "output_port": "output", "input_node": "model_attention_005", "input_port": "input"}, - - # Intermediate stages to classifier - {"output_node": "model_fusion_004", "output_port": "output", "input_node": "model_classifier_006", "input_port": "input"}, - {"output_node": "model_attention_005", "output_port": "output", "input_node": "model_classifier_006", "input_port": "input"}, - - # Classifier to output - {"output_node": "model_classifier_006", "output_port": "output", "input_node": "output_007", "input_port": "input"} - ], - - "version": "1.0" - } - -def demo_simple_pipeline(): - """演示簡單的線性pipeline""" - print("🎯 DEMO 1: Simple Linear Pipeline") - print("="*50) - - simple_pipeline = { - "project_name": "Simple Linear Pipeline", - "nodes": [ - {"id": "model_001", "name": "Detection", "type": "ExactModelNode", "properties": {"model_path": "detect.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "28"}}, - {"id": "model_002", "name": "Classification", "type": "ExactModelNode", "properties": {"model_path": "classify.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "30"}}, - {"id": "model_003", "name": "Verification", "type": "ExactModelNode", "properties": {"model_path": "verify.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "32"}} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_002"}, - {"output_node": "model_002", "input_node": "model_003"} - ] - } - - converter = MFlowConverter() - config = converter._convert_mflow_to_config(simple_pipeline) - print("\n") - -def demo_parallel_pipeline(): - """演示並行pipeline""" - print("🎯 DEMO 2: Parallel Processing Pipeline") - print("="*50) - - parallel_pipeline = { - "project_name": "Parallel Processing Pipeline", - "nodes": [ - {"id": "model_001", "name": "RGB Processor", "type": "ExactModelNode", "properties": {"model_path": "rgb.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "28"}}, - {"id": "model_002", "name": "IR Processor", "type": "ExactModelNode", "properties": {"model_path": "ir.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "30"}}, - {"id": "model_003", "name": "Depth Processor", "type": "ExactModelNode", "properties": {"model_path": "depth.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "32"}}, - {"id": "model_004", "name": "Fusion Engine", "type": "ExactModelNode", "properties": {"model_path": "fusion.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "34"}} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_004"}, - {"output_node": "model_002", "input_node": "model_004"}, - {"output_node": "model_003", "input_node": "model_004"} - ] - } - - converter = MFlowConverter() - config = converter._convert_mflow_to_config(parallel_pipeline) - print("\n") - -def demo_complex_pipeline(): - """演示複雜的多層級pipeline""" - print("🎯 DEMO 3: Complex Multi-Level Pipeline") - print("="*50) - - complex_pipeline = create_demo_pipeline() - - converter = MFlowConverter() - config = converter._convert_mflow_to_config(complex_pipeline) - - # 顯示額外的配置信息 - print("🔧 Generated Pipeline Configuration:") - print(f" • Stage Configs: {len(config.stage_configs)}") - print(f" • Input Config: {config.input_config.get('source_type', 'Unknown')}") - print(f" • Output Config: {config.output_config.get('format', 'Unknown')}") - print("\n") - -def demo_cycle_detection(): - """演示循環檢測和解決""" - print("🎯 DEMO 4: Cycle Detection & Resolution") - print("="*50) - - # 創建一個有循環的pipeline - cycle_pipeline = { - "project_name": "Pipeline with Cycles (Testing)", - "nodes": [ - {"id": "model_A", "name": "Model A", "type": "ExactModelNode", "properties": {"model_path": "a.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "28"}}, - {"id": "model_B", "name": "Model B", "type": "ExactModelNode", "properties": {"model_path": "b.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "30"}}, - {"id": "model_C", "name": "Model C", "type": "ExactModelNode", "properties": {"model_path": "c.nef", "scpu_fw_path": "fw_scpu.bin", "ncpu_fw_path": "fw_ncpu.bin", "port_id": "32"}} - ], - "connections": [ - {"output_node": "model_A", "input_node": "model_B"}, - {"output_node": "model_B", "input_node": "model_C"}, - {"output_node": "model_C", "input_node": "model_A"} # Creates cycle! - ] - } - - converter = MFlowConverter() - config = converter._convert_mflow_to_config(cycle_pipeline) - print("\n") - -def demo_performance_analysis(): - """演示性能分析功能""" - print("🎯 DEMO 5: Performance Analysis") - print("="*50) - - # 使用之前創建的複雜pipeline - complex_pipeline = create_demo_pipeline() - - converter = MFlowConverter() - config = converter._convert_mflow_to_config(complex_pipeline) - - # 驗證配置 - is_valid, errors = converter.validate_config(config) - - print("🔍 Configuration Validation:") - if is_valid: - print(" ✅ All configurations are valid!") - else: - print(" ⚠️ Configuration issues found:") - for error in errors[:3]: # Show first 3 errors - print(f" - {error}") - - print(f"\n📦 Ready for InferencePipeline Creation:") - print(f" • Total Stages: {len(config.stage_configs)}") - print(f" • Pipeline Name: {config.pipeline_name}") - print(f" • Preprocessing Configs: {len(config.preprocessing_configs)}") - print(f" • Postprocessing Configs: {len(config.postprocessing_configs)}") - print("\n") - -def main(): - """主演示函數""" - print("🚀 INTELLIGENT PIPELINE TOPOLOGY SORTING DEMONSTRATION") - print("="*60) - print("This demo showcases our advanced pipeline analysis capabilities:") - print("• Automatic dependency resolution") - print("• Parallel execution optimization") - print("• Cycle detection and prevention") - print("• Critical path analysis") - print("• Performance metrics calculation") - print("="*60 + "\n") - - try: - # 運行所有演示 - demo_simple_pipeline() - demo_parallel_pipeline() - demo_complex_pipeline() - demo_cycle_detection() - demo_performance_analysis() - - print("🎉 ALL DEMONSTRATIONS COMPLETED SUCCESSFULLY!") - print("Ready for production deployment and progress reporting! 🚀") - - except Exception as e: - print(f"Demo error: {e}") - import traceback - traceback.print_exc() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_topology_standalone.py b/cluster4npu_ui/tests/test_topology_standalone.py deleted file mode 100644 index 60e606f..0000000 --- a/cluster4npu_ui/tests/test_topology_standalone.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env python3 -""" -🚀 智慧拓撲排序算法演示 (獨立版本) - -不依賴外部模組,純粹展示拓撲排序算法的核心功能 -""" - -import json -from typing import List, Dict, Any, Tuple -from collections import deque - -class TopologyDemo: - """演示拓撲排序算法的類別""" - - def __init__(self): - self.stage_order = [] - - def analyze_pipeline(self, pipeline_data: Dict[str, Any]): - """分析pipeline並執行拓撲排序""" - print("🔍 Starting intelligent pipeline topology analysis...") - - # 提取模型節點 - model_nodes = [node for node in pipeline_data.get('nodes', []) - if 'model' in node.get('type', '').lower()] - connections = pipeline_data.get('connections', []) - - if not model_nodes: - print(" ⚠️ No model nodes found!") - return [] - - # 建立依賴圖 - dependency_graph = self._build_dependency_graph(model_nodes, connections) - - # 檢測循環 - cycles = self._detect_cycles(dependency_graph) - if cycles: - print(f" ⚠️ Found {len(cycles)} cycles!") - dependency_graph = self._resolve_cycles(dependency_graph, cycles) - - # 執行拓撲排序 - sorted_stages = self._topological_sort_with_optimization(dependency_graph, model_nodes) - - # 計算指標 - metrics = self._calculate_pipeline_metrics(sorted_stages, dependency_graph) - self._display_pipeline_analysis(sorted_stages, metrics) - - return sorted_stages - - def _build_dependency_graph(self, model_nodes: List[Dict], connections: List[Dict]) -> Dict[str, Dict]: - """建立依賴圖""" - print(" 📊 Building dependency graph...") - - graph = {} - for node in model_nodes: - graph[node['id']] = { - 'node': node, - 'dependencies': set(), - 'dependents': set(), - 'depth': 0 - } - - # 分析連接 - for conn in connections: - output_node_id = conn.get('output_node') - input_node_id = conn.get('input_node') - - if output_node_id in graph and input_node_id in graph: - graph[input_node_id]['dependencies'].add(output_node_id) - graph[output_node_id]['dependents'].add(input_node_id) - - dep_count = sum(len(data['dependencies']) for data in graph.values()) - print(f" ✅ Graph built: {len(graph)} nodes, {dep_count} dependencies") - return graph - - def _detect_cycles(self, graph: Dict[str, Dict]) -> List[List[str]]: - """檢測循環""" - print(" 🔍 Checking for dependency cycles...") - - cycles = [] - visited = set() - rec_stack = set() - - def dfs_cycle_detect(node_id, path): - if node_id in rec_stack: - cycle_start = path.index(node_id) - cycle = path[cycle_start:] + [node_id] - cycles.append(cycle) - return True - - if node_id in visited: - return False - - visited.add(node_id) - rec_stack.add(node_id) - path.append(node_id) - - for dependent in graph[node_id]['dependents']: - if dfs_cycle_detect(dependent, path): - return True - - path.pop() - rec_stack.remove(node_id) - return False - - for node_id in graph: - if node_id not in visited: - dfs_cycle_detect(node_id, []) - - if cycles: - print(f" ⚠️ Found {len(cycles)} cycles") - else: - print(" ✅ No cycles detected") - - return cycles - - def _resolve_cycles(self, graph: Dict[str, Dict], cycles: List[List[str]]) -> Dict[str, Dict]: - """解決循環""" - print(" 🔧 Resolving dependency cycles...") - - for cycle in cycles: - node_names = [graph[nid]['node']['name'] for nid in cycle] - print(f" Breaking cycle: {' → '.join(node_names)}") - - if len(cycle) >= 2: - node_to_break = cycle[-2] - dependent_to_break = cycle[-1] - - graph[dependent_to_break]['dependencies'].discard(node_to_break) - graph[node_to_break]['dependents'].discard(dependent_to_break) - - print(f" 🔗 Broke dependency: {graph[node_to_break]['node']['name']} → {graph[dependent_to_break]['node']['name']}") - - return graph - - def _topological_sort_with_optimization(self, graph: Dict[str, Dict], model_nodes: List[Dict]) -> List[Dict]: - """執行優化的拓撲排序""" - print(" 🎯 Performing optimized topological sort...") - - # 計算深度層級 - self._calculate_depth_levels(graph) - - # 按深度分組 - depth_groups = self._group_by_depth(graph) - - # 排序 - sorted_nodes = [] - for depth in sorted(depth_groups.keys()): - group_nodes = depth_groups[depth] - - group_nodes.sort(key=lambda nid: ( - len(graph[nid]['dependencies']), - -len(graph[nid]['dependents']), - graph[nid]['node']['name'] - )) - - for node_id in group_nodes: - sorted_nodes.append(graph[node_id]['node']) - - print(f" ✅ Sorted {len(sorted_nodes)} stages into {len(depth_groups)} execution levels") - return sorted_nodes - - def _calculate_depth_levels(self, graph: Dict[str, Dict]): - """計算深度層級""" - print(" 📏 Calculating execution depth levels...") - - no_deps = [nid for nid, data in graph.items() if not data['dependencies']] - queue = deque([(nid, 0) for nid in no_deps]) - - while queue: - node_id, depth = queue.popleft() - - if graph[node_id]['depth'] < depth: - graph[node_id]['depth'] = depth - - for dependent in graph[node_id]['dependents']: - queue.append((dependent, depth + 1)) - - def _group_by_depth(self, graph: Dict[str, Dict]) -> Dict[int, List[str]]: - """按深度分組""" - depth_groups = {} - - for node_id, data in graph.items(): - depth = data['depth'] - if depth not in depth_groups: - depth_groups[depth] = [] - depth_groups[depth].append(node_id) - - return depth_groups - - def _calculate_pipeline_metrics(self, sorted_stages: List[Dict], graph: Dict[str, Dict]) -> Dict[str, Any]: - """計算指標""" - print(" 📈 Calculating pipeline metrics...") - - total_stages = len(sorted_stages) - max_depth = max([data['depth'] for data in graph.values()]) + 1 if graph else 1 - - depth_distribution = {} - for data in graph.values(): - depth = data['depth'] - depth_distribution[depth] = depth_distribution.get(depth, 0) + 1 - - max_parallel = max(depth_distribution.values()) if depth_distribution else 1 - critical_path = self._find_critical_path(graph) - - return { - 'total_stages': total_stages, - 'pipeline_depth': max_depth, - 'max_parallel_stages': max_parallel, - 'parallelization_efficiency': (total_stages / max_depth) if max_depth > 0 else 1.0, - 'critical_path_length': len(critical_path), - 'critical_path': critical_path - } - - def _find_critical_path(self, graph: Dict[str, Dict]) -> List[str]: - """找出關鍵路徑""" - longest_path = [] - - def dfs_longest_path(node_id, current_path): - nonlocal longest_path - - current_path.append(node_id) - - if not graph[node_id]['dependents']: - if len(current_path) > len(longest_path): - longest_path = current_path.copy() - else: - for dependent in graph[node_id]['dependents']: - dfs_longest_path(dependent, current_path) - - current_path.pop() - - for node_id, data in graph.items(): - if not data['dependencies']: - dfs_longest_path(node_id, []) - - return longest_path - - def _display_pipeline_analysis(self, sorted_stages: List[Dict], metrics: Dict[str, Any]): - """顯示分析結果""" - print("\n" + "="*60) - print("🚀 INTELLIGENT PIPELINE TOPOLOGY ANALYSIS COMPLETE") - print("="*60) - - print(f"📊 Pipeline Metrics:") - print(f" • Total Stages: {metrics['total_stages']}") - print(f" • Pipeline Depth: {metrics['pipeline_depth']} levels") - print(f" • Max Parallel Stages: {metrics['max_parallel_stages']}") - print(f" • Parallelization Efficiency: {metrics['parallelization_efficiency']:.1%}") - - print(f"\n🎯 Optimized Execution Order:") - for i, stage in enumerate(sorted_stages, 1): - print(f" {i:2d}. {stage['name']} (ID: {stage['id'][:8]}...)") - - if metrics['critical_path']: - print(f"\n⚡ Critical Path ({metrics['critical_path_length']} stages):") - critical_names = [] - for node_id in metrics['critical_path']: - node_name = next((stage['name'] for stage in sorted_stages if stage['id'] == node_id), 'Unknown') - critical_names.append(node_name) - print(f" {' → '.join(critical_names)}") - - print(f"\n💡 Performance Insights:") - if metrics['parallelization_efficiency'] > 0.8: - print(" ✅ Excellent parallelization potential!") - elif metrics['parallelization_efficiency'] > 0.6: - print(" ✨ Good parallelization opportunities available") - else: - print(" ⚠️ Limited parallelization - consider pipeline redesign") - - if metrics['pipeline_depth'] <= 3: - print(" ⚡ Low latency pipeline - great for real-time applications") - elif metrics['pipeline_depth'] <= 6: - print(" ⚖️ Balanced pipeline depth - good throughput/latency trade-off") - else: - print(" 🎯 Deep pipeline - optimized for maximum throughput") - - print("="*60 + "\n") - -def create_demo_pipelines(): - """創建演示用的pipeline""" - - # Demo 1: 簡單線性pipeline - simple_pipeline = { - "project_name": "Simple Linear Pipeline", - "nodes": [ - {"id": "model_001", "name": "Object Detection", "type": "ExactModelNode"}, - {"id": "model_002", "name": "Fire Classification", "type": "ExactModelNode"}, - {"id": "model_003", "name": "Result Verification", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_002"}, - {"output_node": "model_002", "input_node": "model_003"} - ] - } - - # Demo 2: 並行pipeline - parallel_pipeline = { - "project_name": "Parallel Processing Pipeline", - "nodes": [ - {"id": "model_001", "name": "RGB Processor", "type": "ExactModelNode"}, - {"id": "model_002", "name": "IR Processor", "type": "ExactModelNode"}, - {"id": "model_003", "name": "Depth Processor", "type": "ExactModelNode"}, - {"id": "model_004", "name": "Fusion Engine", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_004"}, - {"output_node": "model_002", "input_node": "model_004"}, - {"output_node": "model_003", "input_node": "model_004"} - ] - } - - # Demo 3: 複雜多層pipeline - complex_pipeline = { - "project_name": "Advanced Multi-Stage Fire Detection Pipeline", - "nodes": [ - {"id": "model_rgb_001", "name": "RGB Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_edge_002", "name": "Edge Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_thermal_003", "name": "Thermal Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_fusion_004", "name": "Feature Fusion", "type": "ExactModelNode"}, - {"id": "model_attention_005", "name": "Attention Mechanism", "type": "ExactModelNode"}, - {"id": "model_classifier_006", "name": "Fire Classifier", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_rgb_001", "input_node": "model_fusion_004"}, - {"output_node": "model_edge_002", "input_node": "model_fusion_004"}, - {"output_node": "model_thermal_003", "input_node": "model_attention_005"}, - {"output_node": "model_fusion_004", "input_node": "model_classifier_006"}, - {"output_node": "model_attention_005", "input_node": "model_classifier_006"} - ] - } - - # Demo 4: 有循環的pipeline (測試循環檢測) - cycle_pipeline = { - "project_name": "Pipeline with Cycles (Testing)", - "nodes": [ - {"id": "model_A", "name": "Model A", "type": "ExactModelNode"}, - {"id": "model_B", "name": "Model B", "type": "ExactModelNode"}, - {"id": "model_C", "name": "Model C", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_A", "input_node": "model_B"}, - {"output_node": "model_B", "input_node": "model_C"}, - {"output_node": "model_C", "input_node": "model_A"} # 創建循環! - ] - } - - return [simple_pipeline, parallel_pipeline, complex_pipeline, cycle_pipeline] - -def main(): - """主演示函數""" - print("🚀 INTELLIGENT PIPELINE TOPOLOGY SORTING DEMONSTRATION") - print("="*60) - print("This demo showcases our advanced pipeline analysis capabilities:") - print("• Automatic dependency resolution") - print("• Parallel execution optimization") - print("• Cycle detection and prevention") - print("• Critical path analysis") - print("• Performance metrics calculation") - print("="*60 + "\n") - - demo = TopologyDemo() - pipelines = create_demo_pipelines() - demo_names = ["Simple Linear", "Parallel Processing", "Complex Multi-Stage", "Cycle Detection"] - - for i, (pipeline, name) in enumerate(zip(pipelines, demo_names), 1): - print(f"🎯 DEMO {i}: {name} Pipeline") - print("="*50) - demo.analyze_pipeline(pipeline) - print("\n") - - print("🎉 ALL DEMONSTRATIONS COMPLETED SUCCESSFULLY!") - print("Ready for production deployment and progress reporting! 🚀") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_ui_deployment.py b/cluster4npu_ui/tests/test_ui_deployment.py deleted file mode 100644 index 8f73366..0000000 --- a/cluster4npu_ui/tests/test_ui_deployment.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -""" -Test UI deployment dialog without requiring Kneron SDK. -This tests the UI deployment flow to verify our fixes work. -""" - -import sys -import os -from PyQt5.QtWidgets import QApplication -from typing import Dict, Any - -# Add project paths -project_root = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, project_root) - -def create_test_pipeline_data() -> Dict[str, Any]: - """Create a minimal test pipeline that should work.""" - return { - 'project_name': 'Test Deployment Pipeline', - 'description': 'Testing fixed deployment with result handling', - 'version': '1.0', - 'nodes': [ - { - 'id': 'input_1', - 'name': 'Camera Input', - 'type': 'ExactInputNode', - 'pos': [100, 100], - 'properties': { - 'source_type': 'camera', # lowercase to match WorkflowOrchestrator - 'device_id': 0, - 'resolution': '640x480', - 'fps': 10 - } - }, - { - 'id': 'model_1', - 'name': 'Test Model', - 'type': 'ExactModelNode', - 'pos': [300, 100], - 'properties': { - 'model_path': '/path/to/test.nef', - 'scpu_fw_path': 'fw_scpu.bin', - 'ncpu_fw_path': 'fw_ncpu.bin', - 'port_ids': [28, 32], - 'upload_fw': True - } - }, - { - 'id': 'output_1', - 'name': 'Debug Output', - 'type': 'ExactOutputNode', - 'pos': [500, 100], - 'properties': { - 'output_type': 'console', - 'destination': './debug_output' - } - } - ], - 'connections': [ - { - 'input_node': 'input_1', - 'input_port': 'output', - 'output_node': 'model_1', - 'output_port': 'input' - }, - { - 'input_node': 'model_1', - 'input_port': 'output', - 'output_node': 'output_1', - 'output_port': 'input' - } - ] - } - -def main(): - """Test the deployment dialog.""" - print("🧪 TESTING UI DEPLOYMENT DIALOG") - print("=" * 50) - - app = QApplication(sys.argv) - - try: - # Import UI components - from ui.dialogs.deployment import DeploymentDialog - - # Create test pipeline data - pipeline_data = create_test_pipeline_data() - - print("1. Creating deployment dialog...") - dialog = DeploymentDialog(pipeline_data) - - print("2. Showing dialog...") - print(" - Click 'Analyze Pipeline' to test configuration") - print(" - Click 'Deploy to Dongles' to test deployment") - print(" - With our fixes, you should now see result debugging output") - print(" - Results should appear in the Live View tab") - - # Show the dialog - result = dialog.exec_() - - if result == dialog.Accepted: - print("✅ Dialog completed successfully") - else: - print("❌ Dialog was cancelled") - - except ImportError as e: - print(f"❌ Could not import UI components: {e}") - print("This test needs to run with PyQt5 available") - except Exception as e: - print(f"❌ Error testing deployment dialog: {e}") - import traceback - traceback.print_exc() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/cluster4npu_ui/tests/test_ui_fixes.py b/cluster4npu_ui/tests/test_ui_fixes.py deleted file mode 100644 index 5382b40..0000000 --- a/cluster4npu_ui/tests/test_ui_fixes.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for UI fixes: connection counting, canvas cleanup, and global status bar. - -Tests the latest improvements to the dashboard interface. -""" - -import sys -import os - -# Add parent directory to path -current_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(current_dir) -sys.path.insert(0, parent_dir) - -def test_connection_counting(): - """Test improved connection counting logic.""" - print("🔍 Testing connection counting improvements...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check if the updated analyze_pipeline method exists - if hasattr(IntegratedPipelineDashboard, 'analyze_pipeline'): - print("✅ analyze_pipeline method exists") - - # Read the source to verify improved connection counting - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.analyze_pipeline) - - # Check for improved connection counting logic - if 'output_ports' in source and 'connected_ports' in source: - print("✅ Improved connection counting logic found") - else: - print("⚠️ Connection counting logic may need verification") - - # Check for error handling in connection counting - if 'try:' in source and 'except Exception:' in source: - print("✅ Error handling in connection counting") - - else: - print("❌ analyze_pipeline method missing") - return False - - return True - except Exception as e: - print(f"❌ Connection counting test failed: {e}") - return False - -def test_canvas_cleanup(): - """Test canvas cleanup (logo removal).""" - print("\n🔍 Testing canvas cleanup...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check if the setup_node_graph method has logo removal code - if hasattr(IntegratedPipelineDashboard, 'setup_node_graph'): - print("✅ setup_node_graph method exists") - - # Check source for logo removal logic - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.setup_node_graph) - - if 'set_logo_visible' in source or 'show_logo' in source: - print("✅ Logo removal logic found") - else: - print("⚠️ Logo removal logic may need verification") - - if 'set_grid_mode' in source or 'grid_mode' in source: - print("✅ Grid mode configuration found") - - else: - print("❌ setup_node_graph method missing") - return False - - return True - except Exception as e: - print(f"❌ Canvas cleanup test failed: {e}") - return False - -def test_global_status_bar(): - """Test global status bar spanning full width.""" - print("\n🔍 Testing global status bar...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check if setup_integrated_ui has global status bar - if hasattr(IntegratedPipelineDashboard, 'setup_integrated_ui'): - print("✅ setup_integrated_ui method exists") - - # Check source for global status bar - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.setup_integrated_ui) - - if 'global_status_bar' in source: - print("✅ Global status bar found") - else: - print("⚠️ Global status bar may need verification") - - if 'main_layout.addWidget' in source: - print("✅ Status bar added to main layout") - - else: - print("❌ setup_integrated_ui method missing") - return False - - # Check if create_status_bar_widget exists - if hasattr(IntegratedPipelineDashboard, 'create_status_bar_widget'): - print("✅ create_status_bar_widget method exists") - - # Check source for full-width styling - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.create_status_bar_widget) - - if 'border-top' in source and 'background-color' in source: - print("✅ Full-width status bar styling found") - - else: - print("❌ create_status_bar_widget method missing") - return False - - return True - except Exception as e: - print(f"❌ Global status bar test failed: {e}") - return False - -def test_stage_count_widget_updates(): - """Test StageCountWidget updates for global status bar.""" - print("\n🔍 Testing StageCountWidget updates...") - - try: - from cluster4npu_ui.ui.windows.dashboard import StageCountWidget - from PyQt5.QtWidgets import QApplication - - app = QApplication.instance() - if app is None: - app = QApplication([]) - - # Create widget - widget = StageCountWidget() - print("✅ StageCountWidget created successfully") - - # Test size for global status bar - size = widget.size() - if size.width() == 120 and size.height() == 22: - print(f"✅ Correct size for global status bar: {size.width()}x{size.height()}") - else: - print(f"⚠️ Size may need adjustment: {size.width()}x{size.height()}") - - # Test status updates - widget.update_stage_count(0, True, "") - print("✅ Zero stages update test") - - widget.update_stage_count(2, True, "") - print("✅ Valid stages update test") - - widget.update_stage_count(1, False, "Test error") - print("✅ Error state update test") - - return True - except Exception as e: - print(f"❌ StageCountWidget test failed: {e}") - return False - -def test_layout_structure(): - """Test that the layout structure is correct.""" - print("\n🔍 Testing layout structure...") - - try: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - # Check if create_pipeline_editor_panel no longer has status bar - if hasattr(IntegratedPipelineDashboard, 'create_pipeline_editor_panel'): - print("✅ create_pipeline_editor_panel method exists") - - # Check that it doesn't create its own status bar - import inspect - source = inspect.getsource(IntegratedPipelineDashboard.create_pipeline_editor_panel) - - if 'create_status_bar_widget' not in source: - print("✅ Pipeline editor panel no longer creates its own status bar") - else: - print("⚠️ Pipeline editor panel may still create status bar") - - else: - print("❌ create_pipeline_editor_panel method missing") - return False - - return True - except Exception as e: - print(f"❌ Layout structure test failed: {e}") - return False - -def run_all_tests(): - """Run all UI fix tests.""" - print("🚀 Starting UI fixes tests...\n") - - tests = [ - test_connection_counting, - test_canvas_cleanup, - test_global_status_bar, - test_stage_count_widget_updates, - test_layout_structure - ] - - passed = 0 - total = len(tests) - - for test_func in tests: - try: - if test_func(): - passed += 1 - else: - print(f"❌ Test {test_func.__name__} failed") - except Exception as e: - print(f"❌ Test {test_func.__name__} raised exception: {e}") - - print(f"\n📊 Test Results: {passed}/{total} tests passed") - - if passed == total: - print("🎉 All UI fixes tests passed!") - print("\n📋 Summary of fixes:") - print(" ✅ Connection counting improved to handle different port types") - print(" ✅ Canvas logo/icon in bottom-left corner removed") - print(" ✅ Status bar now spans full width across all panels") - print(" ✅ StageCountWidget optimized for global status bar") - print(" ✅ Layout structure cleaned up") - return True - else: - print("❌ Some UI fixes tests failed.") - return False - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/cluster4npu_ui/ui/__init__.py b/cluster4npu_ui/ui/__init__.py deleted file mode 100644 index 1aa2da1..0000000 --- a/cluster4npu_ui/ui/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -User interface components for the Cluster4NPU application. - -This module contains all user interface components including windows, dialogs, -widgets, and other UI elements that make up the application interface. - -Available Components: - - windows: Main application windows (login, dashboard, editor) - - dialogs: Dialog boxes for various operations - - components: Reusable UI components and widgets - -Usage: - from cluster4npu_ui.ui.windows import DashboardLogin - from cluster4npu_ui.ui.dialogs import CreatePipelineDialog - from cluster4npu_ui.ui.components import NodePalette - - # Create main window - dashboard = DashboardLogin() - dashboard.show() -""" - -from . import windows -from . import dialogs -from . import components - -__all__ = [ - "windows", - "dialogs", - "components" -] \ No newline at end of file diff --git a/cluster4npu_ui/ui/components/__init__.py b/cluster4npu_ui/ui/components/__init__.py deleted file mode 100644 index d95b3a8..0000000 --- a/cluster4npu_ui/ui/components/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Reusable UI components and widgets for the Cluster4NPU application. - -This module contains reusable UI components that can be used across different -parts of the application, promoting consistency and code reuse. - -Available Components: - - NodePalette: Node template selector with drag-and-drop (future) - - CustomPropertiesWidget: Dynamic property editor (future) - - CommonWidgets: Shared UI elements and utilities (future) - -Usage: - from cluster4npu_ui.ui.components import NodePalette, CustomPropertiesWidget - - palette = NodePalette(graph) - properties = CustomPropertiesWidget(graph) -""" - -# Import components as they are implemented -# from .node_palette import NodePalette -# from .properties_widget import CustomPropertiesWidget -# from .common_widgets import * - -__all__ = [ - # "NodePalette", - # "CustomPropertiesWidget" -] \ No newline at end of file diff --git a/cluster4npu_ui/ui/components/common_widgets.py b/cluster4npu_ui/ui/components/common_widgets.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/components/node_palette.py b/cluster4npu_ui/ui/components/node_palette.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/components/properties_widget.py b/cluster4npu_ui/ui/components/properties_widget.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/dialogs/__init__.py b/cluster4npu_ui/ui/dialogs/__init__.py deleted file mode 100644 index 978c05a..0000000 --- a/cluster4npu_ui/ui/dialogs/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Dialog boxes and modal windows for the Cluster4NPU UI. - -This module contains various dialog boxes used throughout the application -for specific operations like pipeline creation, configuration, and deployment. - -Available Dialogs: - - CreatePipelineDialog: New pipeline creation (future) - - StageConfigurationDialog: Pipeline stage setup (future) - - PerformanceEstimationPanel: Performance analysis (future) - - SaveDeployDialog: Export and deployment (future) - - SimplePropertiesDialog: Basic property editing (future) - -Usage: - from cluster4npu_ui.ui.dialogs import CreatePipelineDialog - - dialog = CreatePipelineDialog(parent) - if dialog.exec_() == dialog.Accepted: - project_info = dialog.get_project_info() -""" - -# Import dialogs as they are implemented -# from .create_pipeline import CreatePipelineDialog -# from .stage_config import StageConfigurationDialog -# from .performance import PerformanceEstimationPanel -# from .save_deploy import SaveDeployDialog -# from .properties import SimplePropertiesDialog - -__all__ = [ - # "CreatePipelineDialog", - # "StageConfigurationDialog", - # "PerformanceEstimationPanel", - # "SaveDeployDialog", - # "SimplePropertiesDialog" -] \ No newline at end of file diff --git a/cluster4npu_ui/ui/dialogs/create_pipeline.py b/cluster4npu_ui/ui/dialogs/create_pipeline.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/dialogs/deployment.py b/cluster4npu_ui/ui/dialogs/deployment.py deleted file mode 100644 index 00230b1..0000000 --- a/cluster4npu_ui/ui/dialogs/deployment.py +++ /dev/null @@ -1,1017 +0,0 @@ -""" -Pipeline Deployment Dialog - -This dialog handles the conversion of .mflow pipeline data to executable format -and deployment to Kneron dongles using the InferencePipeline system. - -Main Components: - - Pipeline conversion using MFlowConverter - - Topology analysis and optimization - - Dongle status monitoring - - Real-time deployment progress - - Error handling and troubleshooting - -Usage: - from ui.dialogs.deployment import DeploymentDialog - - dialog = DeploymentDialog(pipeline_data, parent=self) - dialog.exec_() -""" - -import os -import sys -import json -import threading -import traceback -import io -import contextlib -from typing import Dict, Any, List, Optional -from PyQt5.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton, - QProgressBar, QTabWidget, QWidget, QFormLayout, QLineEdit, QSpinBox, - QCheckBox, QGroupBox, QScrollArea, QTableWidget, QTableWidgetItem, - QHeaderView, QMessageBox, QSplitter, QFrame -) -from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer -from PyQt5.QtGui import QFont, QColor, QPalette, QImage, QPixmap - -# Import our converter and pipeline system -sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'core', 'functions')) - -try: - from ...core.functions.mflow_converter import MFlowConverter, PipelineConfig - CONVERTER_AVAILABLE = True -except ImportError as e: - print(f"Warning: MFlow converter not available: {e}") - CONVERTER_AVAILABLE = False - -try: - from ...core.functions.Multidongle import MultiDongle - from ...core.functions.InferencePipeline import InferencePipeline - from ...core.functions.workflow_orchestrator import WorkflowOrchestrator - # from workflow_orchestrator import WorkflowOrchestrator - PIPELINE_AVAILABLE = True -except ImportError as e: - print(f"Warning: Pipeline system not available: {e}") - PIPELINE_AVAILABLE = False - - -class StdoutCapture: - """Context manager to capture stdout/stderr and emit to signal.""" - - def __init__(self, signal_emitter): - self.signal_emitter = signal_emitter - self.original_stdout = None - self.original_stderr = None - self.captured_output = io.StringIO() - - def __enter__(self): - self.original_stdout = sys.stdout - self.original_stderr = sys.stderr - - # Create a custom write function that both prints to original and captures - class TeeWriter: - def __init__(self, original, captured, emitter): - self.original = original - self.captured = captured - self.emitter = emitter - self._emitting = False # Prevent recursion - - def write(self, text): - # Write to original stdout/stderr (so it still appears in terminal) - self.original.write(text) - self.original.flush() - - # Capture for GUI if it's a substantial message and not already emitting - if text.strip() and not self._emitting: - self._emitting = True - try: - self.emitter(text) - finally: - self._emitting = False - - def flush(self): - self.original.flush() - - # Replace stdout and stderr with our tee writers - sys.stdout = TeeWriter(self.original_stdout, self.captured_output, self.signal_emitter) - sys.stderr = TeeWriter(self.original_stderr, self.captured_output, self.signal_emitter) - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - # Restore original stdout/stderr - sys.stdout = self.original_stdout - sys.stderr = self.original_stderr - - -class DeploymentWorker(QThread): - """Worker thread for pipeline deployment to avoid blocking UI.""" - - # Signals - progress_updated = pyqtSignal(int, str) # progress, message - topology_analyzed = pyqtSignal(dict) # topology analysis results - conversion_completed = pyqtSignal(object) # PipelineConfig object - deployment_started = pyqtSignal() - deployment_completed = pyqtSignal(bool, str) # success, message - error_occurred = pyqtSignal(str) - frame_updated = pyqtSignal('PyQt_PyObject') # For live view - result_updated = pyqtSignal(dict) # For inference results - terminal_output = pyqtSignal(str) # For terminal output in GUI - stdout_captured = pyqtSignal(str) # For captured stdout/stderr - - def __init__(self, pipeline_data: Dict[str, Any]): - super().__init__() - self.pipeline_data = pipeline_data - self.should_stop = False - self.orchestrator = None - - def run(self): - """Main deployment workflow.""" - try: - # Step 1: Convert .mflow to pipeline config - self.progress_updated.emit(10, "Converting pipeline configuration...") - - if not CONVERTER_AVAILABLE: - self.error_occurred.emit("MFlow converter not available. Please check installation.") - return - - converter = MFlowConverter() - config = converter._convert_mflow_to_config(self.pipeline_data) - - # Emit topology analysis results - self.topology_analyzed.emit({ - 'total_stages': len(config.stage_configs), - 'pipeline_name': config.pipeline_name, - 'input_config': config.input_config, - 'output_config': config.output_config - }) - - self.progress_updated.emit(30, "Pipeline conversion completed") - self.conversion_completed.emit(config) - - if self.should_stop: - return - - # Step 2: Validate configuration - self.progress_updated.emit(40, "Validating pipeline configuration...") - is_valid, errors = converter.validate_config(config) - - if not is_valid: - error_msg = "Configuration validation failed:\n" + "\n".join(errors) - self.error_occurred.emit(error_msg) - return - - self.progress_updated.emit(60, "Configuration validation passed") - - if self.should_stop: - return - - # Step 3: Initialize pipeline (if dongle system available) - self.progress_updated.emit(70, "Initializing inference pipeline...") - - if not PIPELINE_AVAILABLE: - self.progress_updated.emit(100, "Pipeline configuration ready (dongle system not available)") - self.deployment_completed.emit(True, "Pipeline configuration prepared successfully. Dongle system not available for actual deployment.") - return - - # Create InferencePipeline instance with stdout capture - try: - # Capture all stdout/stderr during pipeline operations - with StdoutCapture(self.stdout_captured.emit): - pipeline = converter.create_inference_pipeline(config) - - self.progress_updated.emit(80, "Initializing workflow orchestrator...") - self.deployment_started.emit() - - # Create and start the orchestrator - self.orchestrator = WorkflowOrchestrator(pipeline, config.input_config, config.output_config) - self.orchestrator.set_frame_callback(self.frame_updated.emit) - - # Set up both GUI and terminal result callbacks - def combined_result_callback(result_dict): - # Check if this is a valid result (not async/processing status) - stage_results = result_dict.get('stage_results', {}) - has_valid_result = False - - for stage_id, result in stage_results.items(): - if isinstance(result, dict): - status = result.get('status', '') - if status not in ['async', 'processing']: - has_valid_result = True - break - elif isinstance(result, tuple) and len(result) == 2: - prob, result_str = result - if prob is not None and result_str not in ['Processing']: - has_valid_result = True - break - - # Only display and process if we have valid results - if has_valid_result: - # Add current FPS from pipeline to result_dict - current_fps = pipeline.get_current_fps() - result_dict['current_pipeline_fps'] = current_fps - print(f"DEBUG: Pipeline FPS = {current_fps:.2f}") # Debug info - - # Send to GUI terminal and results display - terminal_output = self._format_terminal_results(result_dict) - self.terminal_output.emit(terminal_output) - # Emit for GUI - self.result_updated.emit(result_dict) - - self.orchestrator.set_result_callback(combined_result_callback) - - self.orchestrator.start() - - self.progress_updated.emit(100, "Pipeline deployed successfully!") - self.deployment_completed.emit(True, f"Pipeline '{config.pipeline_name}' deployed with {len(config.stage_configs)} stages") - - # Keep running until stop is requested with continued stdout capture - while not self.should_stop: - self.msleep(100) # Sleep for 100ms and check again - - except Exception as e: - self.error_occurred.emit(f"Pipeline deployment failed: {str(e)}") - - except Exception as e: - self.error_occurred.emit(f"Deployment error: {str(e)}") - - def stop(self): - """Stop the deployment process.""" - self.should_stop = True - if self.orchestrator: - self.orchestrator.stop() - - def _format_terminal_results(self, result_dict): - """Format inference results for terminal display in GUI.""" - try: - from datetime import datetime - - # Header with timestamp - timestamp = datetime.fromtimestamp(result_dict.get('timestamp', 0)).strftime("%H:%M:%S.%f")[:-3] - pipeline_id = result_dict.get('pipeline_id', 'Unknown') - - output_lines = [] - output_lines.append(f"\nINFERENCE RESULT [{timestamp}]") - output_lines.append(f" Pipeline ID: {pipeline_id}") - output_lines.append(" " + "="*50) - - # Stage results - stage_results = result_dict.get('stage_results', {}) - if stage_results: - for stage_id, result in stage_results.items(): - output_lines.append(f" Stage: {stage_id}") - - if isinstance(result, tuple) and len(result) == 2: - # Handle tuple results (probability, result_string) - matching actual format - probability, result_string = result - output_lines.append(f" Result: {result_string}") - output_lines.append(f" Probability: {probability:.3f}") - - # Add confidence level - if probability > 0.8: - confidence = "Very High" - elif probability > 0.6: - confidence = "High" - elif probability > 0.4: - confidence = "Medium" - else: - confidence = "Low" - output_lines.append(f" Confidence: {confidence}") - - elif isinstance(result, dict): - # Handle dict results - for key, value in result.items(): - if key == 'probability': - output_lines.append(f" {key.title()}: {value:.3f}") - elif key == 'result': - output_lines.append(f" {key.title()}: {value}") - elif key == 'confidence': - output_lines.append(f" {key.title()}: {value}") - elif key == 'fused_probability': - output_lines.append(f" Fused Probability: {value:.3f}") - elif key == 'individual_probs': - output_lines.append(f" Individual Probabilities:") - for prob_key, prob_value in value.items(): - output_lines.append(f" {prob_key}: {prob_value:.3f}") - else: - output_lines.append(f" {key}: {value}") - else: - # Handle other result types - output_lines.append(f" Raw Result: {result}") - - output_lines.append("") # Blank line between stages - else: - output_lines.append(" No stage results available") - - # Processing time if available - metadata = result_dict.get('metadata', {}) - if 'total_processing_time' in metadata: - processing_time = metadata['total_processing_time'] - output_lines.append(f" Processing Time: {processing_time:.3f}s") - - # Real-time FPS calculation based on output queue throughput - current_fps = result_dict.get('current_pipeline_fps', 0.0) - if current_fps > 0: - output_lines.append(f" Pipeline FPS (Output Queue): {current_fps:.2f}") - else: - output_lines.append(f" Pipeline FPS (Output Queue): Calculating...") - - # Additional metadata - if metadata: - interesting_keys = ['dongle_count', 'stage_count', 'queue_sizes', 'error_count'] - for key in interesting_keys: - if key in metadata: - output_lines.append(f" {key.replace('_', ' ').title()}: {metadata[key]}") - - output_lines.append(" " + "="*50) - - return "\n".join(output_lines) - - except Exception as e: - return f"❌ Error formatting terminal results: {e}" - - -class DeploymentDialog(QDialog): - """Main deployment dialog with comprehensive deployment management.""" - - def __init__(self, pipeline_data: Dict[str, Any], parent=None): - super().__init__(parent) - - self.pipeline_data = pipeline_data - self.deployment_worker = None - self.pipeline_config = None - - self.setWindowTitle("Deploy Pipeline to Dongles") - self.setMinimumSize(800, 600) - self.setup_ui() - self.apply_theme() - - def setup_ui(self): - """Setup the dialog UI.""" - layout = QVBoxLayout(self) - - # Header - header_label = QLabel("Pipeline Deployment") - header_label.setFont(QFont("Arial", 16, QFont.Bold)) - header_label.setAlignment(Qt.AlignCenter) - layout.addWidget(header_label) - - # Main content with tabs - self.tab_widget = QTabWidget() - - # Overview tab - self.overview_tab = self.create_overview_tab() - self.tab_widget.addTab(self.overview_tab, "Overview") - - # Topology tab - self.topology_tab = self.create_topology_tab() - self.tab_widget.addTab(self.topology_tab, "Analysis") - - # Configuration tab - self.config_tab = self.create_configuration_tab() - self.tab_widget.addTab(self.config_tab, "Configuration") - - # Deployment tab - self.deployment_tab = self.create_deployment_tab() - self.tab_widget.addTab(self.deployment_tab, "Deployment") - - # Live View tab - self.live_view_tab = self.create_live_view_tab() - self.tab_widget.addTab(self.live_view_tab, "Live View") - - layout.addWidget(self.tab_widget) - - # Progress bar - self.progress_bar = QProgressBar() - self.progress_bar.setVisible(False) - layout.addWidget(self.progress_bar) - - # Status label - self.status_label = QLabel("Ready to deploy") - self.status_label.setAlignment(Qt.AlignCenter) - layout.addWidget(self.status_label) - - # Buttons - button_layout = QHBoxLayout() - - self.analyze_button = QPushButton("Analyze Pipeline") - self.analyze_button.clicked.connect(self.analyze_pipeline) - button_layout.addWidget(self.analyze_button) - - self.deploy_button = QPushButton("Deploy to Dongles") - self.deploy_button.clicked.connect(self.start_deployment) - self.deploy_button.setEnabled(False) - button_layout.addWidget(self.deploy_button) - - self.stop_button = QPushButton("Stop Inference") - self.stop_button.clicked.connect(self.stop_deployment) - self.stop_button.setEnabled(False) - self.stop_button.setVisible(False) - button_layout.addWidget(self.stop_button) - - button_layout.addStretch() - - self.close_button = QPushButton("Close") - self.close_button.clicked.connect(self.accept) - button_layout.addWidget(self.close_button) - - layout.addLayout(button_layout) - - # Populate initial data - self.populate_overview() - - def create_overview_tab(self) -> QWidget: - """Create pipeline overview tab.""" - widget = QWidget() - layout = QVBoxLayout(widget) - - # Pipeline info - info_group = QGroupBox("Pipeline Information") - info_layout = QFormLayout(info_group) - - self.name_label = QLabel() - self.description_label = QLabel() - self.nodes_label = QLabel() - self.connections_label = QLabel() - - info_layout.addRow("Name:", self.name_label) - info_layout.addRow("Description:", self.description_label) - info_layout.addRow("Nodes:", self.nodes_label) - info_layout.addRow("Connections:", self.connections_label) - - layout.addWidget(info_group) - - # Nodes table - nodes_group = QGroupBox("Pipeline Nodes") - nodes_layout = QVBoxLayout(nodes_group) - - self.nodes_table = QTableWidget() - self.nodes_table.setColumnCount(3) - self.nodes_table.setHorizontalHeaderLabels(["Name", "Type", "Status"]) - self.nodes_table.horizontalHeader().setStretchLastSection(True) - nodes_layout.addWidget(self.nodes_table) - - layout.addWidget(nodes_group) - - return widget - - def create_topology_tab(self) -> QWidget: - """Create topology analysis tab.""" - widget = QWidget() - layout = QVBoxLayout(widget) - - # Analysis results - self.topology_text = QTextEdit() - self.topology_text.setReadOnly(True) - self.topology_text.setFont(QFont("Consolas", 10)) - self.topology_text.setText("Click 'Analyze Pipeline' to see topology analysis...") - - layout.addWidget(self.topology_text) - - return widget - - def create_configuration_tab(self) -> QWidget: - """Create configuration tab.""" - widget = QWidget() - layout = QVBoxLayout(widget) - - scroll_area = QScrollArea() - scroll_content = QWidget() - scroll_layout = QVBoxLayout(scroll_content) - - # Stage configurations will be populated after analysis - self.config_content = QLabel("Run pipeline analysis to see stage configurations...") - self.config_content.setAlignment(Qt.AlignCenter) - scroll_layout.addWidget(self.config_content) - - scroll_area.setWidget(scroll_content) - scroll_area.setWidgetResizable(True) - layout.addWidget(scroll_area) - - return widget - - def create_deployment_tab(self) -> QWidget: - """Create deployment monitoring tab.""" - widget = QWidget() - layout = QVBoxLayout(widget) - - # Create splitter for deployment log and terminal output - splitter = QSplitter(Qt.Vertical) - - # Deployment log - log_group = QGroupBox("Deployment Log") - log_layout = QVBoxLayout(log_group) - - self.deployment_log = QTextEdit() - self.deployment_log.setReadOnly(True) - self.deployment_log.setFont(QFont("Consolas", 9)) - self.deployment_log.setMaximumHeight(200) - log_layout.addWidget(self.deployment_log) - - splitter.addWidget(log_group) - - # Terminal output display - terminal_group = QGroupBox("Terminal Output") - terminal_layout = QVBoxLayout(terminal_group) - - self.terminal_output_display = QTextEdit() - self.terminal_output_display.setReadOnly(True) - self.terminal_output_display.setFont(QFont("Consolas", 9)) - self.terminal_output_display.setStyleSheet(""" - QTextEdit { - background-color: #1e1e1e; - color: #ffffff; - font-family: 'Consolas', 'Monaco', monospace; - } - """) - terminal_layout.addWidget(self.terminal_output_display) - - splitter.addWidget(terminal_group) - - # Set splitter proportions (1:2 ratio - more space for terminal) - splitter.setSizes([200, 400]) - - layout.addWidget(splitter) - - # Dongle status (placeholder) - status_group = QGroupBox("Dongle Status") - status_layout = QVBoxLayout(status_group) - - self.dongle_status = QLabel("No dongles detected") - self.dongle_status.setAlignment(Qt.AlignCenter) - status_layout.addWidget(self.dongle_status) - - layout.addWidget(status_group) - - return widget - - def create_live_view_tab(self) -> QWidget: - """Create the live view tab for real-time output.""" - widget = QWidget() - layout = QHBoxLayout(widget) - - # Video display - video_group = QGroupBox("Live Video Feed") - video_layout = QVBoxLayout(video_group) - self.live_view_label = QLabel("Live view will appear here after deployment.") - self.live_view_label.setAlignment(Qt.AlignCenter) - self.live_view_label.setMinimumSize(640, 480) - video_layout.addWidget(self.live_view_label) - layout.addWidget(video_group, 2) - - # Inference results - results_group = QGroupBox("Inference Results") - results_layout = QVBoxLayout(results_group) - self.results_text = QTextEdit() - self.results_text.setReadOnly(True) - results_layout.addWidget(self.results_text) - layout.addWidget(results_group, 1) - - return widget - - def populate_overview(self): - """Populate overview tab with pipeline data.""" - self.name_label.setText(self.pipeline_data.get('project_name', 'Untitled')) - self.description_label.setText(self.pipeline_data.get('description', 'No description')) - - nodes = self.pipeline_data.get('nodes', []) - connections = self.pipeline_data.get('connections', []) - - self.nodes_label.setText(str(len(nodes))) - self.connections_label.setText(str(len(connections))) - - # Populate nodes table - self.nodes_table.setRowCount(len(nodes)) - for i, node in enumerate(nodes): - self.nodes_table.setItem(i, 0, QTableWidgetItem(node.get('name', 'Unknown'))) - self.nodes_table.setItem(i, 1, QTableWidgetItem(node.get('type', 'Unknown'))) - self.nodes_table.setItem(i, 2, QTableWidgetItem("Ready")) - - def analyze_pipeline(self): - """Analyze pipeline topology and configuration.""" - if not CONVERTER_AVAILABLE: - QMessageBox.warning(self, "Analysis Error", - "Pipeline analyzer not available. Please check installation.") - return - - try: - self.status_label.setText("Analyzing pipeline...") - self.analyze_button.setEnabled(False) - - # Create converter and analyze - converter = MFlowConverter() - config = converter._convert_mflow_to_config(self.pipeline_data) - self.pipeline_config = config - - # Update topology tab - analysis_text = f"""Pipeline Analysis Results: - -Name: {config.pipeline_name} -Description: {config.description} -Total Stages: {len(config.stage_configs)} - -Input Configuration: -{json.dumps(config.input_config, indent=2)} - -Output Configuration: -{json.dumps(config.output_config, indent=2)} - -Stage Configurations: -""" - - for i, stage_config in enumerate(config.stage_configs, 1): - analysis_text += f"\nStage {i}: {stage_config.stage_id}\n" - analysis_text += f" Port IDs: {stage_config.port_ids}\n" - analysis_text += f" Model Path: {stage_config.model_path}\n" - analysis_text += f" SCPU Firmware: {stage_config.scpu_fw_path}\n" - analysis_text += f" NCPU Firmware: {stage_config.ncpu_fw_path}\n" - analysis_text += f" Upload Firmware: {stage_config.upload_fw}\n" - analysis_text += f" Max Queue Size: {stage_config.max_queue_size}\n" - - self.topology_text.setText(analysis_text) - - # Update configuration tab - self.update_configuration_tab(config) - - # Validate configuration - is_valid, errors = converter.validate_config(config) - - if is_valid: - self.status_label.setText("Pipeline analysis completed successfully") - self.deploy_button.setEnabled(True) - self.tab_widget.setCurrentIndex(1) # Switch to topology tab - else: - error_msg = "Configuration validation failed:\n" + "\n".join(errors) - QMessageBox.warning(self, "Validation Error", error_msg) - self.status_label.setText("Pipeline analysis failed validation") - - except Exception as e: - QMessageBox.critical(self, "Analysis Error", - f"Failed to analyze pipeline: {str(e)}") - self.status_label.setText("Pipeline analysis failed") - finally: - self.analyze_button.setEnabled(True) - - def update_configuration_tab(self, config: 'PipelineConfig'): - """Update configuration tab with detailed stage information.""" - # Clear existing content - scroll_content = QWidget() - scroll_layout = QVBoxLayout(scroll_content) - - for i, stage_config in enumerate(config.stage_configs, 1): - stage_group = QGroupBox(f"Stage {i}: {stage_config.stage_id}") - stage_layout = QFormLayout(stage_group) - - # Create read-only fields for stage configuration - model_path_edit = QLineEdit(stage_config.model_path) - model_path_edit.setReadOnly(True) - stage_layout.addRow("Model Path:", model_path_edit) - - scpu_fw_edit = QLineEdit(stage_config.scpu_fw_path) - scpu_fw_edit.setReadOnly(True) - stage_layout.addRow("SCPU Firmware:", scpu_fw_edit) - - ncpu_fw_edit = QLineEdit(stage_config.ncpu_fw_path) - ncpu_fw_edit.setReadOnly(True) - stage_layout.addRow("NCPU Firmware:", ncpu_fw_edit) - - port_ids_edit = QLineEdit(str(stage_config.port_ids)) - port_ids_edit.setReadOnly(True) - stage_layout.addRow("Port IDs:", port_ids_edit) - - queue_size_spin = QSpinBox() - queue_size_spin.setValue(stage_config.max_queue_size) - queue_size_spin.setReadOnly(True) - stage_layout.addRow("Queue Size:", queue_size_spin) - - upload_fw_check = QCheckBox() - upload_fw_check.setChecked(stage_config.upload_fw) - upload_fw_check.setEnabled(False) - stage_layout.addRow("Upload Firmware:", upload_fw_check) - - scroll_layout.addWidget(stage_group) - - # Update the configuration tab - config_tab_layout = self.config_tab.layout() - old_scroll_area = config_tab_layout.itemAt(0).widget() - config_tab_layout.removeWidget(old_scroll_area) - old_scroll_area.deleteLater() - - new_scroll_area = QScrollArea() - new_scroll_area.setWidget(scroll_content) - new_scroll_area.setWidgetResizable(True) - config_tab_layout.addWidget(new_scroll_area) - - def start_deployment(self): - """Start the deployment process.""" - if not self.pipeline_config: - QMessageBox.warning(self, "Deployment Error", - "Please analyze the pipeline first.") - return - - # Switch to deployment tab - self.tab_widget.setCurrentIndex(3) - - # Setup UI for deployment - self.progress_bar.setVisible(True) - self.progress_bar.setValue(0) - self.deploy_button.setEnabled(False) - self.close_button.setText("Cancel") - - # Clear deployment log and terminal output - self.deployment_log.clear() - self.deployment_log.append("Starting pipeline deployment...") - self.terminal_output_display.clear() - self.terminal_output_display.append("Pipeline deployment started - terminal output will appear here...") - - # Create and start deployment worker - self.deployment_worker = DeploymentWorker(self.pipeline_data) - self.deployment_worker.progress_updated.connect(self.update_progress) - self.deployment_worker.topology_analyzed.connect(self.update_topology_results) - self.deployment_worker.conversion_completed.connect(self.on_conversion_completed) - self.deployment_worker.deployment_started.connect(self.on_deployment_started) - self.deployment_worker.deployment_completed.connect(self.on_deployment_completed) - self.deployment_worker.error_occurred.connect(self.on_deployment_error) - self.deployment_worker.frame_updated.connect(self.update_live_view) - self.deployment_worker.result_updated.connect(self.update_inference_results) - self.deployment_worker.terminal_output.connect(self.update_terminal_output) - self.deployment_worker.stdout_captured.connect(self.update_terminal_output) - - - self.deployment_worker.start() - - def stop_deployment(self): - """Stop the current deployment/inference.""" - if self.deployment_worker and self.deployment_worker.isRunning(): - reply = QMessageBox.question(self, "Stop Inference", - "Are you sure you want to stop the inference?", - QMessageBox.Yes | QMessageBox.No) - if reply == QMessageBox.Yes: - self.deployment_log.append("Stopping inference...") - self.status_label.setText("Stopping inference...") - - # Disable stop button immediately to prevent multiple clicks - self.stop_button.setEnabled(False) - - self.deployment_worker.stop() - - # Wait for worker to finish in a separate thread to avoid blocking UI - def wait_for_stop(): - if self.deployment_worker.wait(5000): # Wait up to 5 seconds - self.deployment_log.append("Inference stopped successfully.") - else: - self.deployment_log.append("Warning: Inference may not have stopped cleanly.") - - # Update UI on main thread - self.stop_button.setVisible(False) - self.deploy_button.setEnabled(True) - self.close_button.setText("Close") - self.progress_bar.setVisible(False) - self.status_label.setText("Inference stopped") - self.dongle_status.setText("Pipeline stopped") - - import threading - threading.Thread(target=wait_for_stop, daemon=True).start() - - def update_progress(self, value: int, message: str): - """Update deployment progress.""" - self.progress_bar.setValue(value) - self.status_label.setText(message) - self.deployment_log.append(f"[{value}%] {message}") - - def update_topology_results(self, results: Dict): - """Update topology analysis results.""" - self.deployment_log.append(f"Topology Analysis: {results['total_stages']} stages detected") - - def on_conversion_completed(self, config): - """Handle conversion completion.""" - self.deployment_log.append("Pipeline conversion completed successfully") - - def on_deployment_started(self): - """Handle deployment start.""" - self.deployment_log.append("Connecting to dongles...") - self.dongle_status.setText("Initializing dongles...") - - # Show stop button and hide deploy button - self.stop_button.setEnabled(True) - self.stop_button.setVisible(True) - self.deploy_button.setEnabled(False) - - def on_deployment_completed(self, success: bool, message: str): - """Handle deployment completion.""" - self.progress_bar.setValue(100) - - if success: - self.deployment_log.append(f"SUCCESS: {message}") - self.status_label.setText("Deployment completed successfully!") - self.dongle_status.setText("Pipeline running on dongles") - # Keep stop button visible for successful deployment - self.stop_button.setEnabled(True) - self.stop_button.setVisible(True) - QMessageBox.information(self, "Deployment Success", message) - else: - self.deployment_log.append(f"FAILED: {message}") - self.status_label.setText("Deployment failed") - # Hide stop button for failed deployment - self.stop_button.setEnabled(False) - self.stop_button.setVisible(False) - self.deploy_button.setEnabled(True) - - self.close_button.setText("Close") - self.progress_bar.setVisible(False) - - def on_deployment_error(self, error: str): - """Handle deployment error.""" - self.deployment_log.append(f"ERROR: {error}") - self.status_label.setText("Deployment failed") - QMessageBox.critical(self, "Deployment Error", error) - - # Hide stop button and show deploy button on error - self.stop_button.setEnabled(False) - self.stop_button.setVisible(False) - self.deploy_button.setEnabled(True) - self.close_button.setText("Close") - self.progress_bar.setVisible(False) - - def update_live_view(self, frame): - """Update the live view with a new frame.""" - try: - # Convert the OpenCV frame to a QImage - height, width, channel = frame.shape - bytes_per_line = 3 * width - q_image = QImage(frame.data, width, height, bytes_per_line, QImage.Format_RGB888).rgbSwapped() - - # Display the QImage in the QLabel - self.live_view_label.setPixmap(QPixmap.fromImage(q_image)) - except Exception as e: - print(f"Error updating live view: {e}") - - def update_inference_results(self, result_dict): - """Update the inference results display.""" - try: - import json - from datetime import datetime - - # Format the results for display - timestamp = datetime.fromtimestamp(result_dict.get('timestamp', 0)).strftime("%H:%M:%S.%f")[:-3] - stage_results = result_dict.get('stage_results', {}) - - result_text = f"[{timestamp}] Pipeline ID: {result_dict.get('pipeline_id', 'Unknown')}\n" - - # Display results from each stage - for stage_id, result in stage_results.items(): - result_text += f" {stage_id}:\n" - if isinstance(result, tuple) and len(result) == 2: - # Handle tuple results (probability, result_string) - probability, result_string = result - result_text += f" Result: {result_string}\n" - result_text += f" Probability: {probability:.3f}\n" - elif isinstance(result, dict): - # Handle dict results - for key, value in result.items(): - if key == 'probability': - result_text += f" Probability: {value:.3f}\n" - else: - result_text += f" {key}: {value}\n" - else: - result_text += f" {result}\n" - - result_text += "-" * 50 + "\n" - - # Append to results display (keep last 100 lines) - current_text = self.results_text.toPlainText() - lines = current_text.split('\n') - if len(lines) > 100: - lines = lines[-50:] # Keep last 50 lines - current_text = '\n'.join(lines) - - self.results_text.setPlainText(current_text + result_text) - - # Auto-scroll to bottom - scrollbar = self.results_text.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - - except Exception as e: - print(f"Error updating inference results: {e}") - - def update_terminal_output(self, terminal_text: str): - """Update the terminal output display with new text.""" - try: - # Use append() instead of setPlainText() for better performance and no truncation - self.terminal_output_display.append(terminal_text.rstrip('\n')) - - # Auto-scroll to bottom - scrollbar = self.terminal_output_display.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - - # Optional: Limit total lines to prevent excessive memory usage - # Only trim if we have way too many lines (e.g., > 1000) - document = self.terminal_output_display.document() - if document.lineCount() > 1000: - cursor = self.terminal_output_display.textCursor() - cursor.movePosition(cursor.Start) - cursor.movePosition(cursor.Down, cursor.KeepAnchor, 200) # Select first 200 lines - cursor.removeSelectedText() - - except Exception as e: - print(f"Error updating terminal output: {e}") - - def apply_theme(self): - """Apply consistent theme to the dialog.""" - self.setStyleSheet(""" - QDialog { - background-color: #1e1e2e; - color: #cdd6f4; - } - QTabWidget::pane { - border: 1px solid #45475a; - background-color: #313244; - } - QTabWidget::tab-bar { - alignment: center; - } - QTabBar::tab { - background-color: #45475a; - color: #cdd6f4; - padding: 8px 16px; - margin-right: 2px; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - } - QTabBar::tab:selected { - background-color: #89b4fa; - color: #1e1e2e; - } - QTabBar::tab:hover { - background-color: #585b70; - } - QGroupBox { - font-weight: bold; - border: 2px solid #45475a; - border-radius: 5px; - margin-top: 1ex; - padding-top: 5px; - } - QGroupBox::title { - subcontrol-origin: margin; - left: 10px; - padding: 0 10px 0 10px; - } - QPushButton { - background-color: #45475a; - color: #cdd6f4; - border: 1px solid #6c7086; - border-radius: 4px; - padding: 8px 16px; - font-weight: bold; - } - QPushButton:hover { - background-color: #585b70; - } - QPushButton:pressed { - background-color: #313244; - } - QPushButton:disabled { - background-color: #313244; - color: #6c7086; - } - QTextEdit, QLineEdit { - background-color: #313244; - color: #cdd6f4; - border: 1px solid #45475a; - border-radius: 4px; - padding: 4px; - } - QTableWidget { - background-color: #313244; - alternate-background-color: #45475a; - color: #cdd6f4; - border: 1px solid #45475a; - } - QProgressBar { - background-color: #313244; - border: 1px solid #45475a; - border-radius: 4px; - text-align: center; - } - QProgressBar::chunk { - background-color: #a6e3a1; - border-radius: 3px; - } - """) - - def closeEvent(self, event): - """Handle dialog close event.""" - if self.deployment_worker and self.deployment_worker.isRunning(): - reply = QMessageBox.question(self, "Cancel Deployment", - "Deployment is in progress. Are you sure you want to cancel?", - QMessageBox.Yes | QMessageBox.No) - if reply == QMessageBox.Yes: - self.deployment_worker.stop() - self.deployment_worker.wait(3000) # Wait up to 3 seconds - event.accept() - else: - event.ignore() - else: - event.accept() \ No newline at end of file diff --git a/cluster4npu_ui/ui/dialogs/performance.py b/cluster4npu_ui/ui/dialogs/performance.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/dialogs/properties.py b/cluster4npu_ui/ui/dialogs/properties.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/dialogs/save_deploy.py b/cluster4npu_ui/ui/dialogs/save_deploy.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/dialogs/stage_config.py b/cluster4npu_ui/ui/dialogs/stage_config.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/ui/windows/__init__.py b/cluster4npu_ui/ui/windows/__init__.py deleted file mode 100644 index 15864e9..0000000 --- a/cluster4npu_ui/ui/windows/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Main application windows for the Cluster4NPU UI. - -This module contains the primary application windows including the startup -dashboard, main pipeline editor, and integrated development environment. - -Available Windows: - - DashboardLogin: Startup window with project management - - IntegratedPipelineDashboard: Main pipeline design interface (future) - - PipelineEditor: Alternative pipeline editor window (future) - -Usage: - from cluster4npu_ui.ui.windows import DashboardLogin - - dashboard = DashboardLogin() - dashboard.show() -""" - -from .login import DashboardLogin -from .dashboard import IntegratedPipelineDashboard - -__all__ = [ - "DashboardLogin", - "IntegratedPipelineDashboard" -] \ No newline at end of file diff --git a/cluster4npu_ui/ui/windows/dashboard.py b/cluster4npu_ui/ui/windows/dashboard.py deleted file mode 100644 index 83310c2..0000000 --- a/cluster4npu_ui/ui/windows/dashboard.py +++ /dev/null @@ -1,2364 +0,0 @@ -""" -Integrated pipeline dashboard for the Cluster4NPU UI application. - -This module provides the main dashboard window that combines pipeline editing, -stage configuration, performance estimation, and dongle management in a unified -interface with a 3-panel layout. - -Main Components: - - IntegratedPipelineDashboard: Main dashboard window - - Node template palette for pipeline design - - Dynamic property editing panels - - Performance estimation and hardware management - - Pipeline save/load functionality - -Usage: - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - dashboard = IntegratedPipelineDashboard() - dashboard.show() -""" - -import sys -import json -import os -from typing import Optional, Dict, Any, List - -from PyQt5.QtWidgets import ( - QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QLineEdit, QPushButton, - QLabel, QSpinBox, QDoubleSpinBox, QComboBox, QListWidget, QCheckBox, - QSplitter, QAction, QScrollArea, QTabWidget, QTableWidget, QTableWidgetItem, - QHeaderView, QProgressBar, QGroupBox, QGridLayout, QFrame, QTextBrowser, - QSizePolicy, QMessageBox, QFileDialog, QFormLayout, QToolBar, QStatusBar -) -from PyQt5.QtCore import Qt, pyqtSignal, QTimer -from PyQt5.QtGui import QFont - -try: - from NodeGraphQt import NodeGraph - NODEGRAPH_AVAILABLE = True -except ImportError: - NODEGRAPH_AVAILABLE = False - print("Warning: NodeGraphQt not available. Pipeline editor will be disabled.") - -from cluster4npu_ui.config.theme import HARMONIOUS_THEME_STYLESHEET -from cluster4npu_ui.config.settings import get_settings -try: - from cluster4npu_ui.core.nodes import ( - InputNode, ModelNode, PreprocessNode, PostprocessNode, OutputNode, - NODE_TYPES, create_node_property_widget - ) - ADVANCED_NODES_AVAILABLE = True -except ImportError: - ADVANCED_NODES_AVAILABLE = False - -# Use exact nodes that match original properties -from cluster4npu_ui.core.nodes.exact_nodes import ( - ExactInputNode, ExactModelNode, ExactPreprocessNode, - ExactPostprocessNode, ExactOutputNode, EXACT_NODE_TYPES -) - -# Import pipeline analysis functions -try: - from cluster4npu_ui.core.pipeline import get_stage_count, analyze_pipeline_stages, get_pipeline_summary -except ImportError: - # Fallback functions if not available - def get_stage_count(graph): - return 0 - def analyze_pipeline_stages(graph): - return {} - def get_pipeline_summary(graph): - return {'stage_count': 0, 'valid': True, 'error': '', 'total_nodes': 0, 'model_nodes': 0, 'input_nodes': 0, 'output_nodes': 0, 'preprocess_nodes': 0, 'postprocess_nodes': 0, 'stages': []} - - -class StageCountWidget(QWidget): - """Widget to display stage count information in the pipeline editor.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.stage_count = 0 - self.pipeline_valid = True - self.pipeline_error = "" - - self.setup_ui() - self.setFixedSize(120, 22) - - def setup_ui(self): - """Setup the stage count widget UI.""" - layout = QHBoxLayout() - layout.setContentsMargins(5, 2, 5, 2) - - # Stage count label only (compact version) - self.stage_label = QLabel("Stages: 0") - self.stage_label.setFont(QFont("Arial", 10, QFont.Bold)) - self.stage_label.setStyleSheet("color: #cdd6f4; font-weight: bold;") - - layout.addWidget(self.stage_label) - self.setLayout(layout) - - # Style the widget for status bar - ensure it's visible - self.setStyleSheet(""" - StageCountWidget { - background-color: transparent; - border: none; - } - """) - - # Ensure the widget is visible - self.setVisible(True) - self.stage_label.setVisible(True) - - def update_stage_count(self, count: int, valid: bool = True, error: str = ""): - """Update the stage count display.""" - self.stage_count = count - self.pipeline_valid = valid - self.pipeline_error = error - - # Update stage count with status indication - if not valid: - self.stage_label.setText(f"Stages: {count}") - self.stage_label.setStyleSheet("color: #f38ba8; font-weight: bold;") - else: - if count == 0: - self.stage_label.setText("Stages: 0") - self.stage_label.setStyleSheet("color: #f9e2af; font-weight: bold;") - else: - self.stage_label.setText(f"Stages: {count}") - self.stage_label.setStyleSheet("color: #a6e3a1; font-weight: bold;") - - -class IntegratedPipelineDashboard(QMainWindow): - """ - Integrated dashboard combining pipeline editor, stage configuration, and performance estimation. - - This is the main application window that provides a comprehensive interface for - designing, configuring, and managing ML inference pipelines. - """ - - # Signals - pipeline_modified = pyqtSignal() - node_selected = pyqtSignal(object) - pipeline_changed = pyqtSignal() - stage_count_changed = pyqtSignal(int) - - def __init__(self, project_name: str = "", description: str = "", filename: Optional[str] = None): - super().__init__() - - # Project information - self.project_name = project_name or "Untitled Pipeline" - self.description = description - self.current_file = filename - self.is_modified = False - - # Settings - self.settings = get_settings() - - # Initialize UI components that will be created later - self.props_instructions = None - self.node_props_container = None - self.node_props_layout = None - self.fps_label = None - self.latency_label = None - self.memory_label = None - self.suggestions_text = None - self.dongles_list = None - self.detected_devices = [] # Store detected device information - self.stage_count_widget = None - self.analysis_timer = None - self.previous_stage_count = 0 - self.stats_label = None - - # Initialize node graph if available - if NODEGRAPH_AVAILABLE: - self.setup_node_graph() - else: - self.graph = None - - # Setup UI - self.setup_integrated_ui() - self.setup_menu() - self.setup_shortcuts() - self.setup_analysis_timer() - - # Apply styling and configure window - self.apply_styling() - self.update_window_title() - self.setGeometry(50, 50, 1400, 900) - - # Connect signals - self.pipeline_changed.connect(self.analyze_pipeline) - - # Initial analysis - print("🚀 Pipeline Dashboard initialized") - self.analyze_pipeline() - - # Set up a timer to hide UI elements after initialization - self.ui_cleanup_timer = QTimer() - self.ui_cleanup_timer.setSingleShot(True) - self.ui_cleanup_timer.timeout.connect(self.cleanup_node_graph_ui) - self.ui_cleanup_timer.start(1000) # 1 second delay - - def setup_node_graph(self): - """Initialize the node graph system.""" - try: - self.graph = NodeGraph() - - # Configure NodeGraphQt to hide unwanted UI elements - viewer = self.graph.viewer() - if viewer: - # Hide the logo/icon in bottom left corner - if hasattr(viewer, 'set_logo_visible'): - viewer.set_logo_visible(False) - elif hasattr(viewer, 'show_logo'): - viewer.show_logo(False) - - # Try to hide grid - if hasattr(viewer, 'set_grid_mode'): - viewer.set_grid_mode(0) # 0 = no grid - elif hasattr(viewer, 'grid_mode'): - viewer.grid_mode = 0 - - # Try to hide navigation widget/toolbar - if hasattr(viewer, 'set_nav_widget_visible'): - viewer.set_nav_widget_visible(False) - elif hasattr(viewer, 'navigation_widget'): - nav_widget = viewer.navigation_widget() - if nav_widget: - nav_widget.setVisible(False) - - # Try to hide any other UI elements - if hasattr(viewer, 'set_minimap_visible'): - viewer.set_minimap_visible(False) - - # Hide menu bar if exists - if hasattr(viewer, 'set_menu_bar_visible'): - viewer.set_menu_bar_visible(False) - - # Try to hide any toolbar elements - widget = viewer.widget if hasattr(viewer, 'widget') else None - if widget: - # Find and hide toolbar-like children - from PyQt5.QtWidgets import QToolBar, QFrame, QWidget - for child in widget.findChildren(QToolBar): - child.setVisible(False) - - # Look for other UI widgets that might be the horizontal bar - for child in widget.findChildren(QFrame): - # Check if this might be the navigation bar - if hasattr(child, 'objectName') and 'nav' in child.objectName().lower(): - child.setVisible(False) - # Check size and position to identify the horizontal bar - elif hasattr(child, 'geometry'): - geom = child.geometry() - # If it's a horizontal bar at the bottom left - if geom.height() < 50 and geom.width() > 100: - child.setVisible(False) - - # Additional attempt to hide navigation elements - for child in widget.findChildren(QWidget): - if hasattr(child, 'objectName'): - obj_name = child.objectName().lower() - if any(keyword in obj_name for keyword in ['nav', 'toolbar', 'control', 'zoom']): - child.setVisible(False) - - # Use exact nodes that match original properties - nodes_to_register = [ - ExactInputNode, ExactModelNode, ExactPreprocessNode, - ExactPostprocessNode, ExactOutputNode - ] - - print("Registering nodes with NodeGraphQt...") - for node_class in nodes_to_register: - try: - self.graph.register_node(node_class) - print(f"✓ Registered {node_class.__name__} with identifier {node_class.__identifier__}") - except Exception as e: - print(f"✗ Failed to register {node_class.__name__}: {e}") - - # Connect signals - self.graph.node_created.connect(self.mark_modified) - self.graph.nodes_deleted.connect(self.mark_modified) - self.graph.node_selection_changed.connect(self.on_node_selection_changed) - - # Connect pipeline analysis signals - self.graph.node_created.connect(self.schedule_analysis) - self.graph.nodes_deleted.connect(self.schedule_analysis) - if hasattr(self.graph, 'connection_changed'): - self.graph.connection_changed.connect(self.schedule_analysis) - - if hasattr(self.graph, 'property_changed'): - self.graph.property_changed.connect(self.mark_modified) - - print("Node graph setup completed successfully") - - except Exception as e: - print(f"Error setting up node graph: {e}") - import traceback - traceback.print_exc() - self.graph = None - - def cleanup_node_graph_ui(self): - """Clean up NodeGraphQt UI elements after initialization.""" - if not self.graph: - return - - try: - viewer = self.graph.viewer() - if viewer: - widget = viewer.widget if hasattr(viewer, 'widget') else None - if widget: - print("🧹 Cleaning up NodeGraphQt UI elements...") - - # More aggressive cleanup - hide all small widgets at bottom - from PyQt5.QtWidgets import QWidget, QFrame, QLabel, QPushButton - from PyQt5.QtCore import QRect - - for child in widget.findChildren(QWidget): - if hasattr(child, 'geometry'): - geom = child.geometry() - parent_geom = widget.geometry() - - # Check if it's a small widget at the bottom left - if (geom.height() < 100 and - geom.width() < 200 and - geom.y() > parent_geom.height() - 100 and - geom.x() < 200): - print(f"🗑️ Hiding bottom-left widget: {child.__class__.__name__}") - child.setVisible(False) - - # Also try to hide by CSS styling - try: - widget.setStyleSheet(widget.styleSheet() + """ - QWidget[objectName*="nav"] { display: none; } - QWidget[objectName*="toolbar"] { display: none; } - QWidget[objectName*="control"] { display: none; } - QFrame[objectName*="zoom"] { display: none; } - """) - except: - pass - - except Exception as e: - print(f"Error cleaning up NodeGraphQt UI: {e}") - - def setup_integrated_ui(self): - """Setup the integrated UI with node templates, pipeline editor and configuration panels.""" - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # Main layout with status bar at bottom - main_layout = QVBoxLayout(central_widget) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - - # Main horizontal splitter with 3 panels - main_splitter = QSplitter(Qt.Horizontal) - - # Left side: Node Template Panel (25% width) - left_panel = self.create_node_template_panel() - left_panel.setMinimumWidth(250) - left_panel.setMaximumWidth(350) - - # Middle: Pipeline Editor (50% width) - without its own status bar - middle_panel = self.create_pipeline_editor_panel() - - # Right side: Configuration panels (25% width) - optimized for no horizontal scroll - right_panel = self.create_configuration_panel() - right_panel.setMinimumWidth(320) - right_panel.setMaximumWidth(380) - - # Add widgets to splitter - main_splitter.addWidget(left_panel) - main_splitter.addWidget(middle_panel) - main_splitter.addWidget(right_panel) - main_splitter.setSizes([300, 700, 400]) # 25-50-25 split - - # Add splitter to main layout - main_layout.addWidget(main_splitter) - - # Add global status bar at the bottom - self.global_status_bar = self.create_status_bar_widget() - main_layout.addWidget(self.global_status_bar) - - def create_node_template_panel(self) -> QWidget: - """Create left panel with node templates.""" - panel = QWidget() - layout = QVBoxLayout(panel) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(10) - - # Header - header = QLabel("Node Templates") - header.setStyleSheet("color: #f9e2af; font-size: 16px; font-weight: bold; padding: 10px;") - layout.addWidget(header) - - # Node template buttons - use exact nodes matching original - nodes_info = [ - ("Input Node", "Data input source", ExactInputNode), - ("Model Node", "AI inference model", ExactModelNode), - ("Preprocess Node", "Data preprocessing", ExactPreprocessNode), - ("Postprocess Node", "Output processing", ExactPostprocessNode), - ("Output Node", "Final output", ExactOutputNode) - ] - - for name, description, node_class in nodes_info: - # Create container for each node type - node_container = QFrame() - node_container.setStyleSheet(""" - QFrame { - background-color: #313244; - border: 2px solid #45475a; - border-radius: 8px; - padding: 5px; - } - QFrame:hover { - border-color: #89b4fa; - background-color: #383a59; - } - """) - - container_layout = QVBoxLayout(node_container) - container_layout.setContentsMargins(8, 8, 8, 8) - container_layout.setSpacing(4) - - # Node name - name_label = QLabel(name) - name_label.setStyleSheet("color: #cdd6f4; font-weight: bold; font-size: 12px;") - container_layout.addWidget(name_label) - - # Description - desc_label = QLabel(description) - desc_label.setStyleSheet("color: #a6adc8; font-size: 10px;") - desc_label.setWordWrap(True) - container_layout.addWidget(desc_label) - - # Add button - add_btn = QPushButton("+ Add") - add_btn.setStyleSheet(""" - QPushButton { - background-color: #89b4fa; - color: #1e1e2e; - border: none; - padding: 4px 8px; - border-radius: 4px; - font-size: 10px; - font-weight: bold; - } - QPushButton:hover { - background-color: #a6c8ff; - } - QPushButton:pressed { - background-color: #7287fd; - } - """) - add_btn.clicked.connect(lambda checked, nc=node_class: self.add_node_to_graph(nc)) - container_layout.addWidget(add_btn) - - layout.addWidget(node_container) - - # Pipeline Operations Section - operations_label = QLabel("Pipeline Operations") - operations_label.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 10px;") - layout.addWidget(operations_label) - - # Create operation buttons - operations = [ - ("Validate Pipeline", self.validate_pipeline), - ("Clear Pipeline", self.clear_pipeline), - ] - - for name, handler in operations: - btn = QPushButton(name) - btn.setStyleSheet(""" - QPushButton { - background-color: #45475a; - color: #cdd6f4; - border: 1px solid #585b70; - border-radius: 6px; - padding: 8px 12px; - font-size: 11px; - font-weight: bold; - margin: 2px; - } - QPushButton:hover { - background-color: #585b70; - border-color: #89b4fa; - } - QPushButton:pressed { - background-color: #313244; - } - """) - btn.clicked.connect(handler) - layout.addWidget(btn) - - # Add stretch to push everything to top - layout.addStretch() - - # Instructions - instructions = QLabel("Click 'Add' to insert nodes into the pipeline editor") - instructions.setStyleSheet(""" - color: #f9e2af; - font-size: 10px; - padding: 10px; - background-color: #313244; - border-radius: 6px; - border-left: 3px solid #89b4fa; - """) - instructions.setWordWrap(True) - layout.addWidget(instructions) - - return panel - - def create_pipeline_editor_panel(self) -> QWidget: - """Create the middle panel with pipeline editor.""" - panel = QWidget() - layout = QVBoxLayout(panel) - layout.setContentsMargins(5, 5, 5, 5) - - # Header - header = QLabel("Pipeline Editor") - header.setStyleSheet("color: #f9e2af; font-size: 16px; font-weight: bold; padding: 10px;") - layout.addWidget(header) - - if self.graph and NODEGRAPH_AVAILABLE: - # Add the node graph widget directly - graph_widget = self.graph.widget - graph_widget.setMinimumHeight(400) - layout.addWidget(graph_widget) - else: - # Fallback: show placeholder - placeholder = QLabel("Pipeline Editor\n(NodeGraphQt not available)") - placeholder.setStyleSheet(""" - color: #6c7086; - font-size: 14px; - padding: 40px; - background-color: #313244; - border-radius: 8px; - border: 2px dashed #45475a; - """) - placeholder.setAlignment(Qt.AlignCenter) - layout.addWidget(placeholder) - - return panel - - def create_pipeline_toolbar(self) -> QToolBar: - """Create toolbar for pipeline operations.""" - toolbar = QToolBar("Pipeline Operations") - toolbar.setStyleSheet(""" - QToolBar { - background-color: #313244; - border: 1px solid #45475a; - spacing: 5px; - padding: 5px; - } - QToolBar QAction { - padding: 5px 10px; - margin: 2px; - border: 1px solid #45475a; - border-radius: 3px; - background-color: #45475a; - color: #cdd6f4; - } - QToolBar QAction:hover { - background-color: #585b70; - } - """) - - # Add nodes actions - add_input_action = QAction("Add Input", self) - add_input_action.triggered.connect(lambda: self.add_node_to_graph(ExactInputNode)) - toolbar.addAction(add_input_action) - - add_model_action = QAction("Add Model", self) - add_model_action.triggered.connect(lambda: self.add_node_to_graph(ExactModelNode)) - toolbar.addAction(add_model_action) - - add_preprocess_action = QAction("Add Preprocess", self) - add_preprocess_action.triggered.connect(lambda: self.add_node_to_graph(ExactPreprocessNode)) - toolbar.addAction(add_preprocess_action) - - add_postprocess_action = QAction("Add Postprocess", self) - add_postprocess_action.triggered.connect(lambda: self.add_node_to_graph(ExactPostprocessNode)) - toolbar.addAction(add_postprocess_action) - - add_output_action = QAction("Add Output", self) - add_output_action.triggered.connect(lambda: self.add_node_to_graph(ExactOutputNode)) - toolbar.addAction(add_output_action) - - toolbar.addSeparator() - - # Pipeline actions - validate_action = QAction("Validate Pipeline", self) - validate_action.triggered.connect(self.validate_pipeline) - toolbar.addAction(validate_action) - - clear_action = QAction("Clear Pipeline", self) - clear_action.triggered.connect(self.clear_pipeline) - toolbar.addAction(clear_action) - - toolbar.addSeparator() - - # Deploy action - deploy_action = QAction("Deploy Pipeline", self) - deploy_action.setToolTip("Convert pipeline to executable format and deploy to dongles") - deploy_action.triggered.connect(self.deploy_pipeline) - deploy_action.setStyleSheet(""" - QAction { - background-color: #a6e3a1; - color: #1e1e2e; - font-weight: bold; - } - QAction:hover { - background-color: #94d2a3; - } - """) - toolbar.addAction(deploy_action) - - return toolbar - - def setup_analysis_timer(self): - """Setup timer for pipeline analysis.""" - self.analysis_timer = QTimer() - self.analysis_timer.setSingleShot(True) - self.analysis_timer.timeout.connect(self.analyze_pipeline) - self.analysis_timer.setInterval(500) # 500ms delay - - def schedule_analysis(self): - """Schedule pipeline analysis after a delay.""" - if self.analysis_timer: - self.analysis_timer.start() - - def analyze_pipeline(self): - """Analyze the current pipeline and update stage count.""" - if not self.graph: - return - - try: - # Get pipeline summary - summary = get_pipeline_summary(self.graph) - current_stage_count = summary['stage_count'] - - # Print detailed pipeline analysis - self.print_pipeline_analysis(summary, current_stage_count) - - # Update stage count widget - if self.stage_count_widget: - print(f"🔄 Updating stage count widget: {current_stage_count} stages") - self.stage_count_widget.update_stage_count( - current_stage_count, - summary['valid'], - summary.get('error', '') - ) - - # Update statistics label - if hasattr(self, 'stats_label') and self.stats_label: - total_nodes = summary['total_nodes'] - # Count connections more accurately - connection_count = 0 - if self.graph: - for node in self.graph.all_nodes(): - try: - if hasattr(node, 'output_ports'): - for output_port in node.output_ports(): - if hasattr(output_port, 'connected_ports'): - connection_count += len(output_port.connected_ports()) - elif hasattr(node, 'outputs'): - for output in node.outputs(): - if hasattr(output, 'connected_ports'): - connection_count += len(output.connected_ports()) - elif hasattr(output, 'connected_inputs'): - connection_count += len(output.connected_inputs()) - except Exception: - # If there's any error accessing connections, skip this node - continue - - self.stats_label.setText(f"Nodes: {total_nodes} | Connections: {connection_count}") - - # Update info panel (if it exists) - if hasattr(self, 'info_text') and self.info_text: - self.update_info_panel(summary) - - # Update previous count for next comparison - self.previous_stage_count = current_stage_count - - # Emit signal - self.stage_count_changed.emit(current_stage_count) - - except Exception as e: - print(f"Pipeline analysis error: {str(e)}") - if self.stage_count_widget: - self.stage_count_widget.update_stage_count(0, False, f"Analysis error: {str(e)}") - - def print_pipeline_analysis(self, summary, current_stage_count): - """Print detailed pipeline analysis to terminal.""" - # Check if stage count changed - if current_stage_count != self.previous_stage_count: - if self.previous_stage_count == 0 and current_stage_count > 0: - print(f"Initial stage count: {current_stage_count}") - elif current_stage_count != self.previous_stage_count: - change = current_stage_count - self.previous_stage_count - if change > 0: - print(f"Stage count increased: {self.previous_stage_count} → {current_stage_count} (+{change})") - else: - print(f"Stage count decreased: {self.previous_stage_count} → {current_stage_count} ({change})") - - # Always print current pipeline status for clarity - print(f"Current Pipeline Status:") - print(f" • Stages: {current_stage_count}") - print(f" • Total Nodes: {summary['total_nodes']}") - print(f" • Model Nodes: {summary['model_nodes']}") - print(f" • Input Nodes: {summary['input_nodes']}") - print(f" • Output Nodes: {summary['output_nodes']}") - print(f" • Preprocess Nodes: {summary['preprocess_nodes']}") - print(f" • Postprocess Nodes: {summary['postprocess_nodes']}") - print(f" • Valid: {'V' if summary['valid'] else 'X'}") - - if not summary['valid'] and summary.get('error'): - print(f" • Error: {summary['error']}") - - # Print stage details if available - if summary.get('stages') and len(summary['stages']) > 0: - print(f"Stage Details:") - for i, stage in enumerate(summary['stages'], 1): - model_name = stage['model_config'].get('node_name', 'Unknown Model') - preprocess_count = len(stage['preprocess_configs']) - postprocess_count = len(stage['postprocess_configs']) - - stage_info = f" Stage {i}: {model_name}" - if preprocess_count > 0: - stage_info += f" (with {preprocess_count} preprocess)" - if postprocess_count > 0: - stage_info += f" (with {postprocess_count} postprocess)" - - print(stage_info) - elif current_stage_count > 0: - print(f"{current_stage_count} stage(s) detected but details not available") - - print("─" * 50) # Separator line - - def update_info_panel(self, summary): - """Update the pipeline info panel with analysis results.""" - # This method is kept for compatibility but no longer used - # since we removed the separate info panel - pass - - def clear_pipeline(self): - """Clear the entire pipeline.""" - if self.graph: - print("Clearing entire pipeline...") - self.graph.clear_session() - self.schedule_analysis() - - def create_configuration_panel(self) -> QWidget: - """Create the right panel with configuration tabs.""" - panel = QWidget() - layout = QVBoxLayout(panel) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(10) - - # Create tabs for different configuration sections - config_tabs = QTabWidget() - config_tabs.setStyleSheet(""" - QTabWidget::pane { - border: 2px solid #45475a; - border-radius: 8px; - background-color: #313244; - } - QTabWidget::tab-bar { - alignment: center; - } - QTabBar::tab { - background-color: #45475a; - color: #cdd6f4; - padding: 6px 12px; - margin: 1px; - border-radius: 4px; - font-size: 11px; - } - QTabBar::tab:selected { - background-color: #89b4fa; - color: #1e1e2e; - font-weight: bold; - } - QTabBar::tab:hover { - background-color: #585b70; - } - """) - - # Add tabs - config_tabs.addTab(self.create_node_properties_panel(), "Properties") - config_tabs.addTab(self.create_performance_panel(), "Performance") - config_tabs.addTab(self.create_dongle_panel(), "Dongles") - - layout.addWidget(config_tabs) - return panel - - def create_node_properties_panel(self) -> QWidget: - """Create node properties editing panel.""" - widget = QScrollArea() - - # Configure scroll area to prevent horizontal scrolling - widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - widget.setWidgetResizable(True) - - content = QWidget() - layout = QVBoxLayout(content) - layout.setContentsMargins(10, 10, 10, 10) # Add some padding - - # Header - header = QLabel("Node Properties") - header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;") - layout.addWidget(header) - - # Instructions when no node selected - self.props_instructions = QLabel("Select a node in the pipeline editor to view and edit its properties") - self.props_instructions.setStyleSheet(""" - color: #a6adc8; - font-size: 12px; - padding: 20px; - background-color: #313244; - border-radius: 8px; - border: 2px dashed #45475a; - """) - self.props_instructions.setWordWrap(True) - self.props_instructions.setAlignment(Qt.AlignCenter) - layout.addWidget(self.props_instructions) - - # Container for dynamic properties - self.node_props_container = QWidget() - self.node_props_layout = QVBoxLayout(self.node_props_container) - layout.addWidget(self.node_props_container) - - # Initially hide the container - self.node_props_container.setVisible(False) - - layout.addStretch() - widget.setWidget(content) - widget.setWidgetResizable(True) - - return widget - - def create_status_bar_widget(self) -> QWidget: - """Create a global status bar widget for pipeline information.""" - status_widget = QWidget() - status_widget.setFixedHeight(28) - status_widget.setStyleSheet(""" - QWidget { - background-color: #1e1e2e; - border-top: 1px solid #45475a; - margin: 0px; - padding: 0px; - } - """) - - layout = QHBoxLayout(status_widget) - layout.setContentsMargins(15, 3, 15, 3) - layout.setSpacing(20) - - # Left side: Stage count display - self.stage_count_widget = StageCountWidget() - self.stage_count_widget.setFixedSize(120, 22) - layout.addWidget(self.stage_count_widget) - - # Center spacer - layout.addStretch() - - # Right side: Pipeline statistics - self.stats_label = QLabel("Nodes: 0 | Connections: 0") - self.stats_label.setStyleSheet("color: #a6adc8; font-size: 10px;") - layout.addWidget(self.stats_label) - - return status_widget - - def create_performance_panel(self) -> QWidget: - """Create performance estimation panel.""" - widget = QScrollArea() - content = QWidget() - layout = QVBoxLayout(content) - - # Header - header = QLabel("Performance Estimation") - header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;") - layout.addWidget(header) - - # Performance metrics - metrics_group = QGroupBox("Estimated Metrics") - metrics_layout = QFormLayout(metrics_group) - - self.fps_label = QLabel("-- FPS") - self.latency_label = QLabel("-- ms") - self.memory_label = QLabel("-- MB") - - metrics_layout.addRow("Throughput:", self.fps_label) - metrics_layout.addRow("Latency:", self.latency_label) - metrics_layout.addRow("Memory Usage:", self.memory_label) - - layout.addWidget(metrics_group) - - # Suggestions - suggestions_group = QGroupBox("Optimization Suggestions") - suggestions_layout = QVBoxLayout(suggestions_group) - - self.suggestions_text = QTextBrowser() - self.suggestions_text.setMaximumHeight(150) - self.suggestions_text.setPlainText("Connect nodes to see performance analysis and optimization suggestions.") - suggestions_layout.addWidget(self.suggestions_text) - - layout.addWidget(suggestions_group) - - # Deploy section - deploy_group = QGroupBox("Pipeline Deployment") - deploy_layout = QVBoxLayout(deploy_group) - - # Deploy button - self.deploy_button = QPushButton("Deploy Pipeline") - self.deploy_button.setToolTip("Convert pipeline to executable format and deploy to dongles") - self.deploy_button.clicked.connect(self.deploy_pipeline) - self.deploy_button.setStyleSheet(""" - QPushButton { - background-color: #a6e3a1; - color: #1e1e2e; - border: 2px solid #a6e3a1; - border-radius: 8px; - padding: 12px 24px; - font-weight: bold; - font-size: 14px; - min-height: 20px; - } - QPushButton:hover { - background-color: #94d2a3; - border-color: #94d2a3; - } - QPushButton:pressed { - background-color: #7dc4b0; - border-color: #7dc4b0; - } - QPushButton:disabled { - background-color: #6c7086; - color: #45475a; - border-color: #6c7086; - } - """) - deploy_layout.addWidget(self.deploy_button) - - # Deployment status - self.deployment_status = QLabel("Ready to deploy") - self.deployment_status.setStyleSheet("color: #a6adc8; font-size: 11px; margin-top: 5px;") - self.deployment_status.setAlignment(Qt.AlignCenter) - deploy_layout.addWidget(self.deployment_status) - - layout.addWidget(deploy_group) - - layout.addStretch() - widget.setWidget(content) - widget.setWidgetResizable(True) - - return widget - - def create_dongle_panel(self) -> QWidget: - """Create dongle management panel.""" - widget = QScrollArea() - content = QWidget() - layout = QVBoxLayout(content) - - # Header - header = QLabel("Dongle Management") - header.setStyleSheet("color: #f9e2af; font-size: 14px; font-weight: bold; padding: 5px;") - layout.addWidget(header) - - # Detect dongles button - detect_btn = QPushButton("Detect Dongles") - detect_btn.clicked.connect(self.detect_dongles) - layout.addWidget(detect_btn) - - # Dongles list - self.dongles_list = QListWidget() - self.dongles_list.addItem("No dongles detected. Click 'Detect Dongles' to scan.") - layout.addWidget(self.dongles_list) - - layout.addStretch() - widget.setWidget(content) - widget.setWidgetResizable(True) - - return widget - - def setup_menu(self): - """Setup the menu bar.""" - menubar = self.menuBar() - - # File menu - file_menu = menubar.addMenu('&File') - - # New pipeline - new_action = QAction('&New Pipeline', self) - new_action.setShortcut('Ctrl+N') - new_action.triggered.connect(self.new_pipeline) - file_menu.addAction(new_action) - - # Open pipeline - open_action = QAction('&Open Pipeline...', self) - open_action.setShortcut('Ctrl+O') - open_action.triggered.connect(self.open_pipeline) - file_menu.addAction(open_action) - - file_menu.addSeparator() - - # Save pipeline - save_action = QAction('&Save Pipeline', self) - save_action.setShortcut('Ctrl+S') - save_action.triggered.connect(self.save_pipeline) - file_menu.addAction(save_action) - - # Save As - save_as_action = QAction('Save &As...', self) - save_as_action.setShortcut('Ctrl+Shift+S') - save_as_action.triggered.connect(self.save_pipeline_as) - file_menu.addAction(save_as_action) - - file_menu.addSeparator() - - # Export - export_action = QAction('&Export Configuration...', self) - export_action.triggered.connect(self.export_configuration) - file_menu.addAction(export_action) - - # Pipeline menu - pipeline_menu = menubar.addMenu('&Pipeline') - - # Validate pipeline - validate_action = QAction('&Validate Pipeline', self) - validate_action.triggered.connect(self.validate_pipeline) - pipeline_menu.addAction(validate_action) - - # Performance estimation - perf_action = QAction('&Performance Analysis', self) - perf_action.triggered.connect(self.update_performance_estimation) - pipeline_menu.addAction(perf_action) - - def setup_shortcuts(self): - """Setup keyboard shortcuts.""" - # Delete shortcut - self.delete_shortcut = QAction("Delete", self) - self.delete_shortcut.setShortcut('Delete') - self.delete_shortcut.triggered.connect(self.delete_selected_nodes) - self.addAction(self.delete_shortcut) - - def apply_styling(self): - """Apply the application stylesheet.""" - self.setStyleSheet(HARMONIOUS_THEME_STYLESHEET) - - # Event handlers and utility methods - - def add_node_to_graph(self, node_class): - """Add a new node to the graph.""" - if not self.graph: - QMessageBox.warning(self, "Node Graph Not Available", - "NodeGraphQt is not available. Cannot add nodes.") - return - - try: - print(f"Attempting to create node with identifier: {node_class.__identifier__}") - - # Try different identifier formats that NodeGraphQt might use - identifiers_to_try = [ - node_class.__identifier__, # Original identifier - f"{node_class.__identifier__}.{node_class.__name__}", # Full format - node_class.__name__, # Just class name - ] - - node = None - for identifier in identifiers_to_try: - try: - print(f"Trying identifier: {identifier}") - node = self.graph.create_node(identifier) - print(f"Success with identifier: {identifier}") - break - except Exception as e: - print(f"Failed with {identifier}: {e}") - continue - - if not node: - raise Exception("Could not create node with any identifier format") - - # Position the node with some randomization to avoid overlap - import random - x_pos = random.randint(50, 300) - y_pos = random.randint(50, 300) - node.set_pos(x_pos, y_pos) - - print(f"✓ Successfully created node: {node.name()}") - self.mark_modified() - - except Exception as e: - error_msg = f"Failed to create node: {e}" - print(f"✗ {error_msg}") - import traceback - traceback.print_exc() - - # Show user-friendly error - QMessageBox.critical(self, "Node Creation Error", - f"Could not create {node_class.NODE_NAME}.\n\n" - f"Error: {e}\n\n" - f"This might be due to:\n" - f"• Node not properly registered\n" - f"• NodeGraphQt compatibility issue\n" - f"• Missing dependencies") - - def on_node_selection_changed(self): - """Handle node selection changes.""" - if not self.graph: - return - - selected_nodes = self.graph.selected_nodes() - if selected_nodes: - self.update_node_properties_panel(selected_nodes[0]) - self.node_selected.emit(selected_nodes[0]) - else: - self.clear_node_properties_panel() - - def update_node_properties_panel(self, node): - """Update the properties panel for the selected node.""" - if not self.node_props_container: - return - - # Clear existing properties - self.clear_node_properties_panel() - - # Show the container and hide instructions - self.node_props_container.setVisible(True) - self.props_instructions.setVisible(False) - - # Create property form - form_widget = QWidget() - form_layout = QFormLayout(form_widget) - - # Node info - info_label = QLabel(f"Editing: {node.name()}") - info_label.setStyleSheet("color: #89b4fa; font-weight: bold; margin-bottom: 10px;") - form_layout.addRow(info_label) - - # Get node properties - try different methods - try: - properties = {} - - # Method 1: Try custom properties (for enhanced nodes) - if hasattr(node, 'get_business_properties'): - properties = node.get_business_properties() - - # Method 1.5: Try ExactNode properties (with _property_options) - elif hasattr(node, '_property_options') and node._property_options: - properties = {} - for prop_name in node._property_options.keys(): - if hasattr(node, 'get_property'): - try: - properties[prop_name] = node.get_property(prop_name) - except: - # If property doesn't exist, use a default value - properties[prop_name] = None - - # Method 2: Try standard NodeGraphQt properties - elif hasattr(node, 'properties'): - all_props = node.properties() - # Filter out system properties, keep user properties - for key, value in all_props.items(): - if not key.startswith('_') and key not in ['name', 'selected', 'disabled', 'custom']: - properties[key] = value - - # Method 3: Use exact original properties based on node type - else: - node_type = node.__class__.__name__ - if 'Input' in node_type: - # Exact InputNode properties from original - properties = { - 'source_type': node.get_property('source_type') if hasattr(node, 'get_property') else 'Camera', - 'device_id': node.get_property('device_id') if hasattr(node, 'get_property') else 0, - 'source_path': node.get_property('source_path') if hasattr(node, 'get_property') else '', - 'resolution': node.get_property('resolution') if hasattr(node, 'get_property') else '1920x1080', - 'fps': node.get_property('fps') if hasattr(node, 'get_property') else 30 - } - elif 'Model' in node_type: - # Exact ModelNode properties from original - including upload_fw checkbox - properties = { - 'model_path': node.get_property('model_path') if hasattr(node, 'get_property') else '', - 'scpu_fw_path': node.get_property('scpu_fw_path') if hasattr(node, 'get_property') else '', - 'ncpu_fw_path': node.get_property('ncpu_fw_path') if hasattr(node, 'get_property') else '', - 'dongle_series': node.get_property('dongle_series') if hasattr(node, 'get_property') else '520', - 'num_dongles': node.get_property('num_dongles') if hasattr(node, 'get_property') else 1, - 'port_id': node.get_property('port_id') if hasattr(node, 'get_property') else '', - 'upload_fw': node.get_property('upload_fw') if hasattr(node, 'get_property') else True - } - elif 'Preprocess' in node_type: - # Exact PreprocessNode properties from original - properties = { - 'resize_width': node.get_property('resize_width') if hasattr(node, 'get_property') else 640, - 'resize_height': node.get_property('resize_height') if hasattr(node, 'get_property') else 480, - 'normalize': node.get_property('normalize') if hasattr(node, 'get_property') else True, - 'crop_enabled': node.get_property('crop_enabled') if hasattr(node, 'get_property') else False, - 'operations': node.get_property('operations') if hasattr(node, 'get_property') else 'resize,normalize' - } - elif 'Postprocess' in node_type: - # Exact PostprocessNode properties from original - properties = { - 'output_format': node.get_property('output_format') if hasattr(node, 'get_property') else 'JSON', - 'confidence_threshold': node.get_property('confidence_threshold') if hasattr(node, 'get_property') else 0.5, - 'nms_threshold': node.get_property('nms_threshold') if hasattr(node, 'get_property') else 0.4, - 'max_detections': node.get_property('max_detections') if hasattr(node, 'get_property') else 100 - } - elif 'Output' in node_type: - # Exact OutputNode properties from original - properties = { - 'output_type': node.get_property('output_type') if hasattr(node, 'get_property') else 'File', - 'destination': node.get_property('destination') if hasattr(node, 'get_property') else '', - 'format': node.get_property('format') if hasattr(node, 'get_property') else 'JSON', - 'save_interval': node.get_property('save_interval') if hasattr(node, 'get_property') else 1.0 - } - - if properties: - for prop_name, prop_value in properties.items(): - # Create widget based on property type and name - widget = self.create_property_widget_enhanced(node, prop_name, prop_value) - - # Add to form with appropriate labels - if prop_name == 'upload_fw': - # For upload_fw, don't show a separate label since the checkbox has its own text - form_layout.addRow(widget) - else: - label = prop_name.replace('_', ' ').title() - form_layout.addRow(f"{label}:", widget) - else: - # Show available properties for debugging - info_text = f"Node type: {node.__class__.__name__}\n" - if hasattr(node, 'properties'): - props = node.properties() - info_text += f"Available properties: {list(props.keys())}" - else: - info_text += "No properties method found" - - info_label = QLabel(info_text) - info_label.setStyleSheet("color: #f9e2af; font-size: 10px;") - form_layout.addRow(info_label) - - except Exception as e: - error_label = QLabel(f"Error loading properties: {e}") - error_label.setStyleSheet("color: #f38ba8;") - form_layout.addRow(error_label) - import traceback - traceback.print_exc() - - self.node_props_layout.addWidget(form_widget) - - def create_property_widget(self, node, prop_name: str, prop_value, options: Dict): - """Create appropriate widget for a property.""" - # Simple implementation - can be enhanced - if isinstance(prop_value, bool): - widget = QCheckBox() - widget.setChecked(prop_value) - elif isinstance(prop_value, int): - widget = QSpinBox() - widget.setValue(prop_value) - if 'min' in options: - widget.setMinimum(options['min']) - if 'max' in options: - widget.setMaximum(options['max']) - elif isinstance(prop_value, float): - widget = QDoubleSpinBox() - widget.setValue(prop_value) - if 'min' in options: - widget.setMinimum(options['min']) - if 'max' in options: - widget.setMaximum(options['max']) - elif isinstance(options, list): - widget = QComboBox() - widget.addItems(options) - if prop_value in options: - widget.setCurrentText(str(prop_value)) - else: - widget = QLineEdit() - widget.setText(str(prop_value)) - - return widget - - def truncate_path_smart(self, path: str, max_length: int = 35) -> str: - """ - Smart path truncation that preserves important parts. - Shows: ...drive/important_folder/filename.ext - """ - if not path or len(path) <= max_length: - return path - - import os - - # Split path into components - drive, path_without_drive = os.path.splitdrive(path) - path_parts = path_without_drive.replace('\\', '/').split('/') - - if len(path_parts) <= 2: - # Very short path, just truncate from start - return '...' + path[-(max_length-3):] - - filename = path_parts[-1] if path_parts[-1] else path_parts[-2] - - # Always keep filename and one parent directory if possible - if len(filename) > max_length - 10: - # Filename itself is too long - return '...' + filename[-(max_length-3):] - - # Try to keep parent folder + filename - parent_dir = path_parts[-2] if len(path_parts) >= 2 else '' - short_end = f"/{parent_dir}/{filename}" if parent_dir else f"/{filename}" - - if len(short_end) <= max_length - 3: - return '...' + short_end - else: - # Just keep filename - return '.../' + filename - - def create_property_widget_enhanced(self, node, prop_name: str, prop_value): - """Create enhanced property widget with better type detection.""" - # Create widget based on property name and value - widget = None - - # Get property options from the node if available - prop_options = None - if hasattr(node, '_property_options') and prop_name in node._property_options: - prop_options = node._property_options[prop_name] - - # Check for file path properties first (from prop_options or name pattern) - if (prop_options and isinstance(prop_options, dict) and prop_options.get('type') == 'file_path') or \ - prop_name in ['model_path', 'source_path', 'destination']: - # File path property with smart truncation and width limits - display_text = self.truncate_path_smart(str(prop_value)) if prop_value else 'Select File...' - widget = QPushButton(display_text) - - # Set fixed width and styling to prevent expansion - widget.setMaximumWidth(250) # Limit button width - widget.setMinimumWidth(200) - widget.setStyleSheet(""" - QPushButton { - text-align: left; - padding: 5px 8px; - background-color: #45475a; - color: #cdd6f4; - border: 1px solid #585b70; - border-radius: 4px; - font-size: 10px; - } - QPushButton:hover { - background-color: #585b70; - border-color: #74c7ec; - } - QPushButton:pressed { - background-color: #313244; - } - """) - - # Store full path for tooltip and internal use - full_path = str(prop_value) if prop_value else '' - widget.setToolTip(f"Full path: {full_path}\n\nClick to browse for {prop_name.replace('_', ' ')}") - - def browse_file(): - # Use filter from prop_options if available, otherwise use defaults - if prop_options and 'filter' in prop_options: - file_filter = prop_options['filter'] - else: - # Fallback to original filters - filters = { - 'model_path': 'NEF Model files (*.nef)', - 'scpu_fw_path': 'SCPU Firmware files (*.bin)', - 'ncpu_fw_path': 'NCPU Firmware files (*.bin)', - 'source_path': 'Media files (*.mp4 *.avi *.mov *.mkv *.wav *.mp3)', - 'destination': 'Output files (*.json *.xml *.csv *.txt)' - } - file_filter = filters.get(prop_name, 'All files (*)') - - file_path, _ = QFileDialog.getOpenFileName(self, f'Select {prop_name}', '', file_filter) - if file_path: - # Update button text with truncated path - truncated_text = self.truncate_path_smart(file_path) - widget.setText(truncated_text) - # Update tooltip with full path - widget.setToolTip(f"Full path: {file_path}\n\nClick to browse for {prop_name.replace('_', ' ')}") - # Set property with full path - if hasattr(node, 'set_property'): - node.set_property(prop_name, file_path) - - widget.clicked.connect(browse_file) - - # Check for dropdown properties (list options from prop_options or predefined) - elif (prop_options and isinstance(prop_options, list)) or \ - prop_name in ['source_type', 'dongle_series', 'output_format', 'format', 'output_type', 'resolution']: - # Dropdown property with width limits - widget = QComboBox() - - # Set maximum width to prevent expansion - widget.setMaximumWidth(250) - widget.setMinimumWidth(150) - widget.setStyleSheet(""" - QComboBox { - padding: 4px 8px; - background-color: #45475a; - color: #cdd6f4; - border: 1px solid #585b70; - border-radius: 4px; - font-size: 11px; - } - QComboBox:hover { - border-color: #74c7ec; - } - QComboBox::drop-down { - border: none; - width: 20px; - } - QComboBox::down-arrow { - image: none; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 4px solid #cdd6f4; - margin-right: 4px; - } - QComboBox QAbstractItemView { - background-color: #313244; - color: #cdd6f4; - selection-background-color: #89b4fa; - border: 1px solid #585b70; - } - """) - - # Use options from prop_options if available, otherwise use defaults - if prop_options and isinstance(prop_options, list): - items = prop_options - else: - # Fallback to original options - options = { - 'source_type': ['Camera', 'Microphone', 'File', 'RTSP Stream', 'HTTP Stream'], - 'dongle_series': ['520', '720', '1080', 'Custom'], - 'output_format': ['JSON', 'XML', 'CSV', 'Binary'], - 'format': ['JSON', 'XML', 'CSV', 'Binary'], - 'output_type': ['File', 'API Endpoint', 'Database', 'Display', 'MQTT'], - 'resolution': ['640x480', '1280x720', '1920x1080', '3840x2160', 'Custom'] - } - items = options.get(prop_name, [str(prop_value)]) - - widget.addItems(items) - - if str(prop_value) in items: - widget.setCurrentText(str(prop_value)) - - def on_change(text): - if hasattr(node, 'set_property'): - node.set_property(prop_name, text) - - widget.currentTextChanged.connect(on_change) - - elif isinstance(prop_value, bool): - # Boolean property (like upload_fw checkbox) - widget = QCheckBox() - widget.setChecked(prop_value) - - # Add special styling for upload_fw checkbox - if prop_name == 'upload_fw': - widget.setText("Upload Firmware to Device") - widget.setStyleSheet(""" - QCheckBox { - color: #cdd6f4; - font-size: 11px; - padding: 2px; - } - QCheckBox::indicator { - width: 16px; - height: 16px; - border-radius: 3px; - border: 2px solid #45475a; - background-color: #313244; - } - QCheckBox::indicator:checked { - background-color: #89b4fa; - border-color: #89b4fa; - } - QCheckBox::indicator:hover { - border-color: #74c7ec; - } - """) - else: - widget.setStyleSheet(""" - QCheckBox { - color: #cdd6f4; - font-size: 11px; - padding: 2px; - } - QCheckBox::indicator { - width: 14px; - height: 14px; - border-radius: 2px; - border: 1px solid #45475a; - background-color: #313244; - } - QCheckBox::indicator:checked { - background-color: #a6e3a1; - border-color: #a6e3a1; - } - """) - - def on_change(state): - if hasattr(node, 'set_property'): - node.set_property(prop_name, state == 2) - # For upload_fw, also print confirmation - if prop_name == 'upload_fw': - status = "enabled" if state == 2 else "disabled" - print(f"Upload Firmware {status} for {node.name()}") - - widget.stateChanged.connect(on_change) - - elif isinstance(prop_value, int): - # Integer property with width limits - widget = QSpinBox() - widget.setValue(prop_value) - - # Set width limits to prevent expansion - widget.setMaximumWidth(120) - widget.setMinimumWidth(80) - widget.setStyleSheet(""" - QSpinBox { - padding: 4px 6px; - background-color: #45475a; - color: #cdd6f4; - border: 1px solid #585b70; - border-radius: 4px; - font-size: 11px; - } - QSpinBox:hover { - border-color: #74c7ec; - } - QSpinBox:focus { - border-color: #89b4fa; - } - QSpinBox::up-button, QSpinBox::down-button { - width: 16px; - background-color: #585b70; - border: none; - } - QSpinBox::up-button:hover, QSpinBox::down-button:hover { - background-color: #6c7086; - } - QSpinBox::up-arrow { - border-left: 3px solid transparent; - border-right: 3px solid transparent; - border-bottom: 3px solid #cdd6f4; - } - QSpinBox::down-arrow { - border-left: 3px solid transparent; - border-right: 3px solid transparent; - border-top: 3px solid #cdd6f4; - } - """) - - # Set range from prop_options if available, otherwise use defaults - if prop_options and isinstance(prop_options, dict) and 'min' in prop_options and 'max' in prop_options: - widget.setRange(prop_options['min'], prop_options['max']) - else: - # Fallback to original ranges for specific properties - widget.setRange(0, 99999) # Default range - if prop_name in ['device_id']: - widget.setRange(0, 10) - elif prop_name in ['fps']: - widget.setRange(1, 120) - elif prop_name in ['resize_width', 'resize_height']: - widget.setRange(64, 4096) - elif prop_name in ['num_dongles']: - widget.setRange(1, 16) - elif prop_name in ['max_detections']: - widget.setRange(1, 1000) - - def on_change(value): - if hasattr(node, 'set_property'): - node.set_property(prop_name, value) - - widget.valueChanged.connect(on_change) - - elif isinstance(prop_value, float): - # Float property with width limits - widget = QDoubleSpinBox() - widget.setValue(prop_value) - widget.setDecimals(2) - - # Set width limits to prevent expansion - widget.setMaximumWidth(120) - widget.setMinimumWidth(80) - widget.setStyleSheet(""" - QDoubleSpinBox { - padding: 4px 6px; - background-color: #45475a; - color: #cdd6f4; - border: 1px solid #585b70; - border-radius: 4px; - font-size: 11px; - } - QDoubleSpinBox:hover { - border-color: #74c7ec; - } - QDoubleSpinBox:focus { - border-color: #89b4fa; - } - QDoubleSpinBox::up-button, QDoubleSpinBox::down-button { - width: 16px; - background-color: #585b70; - border: none; - } - QDoubleSpinBox::up-button:hover, QDoubleSpinBox::down-button:hover { - background-color: #6c7086; - } - QDoubleSpinBox::up-arrow { - border-left: 3px solid transparent; - border-right: 3px solid transparent; - border-bottom: 3px solid #cdd6f4; - } - QDoubleSpinBox::down-arrow { - border-left: 3px solid transparent; - border-right: 3px solid transparent; - border-top: 3px solid #cdd6f4; - } - """) - - # Set range and step from prop_options if available, otherwise use defaults - if prop_options and isinstance(prop_options, dict): - if 'min' in prop_options and 'max' in prop_options: - widget.setRange(prop_options['min'], prop_options['max']) - else: - widget.setRange(0.0, 999.0) # Default range - - if 'step' in prop_options: - widget.setSingleStep(prop_options['step']) - else: - widget.setSingleStep(0.01) # Default step - else: - # Fallback to original ranges for specific properties - widget.setRange(0.0, 999.0) # Default range - if prop_name in ['confidence_threshold', 'nms_threshold']: - widget.setRange(0.0, 1.0) - widget.setSingleStep(0.1) - elif prop_name in ['save_interval']: - widget.setRange(0.1, 60.0) - widget.setSingleStep(0.1) - - def on_change(value): - if hasattr(node, 'set_property'): - node.set_property(prop_name, value) - - widget.valueChanged.connect(on_change) - - else: - # String property (default) with width limits - widget = QLineEdit() - widget.setText(str(prop_value)) - - # Set width limits to prevent expansion - widget.setMaximumWidth(250) - widget.setMinimumWidth(150) - widget.setStyleSheet(""" - QLineEdit { - padding: 4px 8px; - background-color: #45475a; - color: #cdd6f4; - border: 1px solid #585b70; - border-radius: 4px; - font-size: 11px; - } - QLineEdit:hover { - border-color: #74c7ec; - } - QLineEdit:focus { - border-color: #89b4fa; - } - QLineEdit::placeholder { - color: #6c7086; - } - """) - - # Set placeholders for specific properties - placeholders = { - 'model_path': 'Path to model file', - 'destination': 'Output file path', - 'resolution': 'e.g., 1920x1080', - 'port_id': 'e.g., 6,7,8 or auto', - 'operations': 'e.g., resize,normalize' - } - - if prop_name in placeholders: - widget.setPlaceholderText(placeholders[prop_name]) - - def on_change(text): - if hasattr(node, 'set_property'): - node.set_property(prop_name, text) - - widget.textChanged.connect(on_change) - - return widget - - def clear_node_properties_panel(self): - """Clear the node properties panel.""" - if not self.node_props_layout: - return - - # Remove all widgets - for i in reversed(range(self.node_props_layout.count())): - child = self.node_props_layout.itemAt(i).widget() - if child: - child.deleteLater() - - # Show instructions and hide container - self.node_props_container.setVisible(False) - self.props_instructions.setVisible(True) - - - def detect_dongles(self): - """Detect available dongles using actual device scanning.""" - if not self.dongles_list: - return - - self.dongles_list.clear() - - try: - # Import MultiDongle for device scanning - from cluster4npu_ui.core.functions.Multidongle import MultiDongle - - # Scan for available devices - devices = MultiDongle.scan_devices() - - if devices: - # Add detected devices to the list - for device in devices: - port_id = device['port_id'] - series = device['series'] - self.dongles_list.addItem(f"{series} Dongle - Port {port_id}") - - # Add summary item - self.dongles_list.addItem(f"Total: {len(devices)} device(s) detected") - - # Store device info for later use - self.detected_devices = devices - - else: - self.dongles_list.addItem("No Kneron devices detected") - self.detected_devices = [] - - except Exception as e: - # Fallback to simulation if scanning fails - self.dongles_list.addItem("Device scanning failed - using simulation") - self.dongles_list.addItem("Simulated KL520 Dongle - Port 28") - self.dongles_list.addItem("Simulated KL720 Dongle - Port 32") - self.detected_devices = [] - - # Print error for debugging - print(f"Dongle detection error: {str(e)}") - - def get_detected_devices(self): - """ - Get the list of detected devices with their port IDs and series. - - Returns: - List[Dict]: List of device information with port_id and series - """ - return getattr(self, 'detected_devices', []) - - def refresh_dongle_detection(self): - """ - Refresh the dongle detection and update the UI. - This can be called when dongles are plugged/unplugged. - """ - self.detect_dongles() - - # Update any other UI components that depend on dongle detection - self.update_performance_estimation() - - def get_available_ports(self): - """ - Get list of available port IDs from detected devices. - - Returns: - List[int]: List of available port IDs - """ - return [device['port_id'] for device in self.get_detected_devices()] - - def get_device_by_port(self, port_id): - """ - Get device information by port ID. - - Args: - port_id (int): Port ID to search for - - Returns: - Dict or None: Device information if found, None otherwise - """ - for device in self.get_detected_devices(): - if device['port_id'] == port_id: - return device - return None - - def update_performance_estimation(self): - """Update performance metrics based on pipeline and detected devices.""" - if not all([self.fps_label, self.latency_label, self.memory_label]): - return - - # Enhanced performance estimation with device information - if self.graph: - num_nodes = len(self.graph.all_nodes()) - num_devices = len(self.get_detected_devices()) - - # Base performance calculation - base_fps = max(1, 60 - (num_nodes * 5)) - base_latency = num_nodes * 10 - base_memory = num_nodes * 50 - - # Adjust for device availability - if num_devices > 0: - # More devices can potentially improve performance - device_multiplier = min(1.5, 1 + (num_devices - 1) * 0.1) - estimated_fps = int(base_fps * device_multiplier) - estimated_latency = max(5, int(base_latency / device_multiplier)) - estimated_memory = base_memory # Memory usage doesn't change much - else: - # No devices detected - show warning performance - estimated_fps = 1 - estimated_latency = 999 - estimated_memory = base_memory - - self.fps_label.setText(f"{estimated_fps} FPS") - self.latency_label.setText(f"{estimated_latency} ms") - self.memory_label.setText(f"{estimated_memory} MB") - - if self.suggestions_text: - suggestions = [] - - # Device-specific suggestions - if num_devices == 0: - suggestions.append("No Kneron devices detected. Connect dongles to enable inference.") - elif num_devices < num_nodes: - suggestions.append(f"Consider connecting more devices ({num_devices} available, {num_nodes} pipeline stages).") - - # Performance suggestions - if num_nodes > 5: - suggestions.append("Consider reducing the number of pipeline stages for better performance.") - if estimated_fps < 30 and num_devices > 0: - suggestions.append("Current configuration may not achieve real-time performance.") - - # Hardware-specific suggestions - detected_devices = self.get_detected_devices() - if detected_devices: - device_series = set(device['series'] for device in detected_devices) - if len(device_series) > 1: - suggestions.append(f"Mixed device types detected: {', '.join(device_series)}. Performance may vary.") - - if not suggestions: - suggestions.append("Pipeline configuration looks good for optimal performance.") - - self.suggestions_text.setPlainText("\n".join(suggestions)) - - def delete_selected_nodes(self): - """Delete selected nodes from the graph.""" - if not self.graph: - return - - selected_nodes = self.graph.selected_nodes() - if selected_nodes: - for node in selected_nodes: - self.graph.delete_node(node) - self.mark_modified() - - def validate_pipeline(self): - """Validate the current pipeline.""" - if not self.graph: - QMessageBox.information(self, "Validation", "No pipeline to validate.") - return - - print("Validating pipeline...") - summary = get_pipeline_summary(self.graph) - - if summary['valid']: - print(f"Pipeline validation passed - {summary['stage_count']} stages, {summary['total_nodes']} nodes") - QMessageBox.information(self, "Pipeline Validation", - f"Pipeline is valid!\n\n" - f"Stages: {summary['stage_count']}\n" - f"Total nodes: {summary['total_nodes']}") - else: - print(f"Pipeline validation failed: {summary['error']}") - QMessageBox.warning(self, "Pipeline Validation", - f"Pipeline validation failed:\n\n{summary['error']}") - - # File operations - - def new_pipeline(self): - """Create a new pipeline.""" - if self.is_modified: - reply = QMessageBox.question(self, "Save Changes", - "Save changes to current pipeline?", - QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) - if reply == QMessageBox.Yes: - self.save_pipeline() - elif reply == QMessageBox.Cancel: - return - - # Clear the graph - if self.graph: - self.graph.clear_session() - - self.project_name = "Untitled Pipeline" - self.current_file = None - self.is_modified = False - self.update_window_title() - - def open_pipeline(self): - """Open a pipeline file.""" - file_path, _ = QFileDialog.getOpenFileName( - self, "Open Pipeline", - self.settings.get_default_project_location(), - "Pipeline files (*.mflow);;All files (*)" - ) - - if file_path: - self.load_pipeline_file(file_path) - - def save_pipeline(self): - """Save the current pipeline.""" - if self.current_file: - self.save_to_file(self.current_file) - else: - self.save_pipeline_as() - - def save_pipeline_as(self): - """Save pipeline with a new name.""" - file_path, _ = QFileDialog.getSaveFileName( - self, "Save Pipeline", - os.path.join(self.settings.get_default_project_location(), f"{self.project_name}.mflow"), - "Pipeline files (*.mflow)" - ) - - if file_path: - self.save_to_file(file_path) - - def save_to_file(self, file_path: str): - """Save pipeline to specified file.""" - try: - pipeline_data = { - 'project_name': self.project_name, - 'description': self.description, - 'nodes': [], - 'connections': [], - 'version': '1.0' - } - - # Save node data if graph is available - if self.graph: - for node in self.graph.all_nodes(): - node_data = { - 'id': node.id, - 'name': node.name(), - 'type': node.__class__.__name__, - 'pos': node.pos() - } - if hasattr(node, 'get_business_properties'): - node_data['properties'] = node.get_business_properties() - pipeline_data['nodes'].append(node_data) - - # Save connections - for node in self.graph.all_nodes(): - for output_port in node.output_ports(): - for input_port in output_port.connected_ports(): - connection_data = { - 'input_node': input_port.node().id, - 'input_port': input_port.name(), - 'output_node': node.id, - 'output_port': output_port.name() - } - pipeline_data['connections'].append(connection_data) - - with open(file_path, 'w') as f: - json.dump(pipeline_data, f, indent=2) - - self.current_file = file_path - self.settings.add_recent_file(file_path) - self.mark_saved() - QMessageBox.information(self, "Saved", f"Pipeline saved to {file_path}") - - except Exception as e: - QMessageBox.critical(self, "Save Error", f"Failed to save pipeline: {e}") - - def load_pipeline_file(self, file_path: str): - """Load pipeline from file.""" - try: - with open(file_path, 'r') as f: - pipeline_data = json.load(f) - - self.project_name = pipeline_data.get('project_name', 'Loaded Pipeline') - self.description = pipeline_data.get('description', '') - self.current_file = file_path - - # Clear existing pipeline - if self.graph: - self.graph.clear_session() - - # Load nodes and connections - self._load_nodes_from_data(pipeline_data.get('nodes', [])) - self._load_connections_from_data(pipeline_data.get('connections', [])) - - self.settings.add_recent_file(file_path) - self.mark_saved() - self.update_window_title() - - except Exception as e: - QMessageBox.critical(self, "Load Error", f"Failed to load pipeline: {e}") - - def export_configuration(self): - """Export pipeline configuration.""" - QMessageBox.information(self, "Export", "Export functionality will be implemented in a future version.") - - def _load_nodes_from_data(self, nodes_data): - """Load nodes from saved data.""" - if not self.graph: - return - - # Import node types - from core.nodes.exact_nodes import EXACT_NODE_TYPES - - # Create a mapping from class names to node classes - class_to_node_type = {} - for node_name, node_class in EXACT_NODE_TYPES.items(): - class_to_node_type[node_class.__name__] = node_class - - # Create a mapping from old IDs to new nodes - self._node_id_mapping = {} - - for node_data in nodes_data: - try: - node_type = node_data.get('type') - old_node_id = node_data.get('id') - - if node_type and node_type in class_to_node_type: - node_class = class_to_node_type[node_type] - - # Try different identifier formats - identifiers_to_try = [ - node_class.__identifier__, - f"{node_class.__identifier__}.{node_class.__name__}", - node_class.__name__ - ] - - node = None - for identifier in identifiers_to_try: - try: - node = self.graph.create_node(identifier) - break - except Exception: - continue - - if node: - # Map old ID to new node - if old_node_id: - self._node_id_mapping[old_node_id] = node - print(f"Mapped old ID {old_node_id} to new node {node.id}") - - # Set node properties - if 'name' in node_data: - node.set_name(node_data['name']) - if 'pos' in node_data: - node.set_pos(*node_data['pos']) - - # Restore business properties - if 'properties' in node_data: - for prop_name, prop_value in node_data['properties'].items(): - try: - node.set_property(prop_name, prop_value) - except Exception as e: - print(f"Warning: Could not set property {prop_name}: {e}") - - except Exception as e: - print(f"Error loading node {node_data}: {e}") - - def _load_connections_from_data(self, connections_data): - """Load connections from saved data.""" - if not self.graph: - return - - print(f"Loading {len(connections_data)} connections...") - - # Check if we have the node ID mapping - if not hasattr(self, '_node_id_mapping'): - print(" Warning: No node ID mapping available") - return - - # Create connections between nodes - for i, connection_data in enumerate(connections_data): - try: - input_node_id = connection_data.get('input_node') - input_port_name = connection_data.get('input_port') - output_node_id = connection_data.get('output_node') - output_port_name = connection_data.get('output_port') - - print(f"Connection {i+1}: {output_node_id}:{output_port_name} -> {input_node_id}:{input_port_name}") - - # Find the nodes using the ID mapping - input_node = self._node_id_mapping.get(input_node_id) - output_node = self._node_id_mapping.get(output_node_id) - - if not input_node: - print(f" Warning: Input node {input_node_id} not found in mapping") - continue - if not output_node: - print(f" Warning: Output node {output_node_id} not found in mapping") - continue - - # Get the ports - input_port = input_node.get_input(input_port_name) - output_port = output_node.get_output(output_port_name) - - if not input_port: - print(f" Warning: Input port '{input_port_name}' not found on node {input_node.name()}") - continue - if not output_port: - print(f" Warning: Output port '{output_port_name}' not found on node {output_node.name()}") - continue - - # Create the connection - output connects to input - output_port.connect_to(input_port) - print(f" ✓ Connection created successfully") - - except Exception as e: - print(f"Error loading connection {connection_data}: {e}") - - # State management - - def mark_modified(self): - """Mark the pipeline as modified.""" - self.is_modified = True - self.update_window_title() - self.pipeline_modified.emit() - - # Schedule pipeline analysis - self.schedule_analysis() - - # Update performance estimation when pipeline changes - self.update_performance_estimation() - - def mark_saved(self): - """Mark the pipeline as saved.""" - self.is_modified = False - self.update_window_title() - - def update_window_title(self): - """Update the window title.""" - title = f"Cluster4NPU - {self.project_name}" - if self.is_modified: - title += " *" - if self.current_file: - title += f" - {os.path.basename(self.current_file)}" - self.setWindowTitle(title) - - def closeEvent(self, event): - """Handle window close event.""" - if self.is_modified: - reply = QMessageBox.question(self, "Save Changes", - "Save changes before closing?", - QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) - if reply == QMessageBox.Yes: - self.save_pipeline() - event.accept() - elif reply == QMessageBox.No: - event.accept() - else: - event.ignore() - else: - event.accept() - - # Pipeline Deployment - - def deploy_pipeline(self): - """Deploy the current pipeline to dongles.""" - try: - # First validate the pipeline - if not self.validate_pipeline_for_deployment(): - return - - # Convert current pipeline to .mflow format - pipeline_data = self.export_pipeline_data() - - # Show deployment dialog - self.show_deployment_dialog(pipeline_data) - - except Exception as e: - QMessageBox.critical(self, "Deployment Error", - f"Failed to prepare pipeline for deployment: {str(e)}") - - def validate_pipeline_for_deployment(self) -> bool: - """Validate pipeline is ready for deployment.""" - if not self.graph: - QMessageBox.warning(self, "Deployment Error", - "No pipeline to deploy. Please create a pipeline first.") - return False - - # Check if pipeline has required nodes - all_nodes = self.graph.all_nodes() - if not all_nodes: - QMessageBox.warning(self, "Deployment Error", - "Pipeline is empty. Please add nodes to your pipeline.") - return False - - # Check for required node types - has_input = any(self.is_input_node(node) for node in all_nodes) - has_model = any(self.is_model_node(node) for node in all_nodes) - has_output = any(self.is_output_node(node) for node in all_nodes) - - if not has_input: - QMessageBox.warning(self, "Deployment Error", - "Pipeline must have at least one Input node.") - return False - - if not has_model: - QMessageBox.warning(self, "Deployment Error", - "Pipeline must have at least one Model node.") - return False - - if not has_output: - QMessageBox.warning(self, "Deployment Error", - "Pipeline must have at least one Output node.") - return False - - # Validate model node configurations - validation_errors = [] - for node in all_nodes: - if self.is_model_node(node): - errors = self.validate_model_node_for_deployment(node) - validation_errors.extend(errors) - - if validation_errors: - error_msg = "Please fix the following issues before deployment:\n\n" - error_msg += "\n".join(f"• {error}" for error in validation_errors) - QMessageBox.warning(self, "Deployment Validation", error_msg) - return False - - return True - - def validate_model_node_for_deployment(self, node) -> List[str]: - """Validate a model node for deployment requirements.""" - errors = [] - - try: - # Get node properties - if hasattr(node, 'get_property'): - model_path = node.get_property('model_path') - scpu_fw_path = node.get_property('scpu_fw_path') - ncpu_fw_path = node.get_property('ncpu_fw_path') - port_id = node.get_property('port_id') - else: - errors.append(f"Model node '{node.name()}' cannot read properties") - return errors - - # Check model path - if not model_path or not model_path.strip(): - errors.append(f"Model node '{node.name()}' missing model path") - elif not os.path.exists(model_path): - errors.append(f"Model file not found: {model_path}") - elif not model_path.endswith('.nef'): - errors.append(f"Model file must be .nef format: {model_path}") - - # Check firmware paths - if not scpu_fw_path or not scpu_fw_path.strip(): - errors.append(f"Model node '{node.name()}' missing SCPU firmware path") - elif not os.path.exists(scpu_fw_path): - errors.append(f"SCPU firmware not found: {scpu_fw_path}") - - if not ncpu_fw_path or not ncpu_fw_path.strip(): - errors.append(f"Model node '{node.name()}' missing NCPU firmware path") - elif not os.path.exists(ncpu_fw_path): - errors.append(f"NCPU firmware not found: {ncpu_fw_path}") - - # Check port ID - if not port_id or not port_id.strip(): - errors.append(f"Model node '{node.name()}' missing port ID") - else: - # Validate port ID format - try: - port_ids = [int(p.strip()) for p in port_id.split(',') if p.strip()] - if not port_ids: - errors.append(f"Model node '{node.name()}' has invalid port ID format") - except ValueError: - errors.append(f"Model node '{node.name()}' has invalid port ID: {port_id}") - - except Exception as e: - errors.append(f"Error validating model node '{node.name()}': {str(e)}") - - return errors - - def export_pipeline_data(self) -> Dict[str, Any]: - """Export current pipeline to dictionary format for deployment.""" - pipeline_data = { - 'project_name': self.project_name, - 'description': self.description, - 'nodes': [], - 'connections': [], - 'version': '1.0' - } - - if not self.graph: - return pipeline_data - - # Export nodes - for node in self.graph.all_nodes(): - node_data = { - 'id': node.id, - 'name': node.name(), - 'type': node.__class__.__name__, - 'pos': node.pos(), - 'properties': {} - } - - # Get node properties - if hasattr(node, 'get_business_properties'): - node_data['properties'] = node.get_business_properties() - elif hasattr(node, '_property_options') and node._property_options: - for prop_name in node._property_options.keys(): - if hasattr(node, 'get_property'): - try: - node_data['properties'][prop_name] = node.get_property(prop_name) - except: - pass - - pipeline_data['nodes'].append(node_data) - - # Export connections - for node in self.graph.all_nodes(): - if hasattr(node, 'output_ports'): - for output_port in node.output_ports(): - if hasattr(output_port, 'connected_ports'): - for input_port in output_port.connected_ports(): - connection_data = { - 'input_node': input_port.node().id, - 'input_port': input_port.name(), - 'output_node': node.id, - 'output_port': output_port.name() - } - pipeline_data['connections'].append(connection_data) - - return pipeline_data - - def show_deployment_dialog(self, pipeline_data: Dict[str, Any]): - """Show deployment dialog and handle deployment process.""" - from ..dialogs.deployment import DeploymentDialog - - dialog = DeploymentDialog(pipeline_data, parent=self) - if dialog.exec_() == dialog.Accepted: - # Deployment was successful or initiated - self.statusBar().showMessage("Pipeline deployment initiated...", 3000) - - def is_input_node(self, node) -> bool: - """Check if node is an input node.""" - return ('input' in str(type(node)).lower() or - hasattr(node, 'NODE_NAME') and 'input' in str(node.NODE_NAME).lower()) - - def is_model_node(self, node) -> bool: - """Check if node is a model node.""" - return ('model' in str(type(node)).lower() or - hasattr(node, 'NODE_NAME') and 'model' in str(node.NODE_NAME).lower()) - - def is_output_node(self, node) -> bool: - """Check if node is an output node.""" - return ('output' in str(type(node)).lower() or - hasattr(node, 'NODE_NAME') and 'output' in str(node.NODE_NAME).lower()) \ No newline at end of file diff --git a/cluster4npu_ui/ui/windows/login.py b/cluster4npu_ui/ui/windows/login.py deleted file mode 100644 index 3303478..0000000 --- a/cluster4npu_ui/ui/windows/login.py +++ /dev/null @@ -1,459 +0,0 @@ -""" -Dashboard login and startup window for the Cluster4NPU UI application. - -This module provides the main entry point window that allows users to create -new pipelines or load existing ones. It serves as the application launcher -and recent files manager. - -Main Components: - - DashboardLogin: Main startup window with project management - - Recent files management and display - - New pipeline creation workflow - - Application navigation and routing - -Usage: - from cluster4npu_ui.ui.windows.login import DashboardLogin - - dashboard = DashboardLogin() - dashboard.show() -""" - -import os -from pathlib import Path -from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QListWidget, QListWidgetItem, QMessageBox, QFileDialog, - QFrame, QSizePolicy, QSpacerItem -) -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QFont, QPixmap, QIcon - -from cluster4npu_ui.config.settings import get_settings - - -class DashboardLogin(QWidget): - """ - Main startup window for the Cluster4NPU application. - - Provides options to create new pipelines, load existing ones, and manage - recent files. Serves as the application's main entry point. - """ - - # Signals - pipeline_requested = pyqtSignal(str) # Emitted when user wants to open/create pipeline - - def __init__(self): - super().__init__() - self.settings = get_settings() - self.setup_ui() - self.load_recent_files() - - # Connect to integrated dashboard (will be implemented) - self.dashboard_window = None - - def setup_ui(self): - """Initialize the user interface.""" - self.setWindowTitle("Cluster4NPU - Pipeline Dashboard") - self.setMinimumSize(800, 600) - self.resize(1000, 700) - - # Main layout - main_layout = QVBoxLayout(self) - main_layout.setSpacing(20) - main_layout.setContentsMargins(40, 40, 40, 40) - - # Header section - self.create_header(main_layout) - - # Content section - content_layout = QHBoxLayout() - content_layout.setSpacing(30) - - # Left side - Actions - self.create_actions_panel(content_layout) - - # Right side - Recent files - self.create_recent_files_panel(content_layout) - - main_layout.addLayout(content_layout) - - # Footer - self.create_footer(main_layout) - - def create_header(self, parent_layout): - """Create the header section with title and description.""" - header_frame = QFrame() - header_frame.setStyleSheet(""" - QFrame { - background-color: #313244; - border-radius: 12px; - padding: 20px; - } - """) - header_layout = QVBoxLayout(header_frame) - - # Title - title_label = QLabel("Cluster4NPU Pipeline Designer") - title_label.setFont(QFont("Arial", 24, QFont.Bold)) - title_label.setStyleSheet("color: #89b4fa; margin-bottom: 10px;") - title_label.setAlignment(Qt.AlignCenter) - header_layout.addWidget(title_label) - - # Subtitle - subtitle_label = QLabel("Design, configure, and deploy high-performance ML inference pipelines") - subtitle_label.setFont(QFont("Arial", 14)) - subtitle_label.setStyleSheet("color: #cdd6f4; margin-bottom: 5px;") - subtitle_label.setAlignment(Qt.AlignCenter) - header_layout.addWidget(subtitle_label) - - # Version info - version_label = QLabel("Version 1.0.0 - Multi-stage NPU Pipeline System") - version_label.setFont(QFont("Arial", 10)) - version_label.setStyleSheet("color: #6c7086;") - version_label.setAlignment(Qt.AlignCenter) - header_layout.addWidget(version_label) - - parent_layout.addWidget(header_frame) - - def create_actions_panel(self, parent_layout): - """Create the actions panel with main buttons.""" - actions_frame = QFrame() - actions_frame.setStyleSheet(""" - QFrame { - background-color: #313244; - border-radius: 12px; - padding: 20px; - } - """) - actions_frame.setMaximumWidth(350) - actions_layout = QVBoxLayout(actions_frame) - - # Panel title - actions_title = QLabel("Get Started") - actions_title.setFont(QFont("Arial", 16, QFont.Bold)) - actions_title.setStyleSheet("color: #f9e2af; margin-bottom: 20px;") - actions_layout.addWidget(actions_title) - - # Create new pipeline button - self.new_pipeline_btn = QPushButton("Create New Pipeline") - self.new_pipeline_btn.setFont(QFont("Arial", 12, QFont.Bold)) - self.new_pipeline_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #89b4fa, stop:1 #74c7ec); - color: #1e1e2e; - border: none; - padding: 15px 20px; - border-radius: 10px; - margin-bottom: 10px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #a6c8ff, stop:1 #89dceb); - } - """) - self.new_pipeline_btn.clicked.connect(self.create_new_pipeline) - actions_layout.addWidget(self.new_pipeline_btn) - - # Open existing pipeline button - self.open_pipeline_btn = QPushButton("Open Existing Pipeline") - self.open_pipeline_btn.setFont(QFont("Arial", 12)) - self.open_pipeline_btn.setStyleSheet(""" - QPushButton { - background-color: #45475a; - color: #cdd6f4; - border: 2px solid #585b70; - padding: 15px 20px; - border-radius: 10px; - margin-bottom: 10px; - } - QPushButton:hover { - background-color: #585b70; - border-color: #89b4fa; - } - """) - self.open_pipeline_btn.clicked.connect(self.open_existing_pipeline) - actions_layout.addWidget(self.open_pipeline_btn) - - # Import from template button - # self.import_template_btn = QPushButton("Import from Template") - # self.import_template_btn.setFont(QFont("Arial", 12)) - # self.import_template_btn.setStyleSheet(""" - # QPushButton { - # background-color: #45475a; - # color: #cdd6f4; - # border: 2px solid #585b70; - # padding: 15px 20px; - # border-radius: 10px; - # margin-bottom: 20px; - # } - # QPushButton:hover { - # background-color: #585b70; - # border-color: #a6e3a1; - # } - # """) - # self.import_template_btn.clicked.connect(self.import_template) - # actions_layout.addWidget(self.import_template_btn) - - # Additional info - # info_label = QLabel("Start by creating a new pipeline or opening an existing .mflow file") - # info_label.setFont(QFont("Arial", 10)) - # info_label.setStyleSheet("color: #6c7086; padding: 10px; background-color: #45475a; border-radius: 8px;") - # info_label.setWordWrap(True) - # actions_layout.addWidget(info_label) - - # Spacer - actions_layout.addItem(QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)) - - parent_layout.addWidget(actions_frame) - - def create_recent_files_panel(self, parent_layout): - """Create the recent files panel.""" - recent_frame = QFrame() - recent_frame.setStyleSheet(""" - QFrame { - background-color: #313244; - border-radius: 12px; - padding: 20px; - } - """) - recent_layout = QVBoxLayout(recent_frame) - - # Panel title with clear button - title_layout = QHBoxLayout() - recent_title = QLabel("Recent Pipelines") - recent_title.setFont(QFont("Arial", 16, QFont.Bold)) - recent_title.setStyleSheet("color: #f9e2af;") - title_layout.addWidget(recent_title) - - title_layout.addItem(QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) - - self.clear_recent_btn = QPushButton("Clear All") - self.clear_recent_btn.setStyleSheet(""" - QPushButton { - background-color: #f38ba8; - color: #1e1e2e; - border: none; - padding: 5px 10px; - border-radius: 5px; - font-size: 10px; - } - QPushButton:hover { - background-color: #f2d5de; - } - """) - self.clear_recent_btn.clicked.connect(self.clear_recent_files) - title_layout.addWidget(self.clear_recent_btn) - - recent_layout.addLayout(title_layout) - - # Recent files list - self.recent_files_list = QListWidget() - self.recent_files_list.setStyleSheet(""" - QListWidget { - background-color: #1e1e2e; - border: 2px solid #45475a; - border-radius: 8px; - padding: 5px; - } - QListWidget::item { - padding: 10px; - border-bottom: 1px solid #45475a; - border-radius: 4px; - margin: 2px; - } - 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.recent_files_list.itemDoubleClicked.connect(self.open_recent_file) - recent_layout.addWidget(self.recent_files_list) - - parent_layout.addWidget(recent_frame) - - def create_footer(self, parent_layout): - """Create the footer with additional options.""" - footer_layout = QHBoxLayout() - - # Documentation link - docs_btn = QPushButton("Documentation") - docs_btn.setStyleSheet(""" - QPushButton { - background-color: transparent; - color: #89b4fa; - border: none; - text-decoration: underline; - padding: 5px; - } - QPushButton:hover { - color: #a6c8ff; - } - """) - footer_layout.addWidget(docs_btn) - - footer_layout.addItem(QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) - - # Examples link - examples_btn = QPushButton("Examples") - examples_btn.setStyleSheet(""" - QPushButton { - background-color: transparent; - color: #a6e3a1; - border: none; - text-decoration: underline; - padding: 5px; - } - QPushButton:hover { - color: #b3f5c0; - } - """) - footer_layout.addWidget(examples_btn) - - footer_layout.addItem(QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) - - # Settings link - settings_btn = QPushButton("Settings") - settings_btn.setStyleSheet(""" - QPushButton { - background-color: transparent; - color: #f9e2af; - border: none; - text-decoration: underline; - padding: 5px; - } - QPushButton:hover { - color: #fdeaa7; - } - """) - footer_layout.addWidget(settings_btn) - - parent_layout.addLayout(footer_layout) - - def load_recent_files(self): - """Load and display recent files.""" - self.recent_files_list.clear() - recent_files = self.settings.get_recent_files() - - if not recent_files: - item = QListWidgetItem("No recent files") - item.setFlags(Qt.NoItemFlags) # Make it non-selectable - item.setData(Qt.UserRole, None) - self.recent_files_list.addItem(item) - return - - for file_path in recent_files: - if os.path.exists(file_path): - # Extract filename and directory - file_name = os.path.basename(file_path) - file_dir = os.path.dirname(file_path) - - # Create list item - item_text = f"{file_name}\n{file_dir}" - item = QListWidgetItem(item_text) - item.setData(Qt.UserRole, file_path) - item.setToolTip(file_path) - self.recent_files_list.addItem(item) - else: - # Remove non-existent files - self.settings.remove_recent_file(file_path) - - def create_new_pipeline(self): - """Create a new pipeline.""" - try: - # Import here to avoid circular imports - from cluster4npu_ui.ui.dialogs.create_pipeline import CreatePipelineDialog - - dialog = CreatePipelineDialog(self) - if dialog.exec_() == dialog.Accepted: - project_info = dialog.get_project_info() - self.launch_pipeline_editor(project_info.get('name', 'Untitled')) - - except ImportError: - # Fallback: directly launch editor - self.launch_pipeline_editor("New Pipeline") - - def open_existing_pipeline(self): - """Open an existing pipeline file.""" - file_path, _ = QFileDialog.getOpenFileName( - self, - "Open Pipeline File", - self.settings.get_default_project_location(), - "Pipeline files (*.mflow);;All files (*)" - ) - - if file_path: - self.settings.add_recent_file(file_path) - self.load_recent_files() - self.launch_pipeline_editor(file_path) - - def open_recent_file(self, item: QListWidgetItem): - """Open a recent file.""" - file_path = item.data(Qt.UserRole) - if file_path and os.path.exists(file_path): - self.launch_pipeline_editor(file_path) - elif file_path: - QMessageBox.warning(self, "File Not Found", f"The file '{file_path}' could not be found.") - self.settings.remove_recent_file(file_path) - self.load_recent_files() - - def import_template(self): - """Import a pipeline from template.""" - QMessageBox.information( - self, - "Import Template", - "Template import functionality will be available in a future version." - ) - - def clear_recent_files(self): - """Clear all recent files.""" - reply = QMessageBox.question( - self, - "Clear Recent Files", - "Are you sure you want to clear all recent files?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No - ) - - if reply == QMessageBox.Yes: - self.settings.clear_recent_files() - self.load_recent_files() - - def launch_pipeline_editor(self, project_info): - """Launch the main pipeline editor.""" - try: - # Import here to avoid circular imports - from cluster4npu_ui.ui.windows.dashboard import IntegratedPipelineDashboard - - self.dashboard_window = IntegratedPipelineDashboard() - - # Load project if it's a file path - if isinstance(project_info, str) and os.path.exists(project_info): - # Load the pipeline file - try: - self.dashboard_window.load_pipeline_file(project_info) - except Exception as e: - QMessageBox.warning( - self, - "File Load Warning", - f"Could not load pipeline file: {e}\n\n" - "Opening with empty pipeline instead." - ) - - self.dashboard_window.show() - self.hide() # Hide the login window - - except ImportError as e: - QMessageBox.critical( - self, - "Error", - f"Could not launch pipeline editor: {e}\n\n" - "Please ensure all required modules are available." - ) - - def closeEvent(self, event): - """Handle window close event.""" - # Save window geometry - self.settings.set_window_geometry(self.saveGeometry()) - event.accept() \ No newline at end of file diff --git a/cluster4npu_ui/ui/windows/pipeline_editor.py b/cluster4npu_ui/ui/windows/pipeline_editor.py deleted file mode 100644 index 34f6a5e..0000000 --- a/cluster4npu_ui/ui/windows/pipeline_editor.py +++ /dev/null @@ -1,667 +0,0 @@ -# """ -# Pipeline Editor window with stage counting functionality. - -# This module provides the main pipeline editor interface with visual node-based -# pipeline design and automatic stage counting display. - -# Main Components: -# - PipelineEditor: Main pipeline editor window -# - Stage counting display in canvas -# - Node graph integration -# - Pipeline validation and analysis - -# Usage: -# from cluster4npu_ui.ui.windows.pipeline_editor import PipelineEditor - -# editor = PipelineEditor() -# editor.show() -# """ - -# import sys -# from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, -# QLabel, QStatusBar, QFrame, QPushButton, QAction, -# QMenuBar, QToolBar, QSplitter, QTextEdit, QMessageBox, -# QScrollArea) -# from PyQt5.QtCore import Qt, QTimer, pyqtSignal -# from PyQt5.QtGui import QFont, QPixmap, QIcon, QTextCursor - -# try: -# from NodeGraphQt import NodeGraph -# from NodeGraphQt.constants import IN_PORT, OUT_PORT -# NODEGRAPH_AVAILABLE = True -# except ImportError: -# NODEGRAPH_AVAILABLE = False -# print("NodeGraphQt not available. Install with: pip install NodeGraphQt") - -# from ...core.pipeline import get_stage_count, analyze_pipeline_stages, get_pipeline_summary -# from ...core.nodes.exact_nodes import ( -# ExactInputNode, ExactModelNode, ExactPreprocessNode, -# ExactPostprocessNode, ExactOutputNode -# ) -# # Keep the original imports as fallback -# try: -# from ...core.nodes.model_node import ModelNode -# from ...core.nodes.preprocess_node import PreprocessNode -# from ...core.nodes.postprocess_node import PostprocessNode -# from ...core.nodes.input_node import InputNode -# from ...core.nodes.output_node import OutputNode -# except ImportError: -# # Use ExactNodes as fallback -# ModelNode = ExactModelNode -# PreprocessNode = ExactPreprocessNode -# PostprocessNode = ExactPostprocessNode -# InputNode = ExactInputNode -# OutputNode = ExactOutputNode - - -# class StageCountWidget(QWidget): -# """Widget to display stage count information in the pipeline editor.""" - -# def __init__(self, parent=None): -# super().__init__(parent) -# self.stage_count = 0 -# self.pipeline_valid = True -# self.pipeline_error = "" - -# self.setup_ui() -# self.setFixedSize(200, 80) - -# def setup_ui(self): -# """Setup the stage count widget UI.""" -# layout = QVBoxLayout() -# layout.setContentsMargins(10, 5, 10, 5) - -# # Stage count label -# self.stage_label = QLabel("Stages: 0") -# self.stage_label.setFont(QFont("Arial", 11, QFont.Bold)) -# self.stage_label.setStyleSheet("color: #2E7D32; font-weight: bold;") - -# # Status label -# self.status_label = QLabel("Ready") -# self.status_label.setFont(QFont("Arial", 9)) -# self.status_label.setStyleSheet("color: #666666;") - -# # Error label (initially hidden) -# self.error_label = QLabel("") -# self.error_label.setFont(QFont("Arial", 8)) -# self.error_label.setStyleSheet("color: #D32F2F;") -# self.error_label.setWordWrap(True) -# self.error_label.setMaximumHeight(30) -# self.error_label.hide() - -# layout.addWidget(self.stage_label) -# layout.addWidget(self.status_label) -# layout.addWidget(self.error_label) - -# self.setLayout(layout) - -# # Style the widget -# self.setStyleSheet(""" -# StageCountWidget { -# background-color: #F5F5F5; -# border: 1px solid #E0E0E0; -# border-radius: 5px; -# } -# """) - -# def update_stage_count(self, count: int, valid: bool = True, error: str = ""): -# """Update the stage count display.""" -# self.stage_count = count -# self.pipeline_valid = valid -# self.pipeline_error = error - -# # Update stage count -# self.stage_label.setText(f"Stages: {count}") - -# # Update status and styling -# if not valid: -# self.stage_label.setStyleSheet("color: #D32F2F; font-weight: bold;") -# self.status_label.setText("Invalid Pipeline") -# self.status_label.setStyleSheet("color: #D32F2F;") -# self.error_label.setText(error) -# self.error_label.show() -# else: -# self.stage_label.setStyleSheet("color: #2E7D32; font-weight: bold;") -# if count == 0: -# self.status_label.setText("No stages defined") -# self.status_label.setStyleSheet("color: #FF8F00;") -# else: -# self.status_label.setText(f"Pipeline ready ({count} stage{'s' if count != 1 else ''})") -# self.status_label.setStyleSheet("color: #2E7D32;") -# self.error_label.hide() - - -# class PipelineEditor(QMainWindow): -# """ -# Main pipeline editor window with stage counting functionality. - -# This window provides a visual node-based pipeline editor with automatic -# stage detection and counting displayed in the canvas. -# """ - -# # Signals -# pipeline_changed = pyqtSignal() -# stage_count_changed = pyqtSignal(int) - -# def __init__(self, parent=None): -# super().__init__(parent) - -# self.node_graph = None -# self.stage_count_widget = None -# self.analysis_timer = None -# self.previous_stage_count = 0 # Track previous stage count for comparison - -# self.setup_ui() -# self.setup_node_graph() -# self.setup_analysis_timer() - -# # Connect signals -# self.pipeline_changed.connect(self.analyze_pipeline) - -# # Initial analysis -# print("Pipeline Editor initialized") -# self.analyze_pipeline() - -# def setup_ui(self): -# """Setup the main UI components.""" -# self.setWindowTitle("Pipeline Editor - Cluster4NPU") -# self.setGeometry(100, 100, 1200, 800) - -# # Create central widget -# central_widget = QWidget() -# self.setCentralWidget(central_widget) - -# # Create main layout -# main_layout = QVBoxLayout() -# central_widget.setLayout(main_layout) - -# # Create splitter for main content -# splitter = QSplitter(Qt.Horizontal) -# main_layout.addWidget(splitter) - -# # Left panel for node graph -# self.graph_widget = QWidget() -# self.graph_layout = QVBoxLayout() -# self.graph_widget.setLayout(self.graph_layout) -# splitter.addWidget(self.graph_widget) - -# # Right panel for properties and tools -# right_panel = QWidget() -# right_panel.setMaximumWidth(300) -# right_layout = QVBoxLayout() -# right_panel.setLayout(right_layout) - -# # Stage count widget (positioned at bottom right) -# self.stage_count_widget = StageCountWidget() -# right_layout.addWidget(self.stage_count_widget) - -# # Properties panel -# properties_label = QLabel("Properties") -# properties_label.setFont(QFont("Arial", 10, QFont.Bold)) -# right_layout.addWidget(properties_label) - -# self.properties_text = QTextEdit() -# self.properties_text.setMaximumHeight(200) -# self.properties_text.setReadOnly(True) -# right_layout.addWidget(self.properties_text) - -# # Pipeline info panel -# info_label = QLabel("Pipeline Info") -# info_label.setFont(QFont("Arial", 10, QFont.Bold)) -# right_layout.addWidget(info_label) - -# self.info_text = QTextEdit() -# self.info_text.setReadOnly(True) -# right_layout.addWidget(self.info_text) - -# splitter.addWidget(right_panel) - -# # Set splitter proportions -# splitter.setSizes([800, 300]) - -# # Create toolbar -# self.create_toolbar() - -# # Create status bar -# self.create_status_bar() - -# # Apply styling -# self.apply_styling() - -# def create_toolbar(self): -# """Create the toolbar with pipeline operations.""" -# toolbar = self.addToolBar("Pipeline Operations") - -# # Add nodes actions -# add_input_action = QAction("Add Input", self) -# add_input_action.triggered.connect(self.add_input_node) -# toolbar.addAction(add_input_action) - -# add_model_action = QAction("Add Model", self) -# add_model_action.triggered.connect(self.add_model_node) -# toolbar.addAction(add_model_action) - -# add_preprocess_action = QAction("Add Preprocess", self) -# add_preprocess_action.triggered.connect(self.add_preprocess_node) -# toolbar.addAction(add_preprocess_action) - -# add_postprocess_action = QAction("Add Postprocess", self) -# add_postprocess_action.triggered.connect(self.add_postprocess_node) -# toolbar.addAction(add_postprocess_action) - -# add_output_action = QAction("Add Output", self) -# add_output_action.triggered.connect(self.add_output_node) -# toolbar.addAction(add_output_action) - -# toolbar.addSeparator() - -# # Pipeline actions -# validate_action = QAction("Validate Pipeline", self) -# validate_action.triggered.connect(self.validate_pipeline) -# toolbar.addAction(validate_action) - -# clear_action = QAction("Clear Pipeline", self) -# clear_action.triggered.connect(self.clear_pipeline) -# toolbar.addAction(clear_action) - -# def create_status_bar(self): -# """Create the status bar.""" -# self.status_bar = QStatusBar() -# self.setStatusBar(self.status_bar) -# self.status_bar.showMessage("Ready") - -# def setup_node_graph(self): -# """Setup the node graph widget.""" -# if not NODEGRAPH_AVAILABLE: -# # Show error message -# error_label = QLabel("NodeGraphQt not available. Please install it to use the pipeline editor.") -# error_label.setAlignment(Qt.AlignCenter) -# error_label.setStyleSheet("color: red; font-size: 14px;") -# self.graph_layout.addWidget(error_label) -# return - -# # Create node graph -# self.node_graph = NodeGraph() - -# # Register node types - use ExactNode classes -# print("Registering nodes with NodeGraphQt...") - -# # Try to register ExactNode classes first -# try: -# self.node_graph.register_node(ExactInputNode) -# print(f"✓ Registered ExactInputNode with identifier {ExactInputNode.__identifier__}") -# except Exception as e: -# print(f"✗ Failed to register ExactInputNode: {e}") - -# try: -# self.node_graph.register_node(ExactModelNode) -# print(f"✓ Registered ExactModelNode with identifier {ExactModelNode.__identifier__}") -# except Exception as e: -# print(f"✗ Failed to register ExactModelNode: {e}") - -# try: -# self.node_graph.register_node(ExactPreprocessNode) -# print(f"✓ Registered ExactPreprocessNode with identifier {ExactPreprocessNode.__identifier__}") -# except Exception as e: -# print(f"✗ Failed to register ExactPreprocessNode: {e}") - -# try: -# self.node_graph.register_node(ExactPostprocessNode) -# print(f"✓ Registered ExactPostprocessNode with identifier {ExactPostprocessNode.__identifier__}") -# except Exception as e: -# print(f"✗ Failed to register ExactPostprocessNode: {e}") - -# try: -# self.node_graph.register_node(ExactOutputNode) -# print(f"✓ Registered ExactOutputNode with identifier {ExactOutputNode.__identifier__}") -# except Exception as e: -# print(f"✗ Failed to register ExactOutputNode: {e}") - -# print("Node graph setup completed successfully") - -# # Connect node graph signals -# self.node_graph.node_created.connect(self.on_node_created) -# self.node_graph.node_deleted.connect(self.on_node_deleted) -# self.node_graph.connection_changed.connect(self.on_connection_changed) - -# # Connect additional signals for more comprehensive updates -# if hasattr(self.node_graph, 'nodes_deleted'): -# self.node_graph.nodes_deleted.connect(self.on_nodes_deleted) -# if hasattr(self.node_graph, 'connection_sliced'): -# self.node_graph.connection_sliced.connect(self.on_connection_changed) - -# # Add node graph widget to layout -# self.graph_layout.addWidget(self.node_graph.widget) - -# def setup_analysis_timer(self): -# """Setup timer for pipeline analysis.""" -# self.analysis_timer = QTimer() -# self.analysis_timer.setSingleShot(True) -# self.analysis_timer.timeout.connect(self.analyze_pipeline) -# self.analysis_timer.setInterval(500) # 500ms delay - -# def apply_styling(self): -# """Apply custom styling to the editor.""" -# self.setStyleSheet(""" -# QMainWindow { -# background-color: #FAFAFA; -# } -# QToolBar { -# background-color: #FFFFFF; -# border: 1px solid #E0E0E0; -# spacing: 5px; -# padding: 5px; -# } -# QToolBar QAction { -# padding: 5px 10px; -# margin: 2px; -# border: 1px solid #E0E0E0; -# border-radius: 3px; -# background-color: #FFFFFF; -# } -# QToolBar QAction:hover { -# background-color: #F5F5F5; -# } -# QTextEdit { -# border: 1px solid #E0E0E0; -# border-radius: 3px; -# padding: 5px; -# background-color: #FFFFFF; -# } -# QLabel { -# color: #333333; -# } -# """) - -# def add_input_node(self): -# """Add an input node to the pipeline.""" -# if self.node_graph: -# print("Adding Input Node via toolbar...") -# # Try multiple identifier formats -# identifiers = [ -# 'com.cluster.input_node', -# 'com.cluster.input_node.ExactInputNode', -# 'com.cluster.input_node.ExactInputNode.ExactInputNode' -# ] -# node = self.create_node_with_fallback(identifiers, "Input Node") -# self.schedule_analysis() - -# def add_model_node(self): -# """Add a model node to the pipeline.""" -# if self.node_graph: -# print("Adding Model Node via toolbar...") -# # Try multiple identifier formats -# identifiers = [ -# 'com.cluster.model_node', -# 'com.cluster.model_node.ExactModelNode', -# 'com.cluster.model_node.ExactModelNode.ExactModelNode' -# ] -# node = self.create_node_with_fallback(identifiers, "Model Node") -# self.schedule_analysis() - -# def add_preprocess_node(self): -# """Add a preprocess node to the pipeline.""" -# if self.node_graph: -# print("Adding Preprocess Node via toolbar...") -# # Try multiple identifier formats -# identifiers = [ -# 'com.cluster.preprocess_node', -# 'com.cluster.preprocess_node.ExactPreprocessNode', -# 'com.cluster.preprocess_node.ExactPreprocessNode.ExactPreprocessNode' -# ] -# node = self.create_node_with_fallback(identifiers, "Preprocess Node") -# self.schedule_analysis() - -# def add_postprocess_node(self): -# """Add a postprocess node to the pipeline.""" -# if self.node_graph: -# print("Adding Postprocess Node via toolbar...") -# # Try multiple identifier formats -# identifiers = [ -# 'com.cluster.postprocess_node', -# 'com.cluster.postprocess_node.ExactPostprocessNode', -# 'com.cluster.postprocess_node.ExactPostprocessNode.ExactPostprocessNode' -# ] -# node = self.create_node_with_fallback(identifiers, "Postprocess Node") -# self.schedule_analysis() - -# def add_output_node(self): -# """Add an output node to the pipeline.""" -# if self.node_graph: -# print("Adding Output Node via toolbar...") -# # Try multiple identifier formats -# identifiers = [ -# 'com.cluster.output_node', -# 'com.cluster.output_node.ExactOutputNode', -# 'com.cluster.output_node.ExactOutputNode.ExactOutputNode' -# ] -# node = self.create_node_with_fallback(identifiers, "Output Node") -# self.schedule_analysis() - -# def create_node_with_fallback(self, identifiers, node_type): -# """Try to create a node with multiple identifier fallbacks.""" -# for identifier in identifiers: -# try: -# node = self.node_graph.create_node(identifier) -# print(f"✓ Successfully created {node_type} with identifier: {identifier}") -# return node -# except Exception as e: -# continue - -# print(f"Failed to create {node_type} with any identifier: {identifiers}") -# return None - -# def validate_pipeline(self): -# """Validate the current pipeline configuration.""" -# if not self.node_graph: -# return - -# print("🔍 Validating pipeline...") -# summary = get_pipeline_summary(self.node_graph) - -# if summary['valid']: -# print(f"Pipeline validation passed - {summary['stage_count']} stages, {summary['total_nodes']} nodes") -# QMessageBox.information(self, "Pipeline Validation", -# f"Pipeline is valid!\n\n" -# f"Stages: {summary['stage_count']}\n" -# f"Total nodes: {summary['total_nodes']}") -# else: -# print(f"Pipeline validation failed: {summary['error']}") -# QMessageBox.warning(self, "Pipeline Validation", -# f"Pipeline validation failed:\n\n{summary['error']}") - -# def clear_pipeline(self): -# """Clear the entire pipeline.""" -# if self.node_graph: -# print("Clearing entire pipeline...") -# self.node_graph.clear_session() -# self.schedule_analysis() - -# def schedule_analysis(self): -# """Schedule pipeline analysis after a delay.""" -# if self.analysis_timer: -# self.analysis_timer.start() - -# def analyze_pipeline(self): -# """Analyze the current pipeline and update stage count.""" -# if not self.node_graph: -# return - -# try: -# # Get pipeline summary -# summary = get_pipeline_summary(self.node_graph) -# current_stage_count = summary['stage_count'] - -# # Print detailed pipeline analysis -# self.print_pipeline_analysis(summary, current_stage_count) - -# # Update stage count widget -# self.stage_count_widget.update_stage_count( -# current_stage_count, -# summary['valid'], -# summary.get('error', '') -# ) - -# # Update info panel -# self.update_info_panel(summary) - -# # Update status bar -# if summary['valid']: -# self.status_bar.showMessage(f"Pipeline ready - {current_stage_count} stages") -# else: -# self.status_bar.showMessage(f"Pipeline invalid - {summary.get('error', 'Unknown error')}") - -# # Update previous count for next comparison -# self.previous_stage_count = current_stage_count - -# # Emit signal -# self.stage_count_changed.emit(current_stage_count) - -# except Exception as e: -# print(f"X Pipeline analysis error: {str(e)}") -# self.stage_count_widget.update_stage_count(0, False, f"Analysis error: {str(e)}") -# self.status_bar.showMessage(f"Analysis error: {str(e)}") - -# def print_pipeline_analysis(self, summary, current_stage_count): -# """Print detailed pipeline analysis to terminal.""" -# # Check if stage count changed -# if current_stage_count != self.previous_stage_count: -# if self.previous_stage_count == 0 and current_stage_count > 0: -# print(f"Initial stage count: {current_stage_count}") -# elif current_stage_count != self.previous_stage_count: -# change = current_stage_count - self.previous_stage_count -# if change > 0: -# print(f"Stage count increased: {self.previous_stage_count} → {current_stage_count} (+{change})") -# else: -# print(f"Stage count decreased: {self.previous_stage_count} → {current_stage_count} ({change})") - -# # Always print current pipeline status for clarity -# print(f"Current Pipeline Status:") -# print(f" • Stages: {current_stage_count}") -# print(f" • Total Nodes: {summary['total_nodes']}") -# print(f" • Model Nodes: {summary['model_nodes']}") -# print(f" • Input Nodes: {summary['input_nodes']}") -# print(f" • Output Nodes: {summary['output_nodes']}") -# print(f" • Preprocess Nodes: {summary['preprocess_nodes']}") -# print(f" • Postprocess Nodes: {summary['postprocess_nodes']}") -# print(f" • Valid: {'V' if summary['valid'] else 'X'}") - -# if not summary['valid'] and summary.get('error'): -# print(f" • Error: {summary['error']}") - -# # Print stage details if available -# if summary.get('stages') and len(summary['stages']) > 0: -# print(f"Stage Details:") -# for i, stage in enumerate(summary['stages'], 1): -# model_name = stage['model_config'].get('node_name', 'Unknown Model') -# preprocess_count = len(stage['preprocess_configs']) -# postprocess_count = len(stage['postprocess_configs']) - -# stage_info = f" Stage {i}: {model_name}" -# if preprocess_count > 0: -# stage_info += f" (with {preprocess_count} preprocess)" -# if postprocess_count > 0: -# stage_info += f" (with {postprocess_count} postprocess)" - -# print(stage_info) -# elif current_stage_count > 0: -# print(f"{current_stage_count} stage(s) detected but details not available") - -# print("─" * 50) # Separator line - -# def update_info_panel(self, summary): -# """Update the pipeline info panel with analysis results.""" -# info_text = f"""Pipeline Analysis: - -# Stage Count: {summary['stage_count']} -# Valid: {'Yes' if summary['valid'] else 'No'} -# {f"Error: {summary['error']}" if summary.get('error') else ""} - -# Node Statistics: -# - Total Nodes: {summary['total_nodes']} -# - Input Nodes: {summary['input_nodes']} -# - Model Nodes: {summary['model_nodes']} -# - Preprocess Nodes: {summary['preprocess_nodes']} -# - Postprocess Nodes: {summary['postprocess_nodes']} -# - Output Nodes: {summary['output_nodes']} - -# Stages:""" - -# for i, stage in enumerate(summary.get('stages', []), 1): -# info_text += f"\n Stage {i}: {stage['model_config']['node_name']}" -# if stage['preprocess_configs']: -# info_text += f" (with {len(stage['preprocess_configs'])} preprocess)" -# if stage['postprocess_configs']: -# info_text += f" (with {len(stage['postprocess_configs'])} postprocess)" - -# self.info_text.setPlainText(info_text) - -# def on_node_created(self, node): -# """Handle node creation.""" -# node_type = self.get_node_type_name(node) -# print(f"+ Node added: {node_type}") -# self.schedule_analysis() - -# def on_node_deleted(self, node): -# """Handle node deletion.""" -# node_type = self.get_node_type_name(node) -# print(f"- Node removed: {node_type}") -# self.schedule_analysis() - -# def on_nodes_deleted(self, nodes): -# """Handle multiple node deletion.""" -# node_types = [self.get_node_type_name(node) for node in nodes] -# print(f"- Multiple nodes removed: {', '.join(node_types)}") -# self.schedule_analysis() - -# def on_connection_changed(self, input_port, output_port): -# """Handle connection changes.""" -# print(f"🔗 Connection changed: {input_port} <-> {output_port}") -# self.schedule_analysis() - -# def get_node_type_name(self, node): -# """Get a readable name for the node type.""" -# if hasattr(node, 'NODE_NAME'): -# return node.NODE_NAME -# elif hasattr(node, '__identifier__'): -# # Convert identifier to readable name -# identifier = node.__identifier__ -# if 'model' in identifier: -# return "Model Node" -# elif 'input' in identifier: -# return "Input Node" -# elif 'output' in identifier: -# return "Output Node" -# elif 'preprocess' in identifier: -# return "Preprocess Node" -# elif 'postprocess' in identifier: -# return "Postprocess Node" - -# # Fallback to class name -# return type(node).__name__ - -# def get_current_stage_count(self): -# """Get the current stage count.""" -# return self.stage_count_widget.stage_count if self.stage_count_widget else 0 - -# def get_pipeline_summary(self): -# """Get the current pipeline summary.""" -# if self.node_graph: -# return get_pipeline_summary(self.node_graph) -# return {'stage_count': 0, 'valid': False, 'error': 'No pipeline graph'} - - -# def main(): -# """Main function for testing the pipeline editor.""" -# from PyQt5.QtWidgets import QApplication - -# app = QApplication(sys.argv) - -# editor = PipelineEditor() -# editor.show() - -# sys.exit(app.exec_()) - - -# if __name__ == '__main__': -# main() \ No newline at end of file diff --git a/cluster4npu_ui/utils/__init__.py b/cluster4npu_ui/utils/__init__.py deleted file mode 100644 index c260525..0000000 --- a/cluster4npu_ui/utils/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Utility functions and helper modules for the Cluster4NPU application. - -This module provides various utility functions, helpers, and common operations -that are used throughout the application. - -Available Utilities: - - file_utils: File operations and I/O helpers (future) - - ui_utils: UI-related utility functions (future) - -Usage: - from cluster4npu_ui.utils import file_utils, ui_utils - - # File operations - pipeline_data = file_utils.load_pipeline('path/to/file.mflow') - - # UI helpers - ui_utils.show_error_dialog(parent, "Error message") -""" - -# Import utilities as they are implemented -# from . import file_utils -# from . import ui_utils - -__all__ = [ - # "file_utils", - # "ui_utils" -] \ No newline at end of file diff --git a/cluster4npu_ui/utils/file_utils.py b/cluster4npu_ui/utils/file_utils.py deleted file mode 100644 index e69de29..0000000 diff --git a/cluster4npu_ui/utils/ui_utils.py b/cluster4npu_ui/utils/ui_utils.py deleted file mode 100644 index e69de29..0000000 diff --git a/debug_registration.py b/debug_registration.py deleted file mode 100644 index c15ce61..0000000 --- a/debug_registration.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -""" -Debug the node registration process to find the exact issue. -""" - -import sys -import os - -# Add the project root to Python path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from PyQt5.QtWidgets import QApplication - -def debug_registration_detailed(): - """Debug the registration process in detail.""" - app = QApplication(sys.argv) - - try: - from NodeGraphQt import NodeGraph - from cluster4npu_ui.core.nodes.simple_input_node import SimpleInputNode - - print("Creating NodeGraph...") - graph = NodeGraph() - - print(f"Node class: {SimpleInputNode}") - print(f"Node identifier: {SimpleInputNode.__identifier__}") - print(f"Node name: {SimpleInputNode.NODE_NAME}") - - # Check if the node class has required methods - required_methods = ['__init__', 'add_input', 'add_output', 'set_color', 'create_property'] - for method in required_methods: - if hasattr(SimpleInputNode, method): - print(f"✓ Has method: {method}") - else: - print(f"✗ Missing method: {method}") - - print("\nAttempting registration...") - try: - graph.register_node(SimpleInputNode) - print("✓ Registration successful") - except Exception as e: - print(f"✗ Registration failed: {e}") - import traceback - traceback.print_exc() - return False - - print("\nChecking registered nodes...") - try: - # Different ways to check registered nodes - if hasattr(graph, 'registered_nodes'): - registered = graph.registered_nodes() - print(f"Registered nodes (method 1): {registered}") - - if hasattr(graph, '_registered_nodes'): - registered = graph._registered_nodes - print(f"Registered nodes (method 2): {registered}") - - if hasattr(graph, 'node_factory'): - factory = graph.node_factory - print(f"Node factory: {factory}") - if hasattr(factory, '_NodeFactory__nodes'): - nodes = factory._NodeFactory__nodes - print(f"Factory nodes: {list(nodes.keys())}") - - except Exception as e: - print(f"Error checking registered nodes: {e}") - - print("\nAttempting node creation...") - try: - node = graph.create_node('com.cluster.input_node') - print(f"✓ Node created successfully: {node}") - return True - except Exception as e: - print(f"✗ Node creation failed: {e}") - - # Try alternative identifiers - alternatives = [ - 'SimpleInputNode', - 'Input Node', - 'com.cluster.InputNode', - 'cluster.input_node' - ] - - for alt_id in alternatives: - try: - print(f"Trying alternative identifier: {alt_id}") - node = graph.create_node(alt_id) - print(f"✓ Success with identifier: {alt_id}") - return True - except: - print(f"✗ Failed with: {alt_id}") - - return False - - except Exception as e: - print(f"Debug failed: {e}") - import traceback - traceback.print_exc() - return False - finally: - app.quit() - -def main(): - """Run detailed debugging.""" - print("DETAILED NODE REGISTRATION DEBUG") - print("=" * 50) - - success = debug_registration_detailed() - - print("\n" + "=" * 50) - if success: - print("DEBUG SUCCESSFUL - Node creation working") - else: - print("DEBUG FAILED - Need to fix registration") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/demo_modular_app.py b/demo_modular_app.py deleted file mode 100644 index ab5682b..0000000 --- a/demo_modular_app.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -""" -Demonstration script for the modularized Cluster4NPU UI application. - -This script demonstrates how to use the newly modularized components and -shows the benefits of the refactored architecture. - -Run this script to: -1. Test the modular node system -2. Demonstrate configuration management -3. Show theme application -4. Launch the modular UI - -Usage: - python demo_modular_app.py -""" - -import sys -import os - -# Add the project root to Python path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -def demo_node_system(): - """Demonstrate the modular node system.""" - print("Testing Modular Node System") - print("-" * 40) - - # Import nodes from the modular structure - from cluster4npu_ui.core.nodes import ( - InputNode, ModelNode, PreprocessNode, - PostprocessNode, OutputNode, NODE_TYPES - ) - - # Create and configure nodes - print("Creating nodes...") - - # Input node - input_node = InputNode() - input_node.set_property('source_type', 'Camera') - input_node.set_property('resolution', '1920x1080') - input_node.set_property('fps', 30) - - # Preprocessing node - preprocess_node = PreprocessNode() - preprocess_node.set_property('resize_width', 640) - preprocess_node.set_property('resize_height', 480) - preprocess_node.set_property('normalize', True) - - # Model node - model_node = ModelNode() - model_node.set_property('dongle_series', '720') - model_node.set_property('num_dongles', 2) - model_node.set_property('batch_size', 4) - - # Postprocessing node - postprocess_node = PostprocessNode() - postprocess_node.set_property('confidence_threshold', 0.7) - postprocess_node.set_property('output_format', 'JSON') - - # Output node - output_node = OutputNode() - output_node.set_property('output_type', 'File') - output_node.set_property('format', 'JSON') - - # Display configuration - print(f"✅ Input Node: {input_node.get_property('source_type')} @ {input_node.get_property('resolution')}") - print(f"✅ Preprocess: {input_node.get_property('resize_width')}x{preprocess_node.get_property('resize_height')}") - print(f"✅ Model: {model_node.get_property('dongle_series')} series, {model_node.get_property('num_dongles')} dongles") - print(f"✅ Postprocess: Confidence >= {postprocess_node.get_property('confidence_threshold')}") - print(f"✅ Output: {output_node.get_property('output_type')} in {output_node.get_property('format')} format") - - # Show available node types - print(f"📋 Available Node Types: {list(NODE_TYPES.keys())}") - - # Test validation - print("\n🔍 Testing Validation...") - for node, name in [(input_node, "Input"), (model_node, "Model"), (postprocess_node, "Postprocess")]: - valid, error = node.validate_configuration() - status = "✅ Valid" if valid else f"❌ Invalid: {error}" - print(f" {name} Node: {status}") - - print() - - -def demo_configuration_system(): - """Demonstrate the configuration management system.""" - print("⚙️ Testing Configuration System") - print("-" * 40) - - from cluster4npu_ui.config import get_settings, Colors - - # Test settings management - settings = get_settings() - - print(f"✅ Settings loaded from: {settings.config_file}") - print(f"✅ Default project location: {settings.get_default_project_location()}") - print(f"✅ Auto-save enabled: {settings.get('general.auto_save')}") - print(f"✅ Theme: {settings.get('general.theme')}") - - # Test recent files management - settings.add_recent_file("/tmp/test_pipeline.mflow") - recent_files = settings.get_recent_files() - print(f"✅ Recent files count: {len(recent_files)}") - - # Test color system - print(f"✅ Primary accent color: {Colors.ACCENT_PRIMARY}") - print(f"✅ Background color: {Colors.BACKGROUND_MAIN}") - - print() - - -def demo_theme_system(): - """Demonstrate the theme system.""" - print("🎨 Testing Theme System") - print("-" * 40) - - from cluster4npu_ui.config.theme import HARMONIOUS_THEME_STYLESHEET, Colors - - print(f"✅ Theme stylesheet loaded: {len(HARMONIOUS_THEME_STYLESHEET)} characters") - print(f"✅ Color constants available: {len([attr for attr in dir(Colors) if not attr.startswith('_')])} colors") - print(f"✅ Primary text color: {Colors.TEXT_PRIMARY}") - print(f"✅ Success color: {Colors.SUCCESS}") - - print() - - -def launch_modular_app(): - """Launch the modular application.""" - print("🚀 Launching Modular Application") - print("-" * 40) - - try: - from cluster4npu_ui.main import main - print("✅ Application entry point imported successfully") - print("✅ Starting application...") - - # Note: This would launch the full UI - # main() # Uncomment to actually launch - - print("📝 Note: Uncomment the main() call to launch the full UI") - - except Exception as e: - print(f"❌ Error launching application: {e}") - import traceback - traceback.print_exc() - - print() - - -def show_project_structure(): - """Show the modular project structure.""" - print("📁 Modular Project Structure") - print("-" * 40) - - structure = """ - cluster4npu_ui/ - ├── __init__.py ✅ Package initialization - ├── main.py ✅ Application entry point - ├── config/ - │ ├── __init__.py ✅ Config package - │ ├── theme.py ✅ QSS themes and colors - │ └── settings.py ✅ Settings management - ├── core/ - │ ├── __init__.py ✅ Core package - │ ├── nodes/ - │ │ ├── __init__.py ✅ Node registry - │ │ ├── base_node.py ✅ Base node functionality - │ │ ├── input_node.py ✅ Input sources - │ │ ├── model_node.py ✅ Model inference - │ │ ├── preprocess_node.py ✅ Preprocessing - │ │ ├── postprocess_node.py✅ Postprocessing - │ │ └── output_node.py ✅ Output destinations - │ └── pipeline.py 🔄 Future: Pipeline logic - ├── ui/ - │ ├── __init__.py ✅ UI package - │ ├── components/ - │ │ ├── __init__.py 📋 UI components - │ │ ├── node_palette.py 🔄 Node templates - │ │ ├── properties_widget.py 🔄 Property editor - │ │ └── common_widgets.py 🔄 Shared widgets - │ ├── dialogs/ - │ │ ├── __init__.py 📋 Dialog package - │ │ ├── create_pipeline.py 🔄 Pipeline creation - │ │ ├── stage_config.py 🔄 Stage configuration - │ │ ├── performance.py 🔄 Performance analysis - │ │ ├── save_deploy.py 🔄 Export and deploy - │ │ └── properties.py 🔄 Property dialogs - │ └── windows/ - │ ├── __init__.py 📋 Windows package - │ ├── dashboard.py 🔄 Main dashboard - │ ├── login.py ✅ Startup window - │ └── pipeline_editor.py 🔄 Pipeline editor - ├── utils/ - │ ├── __init__.py 📋 Utilities package - │ ├── file_utils.py 🔄 File operations - │ └── ui_utils.py 🔄 UI helpers - └── resources/ - ├── __init__.py 📋 Resources package - ├── icons/ 📁 Icon files - └── styles/ 📁 Additional styles - - Legend: - ✅ Implemented and tested - 🔄 Planned for implementation - 📋 Package structure ready - 📁 Directory created - """ - - print(structure) - - -def main(): - """Main demonstration function.""" - print("=" * 60) - print("🎯 CLUSTER4NPU UI - MODULAR ARCHITECTURE DEMO") - print("=" * 60) - print() - - # Run demonstrations - demo_node_system() - demo_configuration_system() - demo_theme_system() - launch_modular_app() - show_project_structure() - - print("=" * 60) - print("✨ REFACTORING COMPLETE - 85% DONE") - print("=" * 60) - print() - print("Key Benefits Achieved:") - print("• 🏗️ Modular architecture with clear separation of concerns") - print("• 🧪 Enhanced testability with isolated components") - print("• 🤝 Better collaboration support with focused modules") - print("• 🚀 Improved performance through optimized imports") - print("• 🔧 Type-safe node system with comprehensive validation") - print("• ⚙️ Professional configuration management") - print("• 🎨 Centralized theme and styling system") - print("• 📖 Complete documentation and migration tracking") - print() - print("Original: 3,345 lines in one file") - print("Modular: Multiple focused modules (~200-400 lines each)") - print("Reduction: 94% per-module complexity reduction") - print() - print("Next Steps:") - print("• Complete UI component extraction") - print("• Implement remaining dialogs and windows") - print("• Add comprehensive test suite") - print("• Finalize integration and validation") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f2f63d0..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,13 +0,0 @@ -[project] -name = "cluster4npu" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.10, <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", -] \ No newline at end of file diff --git a/src/cluster4npu/__init__.py b/src/cluster4npu/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test.py b/test.py deleted file mode 100644 index 060eeb9..0000000 --- a/test.py +++ /dev/null @@ -1,297 +0,0 @@ -import kp -import time -import numpy as np -from typing import List, Dict, Any, Callable, Optional -import queue -import threading -import multiprocessing -import cv2 -import os - -# 定義一個 Dongle 的設定結構 -class DongleConfig: - def __init__(self, port_id: list, scpu_fw_path: str, ncpu_fw_path: str, model_path: str, device_type: str = "KL520"): - self.port_id = port_id - self.scpu_fw_path = scpu_fw_path - self.ncpu_fw_path = ncpu_fw_path - self.model_path = model_path - self.device_type = device_type - -# 定義一個 Pipeline 的層級結構 -class PipelineLayer: - def __init__(self, name: str, dongle_config: DongleConfig, preprocess_func: Optional[Callable] = None, postprocess_func: Optional[Callable] = None): - self.name = name - self.dongle_config = dongle_config - self.preprocess_func = preprocess_func - self.postprocess_func = postprocess_func - -class KneronPipeline: - def __init__(self, pipeline_layers: List[PipelineLayer]): - if not pipeline_layers: - raise ValueError("Pipeline must have at least one layer.") - self.pipeline_layers = pipeline_layers - self._dongles: Dict[str, Any] = {} # 儲存 kp.core.DeviceGroup 實例 - self._model_descriptors: Dict[str, Any] = {} # 儲存模型描述符 - self._layer_connections: List[tuple] = [] # 儲存層之間的連接關係 - self._initialized = False - self._lock = threading.Lock() # 用於初始化保護 - - def add_layer_connection(self, from_layer_name: str, to_layer_name: str): - """ - 定義不同層之間的資料流向。 - 例如: pipeline.add_layer_connection("layer1", "layer2") - 表示 layer1 的輸出作為 layer2 的輸入。 - 更複雜的連接方式可能需要更詳細的定義,例如指定輸出節點到輸入節點的對應。 - """ - from_layer = next((layer for layer in self.pipeline_layers if layer.name == from_layer_name), None) - to_layer = next((layer for layer in self.pipeline_layers if layer.name == to_layer_name), None) - if not from_layer or not to_layer: - raise ValueError(f"Invalid layer names: {from_layer_name} or {to_layer_name} not found.") - self._layer_connections.append((from_layer_name, to_layer_name)) - - def initialize(self): - """ - 初始化所有 dongles, 載入韌體和模型。 - """ - with self._lock: - if self._initialized: - print("Pipeline already initialized.") - return - - print("[初始化 Pipeline...]") - for layer in self.pipeline_layers: - config = layer.dongle_config - print(f"[連接設備] Layer: {layer.name}, Port: {config.port_id}") - try: - # 使用單獨的 DeviceGroup 來管理每個 dongle - device_group = kp.core.connect_devices(usb_port_ids=config.port_id) - self._dongles[layer.name] = device_group - print(f" - {layer.name}: 連接成功") - - print(f"[設置超時] Layer: {layer.name}") - kp.core.set_timeout(device_group=device_group, milliseconds=5000) - print(f" - {layer.name}: 超時設置成功") - - print(f"[上傳韌體] Layer: {layer.name}") - kp.core.load_firmware_from_file(device_group=device_group, - scpu_fw_path=config.scpu_fw_path, - ncpu_fw_path=config.ncpu_fw_path) - print(f" - {layer.name}: 韌體上傳成功") - - print(f"[上傳模型] Layer: {layer.name}") - model_descriptor = kp.core.load_model_from_file(device_group=device_group, - file_path=config.model_path) - self._model_descriptors[layer.name] = model_descriptor - print(f" - {layer.name}: 模型上傳成功") - - except Exception as e: - print(f"錯誤: 初始化 Layer {layer.name} 失敗: {str(e)}") - # 清理已連接的設備 - self.release() - raise e - - self._initialized = True - print("[Pipeline 初始化完成]") - - def run(self, input_data: Any) -> Dict[str, Any]: - """ - 執行整個 pipeline。 - 這部分需要處理平行和串行的執行邏輯。 - 輸入可以是原始數據 (例如圖片路徑),第一個 layer 的 preprocess 會處理它。 - """ - if not self._initialized: - raise RuntimeError("Pipeline not initialized. Call .initialize() first.") - - # 這裡需要實現平行和多層邏輯。 - # 一種方式是使用 ThreadPoolExecutor 或 ProcessPoolExecutor。 - # 另一種是手動管理 Thread/Process。 - # 考慮到 dongle 通訊的 I/O 綁定特性,Thread 可能更適合平行處理。 - # 但如果 preprocess/postprocess 是 CPU 綁定,則 multiprocessing 更優。 - # 我們先假設 dongle 通訊是主要瓶頸,使用 threading。 - # 如果 preprocess/postprocess 也是瓶頸,可以考慮在 pipeline 內部針對這些步驟使用 Process。 - - results: Dict[str, Any] = {} - # 這個範例只處理簡單的順序 pipeline,平行和複雜串接需要更多邏輯 - # TODO: 實現平行和複雜串接邏輯 - - current_input = input_data - for i, layer in enumerate(self.pipeline_layers): - print(f"[執行 Layer] {layer.name}") - dongle = self._dongles[layer.name] - model_descriptor = self._model_descriptors[layer.name] - - # 預處理 - processed_input = current_input - if layer.preprocess_func: - print(f" - 執行 {layer.name} 的預處理") - processed_input = layer.preprocess_func(current_input) - - # 推論 - print(f" - 執行 {layer.name} 的推論") - try: - # 假設 processed_input 是 kp.GenericInputNodeImage 列表或可轉換為它 - # 這裡需要根據實際的 preprocess 輸出和模型輸入來調整 - if isinstance(processed_input, list) and all(isinstance(item, kp.GenericInputNodeImage) for item in processed_input): - inference_input_descriptor = kp.GenericImageInferenceDescriptor( - model_id=model_descriptor.models[0].id, - inference_number=0, - input_node_image_list=processed_input - ) - elif isinstance(processed_input, np.ndarray): - # 假設 preprocess 輸出了 numpy array, 需要轉換為 GenericInputNodeImage - # 這需要更詳細的 info, 例如圖像格式, resize, padding, normalize - # 這裡先給一個簡易範例,假設是BGR565, 128x128 - inference_input_descriptor = kp.GenericImageInferenceDescriptor( - model_id=model_descriptor.models[0].id, - inference_number=0, - input_node_image_list=[ - kp.GenericInputNodeImage( - image=processed_input, - image_format=kp.ImageFormat.KP_IMAGE_FORMAT_RGB565, # 這裡需要根據你的 preprocess 輸出調整 - resize_mode=kp.ResizeMode.KP_RESIZE_ENABLE, - padding_mode=kp.PaddingMode.KP_PADDING_CORNER, - normalize_mode=kp.NormalizeMode.KP_NORMALIZE_KNERON - ) - ] - ) - else: - raise TypeError(f"Unsupported processed input type for layer {layer.name}: {type(processed_input)}") - - - kp.inference.generic_image_inference_send(device_group=dongle, - generic_inference_input_descriptor=inference_input_descriptor) - generic_raw_result = kp.inference.generic_image_inference_receive(device_group=dongle) - - # 處理原始結果 - inf_node_output_list = [] - for node_idx in range(generic_raw_result.header.num_output_node): - # 這裡假設輸出是 float 類型,需要根據你的模型輸出類型調整 - inference_float_node_output = kp.inference.generic_inference_retrieve_float_node( - node_idx=node_idx, - generic_raw_result=generic_raw_result, - channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW # 需要根據模型輸出調整 - ) - inf_node_output_list.append(inference_float_node_output.ndarray.copy()) - - raw_output = inf_node_output_list # 可以是 list of numpy arrays - - except Exception as e: - print(f"錯誤: Layer {layer.name} 推論失敗: {str(e)}") - raise e - - # 後處理 - final_output = raw_output - if layer.postprocess_func: - print(f" - 執行 {layer.name} 的後處理") - final_output = layer.postprocess_func(raw_output) - - results[layer.name] = final_output - - # 設定下一個 layer 的輸入 (簡易串接,更複雜需要 _layer_connections 邏輯) - current_input = final_output - - return results - - def release(self): - """ - 釋放所有 dongles 連接。 - """ - with self._lock: - if not self._initialized: - print("Pipeline not initialized.") - return - - print("[釋放 Pipeline...]") - for layer_name, dongle in self._dongles.items(): - try: - kp.core.disconnect_devices(device_group=dongle) - print(f" - {layer_name}: 已斷開連接") - except Exception as e: - print(f"錯誤: 斷開 Layer {layer_name} 連接失敗: {str(e)}") - self._dongles = {} - self._model_descriptors = {} - self._initialized = False - print("[Pipeline 釋放完成]") - - def __enter__(self): - self.initialize() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.release() - -# 範例使用 -if __name__ == '__main__': - # 定義你的 preprocess 和 postprocess 函數 - def my_preprocess(image_path: str): - # 參照你提供的 2_2nef_test.py - img = cv2.imread(image_path) - if img is None: - raise Exception(f"無法讀取圖片: {image_path}") - img_resized = cv2.resize(img, (128, 128)) - img_bgr565 = cv2.cvtColor(img_resized, cv2.COLOR_BGR2BGR565) - # 返回 numpy array,KneronPipeline.run 中會轉換為 GenericInputNodeImage - return img_bgr565 - - def my_postprocess(raw_output: List[np.ndarray]): - # 參照你提供的 2_2nef_test.py - probability = raw_output[0].flatten()[0] # 假設是單一輸出節點,取第一個值 - result = "Fire" if probability > 0.5 else "No Fire" - return {"result": result, "confidence": probability} - - def another_preprocess(data: Any): - # 另一個 layer 的預處理 - print("執行第二層的預處理...") - return data # 這裡只是範例,實際需要根據前一层的輸出和當前層模型輸入來處理 - - def another_postprocess(raw_output: List[np.ndarray]): - # 另一個 layer 的後處理 - print("執行第二層的後處理...") - # 假設這層輸出是另一個分類結果 - class_id = np.argmax(raw_output[0].flatten()) - return {"class_id": class_id} - - - # 定義 Dongle 配置 - dongle_config1 = DongleConfig(port_id=0, scpu_fw_path='fw_scpu.bin', ncpu_fw_path='fw_ncpu.bin', model_path='models_520.nef') - # 如果有另一個 dongle 和模型 - dongle_config2 = DongleConfig(port_id=1, scpu_fw_path='fw_scpu.bin', ncpu_fw_path='fw_ncpu.bin', model_path='another_model.nef') - - - # 定義 Pipeline 層 - # 單層 pipeline (平行處理多個輸入可以使用這個 structure, 但 run 方法需要修改) - # layers_single = [ - # PipelineLayer(name="detector_dongle_0", dongle_config=dongle_config1, preprocess_func=my_preprocess, postprocess_func=my_postprocess), - # # 如果想平行處理,可以在這裡加更多使用不同 dongle 的 layer,但 run 方法需要平行化 - # # PipelineLayer(name="detector_dongle_1", dongle_config=dongle_config2, preprocess_func=my_preprocess, postprocess_func=my_postprocess), - # ] - - # 多層 pipeline (串接不同 dongles) - layers_multi = [ - PipelineLayer(name="detector_layer", dongle_config=dongle_config1, preprocess_func=my_preprocess, postprocess_func=my_postprocess), - PipelineLayer(name="classifier_layer", dongle_config=dongle_config2, preprocess_func=another_preprocess, postprocess_func=another_postprocess), - ] - - - # 建立 Pipeline 實例 - # pipeline = KneronPipeline(pipeline_layers=layers_single) # 單層範例 - pipeline = KneronPipeline(pipeline_layers=layers_multi) # 多層範例 - - # 定義層之間的連接 (僅多層時需要,目前 run 方法只支持簡單順序串接) - # pipeline.add_layer_connection("detector_layer", "classifier_layer") - - # 使用 with 語句確保釋放資源 - try: - with pipeline: - # 執行推論 - image_path = r'C:\Users\USER\Desktop\Yu-An\Firedetection\test_images\fire4.jpeg' - results = pipeline.run(input_data=image_path) - - print("\nPipeline 執行結果:") - for layer_name, output in results.items(): - print(f" Layer '{layer_name}' 輸出: {output}") - - # 如果是平行處理,可以在這裡輸入多個 image paths,然後在 run 方法裡分派給不同的 dongle - - except Exception as e: - print(f"Pipeline 執行過程中發生錯誤: {str(e)}") \ No newline at end of file diff --git a/test_pipeline.py b/test_pipeline.py deleted file mode 100644 index 227b1a9..0000000 --- a/test_pipeline.py +++ /dev/null @@ -1,534 +0,0 @@ -import multiprocessing -import time -import os -import sys -import cv2 -import numpy as np -import math - -# --- Import Kneron Specific Libraries and Utilities --- -# Assuming your Kneron SDK and example files are set up such that these imports work. -# You might need to adjust sys.path or your project structure. -try: - # Attempt to import the core Kneron library - import kp - print("Kneron SDK (kp) imported successfully.") - - # Attempt to import utilities from your specific example files - # Adjust these import paths based on where your files are located relative to this script - # from utils.ExampleHelper import get_device_usb_speed_by_port_id # Assuming this is in utils - # from utils.ExamplePostProcess import post_process_yolo_v5 # Assuming this is in utils - # Import from your provided files directly or ensure they are in Python path - - # Placeholder imports - **YOU MUST ENSURE THESE ACTUALLY WORK** - # Depending on your setup, you might need to copy the functions directly or fix paths. - try: - # Assuming these are in your utils or directly importable - from utils.ExampleHelper import get_device_usb_speed_by_port_id - from utils.ExamplePostProcess import post_process_yolo_v5 - - # Based on snippets from your files - def get_palette(mapping, seed=9487): - print("Using get_palette from snippet.") - np.random.seed(seed) - return [list(np.random.choice(range(256), size=3)) - for _ in range(mapping)] - - # Based on snippet from your files - ensure dtype is correct (np.uint8 or np.int8) - def convert_numpy_to_rgba_and_width_align_4(data): - print("Using convert_numpy_to_rgba_and_width_align_4 from snippet.") - height, width, channel = data.shape - width_aligned = 4 * math.ceil(width / 4.0) - # Use np.uint8 for image data conversion usually - aligned_data = np.zeros((height, width_aligned, 4), dtype=np.uint8) - aligned_data[:height, :width, :channel] = data - aligned_data = aligned_data.flatten() # Flatten as shown in snippet - return aligned_data.tobytes() - - # Based on snippet from your files (adapted to take device_group or device) - # It seems inference calls might take a single device object from the group. - # Let's assume retrieve_inference_node_output needs the raw_result, not device. - def retrieve_inference_node_output(generic_raw_result): - print('[Retrieve Inference Node Output ]') - inf_node_output_list = [] - for node_idx in range(generic_raw_result.header.num_output_node): - inference_float_node_output = kp.inference.generic_inference_retrieve_float_node( - node_idx=node_idx, - generic_raw_result=generic_raw_result, - channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW) # Use actual kp enum - inf_node_output_list.append(inference_float_node_output) - print(' - Success') - return inf_node_output_list - - - print("Kneron utility functions imported/defined from snippets.") - - except ImportError as e: - print(f"Error importing Kneron utility modules (e.g., utils.ExampleHelper): {e}") - print("Please ensure the 'utils' directory is in your Python path or copy the necessary functions.") - raise # Re-raise the error to indicate missing dependencies - - -except ImportError as e: - print(f"Error importing Kneron SDK (kp): {e}") - print("Please ensure Kneron SDK is installed and in your Python path.") - print("Cannot run Kneron pipeline without the SDK.") - sys.exit("Kneron SDK not found.") - - -# --- Worker Functions --- - -def yolo_worker(input_queue: multiprocessing.Queue, output_queue: multiprocessing.Queue, - firmware_path: str, model_path: str, port_id: int): - """ - YOLOv5 processing layer worker. Initializes Kneron device and model using kp.core. - Reads image data, performs YOLO inference, and passes the original image data - to the next layer's queue. - """ - device_group = None - model_yolo_descriptor = None - device = None # Will get the specific device object from the group - - print("YOLO Worker: Starting and initializing Kneron device using kp.core...") - try: - # --- Device and Model Initialization (per process) --- - print(f"YOLO Worker: Connecting to device on port {port_id}") - # Use kp.core.connect_devices - device_group = kp.core.connect_devices(usb_port_ids=[port_id]) - if not device_group or not device_group.devices: - raise RuntimeError(f"YOLO Worker: Failed to connect to device on port {port_id}") - - # Get the specific device object from the group (assuming single device per worker) - # device = device_group.devices[0] - print(f"YOLO Worker: Device connected") - - - print("YOLO Worker: Loading firmware") - # Firmware loading seems to be a method on the device object - # device.load_firmware_from_file(firmware_path) - - - print("YOLO Worker: Loading YOLO model using kp.core") - # Use kp.core.load_model_from_file with the device_group - model_yolo_descriptor = kp.core.load_model_from_file( - device_group=device_group, - file_path=model_path - ) - if not model_yolo_descriptor: - raise RuntimeError(f"YOLO Worker: Failed to load YOLO model from {model_path}") - - print("YOLO Worker: Initialization complete. Waiting for data.") - - # Optional: Check USB speed if needed, using the imported utility - # usb_speed = get_device_usb_speed_by_port_id(port_id) # This utility might need adaptation or just be illustrative - # print(f"YOLO Worker: Device USB Speed: {usb_speed}") # This utility might need adaptation - - - # Set inference feature if required (e.g., for image format) - # Based on examples, sometimes necessary before inference - try: - # Example, check your original code for required features - # device.set_feature(kp.InferenceFeature.INF_FEATURE_IMAGE_FORMAT, kp.ImageFormat.IMAGE_FORMAT_RGBA) - pass # Add relevant set_feature calls from your original code if needed - except Exception as set_feature_e: - print(f"YOLO Worker: Error setting inference features: {set_feature_e}") - # Decide if this is a critical error or warning - - - # --------------------------------------- - - while True: - # Get image data from the input queue - data_item = input_queue.get() - if data_item is None: - print("YOLO Worker: Received termination signal. Propagating None to STDC queue.") - output_queue.put(None) # Propagate the signal - break # Exit the worker loop - - # Assuming data_item is the image numpy array - image_data = data_item - # print("YOLO Worker: Received image data for processing.") # Too verbose for loop - - # --- Perform YOLO Inference --- - img_height, img_width, _ = image_data.shape - inference_input_size = (img_width, img_height) # Kneron expects (width, height) - - # Convert image data format for Kneron inference using the utility - aligned_image_data = convert_numpy_to_rgba_and_width_align_4(image_data) - - # Send image to device and get raw results - try: - # Use kp.inference with the specific device object - generic_raw_result = kp.inference.generic_inference_send_image( - device=device, # Use the device object from the group - data=aligned_image_data, - size=inference_input_size - ) - if not generic_raw_result: - print("YOLO Worker: Warning - generic_inference_send_image returned None.") - continue # Skip post-processing if raw result is none - - - # Retrieve raw node outputs using the utility - # retrieve_inference_node_output utility likely takes the raw_result - inf_node_output_list = retrieve_inference_node_output(generic_raw_result) - - # Perform YOLO specific post-processing using the utility - yolo_results = post_process_yolo_v5( - inference_float_node_list=inf_node_output_list, - hardware_preproc_info=generic_raw_result.header.hw_pre_proc_info_list[0], - thresh_value=0.2 # Example threshold, adjust as needed - ) - # print(f"YOLO Worker: Detected {len(yolo_results.box_list)} objects.") # Too verbose - - - # Pass the *original image data* to the next layer (STDC) - # STDC will perform segmentation on the whole image. - output_queue.put(image_data) - # print("YOLO Worker: Finished inference, put image data to STDC queue.") # Too verbose - - except Exception as inference_e: - print(f"YOLO Worker Inference Error: {inference_e}") - # Handle inference errors - maybe put an error marker in the queue? - # For simplicity in FPS, we just skip this frame or let it potentially raise further - pass # Continue processing next item - - - print("YOLO Worker: Exiting loop.") - - except Exception as e: - print(f"YOLO Worker Initialization or Runtime Error: {e}") - finally: - # --- Device Disconnection --- - # Disconnect the device group - if device_group: - print("YOLO Worker: Disconnecting device group.") - kp.core.disconnect_devices(device_group=device_group) - print("YOLO Worker: Exiting.") - - -def stdc_worker(input_queue: multiprocessing.Queue, output_queue: multiprocessing.Queue, - firmware_path: str, model_path: str, port_id: int): - """ - STDC processing layer worker. Initializes Kneron device and model using kp.core. - Reads image data, performs STDC inference, and puts a completion marker - into the final output queue. - """ - device_group = None - model_stdc_descriptor = None - device = None # Will get the specific device object from the group - - print("STDC Worker: Starting and initializing Kneron device using kp.core...") - try: - # --- Device and Model Initialization (per process) --- - # STDC worker also needs its own device connection and model - print(f"STDC Worker: Connecting to device on port {port_id}") - # Use kp.core.connect_devices - device_group = kp.core.connect_devices(usb_port_ids=[port_id]) - if not device_group or not device_group.devices: - raise RuntimeError(f"STDC Worker: Failed to connect to device on port {port_id}") - - # Get the specific device object from the group (assuming single device per worker) - # device = device_group.devices[0] - print(f"STDC Worker: Device connected") - - # print("STDC Worker: Loading firmware") - # Firmware loading seems to be a method on the device object - # device.load_firmware_from_file(firmware_path) - - print("STDC Worker: Loading STDC model using kp.core") - # Use kp.core.load_model_from_file with the device_group - model_stdc_descriptor = kp.core.load_model_from_file( - device_group=device_group, - file_path=model_path - ) - if not model_stdc_descriptor: - raise RuntimeError(f"STDC Worker: Failed to load STDC model from {model_path}") - - print("STDC Worker: Initialization complete. Waiting for data.") - - # Optional: Check USB speed if needed - # usb_speed = get_device_usb_speed_by_port_id(port_id) # This utility might need adaptation - # print(f"STDC Worker: Device USB Speed: {usb_speed}") # This utility might need adaptation - - # Set inference feature if required (e.g., for image format) - try: - # Example, check your original code for required features - # device.set_feature(kp.InferenceFeature.INF_FEATURE_IMAGE_FORMAT, kp.ImageFormat.IMAGE_FORMAT_RGBA) - pass # Add relevant set_feature calls from your original code if needed - except Exception as set_feature_e: - print(f"STDC Worker: Error setting inference features: {set_feature_e}") - # Decide if this is a critical error or warning - - - # --------------------------------------- - - while True: - # Get image data from the input queue (from YOLO worker) - data_item = input_queue.get() - if data_item is None: - print("STDC Worker: Received termination signal. Putting None to final output queue and exiting.") - output_queue.put(None) # Signal end of results to the main process - break # Exit the worker loop - - # Assuming data_item is the image numpy array - image_data = data_item - # print("STDC Worker: Received image data for processing.") # Too verbose - - # --- Perform STDC Inference --- - img_height, img_width, _ = image_data.shape - inference_input_size = (img_width, img_height) # Kneron expects (width, height) - - # Convert image data format for Kneron inference using the utility - aligned_image_data = convert_numpy_to_rgba_and_width_align_4(image_data) - - # Send image to device and get raw results - try: - # Use kp.inference with the specific device object - generic_raw_result = kp.inference.generic_inference_send_image( - device=device, # Use the device object from the group - data=aligned_image_data, - size=inference_input_size - ) - if not generic_raw_result: - print("STDC Worker: Warning - generic_inference_send_image returned None.") - continue # Skip post-processing if raw result is none - - - # Retrieve raw node outputs using the utility - # retrieve_inference_node_output utility likely takes the raw_result - inf_node_output_list = retrieve_inference_node_output(generic_raw_result) - - # STDC Post-processing (extracting segmentation mask) - # Based on your STDC example, the output is likely in the first node - if inf_node_output_list: - pred_raw = inf_node_output_list[0].ndarray.squeeze() # Shape might be (C, H, W) - # Transpose to (H, W, C) if needed for further visualization/processing - # pred_transposed = pred_raw.transpose(1, 2, 0) # (H, W, C) - - # Example: Get the argmax mask (most likely class per pixel) - # Assuming pred_raw is shaped (C, H, W) after squeeze() - # pred_argmax = np.argmax(pred_raw, axis=0) # Shape (H, W) - - # For FPS, a simple signal per frame is fine: - output_queue.put("STDC_Frame_Done") - # If you needed the mask: output_queue.put(pred_argmax.astype(np.uint8)) - # print("STDC Worker: Finished segmentation inference, put result to final output queue.") # Too verbose - else: - print("STDC Worker: Warning - No output nodes retrieved.") - output_queue.put("STDC_Frame_Error") # Signal processing error for this frame - - except Exception as inference_e: - print(f"STDC Worker Inference Error: {inference_e}") - # Handle inference errors - output_queue.put("STDC_Frame_Error") # Signal processing error for this frame - - - print("STDC Worker: Exiting loop.") - - except Exception as e: - print(f"STDC Worker Initialization or Runtime Error: {e}") - finally: - # --- Device Disconnection --- - # Disconnect the device group - if device_group: - print("STDC Worker: Disconnecting device group.") - kp.core.disconnect_devices(device_group=device_group) - print("STDC Worker: Exiting.") - - -# --- API Function to Run the Pipeline --- - -def run_yolo_stdc_pipeline(image_file_path: str, firmware_path: str, - yolo_model_path: str, stdc_model_path: str, - loop_count: int = 100, port_id: int = 0): - """ - Runs the YOLOv5 + STDC pipeline using multiprocessing.Queue. - Initializes Kneron devices and models within worker processes using kp.core. - Processes the same image 'loop_count' times and calculates FPS. - - Args: - image_file_path (str): Path to the input image file (e.g., .bmp). - firmware_path (str): Path to the Kneron firmware file (.bin). - yolo_model_path (str): Path to the YOLOv5 model file (.nef). - stdc_model_path (str): Path to the STDC model file (.nef). - loop_count (int): Number of times to process the image through the pipeline. - port_id (int): Kneron device port ID to connect to. - - Returns: - float: Calculated FPS for processing 'loop_count' frames. - """ - # Read the input image ONCE - print(f"Main: Reading input image from {image_file_path}") - image_data = cv2.imread(image_file_path) - if image_data is None: - print(f"Error: Could not read image from {image_file_path}") - return 0.0 - print(f"Main: Image read successfully. Shape: {image_data.shape}") - - - # Define queues for inter-process communication - yolo_input_q = multiprocessing.Queue() # Main process puts image data -> YOLO worker reads - stdc_input_q = multiprocessing.Queue() # YOLO worker puts image data -> STDC worker reads - stdc_output_q = multiprocessing.Queue() # STDC worker puts results/markers -> Main process reads - - # Create worker processes - yolo_process = multiprocessing.Process( - target=yolo_worker, - args=(yolo_input_q, stdc_input_q, firmware_path, yolo_model_path, port_id) - ) - stdc_process = multiprocessing.Process( - target=stdc_worker, - args=(stdc_input_q, stdc_output_q, firmware_path, stdc_model_path, port_id) - ) - - # Start the worker processes - print("Main: Starting YOLO and STDC worker processes...") - yolo_process.start() - stdc_process.start() - print("Main: Worker processes started.") - - # Wait briefly for processes to initialize Kneron devices and load models - # This is a heuristic; a more robust method involves workers signaling readiness. - # Given the complexity of Kneron init, 5-10 seconds might be reasonable, adjust as needed. - initialization_wait_time = 10 # seconds - print(f"Main: Waiting {initialization_wait_time}s for workers to initialize devices and models.") - time.sleep(initialization_wait_time) - print("Main: Finished initialization waiting period.") - - - print(f"Main: Putting the same image into YOLO input queue {loop_count} times...") - start_time = time.time() # Start timing the loop - - # Put the same image data into the input queue 'loop_count' times - for i in range(loop_count): - yolo_input_q.put(image_data) - # print(f"Main: Queued image {i+1}/{loop_count}") # Optional: print progress - - print(f"Main: Finished queuing {loop_count} images. Sending termination signal to YOLO worker.") - # Send termination signal to the first worker's input queue - yolo_input_q.put(None) - - # Collect results/completion markers from the final output queue - print("Main: Collecting results from STDC output queue...") - processed_frame_count = 0 - # collected_results = [] # Uncomment if you put actual results in the queue - - while processed_frame_count < loop_count: # Collect exactly 'loop_count' valid results/markers - # Use a timeout in get() to avoid hanging indefinitely if a worker fails - try: - # Adjust timeout based on expected processing time per frame - result = stdc_output_q.get(timeout=60) # Example timeout: 60 seconds per result - if result is None: - # Received None prematurely? This shouldn't happen if workers are correct - # and we are waiting for loop_count items before checking for None. - print("Main: Warning - Received None from STDC output queue before collecting all frames.") - break # Exit collection loop if unexpected None - - if result == "STDC_Frame_Done": - processed_frame_count += 1 - # print(f"Main: Collected completion marker for frame {processed_frame_count}") # Optional - elif result == "STDC_Frame_Error": - processed_frame_count += 1 # Count it as a processed frame, albeit with error - print(f"Main: Collected error marker for a frame ({processed_frame_count}).") - # elif isinstance(result, np.ndarray): # If you put the actual mask (e.g., uint8) - # collected_results.append(result) - # processed_frame_count += 1 - # # print(f"Main: Collected segmentation mask for frame {processed_frame_count}") # Optional - else: - print(f"Main: Warning - Received unexpected item in STDC output queue: {result}") - - except multiprocessing.queues.Empty: - print(f"Main: Timeout ({60}s) while waiting for results from STDC output queue. {processed_frame_count}/{loop_count} frames processed.") - # Decide how to handle this - maybe terminate workers and exit? - break # Exit collection loop on timeout - except Exception as e: - print(f"Main: Error collecting result: {e}") - break # Exit collection loop on other errors - - - end_time = time.time() # Stop timing - - print(f"Main: Collected {processed_frame_count} results/markers.") - # Now wait for the final None signal after collecting all expected results - # This ensures queues are flushed and workers are terminating cleanly. - print("Main: Waiting for final termination signal from STDC output queue...") - try: - final_signal = stdc_output_q.get(timeout=10) # Short timeout for the final None - if final_signal is None: - print("Main: Received final termination signal from STDC output queue.") - else: - print(f"Main: Warning - Expected final None, but received: {final_signal}") - except multiprocessing.queues.Empty: - print("Main: Timeout while waiting for final None from STDC output queue.") - except Exception as e: - print(f"Main: Error getting final signal: {e}") - - - # Wait for the worker processes to fully complete - print("Main: Joining worker processes...") - yolo_process.join(timeout=30) # Add timeout for joining - if yolo_process.is_alive(): - print("Main: YOLO process did not terminate gracefully within timeout. Terminating.") - yolo_process.terminate() - print("Main: YOLO process joined.") - - stdc_process.join(timeout=30) # Add timeout for joining - if stdc_process.is_alive(): - print("Main: STDC process did not terminate gracefully within timeout. Terminating.") - stdc_process.terminate() - print("Main: STDC process joined.") - print("Main: All processes joined.") - - # Calculate FPS - duration = end_time - start_time - if duration > 0 and processed_frame_count > 0: - fps = processed_frame_count / duration - print(f"\n--- Pipeline Performance ---") - print(f"Processed {processed_frame_count} frames in {duration:.4f} seconds.") - print(f"Calculated FPS: {fps:.2f}") - else: - fps = 0.0 - print("Could not calculate FPS (duration is zero or no frames processed).") - - # print("\nCollected STDC Results (Markers or Data):") - # print(collected_results) # If you collected actual results - - return fps - -# --- Example Usage --- -if __name__ == '__main__': - # Required for multiprocessing on Windows - multiprocessing.freeze_support() - - # --- CONFIGURE YOUR FILE PATHS HERE --- - # !! IMPORTANT !! Replace these placeholder paths with your actual file locations. - ACTUAL_FIRMWARE_PATH = "path/to/your/KL720.bin" # e.g., "C:/Kneron_SDK/firmware/KL720/KL720.bin" - ACTUAL_YOLO_MODEL_PATH = "path/to/your/yolov5_model.nef" # e.g., "C:/Kneron_SDK/models/KL720/yolov5/yolov5.nef" - ACTUAL_STDC_MODEL_PATH = "path/to/your/stdc_model.nef" # e.g., "C:/Kneron_SDK/models/KL720/stdc/stdc.nef" - ACTUAL_IMAGE_FILE_PATH = "path/to/your/input_image.bmp" # e.g., "C:/Kneron_SDK/images/people_talk_in_street_1500x1500.bmp" - - # Check if the placeholder paths are still being used - paths_configured = not ("path/to/your/" in ACTUAL_FIRMWARE_PATH or - "path/to/your/" in ACTUAL_YOLO_MODEL_PATH or - "path/to/your/" in ACTUAL_STDC_MODEL_PATH or - "path/to/your/" in ACTUAL_IMAGE_FILE_PATH) - - if not paths_configured: - print("\n===================================================================") - print("!!! WARNING: Please update the file paths in the script before running. !!!") - print("===================================================================") - else: - print("\n--- Running YOLOv5 + STDC Pipeline ---") - try: - final_fps = run_yolo_stdc_pipeline( - image_file_path=ACTUAL_IMAGE_FILE_PATH, - firmware_path=ACTUAL_FIRMWARE_PATH, - yolo_model_path=ACTUAL_YOLO_MODEL_PATH, - stdc_model_path=ACTUAL_STDC_MODEL_PATH, - loop_count=100, - port_id=0 # Change if your device is on a different port - ) - print(f"\nAPI Function Call Complete. Final FPS: {final_fps:.2f}") - except Exception as main_e: - print(f"\nAn error occurred during the main pipeline execution: {main_e}") \ No newline at end of file diff --git a/test_ui.py b/test_ui.py deleted file mode 100644 index 5eed4b1..0000000 --- a/test_ui.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/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 deleted file mode 100644 index 792c939..0000000 --- a/ui_config.py +++ /dev/null @@ -1,415 +0,0 @@ -#!/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 deleted file mode 100644 index e84d9d3..0000000 --- a/ui_integration_example.py +++ /dev/null @@ -1,359 +0,0 @@ -#!/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 deleted file mode 100644 index cd715a6..0000000 --- a/uv.lock +++ /dev/null @@ -1,212 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.10, <3.12" -resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", - "python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version < '3.11' and sys_platform == 'darwin'", - "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", -] - -[[package]] -name = "cluster4npu" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "nodegraphqt" }, - { name = "numpy" }, - { name = "odengraphqt" }, - { name = "opencv-python" }, -] - -[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" }, -] - -[[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]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - -[[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" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322, upload-time = "2025-01-16T13:52:25.887Z" }, - { url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197, upload-time = "2025-01-16T13:55:21.222Z" }, - { url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439, upload-time = "2025-01-16T13:51:35.822Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597, upload-time = "2025-01-16T13:52:08.836Z" }, - { 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 = "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" }, -]