feat(build): 安裝包只打包指定模型並改用中文名稱

安裝包從 8 個 .nef 縮減為 2 個,models.json 保留完整定義以便日後
加回。

- Makefile 加 BUNDLED_NEFS 白名單,三平台共用 copy_bundled_data
  helper;用 POSIX find/cp 而非 rsync(Windows CI 的 Git Bash 沒有
  rsync)
- 白名單檔案不存在時 build 直接失敗,並在複製後驗證 models.json
  存在且 .nef 數量相符 —— 避免產出「安裝後 0 個模型」卻回報成功
- FCOS Detection (KL520) 改名為「物件辨識」
- Tiny YOLOv3 (KL520) 改名為「人型監測」
  (只改 name/description,id 不動以免影響既有設定與紀錄)
- Repository 啟動時過濾 .nef 不存在的模型,否則使用者會看到未打包
  的模型並在選取後拿到莫名錯誤

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-22 19:27:40 +08:00
parent 5c1c37d151
commit 744283bd28
4 changed files with 308 additions and 8 deletions

View File

@ -18,6 +18,61 @@ OS := $(shell uname -s | tr A-Z a-z)
DIST := dist
PAYLOAD := visiona-local/payload
# ── 打包用 .nef 白名單M5-a────────────────────────────────────────
#
# server/data/ 底下有 8 個 .nefKL520 五個、KL720 三個),但安裝包只帶
# 白名單內的這幾個,其餘不進 payload藉此縮小安裝檔體積。
#
# models.json 不做任何過濾7 個 model 定義全部照原樣複製。執行期由
# server/internal/model/repository.go 的 NewRepository() 檢查每個 model 的
# filePath 是否實際存在,不存在的直接不載入(見 M5-c。因此使用者在 UI
# 只會看到白名單內的 model未打包的不會出現、也不會選到後拿到莫名錯誤。
#
# 未來要把某個 model 加回安裝包:把對應的 .nef 相對路徑加進下面這個變數即可,
# models.json 不用動。
#
# 路徑相對於 server/data/。
BUNDLED_NEFS := \
nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef \
nef/kl520/kl520_tiny_yolo_v3.nef
# copy_bundled_data把 server/data/ 複製到 $(1),但 nef/ 只帶 BUNDLED_NEFS 白名單。
# $(1) = 目標 data 目錄payload/darwin/data
#
# 步驟:(a) 複製 server/data/ 下除了 nef/ 以外的所有東西models.json 等)
# (b) 再逐一複製白名單內的 .nef
# (c) 白名單檔案不存在就直接 fail避免安靜地產出缺 model 的安裝包
#
# 只用 POSIX find / cp不用 rsync —— Windows CI 跑在 Git Bashwindows-2022 +
# shell: bash該環境沒有 rsync。
define copy_bundled_data
@set -e; \
echo "==> 複製 server/data → $(1).nef 白名單:$(words $(BUNDLED_NEFS)) 個)"; \
if [ ! -d server/data ]; then echo "!! ERROR: server/data 不存在 !!"; exit 1; fi; \
mkdir -p "$(1)"; \
dest="$$(cd "$(1)" && pwd)"; \
( cd server/data && \
find . -path ./nef -prune -o -type d -print | while read -r d; do mkdir -p "$$dest/$$d"; done && \
find . -path ./nef -prune -o -type f -print | while read -r f; do cp "$$f" "$$dest/$$f"; done ); \
if [ ! -f "$$dest/models.json" ]; then \
echo "!! ERROR: models.json 沒有被複製到 $$dest !!"; exit 1; \
fi; \
for nef in $(BUNDLED_NEFS); do \
if [ ! -f "server/data/$$nef" ]; then \
echo "!! ERROR: BUNDLED_NEFS 列出的 server/data/$$nef 不存在 !!"; \
exit 1; \
fi; \
mkdir -p "$$dest/$$(dirname $$nef)"; \
cp "server/data/$$nef" "$$dest/$$nef"; \
echo " + $$nef"; \
done; \
copied=$$(find "$$dest" -name '*.nef' | wc -l | tr -d ' '); \
if [ "$$copied" != "$(words $(BUNDLED_NEFS))" ]; then \
echo "!! ERROR: 預期 $(words $(BUNDLED_NEFS)) 個 .nef實際 $$copied 個 !!"; exit 1; \
fi; \
echo " models.json + $$copied 個 .nef 已就位"
endef
.PHONY: help \
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \
@ -245,7 +300,7 @@ payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg ## 準備
cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
cp -R server/data/* payload/darwin/data/
$(call copy_bundled_data,payload/darwin/data)
cp -R server/scripts/* payload/darwin/scripts/
cp vendor/python/darwin/python.tar.gz payload/darwin/python/
@cp vendor/wheels/darwin/*.whl payload/darwin/wheels/ 2>/dev/null || true
@ -360,7 +415,7 @@ payload-windows: build-server-windows vendor-python-windows vendor-wheels-window
@# LGPL 授權條款BtbN build 自帶 LICENSE.txtCOPYING.LGPLv3 不一定在壓縮檔內,失敗不致命)
@cp vendor/ffmpeg/windows/LICENSE.txt payload/windows/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
@cp vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
cp -R server/data/. payload/windows/data/
$(call copy_bundled_data,payload/windows/data)
cp -R server/scripts/. payload/windows/scripts/
cp vendor/python/windows/python.tar.gz payload/windows/python/
@cp vendor/wheels/windows/*.whl payload/windows/wheels/ 2>/dev/null || true
@ -444,7 +499,7 @@ payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor
@cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
@cp vendor/ffmpeg/linux/ffprobe payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffprobe || echo "!! WARN: ffprobe 缺失"
@cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
@if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi
$(call copy_bundled_data,payload/linux/data)
@if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi
@if [ ! -f vendor/python/linux/python.tar.gz ]; then \
echo "!! ERROR: vendor/python/linux/python.tar.gz 不存在vendor-python-linux 應該已先跑過 !!"; \

View File

@ -40,8 +40,8 @@
},
{
"id": "kl520-fcos-detection",
"name": "FCOS Detection (KL520)",
"description": "FCOS (Fully Convolutional One-Stage) object detection with DarkNet53s backbone, compiled for KL520. Anchor-free detection at 512x512.",
"name": "物件辨識",
"description": "通用物件偵測模型,可辨識人、車輛、動物等常見物件,適合一般場景的多物件偵測。",
"thumbnail": "/images/models/fcos-det.png",
"taskType": "object_detection",
"categories": [
@ -109,8 +109,8 @@
},
{
"id": "kl520-tiny-yolov3",
"name": "Tiny YOLOv3 (KL520)",
"description": "Tiny YOLOv3 object detection model compiled for KL520. Compact and fast model for general-purpose multi-object detection on edge devices.",
"name": "人型監測",
"description": "輕量快速的人員偵測模型,適合即時監控場景,可在邊緣裝置上高速偵測畫面中的人員。",
"thumbnail": "/images/models/tiny-yolov3.png",
"taskType": "object_detection",
"categories": [

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
@ -13,6 +14,15 @@ type Repository struct {
mu sync.RWMutex
}
// NewRepository 載入 models.json 的內建模型目錄。
//
// models.json 會列出所有「產品支援」的模型定義,但安裝包不一定會帶上每個
// 對應的 .nef見 Makefile 的 BUNDLED_NEFS 白名單)。因此載入後會過濾掉
// .nef 檔案實際不存在的 model —— 否則使用者會在 UI 看到選不了的模型,選下去
// 才在 flash 階段拿到 "model file not found" 這種沒頭沒尾的錯誤。
//
// 過濾只作用在 models.json 的內建模型。使用者上傳的自訂模型走 Add()
// 路徑是絕對路徑且必定存在,不受影響。
func NewRepository(dataPath string) *Repository {
r := &Repository{}
data, err := os.ReadFile(dataPath)
@ -20,12 +30,57 @@ func NewRepository(dataPath string) *Repository {
fmt.Printf("Warning: could not load models from %s: %v\n", dataPath, err)
return r
}
if err := json.Unmarshal(data, &r.models); err != nil {
var declared []Model
if err := json.Unmarshal(data, &declared); err != nil {
fmt.Printf("Warning: could not parse models JSON: %v\n", err)
return r
}
r.models = filterAvailableModels(declared, filepath.Dir(dataPath))
return r
}
// filterAvailableModels 只保留 .nef 檔案實際存在的 model。
//
// dataDir 是 models.json 所在的目錄(即 bundle 內的 data/models.json 的
// filePath 以它為基準解析。
func filterAvailableModels(models []Model, dataDir string) []Model {
available := make([]Model, 0, len(models))
for _, m := range models {
path := resolveBuiltInModelPath(m.FilePath, dataDir)
// 沒宣告 filePath 的 model 不做檔案檢查(沒有東西可以檢查),保留原行為。
if path == "" {
available = append(available, m)
continue
}
if info, err := os.Stat(path); err != nil || info.IsDir() {
fmt.Printf("[INFO] Skipping model %q (%s): .nef not bundled at %s\n", m.ID, m.Name, path)
continue
}
available = append(available, m)
}
return available
}
// resolveBuiltInModelPath 把 models.json 的 filePath 解析成實際的檔案路徑。
//
// 規則與 flash.Service.StartFlash 一致:
// - 絕對路徑 → 原樣使用
// - "data/nef/..." → 去掉 "data/" 前綴後接在 dataDir 之下
// (因為 dataDir 本身就是那個 data/ 目錄,不去掉會變成 data/data/nef/...
// - 其他相對路徑 → 直接接在 dataDir 之下
func resolveBuiltInModelPath(filePath, dataDir string) string {
if filePath == "" {
return ""
}
if filepath.IsAbs(filePath) {
return filePath
}
if strings.HasPrefix(filePath, "data/") || strings.HasPrefix(filePath, "data\\") {
return filepath.Join(dataDir, filePath[len("data/"):])
}
return filepath.Join(dataDir, filePath)
}
func (r *Repository) List(filter ModelFilter) ([]ModelSummary, int) {
r.mu.RLock()
defer r.mu.RUnlock()

View File

@ -1,6 +1,9 @@
package model
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
@ -98,6 +101,193 @@ func TestRepository_Add(t *testing.T) {
}
}
// writeModelsJSON 在 dir 底下建立 models.json回傳它的路徑。
func writeModelsJSON(t *testing.T, dir string, models []Model) string {
t.Helper()
data, err := json.MarshalIndent(models, "", " ")
if err != nil {
t.Fatalf("marshal models: %v", err)
}
path := filepath.Join(dir, "models.json")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write models.json: %v", err)
}
return path
}
// touchNef 在 dir 底下建立一個假的 .nef內容不重要只檢查存在性
func touchNef(t *testing.T, dir, relPath string) {
t.Helper()
full := filepath.Join(dir, relPath)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", relPath, err)
}
if err := os.WriteFile(full, []byte("fake nef"), 0o644); err != nil {
t.Fatalf("write %s: %v", relPath, err)
}
}
func TestResolveBuiltInModelPath(t *testing.T) {
dataDir := filepath.Join("/bundle", "data")
tests := []struct {
name string
filePath string
want string
}{
{
name: "strips data/ prefix so it does not become data/data/",
filePath: "data/nef/kl520/a.nef",
want: filepath.Join("/bundle", "data", "nef", "kl520", "a.nef"),
},
{
name: "relative path without data/ prefix joins directly",
filePath: "nef/kl520/a.nef",
want: filepath.Join("/bundle", "data", "nef", "kl520", "a.nef"),
},
{
name: "absolute path is used as-is",
filePath: filepath.Join("/custom", "models", "x", "model.nef"),
want: filepath.Join("/custom", "models", "x", "model.nef"),
},
{
name: "empty file path stays empty",
filePath: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolveBuiltInModelPath(tt.filePath, dataDir); got != tt.want {
t.Errorf("resolveBuiltInModelPath(%q) = %q, want %q", tt.filePath, got, tt.want)
}
})
}
}
func TestFilterAvailableModels(t *testing.T) {
dataDir := t.TempDir()
touchNef(t, dataDir, "nef/kl520/bundled.nef")
absent := filepath.Join(dataDir, "nef", "kl520", "abs-missing.nef")
present := filepath.Join(dataDir, "nef", "kl520", "abs.nef")
touchNef(t, dataDir, "nef/kl520/abs.nef")
// 目錄而非檔案:不該被當成可用的 model
if err := os.MkdirAll(filepath.Join(dataDir, "nef/kl520/dir.nef"), 0o755); err != nil {
t.Fatalf("mkdir dir.nef: %v", err)
}
models := []Model{
{ID: "bundled", FilePath: "data/nef/kl520/bundled.nef"},
{ID: "not-bundled", FilePath: "data/nef/kl520/nope.nef"},
{ID: "abs-present", FilePath: present},
{ID: "abs-absent", FilePath: absent},
{ID: "no-file-path"},
{ID: "dir-not-file", FilePath: "data/nef/kl520/dir.nef"},
}
got := filterAvailableModels(models, dataDir)
var gotIDs []string
for _, m := range got {
gotIDs = append(gotIDs, m.ID)
}
want := []string{"bundled", "abs-present", "no-file-path"}
if len(gotIDs) != len(want) {
t.Fatalf("filterAvailableModels() = %v, want %v", gotIDs, want)
}
for i := range want {
if gotIDs[i] != want[i] {
t.Errorf("filterAvailableModels()[%d] = %q, want %q", i, gotIDs[i], want[i])
}
}
}
func TestNewRepository_FiltersUnbundledModels(t *testing.T) {
dataDir := t.TempDir()
touchNef(t, dataDir, "nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef")
touchNef(t, dataDir, "nef/kl520/kl520_tiny_yolo_v3.nef")
// 模擬正式情境models.json 宣告 4 個 model但只打包了其中 2 個 .nef
path := writeModelsJSON(t, dataDir, []Model{
{ID: "kl520-fcos-detection", Name: "物件辨識", TaskType: "object_detection",
FilePath: "data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef"},
{ID: "kl520-tiny-yolov3", Name: "人型監測", TaskType: "object_detection",
FilePath: "data/nef/kl520/kl520_tiny_yolo_v3.nef"},
{ID: "kl520-yolov5-detection", Name: "YOLOv5", TaskType: "object_detection",
FilePath: "data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef"},
{ID: "kl720-resnet18-classification", Name: "ResNet18", TaskType: "classification",
FilePath: "data/nef/kl720/kl720_20001_resnet18_w224h224.nef"},
})
repo := NewRepository(path)
if repo.Count() != 2 {
t.Fatalf("Count() = %d, want 2 (only bundled .nef should load)", repo.Count())
}
for _, id := range []string{"kl520-fcos-detection", "kl520-tiny-yolov3"} {
if _, err := repo.GetByID(id); err != nil {
t.Errorf("GetByID(%q) failed, expected it to be available: %v", id, err)
}
}
for _, id := range []string{"kl520-yolov5-detection", "kl720-resnet18-classification"} {
if _, err := repo.GetByID(id); err == nil {
t.Errorf("GetByID(%q) succeeded, expected it to be filtered out", id)
}
}
// 過濾後的清單也不該出現在 List()
results, count := repo.List(ModelFilter{})
if count != 2 || len(results) != 2 {
t.Errorf("List() = %d results (count %d), want 2", len(results), count)
}
}
func TestNewRepository_CustomModelsUnaffectedByFilter(t *testing.T) {
dataDir := t.TempDir()
path := writeModelsJSON(t, dataDir, []Model{
{ID: "built-in-missing", FilePath: "data/nef/kl520/missing.nef"},
})
repo := NewRepository(path)
if repo.Count() != 0 {
t.Fatalf("Count() = %d, want 0 after filtering", repo.Count())
}
// 自訂模型走 Add(),不經過過濾
repo.Add(Model{ID: "custom-1", IsCustom: true, FilePath: "/anywhere/model.nef"})
if repo.Count() != 1 {
t.Errorf("Count() = %d after Add(), want 1", repo.Count())
}
if _, err := repo.GetByID("custom-1"); err != nil {
t.Errorf("GetByID(custom-1) failed: %v", err)
}
}
func TestNewRepository_MissingOrInvalidFile(t *testing.T) {
t.Run("missing models.json yields empty repo", func(t *testing.T) {
repo := NewRepository(filepath.Join(t.TempDir(), "nope.json"))
if repo.Count() != 0 {
t.Errorf("Count() = %d, want 0", repo.Count())
}
})
t.Run("invalid JSON yields empty repo", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "models.json")
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
repo := NewRepository(path)
if repo.Count() != 0 {
t.Errorf("Count() = %d, want 0", repo.Count())
}
})
}
func TestRepository_Remove(t *testing.T) {
repo := newTestRepo()