新增 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>
234 lines
7.5 KiB
Go
234 lines
7.5 KiB
Go
// Package labelfile 解析使用者上傳的 label 檔(`<index> <名稱>` 每行一筆),
|
||
// 產出可直接注入 Python bridge 的密集 []string。
|
||
//
|
||
// 為什麼是獨立 package:解析規則全是純函式、與 HTTP / driver / model 儲存都
|
||
// 無關,抽出來才能用大量 table-driven 測試把容錯規則逐條釘死(規則來源見
|
||
// plan-classification-inference.md §3.2,該表已定死、實作不得自行發揮)。
|
||
package labelfile
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
const (
|
||
// MaxIndex 是允許的最大 class index(含)。
|
||
//
|
||
// 這是本功能最實際的 DoS 面(plan §3.5 / §7 R-7):解析結果要轉成密集
|
||
// []string,一行 `999999999 x` 就會要求配置約 8GB 的 slice header 空間。
|
||
// 4095 對真實模型綽綽有餘(ImageNet 1000 類、COCO 80 類)。
|
||
MaxIndex = 4095
|
||
|
||
// MaxLines 是允許的最大「有效內容行數」(不含空行與註解),與 MaxIndex
|
||
// 互為雙保險:MaxIndex 擋單一巨大 index,MaxLines 擋「index 都合法但行數
|
||
// 爆量」。
|
||
//
|
||
// 為什麼剛好是 MaxIndex+1 而不是更大的值:index 必須唯一且 <= MaxIndex,
|
||
// 所以合法檔案最多就是 MaxIndex+1 行。設得比這更大會讓這道檢查永遠碰不到
|
||
// (重複 index 的檢查會先擋下),變成無效防禦 —— 有一個「看起來有防護但
|
||
// 其實跑不到」的常數,比沒有更糟。
|
||
MaxLines = MaxIndex + 1
|
||
|
||
// MaxFileSize 是允許的檔案大小上限(bytes)。
|
||
// 3 類 label 約 30 bytes;即使 4096 類中文標籤也遠低於 256 KB。
|
||
MaxFileSize = 256 * 1024
|
||
)
|
||
|
||
// Result 是一次成功解析的產物。
|
||
type Result struct {
|
||
// Labels 是密集陣列:位置 = class index,稀疏處為空字串。
|
||
//
|
||
// 為什麼用密集 []string 而非 map(plan §3.2 A1):Model.Labels 與
|
||
// FlashOptions.Labels 都已經是 []string,改成 map 要動 Go struct、TS type、
|
||
// models.json 全部既有 model 與 upload handler。Python 端 _resolve_label
|
||
// 對空字串已會 fallback 回 class_N,稀疏語意天然成立。
|
||
Labels []string
|
||
|
||
// LabelCount 是「實際有名稱的筆數」(不含補洞用的空字串)。
|
||
LabelCount int
|
||
|
||
// MaxIndex 是檔案中出現過的最大 index,等於 len(Labels)-1。
|
||
MaxIndex int
|
||
}
|
||
|
||
// ParseError 帶行號的解析失敗。整檔拒絕(不做部分接受)—— 靜默略過壞行會讓
|
||
// 使用者拿到看似成功但標註錯位的結果,那比直接失敗糟得多。
|
||
type ParseError struct {
|
||
// Line 是 1-based 行號;0 表示錯誤與特定行無關(如空檔、編碼問題)。
|
||
Line int
|
||
// Reason 是給使用者看的說明。
|
||
Reason string
|
||
}
|
||
|
||
func (e *ParseError) Error() string {
|
||
if e.Line > 0 {
|
||
return fmt.Sprintf("第 %d 行:%s", e.Line, e.Reason)
|
||
}
|
||
return e.Reason
|
||
}
|
||
|
||
// utf8BOM 是 UTF-8 位元組順序標記。Windows 記事本另存 UTF-8 會加它,
|
||
// 不 strip 的話第一行的 index token 會帶著 BOM 位元組而解析失敗。
|
||
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||
|
||
// Parse 解析 label 檔內容。
|
||
//
|
||
// 容錯規則完全依照 plan §3.2 的表:
|
||
//
|
||
// 空行 / 純空白行 → 略過
|
||
// `#` 開頭 → 註解、略過
|
||
// 行尾 \r(CRLF) → trim
|
||
// 名稱含空白 → 只 split 第一個空白,其餘全算名稱
|
||
// index 非整數 / 負數 → 整檔拒絕 + 行號
|
||
// index 重複 → 整檔拒絕 + 行號
|
||
// index 不連續 / 不從 0 開始 → 接受,缺的位置補空字串
|
||
// 空檔 / 全空行 → 拒絕
|
||
// 非 UTF-8 → 拒絕(BOM 先 strip)
|
||
// 只有 index 沒名稱 → 拒絕 + 行號
|
||
func Parse(data []byte) (*Result, error) {
|
||
if len(data) > MaxFileSize {
|
||
return nil, &ParseError{
|
||
Reason: fmt.Sprintf("檔案過大(%d bytes),上限為 %d bytes", len(data), MaxFileSize),
|
||
}
|
||
}
|
||
|
||
data = bytes.TrimPrefix(data, utf8BOM)
|
||
|
||
if !utf8.Valid(data) {
|
||
return nil, &ParseError{
|
||
Reason: "檔案不是有效的 UTF-8 編碼,請改存成 UTF-8 後再上傳",
|
||
}
|
||
}
|
||
|
||
// byIndex 保留原始的稀疏語意,最後才展開成密集陣列 —— 先展開的話,
|
||
// 「index 重複」與「index 不連續補洞」兩種情況會分不出來。
|
||
byIndex := make(map[int]string)
|
||
maxIndex := -1
|
||
contentLines := 0
|
||
|
||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||
// 單行上限放寬到 64KB:預設 bufio 上限也是 64KB,但預設 buffer 只有 4KB
|
||
// 起跳、長行會回 bufio.ErrTooLong 而不是我們自己的錯誤訊息。
|
||
scanner.Buffer(make([]byte, 0, 4096), 64*1024)
|
||
|
||
lineNo := 0
|
||
for scanner.Scan() {
|
||
lineNo++
|
||
line := strings.TrimRight(scanner.Text(), "\r")
|
||
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(trimmed, "#") {
|
||
continue
|
||
}
|
||
|
||
contentLines++
|
||
if contentLines > MaxLines {
|
||
return nil, &ParseError{
|
||
Line: lineNo,
|
||
Reason: fmt.Sprintf("標籤行數超過上限 %d", MaxLines),
|
||
}
|
||
}
|
||
|
||
idx, name, err := parseLine(trimmed)
|
||
if err != nil {
|
||
err.Line = lineNo
|
||
return nil, err
|
||
}
|
||
|
||
if _, dup := byIndex[idx]; dup {
|
||
return nil, &ParseError{
|
||
Line: lineNo,
|
||
Reason: fmt.Sprintf("index %d 重複出現", idx),
|
||
}
|
||
}
|
||
|
||
byIndex[idx] = name
|
||
if idx > maxIndex {
|
||
maxIndex = idx
|
||
}
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return nil, &ParseError{Reason: fmt.Sprintf("讀取檔案失敗:%v", err)}
|
||
}
|
||
|
||
if len(byIndex) == 0 {
|
||
return nil, &ParseError{Reason: "標籤檔沒有任何有效內容"}
|
||
}
|
||
|
||
labels := make([]string, maxIndex+1)
|
||
for idx, name := range byIndex {
|
||
labels[idx] = name
|
||
}
|
||
|
||
return &Result{
|
||
Labels: labels,
|
||
LabelCount: len(byIndex),
|
||
MaxIndex: maxIndex,
|
||
}, nil
|
||
}
|
||
|
||
// parseLine 解析單行 `<index> <名稱>`。回傳的 ParseError 不帶 Line,由呼叫端補。
|
||
func parseLine(line string) (int, string, *ParseError) {
|
||
// 只切第一個空白:`0 traffic light` 必須解析成 {0: "traffic light"}。
|
||
// strings.Fields 會把名稱也切碎,所以刻意用 IndexFunc 自己找分界。
|
||
sep := strings.IndexFunc(line, unicode.IsSpace)
|
||
if sep < 0 {
|
||
return 0, "", &ParseError{
|
||
Reason: fmt.Sprintf("缺少標籤名稱(只有 %q),格式應為 `<index> <名稱>`", line),
|
||
}
|
||
}
|
||
|
||
idxToken := line[:sep]
|
||
name := strings.TrimSpace(line[sep:])
|
||
if name == "" {
|
||
return 0, "", &ParseError{
|
||
Reason: fmt.Sprintf("index %s 後面缺少標籤名稱", idxToken),
|
||
}
|
||
}
|
||
|
||
idx, err := strconv.Atoi(idxToken)
|
||
if err != nil {
|
||
return 0, "", &ParseError{
|
||
Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken),
|
||
}
|
||
}
|
||
if idx < 0 {
|
||
return 0, "", &ParseError{
|
||
Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken),
|
||
}
|
||
}
|
||
if idx > MaxIndex {
|
||
return 0, "", &ParseError{
|
||
Reason: fmt.Sprintf("index %d 超過上限 %d", idx, MaxIndex),
|
||
}
|
||
}
|
||
|
||
if bad, ok := findControlChar(name); ok {
|
||
return 0, "", &ParseError{
|
||
Reason: fmt.Sprintf("標籤名稱含有不允許的控制字元(U+%04X)", bad),
|
||
}
|
||
}
|
||
|
||
return idx, name, nil
|
||
}
|
||
|
||
// findControlChar 找出名稱中的控制字元。標籤會直接被渲染到前端 DOM 與 canvas,
|
||
// 控制字元(含 U+202E 這類 bidi override)會造成顯示錯亂,一律拒絕。
|
||
// Tab 已在 TrimSpace / 分隔判斷階段處理掉,這裡不需特別放行。
|
||
func findControlChar(name string) (rune, bool) {
|
||
for _, r := range name {
|
||
if unicode.IsControl(r) || (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
|
||
return r, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|