jim800121chen 4583406efa feat(api): 推論期切換解析方式與上傳 label 檔
新增 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>
2026-07-22 19:27:16 +08:00

234 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 擋單一巨大 indexMaxLines 擋「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 而非 mapplan §3.2 A1Model.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 的表:
//
// 空行 / 純空白行 → 略過
// `#` 開頭 → 註解、略過
// 行尾 \rCRLF → 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
}