改成雙層config架構,調整app處理config資料, 增加inno setup的iss file, auto renew global config

This commit is contained in:
Mason Huang 2025-03-07 00:12:14 +08:00
parent 75e5925046
commit a8ed1ac592
4 changed files with 301 additions and 121 deletions

100
README.md
View File

@ -1,26 +1,7 @@
<!-- main page
1. -> example (python code) -> 讀 folder 檔案 -> 選
-> 抓 camera -> -i image -m model
2. run code -->
# Kneron Academy v2.0
這個應用程式是一個基於 Python、PyQt5、OpenCV 以及 Kneron SDKkp開發的 AI 應用 APP使用者可以透過鏡頭、麥克風或上傳的方式經由 Kneron NPU 裝置進行實時運算。
<!-- config for python script 需要有以下的資料:
1. function name to display
2. script name
3. device availibility (considering if the script name doesn't consist dongle type)
4. model's name
5. input information (frames, images, voice etc.)
6. input parameters
7. output parameters -->
# Innovedus AI Playground
這個應用程式是一個 AI Playground用於通過相機鏡頭或使用者上傳圖片進行推論例如火災檢測。應用程式基於 Python、PyQt5、OpenCV 以及 Kneron SDKkp開發支援 Video 模式與 Image 模式。
---
## 目錄
- [安裝設定](#安裝設定)
- [專案架構](#專案架構)
@ -74,8 +55,9 @@ project/
upload/
└── photos, videos, or mp3 files
utils/
└── plugins/
├── mode1/
├── config.json
├── REAMDE.md
│── mode1/
│ ├── model1/
│ │ ├── script.py
│ │ ├── model_file(s)
@ -93,19 +75,17 @@ utils/
├── script.py
├── model_file(s)
└── config.json
└──firmware\
firmware\
├── KLXXX/
│ ├── fw_scpu.bin
│ ├── fw_ncpu.bin
│ ├── VERSION
│ └── other files
── KLXXX/
── KLXXX/
├── fw_scpu.bin
├── fw_ncpu.bin
├── VERSION
└── other files
└──config.json
└──REAMDE.md
```
## 功能概述
@ -164,3 +144,69 @@ pyinstaller --onefile --windowed main.py --additional-hooks-dir=hooks --add-data
## APP資料加密
目前預計使用 [pyarmor](https://github.com/dashingsoft/pyarmor) 進行加密
## Script & Model 的設定
整個 utils folder 會分成兩層: global config 和 model config
model config 範例如下
``` json
{
"display_name": "人臉偵測 (ResNet-18)",
"description": "使用ResNet-18架構的高精度人臉偵測模型可即時標記影像中的人臉位置",
"model_file": "face_detection.nef",
"input_info": {
"type": "video",
"supported_formats": ["mp4", "avi", "webm"]
},
"input_parameters": {
"threshold": 0.75,
"max_faces": 10,
"tracking": true
},
"compatible_devices": ["KL520", "KL720"]
}
```
global config 範例如下
```json
{
"plugins": [
{
"mode": "face_recognition",
"display_name": "人臉辨識",
"models": [
{
"name": "face_detection",
"display_name": "人臉偵測 (ResNet-18)",
"description": "基於ResNet-18的高精度人臉偵測",
"compatible_devices": ["KL520", "KL720"]
},
{
"name": "age_gender",
"display_name": "年齡性別辨識 (VGG-Face)",
"description": "使用VGG-Face架構辨識人臉年齡與性別",
"compatible_devices": ["KL520", "KL720"]
}
]
},
{
"mode": "object_detection",
"display_name": "物體偵測",
"models": [
{
"name": "yolo_v5",
"display_name": "物體偵測 (YOLOv5)",
"description": "使用YOLOv5進行實時物體偵測與分類",
"compatible_devices": ["KL720"]
},
{
"name": "rcnn",
"display_name": "精確物體識別 (Faster R-CNN)",
"description": "以Faster R-CNN為基礎的高精度物體識別",
"compatible_devices": ["KL720"]
}
]
}
]
}
```

27
dist/test.iss vendored
View File

@ -1,12 +1,10 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Kneron Academy"
#define MyAppVersion "2.0"
#define MyAppPublisher "Innovedus Inc."
#define MyAppURL "https://www.example.com/"
#define MyAppExeName "main.exe"
[Setup]
; 唯一的 AppId請勿在其他應用程式中重複使用
AppId={{0894596D-D78B-4D8C-97CC-D90FE98E26E0}}
@ -28,37 +26,34 @@ PrivilegesRequired=lowest
OutputBaseFilename=mysetup
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
; 定義配對元件,讓使用者選擇是否安裝預設的 Script 與 Model
[Components]
Name: "pair1"; Description: "Fire Detection";
Name: "pair2"; Description: "Photo_quality";
Name: "pair3"; Description: "Test Mode";
[Files]
; 安裝主要執行檔到 {app} 目錄
Source: "C:\Users\mason\Code\demo_gui\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "C:\Users\mason\Code\Kneron Academy 2.0\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
; 安裝 config.json 檔案 (不需使用者勾選)
Source: "C:\Users\mason\AppData\Local\Kneron_Academy\utils\config.json"; DestDir: "{localappdata}\Kneron_Academy\utils"; Flags: ignoreversion
; --- 配對1 ---
; pair 1
Source: "C:\Users\mason\Downloads\Kneron_Academy\utils\models\fire_detection_520.nef"; DestDir: "{localappdata}\Kneron_Academy\utils\models\fire_detection_520.nef"; Components: pair1; Flags: ignoreversion recursesubdirs
Source: "C:\Users\mason\Downloads\Kneron_Academy\utils\scripts\fire_detection_520.py"; DestDir: "{localappdata}\Kneron_Academy\utils\scripts\fire_detection_520.py"; Components: pair1; Flags: ignoreversion recursesubdirs
Source: "C:\Users\mason\AppData\Local\Kneron_Academy\utils\fire detection\yuan\*"; DestDir: "{localappdata}\Kneron_Academy\utils\fire detection\yuan"; Components: pair1; Flags: ignoreversion recursesubdirs createallsubdirs
; pair 2
Source: "C:\Users\mason\Downloads\Kneron_Academy\utils\models\photo_scorer_520.nef"; DestDir: "{localappdata}\Kneron_Academy\utils\models\photo_scorer_520.nef"; Components: pair2; Flags: ignoreversion recursesubdirs
Source: "C:\Users\mason\Downloads\Kneron_Academy\utils\scripts\photo_quality_520.py"; DestDir: "{localappdata}\Kneron_Academy\utils\scripts\photo_quality_520.py"; Components: pair2; Flags: ignoreversion recursesubdirs
Source: "C:\Users\mason\AppData\Local\Kneron_Academy\utils\photo quality\ruby\*"; DestDir: "{localappdata}\Kneron_Academy\utils\photo quality\ruby"; Components: pair2; Flags: ignoreversion recursesubdirs createallsubdirs
; pair 3
Source: "C:\Users\mason\AppData\Local\Kneron_Academy\utils\test mode\test\*"; DestDir: "{localappdata}\Kneron_Academy\utils\test mode\test"; Components: pair3; Flags: ignoreversion recursesubdirs createallsubdirs
[Dirs]
; 如有需要隱藏這些資料夾,設定隱藏屬性
Name: "{localappdata}\Kneron_Academy\utils\scripts"; Attribs: hidden
Name: "{localappdata}\Kneron_Academy\utils\models"; Attribs: hidden
Name: "{localappdata}\Kneron_Academy\utils\fire detection\yuan"; Attribs: hidden
Name: "{localappdata}\Kneron_Academy\utils\photo quality\ruby"; Attribs: hidden
Name: "{localappdata}\Kneron_Academy\utils\test mode\test"; Attribs: hidden
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

@ -4,12 +4,11 @@ APPDATA_PATH = os.environ.get("LOCALAPPDATA")
# 取得專案根目錄的絕對路徑並設定 UXUI_ASSETS 為絕對路徑
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UXUI_ASSETS = os.path.join(PROJECT_ROOT, "uxui", "")
MODEL = os.path.join(APPDATA_PATH,"Kneron_Academy", "utils", "models", "")
SCRIPT = os.path.join(APPDATA_PATH, "Kneron_Academy", "utils", "scripts", "")
SCRIPT_CONFIG = os.path.join(APPDATA_PATH, "Kneron_Academy", "utils", "configs.json")
# 新版路徑結構 (不需要獨立的 models 和 scripts 資料夾)
UTILS_DIR = os.path.join(APPDATA_PATH, "Kneron_Academy", "utils")
SCRIPT_CONFIG = os.path.join(UTILS_DIR, "config.json")
UPLOAD_DIR = os.path.join(APPDATA_PATH, "Kneron_Academy", "uploads")
FW_DIR = os.path.join(APPDATA_PATH, "Kneron_Academy", "utils", "firmware")
FW_DIR = os.path.join(APPDATA_PATH, "Kneron_Academy", "firmware")
# Global Constants
APP_NAME = "Innovedus AI Playground"
WINDOW_SIZE = (1200, 900)

View File

@ -8,8 +8,8 @@ from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt5.QtGui import QPixmap, QMovie, QImage
from ..config import (UXUI_ASSETS, WINDOW_SIZE, BACKGROUND_COLOR, SECONDARY_COLOR,
BUTTON_STYLE, MASK_STYLE, PROJECT_ROOT, SCRIPT_CONFIG, SCRIPT, UPLOAD_DIR,
FW_DIR, DongleModelMap, DongleIconMap, MODEL)
BUTTON_STYLE, MASK_STYLE, PROJECT_ROOT, SCRIPT_CONFIG, UTILS_DIR , UPLOAD_DIR,
FW_DIR, DongleModelMap, DongleIconMap)
from ..services.device_service import check_available_device
@ -56,8 +56,9 @@ def qimage_to_numpy(qimage):
#────────────────────────────────────────────────────────────
# 動態載入 inference 模組的函式
def load_inference_module(script_path):
module_name = os.path.splitext(os.path.basename(script_path))[0]
def load_inference_module(mode, model_name):
script_path = os.path.join(UTILS_DIR, mode, model_name, "script.py")
module_name = f"{mode}_{model_name}"
spec = importlib.util.spec_from_file_location(module_name, script_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@ -70,25 +71,29 @@ class InferenceWorkerThread(QThread):
# 傳出 inference 結果,型態可依需求調整(例如 dict 或 tuple
inference_result_signal = pyqtSignal(object)
def __init__(self, frame_queue, inference_script_path, min_interval=0.5, mse_threshold=500, once_mode=False):
def __init__(self, frame_queue, mode, model_name, min_interval=0.5, mse_threshold=500, once_mode=False):
"""
frame_queue: 傳入的 frame 佇列(numpy 陣列)
inference_script_path: inference 模組的檔案路徑
mode: 模式名稱 ( 'face_recognition')
model_name: 模型名稱 ( 'face_detection')
min_interval: 最小 inference 間隔 ()
mse_threshold: 當前後 frame 之均方誤差低於此值則視為相似
"""
super().__init__()
self.frame_queue = frame_queue
self.inference_script_path = inference_script_path
self.mode = mode
self.model_name = model_name
self.min_interval = min_interval
self.mse_threshold = mse_threshold
self._running = True
self.once_mode = once_mode # 新增旗標:如果 True則只做一次推論
self.once_mode = once_mode
self.last_inference_time = 0
self.last_frame = None
self.cached_result = None
# 動態載入 inference 模組(假設介面函式為 inference(frame, params)
self.inference_module = load_inference_module(self.inference_script_path)
# 動態載入 inference 模組
script_path = os.path.join(UTILS_DIR, mode, model_name, "script.py")
self.inference_module = load_inference_module(mode, model_name)
def run(self):
while self._running:
@ -149,12 +154,13 @@ class MainWindow(QWidget):
self.video_writer = None
self.recorded_frames = []
self.destination = None
# 目前選用的 tool 配置,初始為 None
self.current_tool_config = None
self.inference_worker = None
# 建立 frame 佇列,限制最大數量
self.inference_queue = queue.Queue(maxsize=10)
# 確保目錄存在並更新配置
self.generate_global_config()
self.init_ui()
def init_ui(self): # 初始化UI (暫時不需要修改)
@ -479,28 +485,59 @@ class MainWindow(QWidget):
def select_tool(self, tool_config):
print("選擇工具:", tool_config.get("display_name"))
self.current_tool_config = tool_config
new_script_path = os.path.join(SCRIPT, tool_config.get("script"))
# 取得工具的 input type
# 獲取模式和模型名稱
mode = tool_config.get("mode", "")
model_name = tool_config.get("model_name", "")
# 構建模型路徑
model_path = os.path.join(UTILS_DIR, mode, model_name)
# 讀取特定模型的詳細配置
model_config_path = os.path.join(model_path, "config.json")
detailed_config = {}
if os.path.exists(model_config_path):
try:
with open(model_config_path, "r", encoding="utf-8") as f:
detailed_config = json.load(f)
# 合併基本配置和詳細配置
tool_config = {**tool_config, **detailed_config}
except Exception as e:
print(f"Error reading model config: {e}")
# 獲取工具的輸入類型
input_info = tool_config.get("input_info", {})
tool_type = input_info.get("type", "video")
print("type:", tool_type)
once_mode = True if tool_type == "image" else False
# 組合 input_params從 tool_config 中預設值)
# 組合input_params
input_params = tool_config.get("input_parameters", {}).copy()
# 處理設備相關設定
if hasattr(self, "selected_device") and self.selected_device:
input_params["usb_port_id"] = self.selected_device.get("usb_port_id", 0)
# 直接使用設備的 model在 parse_and_store_devices 已填入)
dongle = self.selected_device.get("dongle", "unknown")
print("選取的 dongle:", dongle)
# 利用 model 當作子資料夾名稱組合 firmware 路徑
# 檢查模型是否支援該設備
compatible_devices = tool_config.get("compatible_devices", [])
if compatible_devices and dongle not in compatible_devices:
self.show_custom_message(
QMessageBox.Warning,
"設備不兼容",
f"所選模型不支援 {dongle} 設備。\n支援的設備: {', '.join(compatible_devices)}"
)
return
# 處理韌體路徑
scpu_path = os.path.join(FW_DIR, dongle, "fw_scpu.bin")
ncpu_path = os.path.join(FW_DIR, dongle, "fw_ncpu.bin")
input_params["scpu_path"] = scpu_path
input_params["ncpu_path"] = ncpu_path
else:
# 預設設備處理邏輯不變
if self.connected_devices and len(self.connected_devices) > 0:
input_params["usb_port_id"] = self.connected_devices[0].get("usb_port_id", 0)
print("Warning: 沒有特別選取 dongle, 預設使用第一個設備")
@ -508,11 +545,11 @@ class MainWindow(QWidget):
input_params["usb_port_id"] = 0
print("Warning: 沒有連接設備, 使用預設 usb_port_id 0")
# 若工具模式需要檔案輸入,則處理 file_path
# 處理檔案輸入
if tool_type in ["image", "voice"]:
# 處理邏輯不變
if hasattr(self, "destination") and self.destination:
input_params["file_path"] = self.destination
# 讀取上傳的圖片並推入 inference_queue
uploaded_img = cv2.imread(self.destination)
if uploaded_img is not None:
if not self.inference_queue.full():
@ -526,12 +563,11 @@ class MainWindow(QWidget):
input_params["file_path"] = ""
print("Warning: 需要檔案輸入,但尚未上傳檔案。")
# 從 config 讀取 model_info組合 model 路徑
if "model_info" in tool_config:
model_name = tool_config["model_info"].get("name", "")
# 假設模型檔案存放在 "src\\utils\\models" 資料夾下,根據需要調整路徑
model_path = os.path.join(MODEL, model_name)
input_params["model"] = model_path
# 添加模型檔案路徑
if "model_file" in tool_config:
model_file = tool_config["model_file"]
model_file_path = os.path.join(model_path, model_file)
input_params["model"] = model_file_path
print("input_params:", input_params)
@ -540,10 +576,11 @@ class MainWindow(QWidget):
self.inference_worker.stop()
self.inference_worker = None
# 建立新的 inference worker
# 建立新的 inference worker (使用修改後的參數)
self.inference_worker = InferenceWorkerThread(
self.inference_queue,
new_script_path,
mode,
model_name,
min_interval=0.5,
mse_threshold=500,
once_mode=once_mode
@ -551,7 +588,7 @@ class MainWindow(QWidget):
self.inference_worker.input_params = input_params
self.inference_worker.inference_result_signal.connect(self.handle_inference_result)
self.inference_worker.start()
print(f"Inference worker 已切換到模組:{new_script_path}")
print(f"Inference worker 已切換到模組:{mode}/{model_name}")
if tool_type == "video":
self.start_camera()
@ -566,20 +603,21 @@ class MainWindow(QWidget):
if os.path.exists(SCRIPT_CONFIG):
with open(SCRIPT_CONFIG, "r", encoding="utf-8") as f:
config = json.load(f)
tools = config.get("tools", [])
# print("tools: ", tools)
plugins = config.get("plugins", [])
else:
print("找不到 toolbox config 檔案,使用空的工具列表")
tools = []
# 若無配置檔,則嘗試自動生成
plugins = self.generate_global_config().get("plugins", [])
if not plugins:
print("無法生成配置,使用空的工具列表")
# 建立工具箱介面
# 創建工具箱UI
toolbox_frame = QFrame(self)
toolbox_frame.setStyleSheet(f"border: none; background: {SECONDARY_COLOR}; border-radius: 15px;")
toolbox_frame.setFixedHeight(450)
toolbox_frame.setFixedWidth(240)
toolbox_layout = QVBoxLayout(toolbox_frame)
# 建立標題列
# 標題列
title_layout = QHBoxLayout()
title_container = QWidget()
container_layout = QHBoxLayout(title_container)
@ -596,12 +634,33 @@ class MainWindow(QWidget):
title_layout.addWidget(title_container)
toolbox_layout.addLayout(title_layout)
# 根據 JSON 配置建立工具按鈕
for tool in tools:
name = tool.get("display_name", "Unnamed Tool")
button = QPushButton(name)
# 使用 lambda 捕捉 tool 設定,避免 late binding 問題
button.clicked.connect(lambda checked, t=tool: self.select_tool(t))
# 建立工具按鈕 (分類顯示)
for plugin in plugins:
mode = plugin.get("mode", "")
display_name = plugin.get("display_name", "")
# 添加分類標題
category_label = QLabel(display_name)
category_label.setStyleSheet("color: white; font-size: 16px; font-weight: bold; margin-top: 10px;")
toolbox_layout.addWidget(category_label)
# 添加該分類下的所有模型按鈕
for model in plugin.get("models", []):
model_name = model.get("name", "")
display_name = model.get("display_name", "")
# 建立工具配置
tool_config = {
"mode": mode,
"model_name": model_name,
"display_name": display_name,
"description": model.get("description", ""),
"compatible_devices": model.get("compatible_devices", [])
}
# 建立按鈕
button = QPushButton(display_name)
button.clicked.connect(lambda checked, t=tool_config: self.select_tool(t))
button.setStyleSheet(BUTTON_STYLE)
button.setFixedHeight(40)
toolbox_layout.addWidget(button)
@ -1141,3 +1200,84 @@ class MainWindow(QWidget):
self.overlay.hide()
except Exception as e:
print(f"Error in hide_device_popup: {e}")
def generate_global_config(self):
"""掃描目錄結構並生成全局配置檔案"""
try:
config = {"plugins": []}
# 確認 utils 目錄存在
if not os.path.exists(UTILS_DIR):
os.makedirs(UTILS_DIR, exist_ok=True)
print(f"已建立 UTILS_DIR: {UTILS_DIR}")
# 列出 utils 目錄下的所有項目以進行偵錯
print(f"UTILS_DIR 內容: {os.listdir(UTILS_DIR) if os.path.exists(UTILS_DIR) else '目錄不存在'}")
# 掃描模式目錄(第一層子目錄)
mode_dirs = [d for d in os.listdir(UTILS_DIR)
if os.path.isdir(os.path.join(UTILS_DIR, d)) and not d.startswith('_')]
print(f"找到的模式目錄: {mode_dirs}")
for mode_name in mode_dirs:
mode_path = os.path.join(UTILS_DIR, mode_name)
mode_info = {
"mode": mode_name,
"display_name": mode_name.replace("_", " ").title(),
"models": []
}
# 列出該模式目錄下的所有項目以進行偵錯
print(f"模式 {mode_name} 的內容: {os.listdir(mode_path)}")
# 掃描模型目錄(第二層子目錄)
model_dirs = [d for d in os.listdir(mode_path)
if os.path.isdir(os.path.join(mode_path, d)) and not d.startswith('_')]
print(f"在模式 {mode_name} 中找到的模型: {model_dirs}")
for model_name in model_dirs:
model_path = os.path.join(mode_path, model_name)
# 檢查模型配置檔案
model_config_path = os.path.join(model_path, "config.json")
if os.path.exists(model_config_path):
try:
with open(model_config_path, "r", encoding="utf-8") as f:
model_config = json.load(f)
print(f"已成功讀取模型配置: {model_config_path}")
model_summary = {
"name": model_name,
"display_name": model_config.get("display_name", model_name.replace("_", " ").title()),
"description": model_config.get("description", ""),
"compatible_devices": model_config.get("compatible_devices", [])
}
mode_info["models"].append(model_summary)
except Exception as e:
print(f"讀取模型配置時發生錯誤 {model_config_path}: {e}")
else:
print(f"未找到模型配置檔: {model_config_path}")
# 可選:自動創建模板配置檔
self.create_model_config_template(model_path)
# 只添加含有模型的模式
if mode_info["models"]:
config["plugins"].append(mode_info)
# 寫入配置檔
os.makedirs(os.path.dirname(SCRIPT_CONFIG), exist_ok=True)
with open(SCRIPT_CONFIG, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
print(f"全局配置已生成: {SCRIPT_CONFIG}")
return config
except Exception as e:
print(f"生成全局配置時發生錯誤: {e}")
import traceback
traceback.print_exc()
return {"plugins": []}