新增 POST /api/devices/:id/inference/options,讓已燒錄的模型不重燒
即可切換 detection/classification 與更換 label。
KL520 一次只能載一個模型(換模型必須重燒),但解析方式與 label 都
只是後處理與顯示層,不碰 NPU,因此可即時生效。
- JSON: {taskType?, labels?};multipart: taskType + labelFile
- 欄位用指標型別以區分「省略 = 不動」與「labels:[] = 清空」
- label 解析器(internal/labelfile):格式 <index> <名稱>
略過空行與 # 註解、trim CRLF、名稱含空白只 split 第一個空白、
index 非整數/負數/重複則整檔拒絕並回報行號、稀疏 index 補空字串
- index 上限 4095、檔案上限 256KB,防記憶體耗盡
- 非法 taskType 一律拒絕(含舊別名 detection),因為 bridge 對無法
辨識的值是靜默 fallback,忽略會回 200 但用錯解析方式
- 走窄能力介面 InferenceOptionsDriver,不動 DeviceDriver 簽章
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
6.1 KiB
Go
181 lines
6.1 KiB
Go
package kneron
|
||
|
||
// set_inference_options_test.go — M4:推論期切換解析方式 / label 表的
|
||
// JSON-RPC payload 契約測試。
|
||
//
|
||
// 這裡的核心不是「欄位名對不對」,而是 **nil / 空陣列 / 缺欄位三種狀態的
|
||
// 語意必須各自可表達**:
|
||
//
|
||
// 缺 task_type 欄位 → bridge 保留當前解析方式
|
||
// 缺 labels 欄位 → bridge 保留當前 label 表
|
||
// labels: [] → bridge 清空 label 表(回到原始 enum)
|
||
//
|
||
// 若照 load_model 的規則用 `len(labels) > 0` 判斷是否放進 payload,第三種
|
||
// 狀態就永遠送不出去 —— 使用者按「清除標籤」會拿到 200 但什麼都沒發生。
|
||
// 這正是這個功能要防的靜默失敗,所以逐條釘死。
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
|
||
"visiona-local/server/internal/driver"
|
||
)
|
||
|
||
func TestBuildSetInferenceOptionsCommand_CommandName(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{TaskType: "classification"})
|
||
|
||
// 必須與 kneron_bridge.py main() dispatch 的字串完全一致。
|
||
if cmd["cmd"] != "set_inference_options" {
|
||
t.Errorf("cmd = %v, want set_inference_options", cmd["cmd"])
|
||
}
|
||
}
|
||
|
||
func TestBuildSetInferenceOptionsCommand_TaskTypeOnly(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{TaskType: "classification"})
|
||
|
||
if cmd["task_type"] != "classification" {
|
||
t.Errorf("task_type = %v, want classification", cmd["task_type"])
|
||
}
|
||
if _, present := cmd["labels"]; present {
|
||
t.Error("沒指定 labels 時不可放進 payload —— bridge 會誤以為要改 label 表")
|
||
}
|
||
}
|
||
|
||
func TestBuildSetInferenceOptionsCommand_LabelsOnly(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||
Labels: []string{"剪刀", "石頭", "布"},
|
||
})
|
||
|
||
if _, present := cmd["task_type"]; present {
|
||
t.Error("沒指定 task_type 時不可放進 payload —— bridge 會誤以為要切解析方式")
|
||
}
|
||
labels, ok := cmd["labels"].([]string)
|
||
if !ok {
|
||
t.Fatalf("labels type = %T, want []string", cmd["labels"])
|
||
}
|
||
if len(labels) != 3 || labels[0] != "剪刀" {
|
||
t.Errorf("labels = %v", labels)
|
||
}
|
||
}
|
||
|
||
// ⭐ 本檔最重要的一條:空陣列必須真的被送出去。
|
||
func TestBuildSetInferenceOptionsCommand_EmptyLabelsIsSent(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||
Labels: []string{},
|
||
})
|
||
|
||
raw, present := cmd["labels"]
|
||
if !present {
|
||
t.Fatal("空 labels 陣列沒被送出 —— 「清空 label 表」的意圖丟失了。" +
|
||
"(是不是用 len(opts.Labels) > 0 判斷?要用 != nil)")
|
||
}
|
||
labels, ok := raw.([]string)
|
||
if !ok {
|
||
t.Fatalf("labels type = %T, want []string", raw)
|
||
}
|
||
if len(labels) != 0 {
|
||
t.Errorf("len(labels) = %d, want 0", len(labels))
|
||
}
|
||
}
|
||
|
||
// 對照組:nil 才代表「不動」。
|
||
func TestBuildSetInferenceOptionsCommand_NilLabelsIsOmitted(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||
TaskType: "classification",
|
||
Labels: nil,
|
||
})
|
||
|
||
if _, present := cmd["labels"]; present {
|
||
t.Error("nil labels 不可放進 payload —— nil 代表「保留當前 label 表」")
|
||
}
|
||
}
|
||
|
||
// 序列化後空陣列要是 JSON 的 [],不能變成 null。
|
||
// bridge 端對 null 與 [] 的處理不同:null → 保留(依 handler 邏輯 pending=None)、
|
||
// [] → 清空。變成 null 會讓清空意圖在 wire 上就失真。
|
||
func TestBuildSetInferenceOptionsCommand_EmptyLabelsMarshalsToArray(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{Labels: []string{}})
|
||
|
||
data, err := json.Marshal(cmd)
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
|
||
var decoded map[string]interface{}
|
||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
|
||
raw, present := decoded["labels"]
|
||
if !present {
|
||
t.Fatalf("labels 欄位不見了:%s", data)
|
||
}
|
||
arr, ok := raw.([]interface{})
|
||
if !ok {
|
||
t.Fatalf("labels 序列化成 %T(%s),want JSON array —— null 會被 bridge "+
|
||
"解讀成「不動」而非「清空」", raw, data)
|
||
}
|
||
if len(arr) != 0 {
|
||
t.Errorf("len = %d, want 0", len(arr))
|
||
}
|
||
}
|
||
|
||
func TestBuildSetInferenceOptionsCommand_Both(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||
TaskType: "object_detection",
|
||
Labels: []string{"a", "", "c"},
|
||
})
|
||
|
||
if cmd["task_type"] != "object_detection" {
|
||
t.Errorf("task_type = %v", cmd["task_type"])
|
||
}
|
||
labels, ok := cmd["labels"].([]string)
|
||
if !ok {
|
||
t.Fatalf("labels type = %T", cmd["labels"])
|
||
}
|
||
// 稀疏佔位的空字串要原樣送過去 —— 位置就是 class index,
|
||
// 壓縮掉會讓所有後面的 index 位移。
|
||
if len(labels) != 3 || labels[1] != "" {
|
||
t.Errorf("labels = %v, want [a c](稀疏佔位必須保留)", labels)
|
||
}
|
||
}
|
||
|
||
// 全空的 options 不該被 builder 擋(那是上層 handler 的責任),但也不該
|
||
// 憑空生出欄位 —— 只帶 cmd。這條確保 builder 保持「純翻譯」不做決策。
|
||
func TestBuildSetInferenceOptionsCommand_ZeroValueOnlyHasCmd(t *testing.T) {
|
||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{})
|
||
|
||
if len(cmd) != 1 {
|
||
t.Errorf("payload = %v, want 只有 cmd 一個鍵", cmd)
|
||
}
|
||
}
|
||
|
||
// ── driver 前置條件 ──────────────────────────────────────────────────
|
||
|
||
func TestSetInferenceOptions_RequiresBridge(t *testing.T) {
|
||
d := &KneronDriver{}
|
||
err := d.SetInferenceOptions(driver.InferenceOptions{TaskType: "classification"})
|
||
|
||
if err == nil {
|
||
t.Fatal("bridge 沒跑時應該回錯")
|
||
}
|
||
if !strings.Contains(err.Error(), "bridge is not running") {
|
||
t.Errorf("err = %v, 應說明 bridge 未執行", err)
|
||
}
|
||
}
|
||
|
||
func TestSetInferenceOptions_RequiresLoadedModel(t *testing.T) {
|
||
// bridge 就緒但沒載 model:切解析方式沒有意義,且 bridge 端也會拒絕。
|
||
// 在 driver 層先擋掉,錯誤訊息才能指出「要先燒錄模型」。
|
||
d := &KneronDriver{pythonReady: true}
|
||
err := d.SetInferenceOptions(driver.InferenceOptions{TaskType: "classification"})
|
||
|
||
if err == nil {
|
||
t.Fatal("沒載 model 時應該回錯")
|
||
}
|
||
if !strings.Contains(err.Error(), "no model loaded") {
|
||
t.Errorf("err = %v, 應說明尚未載入模型", err)
|
||
}
|
||
}
|