This commit is contained in:
2026-08-27 11:04:56 +08:00
parent 0ca8f4c3ab
commit bb7bac3dee
30 changed files with 171 additions and 260 deletions
+4 -4
View File
@@ -16,8 +16,8 @@
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗内 canvas 直接画框/点框删除/清空并保存——AI 自动标注结果直接作为标注,人工可修改/清理全部框;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象,默认 qwen-image/DashScope 付费 API);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`DB 存元数据 + 标注 JSON |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/models/<数据集>/latest.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/models/<数据集>/latest.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/trainings/<数据集名>.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/trainings/<数据集名>.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 全图扫描(`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大后在 canvas 上画框/点框删除/清空/改类别 → 保存即整体覆写 `dataset_image.labels_json`YOLO 归一化 JSON 数组,AI 与人工框同存,人工可修改/清理);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) |
## 架构与数据流
@@ -51,7 +51,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai)、`image_count``labeled_count``status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description``created_at``updated_at`(图片文件在 `app.datasetDir`/`datasets/<name>/`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml``localAi` / `training.ssh` 节点) |
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id``filename``source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`created_at` |
| `model_training` | 训练任务 | `id`(PK)、`name``status`(running/success/failed)、`dataset`(训练机数据集名)、`imgsz`/`epochs`/`batch`/`device`(参数快照)、`current_epoch`/`total_epochs``metrics`(JSON)、`log_tail``pid``error``started_at`/`finished_at``created_at` |
| `model_version` | 模型版本(每数据集独立序列) | `id`(PK)、`dataset_id``version`(m1.0.0 递增, 同数据集 UNIQUE)、`training_id``artifact_file`(workspace 相对路径)、`metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest``notes``created_at`(模型文件不落表:发布即写 `models/<数据集名>/latest.tflite`,客户端固定下载,无存档回退) |
| `model_version` | 模型版本(每数据集独立序列) | `id`(PK)、`dataset_id``version`(m1.0.0 递增, 同数据集 UNIQUE)、`training_id``metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest``notes``created_at`(模型文件不落表:发布即写 `trainings/<数据集名>.tflite`,客户端固定下载,无存档回退) |
| `label_task` | 标注任务 | `id`(PK)、`dataset_id``filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done``created_at``finished_at` |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -160,7 +160,7 @@ App 版本更新检查(公开接口,无需 token,未登录/旧版本均可
- 检测到新版本(服务器版本高于本地版本)即**强制更新**,客户端弹不可关闭的全屏提示,必须跳转更新后才能继续使用;本地已是新版本则不提示
- 客户端以「语义化版本号」比较:`1.10.0 > 1.9.9`(按数字段比较,禁止字符串比较)
- 下载地址为固定静态路径:`/download/observer-latest.apk``app.apkDir` 目录下永远只有最新一个文件,由后端静态托管),客户端拼 `apiBaseUrl` 访问
- **模型热更新(与 APK 更新独立通道)**:服务器有已发布模型时响应额外返回 `models` 数组(与 `GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl);客户端启动与 APK 更新**独立检查**——某数据集服务器版本高于本地已下载版本即下载 `/download/models/<数据集>/latest.tflite` 到应用私有目录,sha256 校验后原子替换,下次识别生效;**非强制**,失败回退旧模型下次启动重试。App 模型管理页列出服务器全部可用模型,用户自由下载/删除/启用;识别时加载全部已启用模型**并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底。无发布模型时不返回 models 字段(旧 App 忽略新字段、新 App 兼容旧服务器)
- **模型热更新(与 APK 更新独立通道)**:服务器有已发布模型时响应额外返回 `models` 数组(与 `GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl);客户端启动与 APK 更新**独立检查**——某数据集服务器版本高于本地已下载版本即下载 `/download/trainings/<数据集名>.tflite` 到应用私有目录,sha256 校验后原子替换,下次识别生效;**非强制**,失败回退旧模型下次启动重试。App 模型管理页列出服务器全部可用模型,用户自由下载/删除/启用;识别时加载全部已启用模型**并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底。无发布模型时不返回 models 字段(旧 App 忽略新字段、新 App 兼容旧服务器)
### GET /download-page
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-B_3W4J54.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DZ3uy8VX.css">
<script type="module" crossorigin src="/admin/assets/index-Dtws4Ia3.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-C62TRiv2.css">
</head>
<body>
<div id="app"></div>
+2 -17
View File
@@ -2,8 +2,6 @@ package controller
import (
"context"
"fmt"
"net/url"
"github.com/gogf/gf/v2/net/ghttp"
@@ -119,13 +117,14 @@ func (c *cAdmin) UploadCover(ctx context.Context, req *dto.AdminDatasetCoverUplo
return service.Dataset.AdminUploadCover(ctx, req)
}
// Cover 封面访问(直写响应体)
// Cover 封面访问(直写响应体;URL 不随封面更换变化,必须 no-store 防浏览器缓存旧图
func (c *cAdmin) Cover(ctx context.Context, req *dto.AdminDatasetCoverReq) (*dto.AdminDatasetCoverRes, error) {
path, err := service.Dataset.CoverFile(ctx, req.DatasetId)
if err != nil {
return nil, err
}
r := ghttp.RequestFromCtx(ctx)
r.Response.Header().Set("Cache-Control", "no-store")
r.Response.ServeFile(path)
return &dto.AdminDatasetCoverRes{}, nil
}
@@ -135,20 +134,6 @@ func (c *cAdmin) DeleteCover(ctx context.Context, req *dto.AdminDatasetCoverDele
return service.Dataset.AdminDeleteCover(ctx, req)
}
// ExportDataset 导出数据集 zip(直写响应体下载)
func (c *cAdmin) ExportDataset(ctx context.Context, req *dto.AdminDatasetExportReq) (*dto.AdminDatasetExportRes, error) {
data, filename, err := service.Dataset.ExportZip(ctx, req.DatasetId)
if err != nil {
return nil, err
}
r := ghttp.RequestFromCtx(ctx)
r.Response.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename)))
r.Response.Header().Set("Content-Type", "application/zip")
r.Response.Write(data)
return &dto.AdminDatasetExportRes{}, nil
}
// ---------- 训练编排 ----------
// ListTrainings 训练任务列表
-6
View File
@@ -30,12 +30,6 @@ func init() {
status TEXT NOT NULL DEFAULT 'building',
cover TEXT,
description TEXT,
ai_endpoint TEXT,
ai_model TEXT,
train_host TEXT,
train_user TEXT,
train_password TEXT,
train_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
+3 -5
View File
@@ -24,7 +24,6 @@ func init() {
status TEXT NOT NULL DEFAULT 'running',
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
boxes_file TEXT,
filenames TEXT,
error TEXT,
created_at TEXT NOT NULL,
@@ -42,7 +41,6 @@ func (d *labelTaskDao) Insert(ctx context.Context, m *entity.LabelTask) (int64,
"status": m.Status,
"total": m.Total,
"done": m.Done,
"boxes_file": m.BoxesFile,
"filenames": m.Filenames,
"error": m.Error,
"created_at": m.CreatedAt,
@@ -102,10 +100,10 @@ func (d *labelTaskDao) UpdateProgress(ctx context.Context, id int64, done int) e
return err
}
// Finish 完成任务(done 状态 + 完成时间 + 候选框文件路径 + 失败原因)
func (d *labelTaskDao) Finish(ctx context.Context, id int64, boxesFile, errMsg string) error {
// Finish 完成任务(done 状态 + 完成时间 + 失败原因)
func (d *labelTaskDao) Finish(ctx context.Context, id int64, errMsg string) error {
_, err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).Where("id", id).
Data(g.Map{"status": consts.LabelTaskDone, "finished_at": gtime.Now(), "boxes_file": boxesFile, "error": errMsg}).Update()
Data(g.Map{"status": consts.LabelTaskDone, "finished_at": gtime.Now(), "error": errMsg}).Update()
return err
}
+10 -12
View File
@@ -22,7 +22,6 @@ func init() {
dataset_id INTEGER NOT NULL,
version TEXT NOT NULL,
training_id INTEGER,
artifact_file TEXT,
metrics TEXT,
labels TEXT NOT NULL,
sha256 TEXT NOT NULL,
@@ -40,17 +39,16 @@ func init() {
// Insert 插入模型版本(is_latest 由 service 先置 0 再插新行置 1
func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (int64, error) {
res, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Data(g.Map{
"dataset_id": m.DatasetId,
"version": m.Version,
"training_id": m.TrainingId,
"artifact_file": m.ArtifactFile,
"metrics": m.Metrics,
"labels": m.Labels,
"sha256": m.Sha256,
"size_bytes": m.SizeBytes,
"is_latest": m.IsLatest,
"notes": m.Notes,
"created_at": m.CreatedAt,
"dataset_id": m.DatasetId,
"version": m.Version,
"training_id": m.TrainingId,
"metrics": m.Metrics,
"labels": m.Labels,
"sha256": m.Sha256,
"size_bytes": m.SizeBytes,
"is_latest": m.IsLatest,
"notes": m.Notes,
"created_at": m.CreatedAt,
}).Insert()
if err != nil {
return 0, err
+4 -12
View File
@@ -8,8 +8,8 @@ import (
// 模型训练体系接口(数据集 → 标注 → 训练 → 模型版本),组前缀 /api/v1/admin。
// 图片/标注/模型文件均在服务器磁盘(app.datasetDir),DB 只存元数据。
// 图片访问与 zip 导出为直接写响应体场景,由 controller 以 *ghttp.Request 方法实现
// (见 controller/admin.go 的 Image/ExportDataset 方法)。
// 图片访问为直接写响应体场景,由 controller 以 *ghttp.Request 方法实现
// (见 controller/admin.go 的 Image 方法)。
// ---------- 数据集管理 ----------
@@ -140,15 +140,7 @@ type AdminDatasetImageReq struct {
type AdminDatasetImageRes struct{}
// AdminDatasetExportReq 数据集 zip 导出(直写响应体下载;标注衔接与备份用
type AdminDatasetExportReq struct {
g.Meta `path:"/datasets/export" method:"get" summary:"导出数据集 zip" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
}
type AdminDatasetExportRes struct{}
// AdminDatasetCoverUploadReq 上传数据集封面(multipart 单文件;覆盖旧封面,存图片目录 cover<ext>
// AdminDatasetCoverUploadReq 上传数据集封面(multipart 单文件;覆盖旧封面,UUID 命名 jpg
type AdminDatasetCoverUploadReq struct {
g.Meta `path:"/datasets/cover" method:"post" summary:"上传数据集封面" tags:"管理端" mime:"multipart/form-data"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
@@ -367,7 +359,7 @@ type ModelCatalogItem struct {
Sha256 string `json:"sha256"`
Notes string `json:"notes"`
PublishedAt *gtime.Time `json:"publishedAt"`
DownloadUrl string `json:"downloadUrl"` // /download/models/<name>/latest.tflite
DownloadUrl string `json:"downloadUrl"` // /download/trainings/<name>.tflite
}
type ModelCatalogRes struct {
+2 -4
View File
@@ -2,16 +2,14 @@ package entity
import "github.com/gogf/gf/v2/os/gtime"
// LabelTask 标注任务:RF-DETR 预标注批量推理进度;候选框存
// datasets/<数据集名>/boxes.jsonYOLO 归一化 xywh + 置信度 + 建议类别)。
// Filenames 为选中图片列表(JSON 数组串;空 = 全量扫描)。
// LabelTask 标注任务:RF-DETR 预标注批量推理进度;标注结果直写
// dataset_image.labels_json,不落文件。Filenames 为选中图片列表(JSON 数组串;空 = 全量扫描)。
type LabelTask struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Status string `json:"status" orm:"status" description:"running|done"`
Total int `json:"total" orm:"total" description:"待标注图片数"`
Done int `json:"done" orm:"done" description:"已处理数"`
BoxesFile string `json:"boxesFile" orm:"boxes_file" description:"候选框 JSON 相对路径"`
Filenames string `json:"filenames" orm:"filenames" description:"选中图片JSON数组(空=全量)"`
Error string `json:"error" orm:"error" description:"失败原因(部分失败/全部失败)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
+12 -13
View File
@@ -3,19 +3,18 @@ package entity
import "github.com/gogf/gf/v2/os/gtime"
// ModelVersion 模型版本:每数据集独立版本序列(m1.0.0 递增,UNIQUE(dataset_id, version))。
// 模型文件不落表:发布即写当前生效副本 models/<数据集>/latest.tflite(无存档回退机制);
// 模型文件不落表:训练成功即直写 trainings/<数据集名>.tflite(无存档回退机制);
// labels 为类别名数组 JSON(App 多模型合并推理依赖)。
type ModelVersion struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Version string `json:"version" orm:"version" description:"m1.0.0 递增"`
TrainingId int64 `json:"trainingId" orm:"training_id" description:"来源训练任务"`
ArtifactFile string `json:"artifactFile" orm:"artifact_file" description:"归档 zip 相对路径"`
Metrics string `json:"metrics" orm:"metrics" description:"JSON 指标"`
Labels string `json:"labels" orm:"labels" description:"JSON 类别名数组"`
Sha256 string `json:"sha256" orm:"sha256" description:"tflite 文件校验"`
SizeBytes int64 `json:"sizeBytes" orm:"size_bytes" description:"文件大小"`
IsLatest int `json:"isLatest" orm:"is_latest" description:"1=该数据集当前生效"`
Notes string `json:"notes" orm:"notes" description:"备注"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发布时间"`
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Version string `json:"version" orm:"version" description:"m1.0.0 递增"`
TrainingId int64 `json:"trainingId" orm:"training_id" description:"来源训练任务"`
Metrics string `json:"metrics" orm:"metrics" description:"JSON 指标"`
Labels string `json:"labels" orm:"labels" description:"JSON 类别名数组"`
Sha256 string `json:"sha256" orm:"sha256" description:"tflite 文件校验"`
SizeBytes int64 `json:"sizeBytes" orm:"size_bytes" description:"文件大小"`
IsLatest int `json:"isLatest" orm:"is_latest" description:"1=该数据集当前生效"`
Notes string `json:"notes" orm:"notes" description:"备注"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发布时间"`
}
+5 -47
View File
@@ -1,7 +1,6 @@
package service
import (
"archive/zip"
"bytes"
"context"
"fmt"
@@ -320,11 +319,11 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
if err != nil {
return nil, err
}
// 文件清理(图片 + 模型文件;删除失败仅记日志,记录已删)
dirs := []string{common.DatasetImagesDir(ctx, name), common.DatasetModelsDir(ctx, name)}
for _, dir := range dirs {
if err := os.RemoveAll(dir); err != nil {
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", dir, err)
// 文件清理(图片目录 + 当前生效模型文件;删除失败仅记日志,记录已删)
paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, name)}
for _, p := range paths {
if err := os.RemoveAll(p); err != nil {
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", p, err)
}
}
return &dto.AdminDatasetDeleteRes{}, nil
@@ -636,44 +635,3 @@ func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filenam
}
return path, nil
}
// ExportZip 打包数据集图片目录为 zip(内存构建;controller 直写响应体下载)
func (s *datasetService) ExportZip(ctx context.Context, datasetId int64) ([]byte, string, error) {
dataset, err := dao.Dataset.GetById(ctx, datasetId)
if err != nil {
return nil, "", err
}
if dataset == nil {
return nil, "", gerror.NewCode(common.CodeDatasetNotFound)
}
dir := common.DatasetImagesDir(ctx, dataset.Name)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, "", gerror.New("数据集图片目录不存在")
}
return nil, "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
w, err := zw.Create(e.Name())
if err != nil {
return nil, "", err
}
if _, err := w.Write(data); err != nil {
return nil, "", err
}
}
if err := zw.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), dataset.Name + ".zip", nil
}
+5 -5
View File
@@ -43,7 +43,7 @@ func (s *labelTaskService) recoverLabelTasks(ctx context.Context) {
return
}
for _, t := range list {
if err := dao.LabelTask.Finish(ctx, t.Id, "", "服务重启,任务中断,可重新发起"); err != nil {
if err := dao.LabelTask.Finish(ctx, t.Id, "服务重启,任务中断,可重新发起"); err != nil {
g.Log().Errorf(ctx, "恢复预标注任务 %d 失败: %+v", t.Id, err)
}
}
@@ -202,7 +202,7 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
bgCtx := context.Background()
go func() {
if client == nil {
_ = dao.LabelTask.Finish(bgCtx, taskId, "", "标注服务未配置")
_ = dao.LabelTask.Finish(bgCtx, taskId, "标注服务未配置")
return
}
dir := common.DatasetImagesDir(bgCtx, dataset.Name)
@@ -256,7 +256,7 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
})
}
if failed != "" {
_ = dao.LabelTask.Finish(bgCtx, taskId, "", failed)
_ = dao.LabelTask.Finish(bgCtx, taskId, failed)
return
}
// 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注,人工保存同走 UpdateLabels
@@ -273,10 +273,10 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
return nil
})
if err != nil {
_ = dao.LabelTask.Finish(bgCtx, taskId, "", err.Error())
_ = dao.LabelTask.Finish(bgCtx, taskId, err.Error())
return
}
_ = dao.LabelTask.Finish(bgCtx, taskId, "", "")
_ = dao.LabelTask.Finish(bgCtx, taskId, "")
// 成功完成:自动补标该数据集未标注图(忙时入库的图等),失败任务不续
s.autoSupplement(bgCtx, dataset)
}()
+2 -2
View File
@@ -15,7 +15,7 @@ type modelVersionService struct{}
var ModelVersion = &modelVersionService{}
// ClientCatalog 客户端模型目录:全部数据集当前生效模型(App 多模型下载热更新)。
// downloadUrl 复用 /download 静态托管(/download/models/<数据集名>/latest.tflite)。
// downloadUrl 复用 /download 静态托管(/download/trainings/<数据集名>.tflite)。
func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCatalogRes, error) {
list, err := dao.ModelVersion.ListAllLatest(ctx)
if err != nil {
@@ -37,7 +37,7 @@ func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCata
Sha256: v.Sha256,
Notes: v.Notes,
PublishedAt: v.CreatedAt,
DownloadUrl: "/download/models/" + datasetName + "/latest.tflite",
DownloadUrl: "/download/trainings/" + datasetName + ".tflite",
})
}
return &dto.ModelCatalogRes{List: items}, nil
+29 -38
View File
@@ -127,8 +127,18 @@ func (s *trainingService) pollOne(ctx context.Context, runner common.TrainingRun
}
}
// finishSuccess 训练成功:解析 result.json(最终指标 + 类别名)→ 更新任务 + 拉取产物到服务器
// finishSuccess 训练成功:解析 result.json(最终指标 + 类别名)→ 拉取 tflite → 更新任务。
// tflite 直写 trainings/<数据集名>.tflite(当前生效模型唯一位,无 per-task 存档、无 zip)。
func (s *trainingService) finishSuccess(ctx context.Context, runner common.TrainingRunner, job *common.TrainingJob, t *entity.ModelTraining, result, tail string) {
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
if err != nil {
g.Log().Errorf(ctx, "训练 %d 查数据集失败: %+v", t.Id, err)
return
}
if dataset == nil {
_ = s.finishFailed(ctx, t, "数据集已删除")
return
}
var res struct {
Metrics map[string]float64 `json:"metrics"`
Names []string `json:"names"`
@@ -155,20 +165,16 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
return
}
// 先拉产物再置成功:产物拉取失败则置失败(发布依赖 tflite 存在)
dest := common.TrainingArtifactsDir(ctx, t.Id)
if res.BestTflite == "" {
_ = s.finishFailed(ctx, t, "训练完成但 result.json 缺少 best_tflite")
return
}
if err := runner.FetchArtifact(ctx, job, res.BestTflite, filepath.Join(dest, "best.tflite")); err != nil {
dest := common.TrainingModelPath(ctx, dataset.Name)
if err := runner.FetchArtifact(ctx, job, res.BestTflite, dest); err != nil {
g.Log().Errorf(ctx, "训练 %d 拉取 best.tflite 失败: %+v", t.Id, err)
_ = s.finishFailed(ctx, t, "拉取训练产物失败: %v", err)
return
}
zipPath := fmt.Sprintf("artifacts/%d.zip", t.Id)
if err := runner.FetchArtifact(ctx, job, zipPath, filepath.Join(dest, "artifact.zip")); err != nil {
g.Log().Warningf(ctx, "训练 %d 拉取 artifact.zip 失败(不阻断): %+v", t.Id, err)
}
// 指标尾部带上类别名,发布时解析 labels
if len(res.Names) > 0 {
if names, err := json.Marshal(res.Names); err == nil {
@@ -384,16 +390,15 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin
Content: []byte(yoloYamlContent(trainPath, localAiClassNames(bgCtx))),
})
taskJSON, _ := json.Marshal(map[string]any{
"workdir": cfg.Workdir,
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)),
"imgsz": imgsz,
"epochs": epochs,
"batch": batch,
"device": device,
"project": filepath.ToSlash(filepath.Join("runs", "tasks", strconv.FormatInt(taskId, 10))),
"log_file": filepath.ToSlash(filepath.Join("logs", strconv.FormatInt(taskId, 10)+".jsonl")),
"result_file": filepath.ToSlash(filepath.Join("results", strconv.FormatInt(taskId, 10)+".json")),
"artifact_zip": filepath.ToSlash(filepath.Join("artifacts", strconv.FormatInt(taskId, 10)+".zip")),
"workdir": cfg.Workdir,
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)),
"imgsz": imgsz,
"epochs": epochs,
"batch": batch,
"device": device,
"project": filepath.ToSlash(filepath.Join("runs", "tasks", strconv.FormatInt(taskId, 10))),
"log_file": filepath.ToSlash(filepath.Join("logs", strconv.FormatInt(taskId, 10)+".jsonl")),
"result_file": filepath.ToSlash(filepath.Join("results", strconv.FormatInt(taskId, 10)+".json")),
})
if err := runner.WriteTaskJson(bgCtx, job, string(taskJSON)); err != nil {
_ = s.finishFailed(bgCtx, &entity.ModelTraining{Id: taskId}, "写任务参数失败: %v", err)
@@ -502,10 +507,9 @@ func (s *trainingService) AdminCancelTraining(ctx context.Context, req *dto.Admi
return &dto.AdminTrainingCancelRes{}, nil
}
// AdminPublish 发布模型版本:仅 success 任务 + 本地 best.tflite 存在;
// AdminPublish 发布模型版本:仅 success 任务 + trainings/<数据集名>.tflite 存在;
// 版本号同数据集内 m<major>.<minor>.<patch> 自增(无记录从 m1.0.0 起)。
// 落库(置旧版 is_latest=0 + 插新版)后写 latest 副本(无存档回退机制),文件失败补偿删记录,
// 保证「记录存在 ⟺ 文件存在」(同 APK 版本管理)。
// 文件在训练成功时已直写最终位置(无额外副本),发布仅落版本记录(sha256/size 取自现有文件)。
func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTrainingPublishReq) (*dto.AdminTrainingPublishRes, error) {
t, err := dao.Training.GetById(ctx, req.Id)
if err != nil {
@@ -524,10 +528,10 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
bestTflite := filepath.Join(common.TrainingArtifactsDir(ctx, t.Id), "best.tflite")
bestTflite := common.TrainingModelPath(ctx, dataset.Name)
data, err := os.ReadFile(bestTflite)
if err != nil {
return nil, gerror.New("训练产物 best.tflite 缺失,无法发布")
return nil, gerror.New("训练产物 tflite 缺失,无法发布")
}
labels := labelsFromMetrics(t.Metrics)
sha256, err := common.Sha256Hex(data)
@@ -539,13 +543,12 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
return nil, err
}
now := gtime.Now()
var mv *entity.ModelVersion
err = common.Serial().Submit(ctx, func() error {
// 该数据集旧版全部置 0,再插新版本(is_latest=1
if err := dao.ModelVersion.ClearLatest(ctx, t.DatasetId); err != nil {
return err
}
id, err := dao.ModelVersion.Insert(ctx, &entity.ModelVersion{
_, err := dao.ModelVersion.Insert(ctx, &entity.ModelVersion{
DatasetId: t.DatasetId,
Version: version,
TrainingId: t.Id,
@@ -557,24 +560,12 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
Notes: t.Name,
CreatedAt: now,
})
if err != nil {
return err
}
mv = &entity.ModelVersion{Id: id, Version: version, DatasetId: t.DatasetId}
return nil
return err
})
if err != nil {
return nil, err
}
// 文件:当前生效副本 latest.tflitetmp+rename 原子覆盖),客户端固定下载
dir := common.DatasetModelsDir(ctx, dataset.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, gerror.Wrap(err, "创建模型目录失败")
}
if err := common.WriteFileAtomic(filepath.Join(dir, "latest.tflite"), data); err != nil {
_ = dao.ModelVersion.DeleteById(ctx, mv.Id)
return nil, gerror.Wrap(err, "写当前生效模型失败")
}
// 文件已由训练成功直写 trainings/<数据集名>.tflite,发布仅落版本记录,无额外副本
return &dto.AdminTrainingPublishRes{Version: version}, nil
}
-1
View File
@@ -26,7 +26,6 @@ import (
// tasks/<taskId>.json 任务参数(Go 侧写入)
// logs/<taskId>.jsonl 每 epoch 一行 JSON{"epoch","total","metrics"}
// results/<taskId>.json 结束结果:{"metrics","names","best_tflite"(相对路径)}
// artifacts/<taskId>.zip 打包产物(best.pt + results.csv + 曲线)
type TrainingRunner interface {
// Start 启动训练进程,返回可探测存活的 pid(subprocess 本机 pidssh 远程 pid
Start(ctx context.Context, job *TrainingJob) (int, error)
+4 -11
View File
@@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/hex"
"path/filepath"
"strconv"
"github.com/gogf/gf/v2/frame/g"
)
@@ -13,8 +12,7 @@ import (
// 训练体系运行时数据布局(config.yml app.datasetDir,默认 ./workspace,挂载持久化、不提交 git):
//
// datasets/<name>/ 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json
// models/<name>/ latest.tflite 当前生效副本(无存档,客户端固定下载)
// trainings/<taskId>/ 训练产物(best.tflite + artifact.zip
// trainings/<name>.tflite 某数据集当前生效模型(训练成功即直写,客户端固定下载,无存档回退
//
// 训练机与 Go 服务器异机时,数据集经 training 通道同步(见 common/training_runner.go)。
@@ -28,14 +26,9 @@ func DatasetImagesDir(ctx context.Context, datasetName string) string {
return filepath.Join(DatasetDir(ctx), "datasets", datasetName)
}
// DatasetModelsDir 某数据集模型目录
func DatasetModelsDir(ctx context.Context, datasetName string) string {
return filepath.Join(DatasetDir(ctx), "models", datasetName)
}
// TrainingArtifactsDir 某训练任务产物目录(拉回的 best.tflite + artifact.zip
func TrainingArtifactsDir(ctx context.Context, taskId int64) string {
return filepath.Join(DatasetDir(ctx), "trainings", strconv.FormatInt(taskId, 10))
// TrainingModelPath 某数据集当前生效模型文件(训练成功即从训练机直写,客户端固定下载)
func TrainingModelPath(ctx context.Context, datasetName string) string {
return filepath.Join(DatasetDir(ctx), "trainings", datasetName+".tflite")
}
// Sha256Hex 计算文件内容 SHA-256 十六进制(模型版本校验用)
Binary file not shown.
+40 -1
View File
@@ -24,7 +24,7 @@ func main() {
s := g.Server()
// Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk
// URL 固定 /download/observer-latest.apk(绕过统一响应包装,纯二进制流);
// 模型热更新文件同根 /download/models/<数据集名>/latest.tflitedatasetDir 下 models 目录)
// 模型热更新文件同根 /download/trainings/<数据集名>.tflitedatasetDir 下 trainings 目录)
if err := os.MkdirAll(common.ApkDir(ctx), 0o755); err != nil {
g.Log().Fatalf(ctx, "创建 APK 目录失败: %+v", err)
}
@@ -198,6 +198,45 @@ func initDatabase(ctx context.Context) {
}
g.Log().Infof(ctx, "数据库初始化完成(version=11)")
}
if version < 12 {
// v12:清理孤儿字段 —— dataset 删 6 列(AI 标注/训练机 SSH 配置统一走 config.yml 的
// localAi/training.ssh,零读写)+ model_version 删 artifact_filezip 产物布局移除后无人写)
// + label_task 删 boxes_file(标注已入库,候选框文件机制废弃)。
// PRAGMA table_info 逐列检测存在才 DROP,新库建表已无此列直接跳过
dropCols := []struct{ table, col string }{
{"dataset", "ai_endpoint"},
{"dataset", "ai_model"},
{"dataset", "train_host"},
{"dataset", "train_user"},
{"dataset", "train_password"},
{"dataset", "train_key"},
{"model_version", "artifact_file"},
{"label_task", "boxes_file"},
}
for _, c := range dropCols {
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info("+c.table+")")
if err != nil {
g.Log().Fatalf(ctx, "读取 %s 表结构失败: %+v", c.table, err)
}
exists := false
for _, col := range cols {
if gconv.String(col["name"]) == c.col {
exists = true
break
}
}
if !exists {
continue
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+c.table+" DROP COLUMN "+c.col); err != nil {
g.Log().Fatalf(ctx, "删除 %s.%s 列失败: %+v", c.table, c.col, err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 12"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=12)")
}
// 死表清理:app_config 全局训练配置表已撤销(配置走 config.yml),存量库残留表启动即删
if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS app_config"); err != nil {
g.Log().Fatalf(ctx, "删除残留 app_config 表失败: %+v", err)
+6 -18
View File
@@ -15,7 +15,6 @@
project 训练输出目录(相对 workdir,末尾自动拼 name
log_file 每 epoch 一行 JSON 的进度日志(相对 workdir
result_file 结束结果 JSON(相对 workdir
artifact_zip 产物打包(best.pt + results.csv + 曲线,相对 workdir
产物契约:
log_file {"epoch":1,"total":150,"metrics":{"metrics/mAP50(B)":0.87,...}}
@@ -32,7 +31,6 @@ import os
import struct
import sys
import traceback
import zipfile
from pathlib import Path
# 抗尺度漂移(与人工训练基线一致):随机缩放输入 0.5~1.5x
@@ -58,7 +56,9 @@ def on_fit_epoch_end(trainer):
def register_callback():
from ultralytics.utils.callbacks import callbacks
# ultralytics ≥8.4 中 callbacks 字典改由 get_default_callbacks() 取得
from ultralytics.utils.callbacks import get_default_callbacks
callbacks = get_default_callbacks()
callbacks["on_fit_epoch_end"].append(on_fit_epoch_end)
@@ -205,17 +205,6 @@ def check_tflite(path, imgsz):
return {"ok": False, "reason": f"解析失败: {e}", "inputs": [], "outputs": []}
def build_artifact_zip(zip_path, save_dir):
Path(zip_path).parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for p in (save_dir / "weights" / "best.pt", save_dir / "results.csv"):
if p.exists():
zf.write(p, arcname=p.name)
# 曲线/混淆矩阵等绘图产物在 save_dir 根目录
for p in sorted(save_dir.glob("*.png")) + sorted(save_dir.glob("*.jpg")):
zf.write(p, arcname=p.name)
def main():
ap = argparse.ArgumentParser(description="observer 训练任务脚本(Go --task-json 驱动)")
ap.add_argument("--task-json", required=True, help="任务参数 JSON 文件路径")
@@ -239,7 +228,6 @@ def main():
log_file = task_path("log_file")
result_file = task_path("result_file")
artifact_zip = task_path("artifact_zip")
global _LOG
_LOG = None
try:
@@ -273,7 +261,8 @@ def main():
best_tflite = find_best_tflite(save_dir)
if best_tflite is None:
model.export(format="tflite", imgsz=imgsz)
# ultralytics ≥8.4 中 tflite 为废弃格式(走 onnx2tf 链路),litert 是官方替代,产物同为 best.tflite
model.export(format="litert", imgsz=imgsz)
best_tflite = find_best_tflite(save_dir)
if best_tflite is None:
raise RuntimeError("tflite 导出失败:weights 目录下未找到任何 .tflite 产物")
@@ -290,8 +279,7 @@ def main():
if isinstance(v, (int, float)) and math.isfinite(v):
clean_metrics[k] = round(float(v), 5)
build_artifact_zip(artifact_zip, save_dir)
# best_tflite 相对 workdirGo 侧按此路径拉取
# best_tflite 相对 workdirGo 侧按此路径拉取(不再打包 zip,仅回传 tflite)
write_result(result_file, {
"metrics": clean_metrics,
"names": names,
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

+9 -19
View File
@@ -71,15 +71,6 @@ CREATE TABLE IF NOT EXISTS dataset (
status TEXT NOT NULL DEFAULT 'building',-- building | labeled | synced
cover TEXT, -- 封面文件名(上传自动转 jpg + UUID 命名,卡片展示)
description TEXT, -- 描述(卡片展示)
-- 遗留列(ai_endpoint/ai_model/train_host/train_user/train_password/train_key):
-- AI 标注/训练机 SSH 配置统一走 config.ymllocalAi / training.ssh),这 6 个覆盖列新代码不再读写,
-- 保留不迁移(存量库列不动、无数据迁移)
ai_endpoint TEXT,
ai_model TEXT,
train_host TEXT,
train_user TEXT,
train_password TEXT,
train_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
-- 图片文件在 app.datasetDir/datasets/<name>/DB 存元数据 + 标注(dataset_image.labels_json
@@ -125,7 +116,6 @@ CREATE TABLE IF NOT EXISTS model_version (
dataset_id INTEGER NOT NULL, -- → dataset.id**每个数据集独立模型版本序列**
version TEXT NOT NULL, -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一)
training_id INTEGER, -- 来源训练任务 → model_training.id
artifact_file TEXT, -- 归档 zipbest.pt + results 曲线),可选
metrics TEXT, -- JSON,与来源任务一致
labels TEXT NOT NULL, -- JSON 类别名数组(随模型下发,App 合并/展示用)
sha256 TEXT NOT NULL, -- tflite 文件校验
@@ -143,7 +133,6 @@ CREATE TABLE IF NOT EXISTS label_task (
status TEXT NOT NULL DEFAULT 'running', -- running | done
total INTEGER NOT NULL DEFAULT 0, -- 待标注图片数
done INTEGER NOT NULL DEFAULT 0, -- 已标注数
boxes_file TEXT, -- 遗留列:候选框 JSON 路径(boxes.json 已废弃,新代码不读写)
error TEXT, -- 失败原因(检测中途失败/服务重启中断)
created_at TEXT NOT NULL,
finished_at TEXT
@@ -164,11 +153,13 @@ CREATE INDEX IF NOT EXISTS idx_model_training_status ON model_training(status);
- v7 = `dataset` / `dataset_image` / `model_training` / `model_version` / `label_task` 新表(模型训练体系;各 `dao` init `CREATE TABLE IF NOT EXISTS` 自动建,此处记录 DDL 变更)
- v8 = `dataset` 加 9 列(`cover`/`description`/`ai_endpoint`/`ai_model`/`train_host`/`train_user`/`train_password`/`train_key`+ `label_task``filenames` 列(多选批量标注;`PRAGMA table_info` 逐列检测缺失才 `ALTER TABLE ... ADD COLUMN`,新库建表自带跳过)——**其中 6 列(ai/train 覆盖字段)为遗留列**:AI 标注/训练机 SSH 配置统一走 `config.yml``localAi` / `training.ssh`),新代码不读写,存量库保留不迁移
- v9 = 标注存储从文件迁移入库:`dataset_image``labels_json`/`candidates_json` 两列(`common.EnsureColumn` 迁移),启动时把历史 `labels/<数据集>/*.txt` 解析入 `labels_json`(幂等:仅未迁移行处理),`boxes.json` 废弃;**历史 labels/ 目录与迁移代码已删除(2026-08-26:数据全部入表后无保留价值)**
- v10 = 标注流程简化(撤销候选确认两阶段):`ALTER TABLE dataset_image DROP COLUMN candidates_json``PRAGMA table_info` 检测列存在才 DROP,新库建表已无此列直接跳过;存量候选数据为空直接删)——AI 预标注结果直写 `labels_json`,人工可修改/清理全部标注框`label_task.boxes_file``dataset` 的 ai/train 六列同为遗留列保留不迁移
- v10 = 标注流程简化(撤销候选确认两阶段):`ALTER TABLE dataset_image DROP COLUMN candidates_json``PRAGMA table_info` 检测列存在才 DROP,新库建表已无此列直接跳过;存量候选数据为空直接删)——AI 预标注结果直写 `labels_json`,人工可修改/清理全部标注框
- v11 = 移除模型存档回退机制:`ALTER TABLE model_version DROP COLUMN model_file`(模型文件不落表:发布即写 `trainings/<数据集名>.tflite`,客户端固定下载)
- v12 = 清理孤儿字段:`dataset` 删 6 列(`ai_endpoint`/`ai_model`/`train_host`/`train_user`/`train_password`/`train_key`,配置统一走 `config.yml``localAi`/`training.ssh`,零读写)+ `model_version``artifact_file`zip 产物布局移除后无人写)+ `label_task``boxes_file`(标注已入库,候选框文件机制废弃)——均 `PRAGMA table_info` 检测列存在才 `ALTER TABLE ... DROP COLUMN`,新库建表已无此列自动跳过
## 全局训练配置(config.yml 直读)
**决策(2026-08-26**AI 标注端点(`localAi`)与训练机 SSH 凭据(`training.ssh`)是全局配置、与数据集无关——**不设独立存储**(曾尝试 `app_config` KV 表 + 管理端「训练配置」入口,2026-08-26 撤销):标注与训练直接读 `config.yml``localAi` / `training.ssh` 节点,改配置需重启服务。数据集表 6 个覆盖列(ai_endpoint 等)为遗留列,代码不读写
**决策(2026-08-26**AI 标注端点(`localAi`)与训练机 SSH 凭据(`training.ssh`)是全局配置、与数据集无关——**不设独立存储**(曾尝试 `app_config` KV 表 + 管理端「训练配置」入口,2026-08-26 撤销):标注与训练直接读 `config.yml``localAi` / `training.ssh` 节点,改配置需重启服务。数据集表 6 个覆盖列(ai_endpoint 等)已随 v12 删除
- AI 客户端:`common.LocalAiClient(ctx)` 直读 `localAi.baseUrl`/`model`,未配置返回 nil(预标注接口报「标注服务未配置」)
- SSH 凭据:`common/training_runner.go` 的 sshRunner 直读 `training.ssh.host`/`user`/`port`/`privateKeyPath`/`password`,未配置 host 报「training.ssh 未配置 host」
@@ -330,7 +321,6 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不
- DB 只存文件名/来源/prompt 等元数据(`dataset` / `dataset_image`),**禁止图片进库**
- 生成/上传图片是**付费资产**:删除接口必须带前端确认文案(提示 AI 生成图有成本);删除 = 删文件 + 删记录,目录清理
- 训练机与 Go 服务器可能异机:训练前按 `training` 通道同步(subprocess 同机 cp、ssh 异机 scp);**数据集在训练机上的权威路径** `training.workdir`/`training.datasetDir`/`<name>/`DB `model_training.dataset` 只存数据集名
- zip 导出(`GET /admin/datasets/export`):打包图片目录为 zip 下载——用于标注衔接(二期前的人工标注路径)与备份
### AI 生成图片(provider 抽象)
@@ -359,12 +349,12 @@ training:
- **训练参数默认走配置(2026-08-26**imgsz/epochs/batch/device 不随管理端请求传(界面一键开始),由 `training` 节点统一配置——device 取决于训练机硬件、imgsz 必须与端侧推理对齐、epochs 取决于算力预期,均为部署级参数;任务记录仍存各值(`model_training.imgsz/epochs/batch/device`)供列表展示
- service 内 `Runner` 接口:`Start(ctx, *TrainingJob) (pid, error)` / `FetchLogTail(ctx, job)` / `IsAlive(ctx, job) bool` / `Cancel(ctx, job)` / `FetchArtifacts(ctx, job, destDir)``subprocess``ssh` 两个实现,按 config `mode` 选择;**ssh 凭据直接读本节点 `training.ssh` 配置**(见「全局训练配置」节)
- **训练脚本**`server/training/train_server.py`,随项目迁移):支持 `--task-json <file>`(含 dataset/imgsz/epochs/batch/device/project 名),每 epoch 输出一行机器可读 JSON 到 `--log-file``{"epoch":1,"total":150,"metrics":{...}}`),结束写 `result.json`(最终指标)+ 自动打包 `artifact.zip`best.pt + results.csv + 曲线);Go 侧解析日志行更新进度、轮询日志尾部截断 N KB 存 `model_training.log_tail`
- **训练脚本**`server/training/train_server.py`,随项目迁移):支持 `--task-json <file>`(含 dataset/imgsz/epochs/batch/device/project 名),每 epoch 输出一行机器可读 JSON 到 `--log-file``{"epoch":1,"total":150,"metrics":{...}}`),结束写 `result.json`(最终指标);Go 侧解析日志行更新进度、轮询日志尾部截断 N KB 存 `model_training.log_tail`
- **tflite 产物自检**2026-08-26):`inspect_tflite.py` 的 flatbuffer 解析逻辑内嵌进 `train_server.py``check_tflite`),训练收尾定位 `best.tflite` 后自动校验并写 `result.json``tflite_check` 字段:`{"ok":bool,"reason":string,"inputs":[{"name","shape","type"}],"outputs":[...]}`;校验规则 = 输入恰 1 张且 4 维、元素总数 == imgsz²×3(兼容 NCHW/NHWC)、输出 ≥ 1 张且 batch 维 = 1`ok=false`(如 shape 漂移、导出异常)时 Go 侧在拉产物前直接置训练失败并带出 reason,杜绝坏产物进入发布链路;`dump_graph.py` 保留作训练机人工深度调试
- **任务生命周期**`running → success/failed`;取消 = 杀进程(ssh 模式远程 kill pid);超时无心跳判死;**Go 服务重启后启动扫描** running 任务按 pid 存活探测(subprocess 本机、ssh 远程 `kill -0`),进程已死则置 failed
- **发起训练异步化(2026-08-27)**:发起请求仅做校验(数据集存在 / `prepareYoloSet` 有标注 / Serial 内并发检查)+ 落 running 记录即返回(毫秒级);训练机侧准备(写任务参数 → ssh tar 同步数据集 → 启动进程,耗时可达分钟级)在后台协程执行(`context.Background()`,与预标注 `runDetection` 同模式),任何一步失败经 `finishFailed` 置任务 failed 由列表/轮询呈现——此前同步执行超过管理端 axios 10s 超时,出现「任务已落库但前端报 timeout」的不一致
- **并发度 1**:发起训练时若已有 running 任务返回错误「训练进行中」;训练任务不排队(简化,管理端人工再点一次)
- 产物拉取:成功后拉 `best.tflite` + `artifact.zip`服务器 `workspace/trainings/<taskId>/`,发布时引用
- 产物拉取2026-08-27 重构):成功后`best.tflite` 直写服务器 `workspace/trainings/<数据集名>.tflite`(原子覆盖,无 per-task 存档、不再打包 zip
- 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
### 模型版本(每数据集一个模型,多模型体系)
@@ -372,11 +362,11 @@ training:
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注(类别名不同则自然互补,同类名跨模型 NMS 去重)。
- 版本号规则:`m<major>.<minor>.<patch>`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复
- 文件布局:当前生效副本 `workspace/models/<数据集名>/latest.tflite`tmp + rename 原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个目录互不影响
- 文件布局2026-08-27 重构):`workspace/trainings/<数据集名>.tflite` 即当前生效模型唯一位——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个文件互不影响
- 类别名:发布时从训练任务/数据集记录类别(训练脚本 result.json 输出 `names`),存 `model_version.labels`(JSON 数组),**App 合并推理依赖它**
- **发布**`POST /admin/trainings/publish`):校验任务 success + tflite 产物存在 → sha256 → 写 latest 副本 → 插 `model_version` + 该数据集旧版 `is_latest=0`「记录存在 ⟺ 文件存在」补偿逻辑同 APK 版本管理
- **发布**`POST /admin/trainings/publish`):校验任务 success + `trainings/<数据集名>.tflite` 存在 → 读文件算 sha256/size → 插 `model_version` + 该数据集旧版 `is_latest=0`文件已在训练成功时就位,发布仅落版本记录
- **管理端无模型管理界面**(2026-08-26 决策):删 `AdminListModels`/`AdminActivateModel`/`AdminDeleteModel` 三个管理接口,`model_version` 表保留——仅支撑客户端下发目录;版本只增不删不回滚(发布即最新)
- **模型目录(客户端拉取)**`GET /api/v1/models`(公开,登录态即可)返回所有数据集当前生效模型:`{datasetId, datasetName, version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}`;下载 URL `/download/models/<数据集名>/latest.tflite`(复用 `/download` 静态托管)
- **模型目录(客户端拉取)**`GET /api/v1/models`(公开,登录态即可)返回所有数据集当前生效模型:`{datasetId, datasetName, version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}`;下载 URL `/download/trainings/<数据集名>.tflite`(复用 `/download` 静态托管,文件名含中文需 URL 编码
### 标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)