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
-3
View File
@@ -1,6 +1,3 @@
# 运行时数据(不提交 git
# 预训练权重资产(训练机部署用,不进 git)
server/training/yolov8n.pt
# 系统文件 # 系统文件
.DS_Store .DS_Store
+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 文件,删历史版本仅删记录 | | 版本管理 | 后台管理端上传 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 | | 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 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` 留作训练机人工深度调试 | | 模型训练 | 从数据集卡片「开始训练」一键触发(参数 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 | | 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——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/models/<数据集>/latest.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 | | 模型目录与多模型推理 | `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 逻辑在服务端) | | 标注 | **图片入库自动触发**:手动上传/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` | 训练数据集 | `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` | | `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_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` | | `label_task` | 标注任务 | `id`(PK)、`dataset_id``filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done``created_at``finished_at` |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。 建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -160,7 +160,7 @@ App 版本更新检查(公开接口,无需 token,未登录/旧版本均可
- 检测到新版本(服务器版本高于本地版本)即**强制更新**,客户端弹不可关闭的全屏提示,必须跳转更新后才能继续使用;本地已是新版本则不提示 - 检测到新版本(服务器版本高于本地版本)即**强制更新**,客户端弹不可关闭的全屏提示,必须跳转更新后才能继续使用;本地已是新版本则不提示
- 客户端以「语义化版本号」比较:`1.10.0 > 1.9.9`(按数字段比较,禁止字符串比较) - 客户端以「语义化版本号」比较:`1.10.0 > 1.9.9`(按数字段比较,禁止字符串比较)
- 下载地址为固定静态路径:`/download/observer-latest.apk``app.apkDir` 目录下永远只有最新一个文件,由后端静态托管),客户端拼 `apiBaseUrl` 访问 - 下载地址为固定静态路径:`/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 ### 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 charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title> <title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-B_3W4J54.js"></script> <script type="module" crossorigin src="/admin/assets/index-Dtws4Ia3.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DZ3uy8VX.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-C62TRiv2.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+2 -17
View File
@@ -2,8 +2,6 @@ package controller
import ( import (
"context" "context"
"fmt"
"net/url"
"github.com/gogf/gf/v2/net/ghttp" "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) return service.Dataset.AdminUploadCover(ctx, req)
} }
// Cover 封面访问(直写响应体) // Cover 封面访问(直写响应体;URL 不随封面更换变化,必须 no-store 防浏览器缓存旧图
func (c *cAdmin) Cover(ctx context.Context, req *dto.AdminDatasetCoverReq) (*dto.AdminDatasetCoverRes, error) { func (c *cAdmin) Cover(ctx context.Context, req *dto.AdminDatasetCoverReq) (*dto.AdminDatasetCoverRes, error) {
path, err := service.Dataset.CoverFile(ctx, req.DatasetId) path, err := service.Dataset.CoverFile(ctx, req.DatasetId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r := ghttp.RequestFromCtx(ctx) r := ghttp.RequestFromCtx(ctx)
r.Response.Header().Set("Cache-Control", "no-store")
r.Response.ServeFile(path) r.Response.ServeFile(path)
return &dto.AdminDatasetCoverRes{}, nil return &dto.AdminDatasetCoverRes{}, nil
} }
@@ -135,20 +134,6 @@ func (c *cAdmin) DeleteCover(ctx context.Context, req *dto.AdminDatasetCoverDele
return service.Dataset.AdminDeleteCover(ctx, req) 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 训练任务列表 // ListTrainings 训练任务列表
-6
View File
@@ -30,12 +30,6 @@ func init() {
status TEXT NOT NULL DEFAULT 'building', status TEXT NOT NULL DEFAULT 'building',
cover TEXT, cover TEXT,
description 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, created_at TEXT NOT NULL,
updated_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', status TEXT NOT NULL DEFAULT 'running',
total INTEGER NOT NULL DEFAULT 0, total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0,
boxes_file TEXT,
filenames TEXT, filenames TEXT,
error TEXT, error TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -42,7 +41,6 @@ func (d *labelTaskDao) Insert(ctx context.Context, m *entity.LabelTask) (int64,
"status": m.Status, "status": m.Status,
"total": m.Total, "total": m.Total,
"done": m.Done, "done": m.Done,
"boxes_file": m.BoxesFile,
"filenames": m.Filenames, "filenames": m.Filenames,
"error": m.Error, "error": m.Error,
"created_at": m.CreatedAt, "created_at": m.CreatedAt,
@@ -102,10 +100,10 @@ func (d *labelTaskDao) UpdateProgress(ctx context.Context, id int64, done int) e
return err return err
} }
// Finish 完成任务(done 状态 + 完成时间 + 候选框文件路径 + 失败原因) // Finish 完成任务(done 状态 + 完成时间 + 失败原因)
func (d *labelTaskDao) Finish(ctx context.Context, id int64, boxesFile, errMsg string) error { func (d *labelTaskDao) Finish(ctx context.Context, id int64, errMsg string) error {
_, err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).Where("id", id). _, 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 return err
} }
+10 -12
View File
@@ -22,7 +22,6 @@ func init() {
dataset_id INTEGER NOT NULL, dataset_id INTEGER NOT NULL,
version TEXT NOT NULL, version TEXT NOT NULL,
training_id INTEGER, training_id INTEGER,
artifact_file TEXT,
metrics TEXT, metrics TEXT,
labels TEXT NOT NULL, labels TEXT NOT NULL,
sha256 TEXT NOT NULL, sha256 TEXT NOT NULL,
@@ -40,17 +39,16 @@ func init() {
// Insert 插入模型版本(is_latest 由 service 先置 0 再插新行置 1 // Insert 插入模型版本(is_latest 由 service 先置 0 再插新行置 1
func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (int64, error) { func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (int64, error) {
res, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Data(g.Map{ res, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Data(g.Map{
"dataset_id": m.DatasetId, "dataset_id": m.DatasetId,
"version": m.Version, "version": m.Version,
"training_id": m.TrainingId, "training_id": m.TrainingId,
"artifact_file": m.ArtifactFile, "metrics": m.Metrics,
"metrics": m.Metrics, "labels": m.Labels,
"labels": m.Labels, "sha256": m.Sha256,
"sha256": m.Sha256, "size_bytes": m.SizeBytes,
"size_bytes": m.SizeBytes, "is_latest": m.IsLatest,
"is_latest": m.IsLatest, "notes": m.Notes,
"notes": m.Notes, "created_at": m.CreatedAt,
"created_at": m.CreatedAt,
}).Insert() }).Insert()
if err != nil { if err != nil {
return 0, err return 0, err
+4 -12
View File
@@ -8,8 +8,8 @@ import (
// 模型训练体系接口(数据集 → 标注 → 训练 → 模型版本),组前缀 /api/v1/admin。 // 模型训练体系接口(数据集 → 标注 → 训练 → 模型版本),组前缀 /api/v1/admin。
// 图片/标注/模型文件均在服务器磁盘(app.datasetDir),DB 只存元数据。 // 图片/标注/模型文件均在服务器磁盘(app.datasetDir),DB 只存元数据。
// 图片访问与 zip 导出为直接写响应体场景,由 controller 以 *ghttp.Request 方法实现 // 图片访问为直接写响应体场景,由 controller 以 *ghttp.Request 方法实现
// (见 controller/admin.go 的 Image/ExportDataset 方法)。 // (见 controller/admin.go 的 Image 方法)。
// ---------- 数据集管理 ---------- // ---------- 数据集管理 ----------
@@ -140,15 +140,7 @@ type AdminDatasetImageReq struct {
type AdminDatasetImageRes struct{} type AdminDatasetImageRes struct{}
// AdminDatasetExportReq 数据集 zip 导出(直写响应体下载;标注衔接与备份用 // AdminDatasetCoverUploadReq 上传数据集封面(multipart 单文件;覆盖旧封面,UUID 命名 jpg
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>
type AdminDatasetCoverUploadReq struct { type AdminDatasetCoverUploadReq struct {
g.Meta `path:"/datasets/cover" method:"post" summary:"上传数据集封面" tags:"管理端" mime:"multipart/form-data"` g.Meta `path:"/datasets/cover" method:"post" summary:"上传数据集封面" tags:"管理端" mime:"multipart/form-data"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"` DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
@@ -367,7 +359,7 @@ type ModelCatalogItem struct {
Sha256 string `json:"sha256"` Sha256 string `json:"sha256"`
Notes string `json:"notes"` Notes string `json:"notes"`
PublishedAt *gtime.Time `json:"publishedAt"` 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 { type ModelCatalogRes struct {
+2 -4
View File
@@ -2,16 +2,14 @@ package entity
import "github.com/gogf/gf/v2/os/gtime" import "github.com/gogf/gf/v2/os/gtime"
// LabelTask 标注任务:RF-DETR 预标注批量推理进度;候选框存 // LabelTask 标注任务:RF-DETR 预标注批量推理进度;标注结果直写
// datasets/<数据集名>/boxes.jsonYOLO 归一化 xywh + 置信度 + 建议类别)。 // dataset_image.labels_json,不落文件。Filenames 为选中图片列表(JSON 数组串;空 = 全量扫描)。
// Filenames 为选中图片列表(JSON 数组串;空 = 全量扫描)。
type LabelTask struct { type LabelTask struct {
Id int64 `json:"id" orm:"id" description:"自增主键"` Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"` DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Status string `json:"status" orm:"status" description:"running|done"` Status string `json:"status" orm:"status" description:"running|done"`
Total int `json:"total" orm:"total" description:"待标注图片数"` Total int `json:"total" orm:"total" description:"待标注图片数"`
Done int `json:"done" orm:"done" 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数组(空=全量)"` Filenames string `json:"filenames" orm:"filenames" description:"选中图片JSON数组(空=全量)"`
Error string `json:"error" orm:"error" description:"失败原因(部分失败/全部失败)"` Error string `json:"error" orm:"error" description:"失败原因(部分失败/全部失败)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" 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" import "github.com/gogf/gf/v2/os/gtime"
// ModelVersion 模型版本:每数据集独立版本序列(m1.0.0 递增,UNIQUE(dataset_id, version))。 // ModelVersion 模型版本:每数据集独立版本序列(m1.0.0 递增,UNIQUE(dataset_id, version))。
// 模型文件不落表:发布即写当前生效副本 models/<数据集>/latest.tflite(无存档回退机制); // 模型文件不落表:训练成功即直写 trainings/<数据集名>.tflite(无存档回退机制);
// labels 为类别名数组 JSON(App 多模型合并推理依赖)。 // labels 为类别名数组 JSON(App 多模型合并推理依赖)。
type ModelVersion struct { type ModelVersion struct {
Id int64 `json:"id" orm:"id" description:"自增主键"` Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"` DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Version string `json:"version" orm:"version" description:"m1.0.0 递增"` Version string `json:"version" orm:"version" description:"m1.0.0 递增"`
TrainingId int64 `json:"trainingId" orm:"training_id" description:"来源训练任务"` 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 指标"`
Metrics string `json:"metrics" orm:"metrics" description:"JSON 指标"` Labels string `json:"labels" orm:"labels" description:"JSON 类别名数组"`
Labels string `json:"labels" orm:"labels" description:"JSON 类别名数组"` Sha256 string `json:"sha256" orm:"sha256" description:"tflite 文件校验"`
Sha256 string `json:"sha256" orm:"sha256" description:"tflite 文件校验"` SizeBytes int64 `json:"sizeBytes" orm:"size_bytes" description:"文件大小"`
SizeBytes int64 `json:"sizeBytes" orm:"size_bytes" description:"文件大小"` IsLatest int `json:"isLatest" orm:"is_latest" description:"1=该数据集当前生效"`
IsLatest int `json:"isLatest" orm:"is_latest" description:"1=该数据集当前生效"` Notes string `json:"notes" orm:"notes" description:"备注"`
Notes string `json:"notes" orm:"notes" description:"备注"` CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发布时间"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发布时间"`
} }
+5 -47
View File
@@ -1,7 +1,6 @@
package service package service
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
@@ -320,11 +319,11 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 文件清理(图片 + 模型文件;删除失败仅记日志,记录已删) // 文件清理(图片目录 + 当前生效模型文件;删除失败仅记日志,记录已删)
dirs := []string{common.DatasetImagesDir(ctx, name), common.DatasetModelsDir(ctx, name)} paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, name)}
for _, dir := range dirs { for _, p := range paths {
if err := os.RemoveAll(dir); err != nil { if err := os.RemoveAll(p); err != nil {
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", dir, err) g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", p, err)
} }
} }
return &dto.AdminDatasetDeleteRes{}, nil return &dto.AdminDatasetDeleteRes{}, nil
@@ -636,44 +635,3 @@ func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filenam
} }
return path, nil 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 return
} }
for _, t := range list { 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) 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() bgCtx := context.Background()
go func() { go func() {
if client == nil { if client == nil {
_ = dao.LabelTask.Finish(bgCtx, taskId, "", "标注服务未配置") _ = dao.LabelTask.Finish(bgCtx, taskId, "标注服务未配置")
return return
} }
dir := common.DatasetImagesDir(bgCtx, dataset.Name) dir := common.DatasetImagesDir(bgCtx, dataset.Name)
@@ -256,7 +256,7 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
}) })
} }
if failed != "" { if failed != "" {
_ = dao.LabelTask.Finish(bgCtx, taskId, "", failed) _ = dao.LabelTask.Finish(bgCtx, taskId, failed)
return return
} }
// 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注,人工保存同走 UpdateLabels // 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注,人工保存同走 UpdateLabels
@@ -273,10 +273,10 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
return nil return nil
}) })
if err != nil { if err != nil {
_ = dao.LabelTask.Finish(bgCtx, taskId, "", err.Error()) _ = dao.LabelTask.Finish(bgCtx, taskId, err.Error())
return return
} }
_ = dao.LabelTask.Finish(bgCtx, taskId, "", "") _ = dao.LabelTask.Finish(bgCtx, taskId, "")
// 成功完成:自动补标该数据集未标注图(忙时入库的图等),失败任务不续 // 成功完成:自动补标该数据集未标注图(忙时入库的图等),失败任务不续
s.autoSupplement(bgCtx, dataset) s.autoSupplement(bgCtx, dataset)
}() }()
+2 -2
View File
@@ -15,7 +15,7 @@ type modelVersionService struct{}
var ModelVersion = &modelVersionService{} var ModelVersion = &modelVersionService{}
// ClientCatalog 客户端模型目录:全部数据集当前生效模型(App 多模型下载热更新)。 // ClientCatalog 客户端模型目录:全部数据集当前生效模型(App 多模型下载热更新)。
// downloadUrl 复用 /download 静态托管(/download/models/<数据集名>/latest.tflite)。 // downloadUrl 复用 /download 静态托管(/download/trainings/<数据集名>.tflite)。
func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCatalogRes, error) { func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCatalogRes, error) {
list, err := dao.ModelVersion.ListAllLatest(ctx) list, err := dao.ModelVersion.ListAllLatest(ctx)
if err != nil { if err != nil {
@@ -37,7 +37,7 @@ func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCata
Sha256: v.Sha256, Sha256: v.Sha256,
Notes: v.Notes, Notes: v.Notes,
PublishedAt: v.CreatedAt, PublishedAt: v.CreatedAt,
DownloadUrl: "/download/models/" + datasetName + "/latest.tflite", DownloadUrl: "/download/trainings/" + datasetName + ".tflite",
}) })
} }
return &dto.ModelCatalogRes{List: items}, nil 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) { 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 { var res struct {
Metrics map[string]float64 `json:"metrics"` Metrics map[string]float64 `json:"metrics"`
Names []string `json:"names"` Names []string `json:"names"`
@@ -155,20 +165,16 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
return return
} }
// 先拉产物再置成功:产物拉取失败则置失败(发布依赖 tflite 存在) // 先拉产物再置成功:产物拉取失败则置失败(发布依赖 tflite 存在)
dest := common.TrainingArtifactsDir(ctx, t.Id)
if res.BestTflite == "" { if res.BestTflite == "" {
_ = s.finishFailed(ctx, t, "训练完成但 result.json 缺少 best_tflite") _ = s.finishFailed(ctx, t, "训练完成但 result.json 缺少 best_tflite")
return 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) g.Log().Errorf(ctx, "训练 %d 拉取 best.tflite 失败: %+v", t.Id, err)
_ = s.finishFailed(ctx, t, "拉取训练产物失败: %v", err) _ = s.finishFailed(ctx, t, "拉取训练产物失败: %v", err)
return 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 // 指标尾部带上类别名,发布时解析 labels
if len(res.Names) > 0 { if len(res.Names) > 0 {
if names, err := json.Marshal(res.Names); err == nil { 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))), Content: []byte(yoloYamlContent(trainPath, localAiClassNames(bgCtx))),
}) })
taskJSON, _ := json.Marshal(map[string]any{ taskJSON, _ := json.Marshal(map[string]any{
"workdir": cfg.Workdir, "workdir": cfg.Workdir,
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)), "yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)),
"imgsz": imgsz, "imgsz": imgsz,
"epochs": epochs, "epochs": epochs,
"batch": batch, "batch": batch,
"device": device, "device": device,
"project": filepath.ToSlash(filepath.Join("runs", "tasks", strconv.FormatInt(taskId, 10))), "project": filepath.ToSlash(filepath.Join("runs", "tasks", strconv.FormatInt(taskId, 10))),
"log_file": filepath.ToSlash(filepath.Join("logs", strconv.FormatInt(taskId, 10)+".jsonl")), "log_file": filepath.ToSlash(filepath.Join("logs", strconv.FormatInt(taskId, 10)+".jsonl")),
"result_file": filepath.ToSlash(filepath.Join("results", strconv.FormatInt(taskId, 10)+".json")), "result_file": filepath.ToSlash(filepath.Join("results", strconv.FormatInt(taskId, 10)+".json")),
"artifact_zip": filepath.ToSlash(filepath.Join("artifacts", strconv.FormatInt(taskId, 10)+".zip")),
}) })
if err := runner.WriteTaskJson(bgCtx, job, string(taskJSON)); err != nil { if err := runner.WriteTaskJson(bgCtx, job, string(taskJSON)); err != nil {
_ = s.finishFailed(bgCtx, &entity.ModelTraining{Id: taskId}, "写任务参数失败: %v", err) _ = 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 return &dto.AdminTrainingCancelRes{}, nil
} }
// AdminPublish 发布模型版本:仅 success 任务 + 本地 best.tflite 存在; // AdminPublish 发布模型版本:仅 success 任务 + trainings/<数据集名>.tflite 存在;
// 版本号同数据集内 m<major>.<minor>.<patch> 自增(无记录从 m1.0.0 起)。 // 版本号同数据集内 m<major>.<minor>.<patch> 自增(无记录从 m1.0.0 起)。
// 落库(置旧版 is_latest=0 + 插新版)后写 latest 副本(无存档回退机制),文件失败补偿删记录, // 文件在训练成功时已直写最终位置(无额外副本),发布仅落版本记录(sha256/size 取自现有文件)。
// 保证「记录存在 ⟺ 文件存在」(同 APK 版本管理)。
func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTrainingPublishReq) (*dto.AdminTrainingPublishRes, error) { func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTrainingPublishReq) (*dto.AdminTrainingPublishRes, error) {
t, err := dao.Training.GetById(ctx, req.Id) t, err := dao.Training.GetById(ctx, req.Id)
if err != nil { if err != nil {
@@ -524,10 +528,10 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
if dataset == nil { if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound) 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) data, err := os.ReadFile(bestTflite)
if err != nil { if err != nil {
return nil, gerror.New("训练产物 best.tflite 缺失,无法发布") return nil, gerror.New("训练产物 tflite 缺失,无法发布")
} }
labels := labelsFromMetrics(t.Metrics) labels := labelsFromMetrics(t.Metrics)
sha256, err := common.Sha256Hex(data) sha256, err := common.Sha256Hex(data)
@@ -539,13 +543,12 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
return nil, err return nil, err
} }
now := gtime.Now() now := gtime.Now()
var mv *entity.ModelVersion
err = common.Serial().Submit(ctx, func() error { err = common.Serial().Submit(ctx, func() error {
// 该数据集旧版全部置 0,再插新版本(is_latest=1 // 该数据集旧版全部置 0,再插新版本(is_latest=1
if err := dao.ModelVersion.ClearLatest(ctx, t.DatasetId); err != nil { if err := dao.ModelVersion.ClearLatest(ctx, t.DatasetId); err != nil {
return err return err
} }
id, err := dao.ModelVersion.Insert(ctx, &entity.ModelVersion{ _, err := dao.ModelVersion.Insert(ctx, &entity.ModelVersion{
DatasetId: t.DatasetId, DatasetId: t.DatasetId,
Version: version, Version: version,
TrainingId: t.Id, TrainingId: t.Id,
@@ -557,24 +560,12 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
Notes: t.Name, Notes: t.Name,
CreatedAt: now, CreatedAt: now,
}) })
if err != nil { return err
return err
}
mv = &entity.ModelVersion{Id: id, Version: version, DatasetId: t.DatasetId}
return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 文件:当前生效副本 latest.tflitetmp+rename 原子覆盖),客户端固定下载 // 文件已由训练成功直写 trainings/<数据集名>.tflite,发布仅落版本记录,无额外副本
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, "写当前生效模型失败")
}
return &dto.AdminTrainingPublishRes{Version: version}, nil return &dto.AdminTrainingPublishRes{Version: version}, nil
} }
-1
View File
@@ -26,7 +26,6 @@ import (
// tasks/<taskId>.json 任务参数(Go 侧写入) // tasks/<taskId>.json 任务参数(Go 侧写入)
// logs/<taskId>.jsonl 每 epoch 一行 JSON{"epoch","total","metrics"} // logs/<taskId>.jsonl 每 epoch 一行 JSON{"epoch","total","metrics"}
// results/<taskId>.json 结束结果:{"metrics","names","best_tflite"(相对路径)} // results/<taskId>.json 结束结果:{"metrics","names","best_tflite"(相对路径)}
// artifacts/<taskId>.zip 打包产物(best.pt + results.csv + 曲线)
type TrainingRunner interface { type TrainingRunner interface {
// Start 启动训练进程,返回可探测存活的 pid(subprocess 本机 pidssh 远程 pid // Start 启动训练进程,返回可探测存活的 pid(subprocess 本机 pidssh 远程 pid
Start(ctx context.Context, job *TrainingJob) (int, error) Start(ctx context.Context, job *TrainingJob) (int, error)
+4 -11
View File
@@ -5,7 +5,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"path/filepath" "path/filepath"
"strconv"
"github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/frame/g"
) )
@@ -13,8 +12,7 @@ import (
// 训练体系运行时数据布局(config.yml app.datasetDir,默认 ./workspace,挂载持久化、不提交 git): // 训练体系运行时数据布局(config.yml app.datasetDir,默认 ./workspace,挂载持久化、不提交 git):
// //
// datasets/<name>/ 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json // datasets/<name>/ 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json
// models/<name>/ latest.tflite 当前生效副本(无存档,客户端固定下载) // trainings/<name>.tflite 某数据集当前生效模型(训练成功即直写,客户端固定下载,无存档回退
// trainings/<taskId>/ 训练产物(best.tflite + artifact.zip
// //
// 训练机与 Go 服务器异机时,数据集经 training 通道同步(见 common/training_runner.go)。 // 训练机与 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) return filepath.Join(DatasetDir(ctx), "datasets", datasetName)
} }
// DatasetModelsDir 某数据集模型目录 // TrainingModelPath 某数据集当前生效模型文件(训练成功即从训练机直写,客户端固定下载)
func DatasetModelsDir(ctx context.Context, datasetName string) string { func TrainingModelPath(ctx context.Context, datasetName string) string {
return filepath.Join(DatasetDir(ctx), "models", datasetName) return filepath.Join(DatasetDir(ctx), "trainings", datasetName+".tflite")
}
// TrainingArtifactsDir 某训练任务产物目录(拉回的 best.tflite + artifact.zip
func TrainingArtifactsDir(ctx context.Context, taskId int64) string {
return filepath.Join(DatasetDir(ctx), "trainings", strconv.FormatInt(taskId, 10))
} }
// Sha256Hex 计算文件内容 SHA-256 十六进制(模型版本校验用) // Sha256Hex 计算文件内容 SHA-256 十六进制(模型版本校验用)
Binary file not shown.
+40 -1
View File
@@ -24,7 +24,7 @@ func main() {
s := g.Server() s := g.Server()
// Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk // Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk
// URL 固定 /download/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 { if err := os.MkdirAll(common.ApkDir(ctx), 0o755); err != nil {
g.Log().Fatalf(ctx, "创建 APK 目录失败: %+v", err) g.Log().Fatalf(ctx, "创建 APK 目录失败: %+v", err)
} }
@@ -198,6 +198,45 @@ func initDatabase(ctx context.Context) {
} }
g.Log().Infof(ctx, "数据库初始化完成(version=11)") 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),存量库残留表启动即删 // 死表清理:app_config 全局训练配置表已撤销(配置走 config.yml),存量库残留表启动即删
if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS app_config"); err != nil { if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS app_config"); err != nil {
g.Log().Fatalf(ctx, "删除残留 app_config 表失败: %+v", err) g.Log().Fatalf(ctx, "删除残留 app_config 表失败: %+v", err)
+6 -18
View File
@@ -15,7 +15,6 @@
project 训练输出目录(相对 workdir,末尾自动拼 name project 训练输出目录(相对 workdir,末尾自动拼 name
log_file 每 epoch 一行 JSON 的进度日志(相对 workdir log_file 每 epoch 一行 JSON 的进度日志(相对 workdir
result_file 结束结果 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,...}} log_file {"epoch":1,"total":150,"metrics":{"metrics/mAP50(B)":0.87,...}}
@@ -32,7 +31,6 @@ import os
import struct import struct
import sys import sys
import traceback import traceback
import zipfile
from pathlib import Path from pathlib import Path
# 抗尺度漂移(与人工训练基线一致):随机缩放输入 0.5~1.5x # 抗尺度漂移(与人工训练基线一致):随机缩放输入 0.5~1.5x
@@ -58,7 +56,9 @@ def on_fit_epoch_end(trainer):
def register_callback(): 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) 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": []} 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(): def main():
ap = argparse.ArgumentParser(description="observer 训练任务脚本(Go --task-json 驱动)") ap = argparse.ArgumentParser(description="observer 训练任务脚本(Go --task-json 驱动)")
ap.add_argument("--task-json", required=True, help="任务参数 JSON 文件路径") ap.add_argument("--task-json", required=True, help="任务参数 JSON 文件路径")
@@ -239,7 +228,6 @@ def main():
log_file = task_path("log_file") log_file = task_path("log_file")
result_file = task_path("result_file") result_file = task_path("result_file")
artifact_zip = task_path("artifact_zip")
global _LOG global _LOG
_LOG = None _LOG = None
try: try:
@@ -273,7 +261,8 @@ def main():
best_tflite = find_best_tflite(save_dir) best_tflite = find_best_tflite(save_dir)
if best_tflite is None: 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) best_tflite = find_best_tflite(save_dir)
if best_tflite is None: if best_tflite is None:
raise RuntimeError("tflite 导出失败:weights 目录下未找到任何 .tflite 产物") raise RuntimeError("tflite 导出失败:weights 目录下未找到任何 .tflite 产物")
@@ -290,8 +279,7 @@ def main():
if isinstance(v, (int, float)) and math.isfinite(v): if isinstance(v, (int, float)) and math.isfinite(v):
clean_metrics[k] = round(float(v), 5) clean_metrics[k] = round(float(v), 5)
build_artifact_zip(artifact_zip, save_dir) # best_tflite 相对 workdirGo 侧按此路径拉取(不再打包 zip,仅回传 tflite)
# best_tflite 相对 workdirGo 侧按此路径拉取
write_result(result_file, { write_result(result_file, {
"metrics": clean_metrics, "metrics": clean_metrics,
"names": names, "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 status TEXT NOT NULL DEFAULT 'building',-- building | labeled | synced
cover TEXT, -- 封面文件名(上传自动转 jpg + UUID 命名,卡片展示) cover TEXT, -- 封面文件名(上传自动转 jpg + UUID 命名,卡片展示)
description TEXT, -- 描述(卡片展示) 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, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
-- 图片文件在 app.datasetDir/datasets/<name>/DB 存元数据 + 标注(dataset_image.labels_json -- 图片文件在 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**每个数据集独立模型版本序列** dataset_id INTEGER NOT NULL, -- → dataset.id**每个数据集独立模型版本序列**
version TEXT NOT NULL, -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一) version TEXT NOT NULL, -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一)
training_id INTEGER, -- 来源训练任务 → model_training.id training_id INTEGER, -- 来源训练任务 → model_training.id
artifact_file TEXT, -- 归档 zipbest.pt + results 曲线),可选
metrics TEXT, -- JSON,与来源任务一致 metrics TEXT, -- JSON,与来源任务一致
labels TEXT NOT NULL, -- JSON 类别名数组(随模型下发,App 合并/展示用) labels TEXT NOT NULL, -- JSON 类别名数组(随模型下发,App 合并/展示用)
sha256 TEXT NOT NULL, -- tflite 文件校验 sha256 TEXT NOT NULL, -- tflite 文件校验
@@ -143,7 +133,6 @@ CREATE TABLE IF NOT EXISTS label_task (
status TEXT NOT NULL DEFAULT 'running', -- running | done status TEXT NOT NULL DEFAULT 'running', -- running | done
total INTEGER NOT NULL DEFAULT 0, -- 待标注图片数 total INTEGER NOT NULL DEFAULT 0, -- 待标注图片数
done INTEGER NOT NULL DEFAULT 0, -- 已标注数 done INTEGER NOT NULL DEFAULT 0, -- 已标注数
boxes_file TEXT, -- 遗留列:候选框 JSON 路径(boxes.json 已废弃,新代码不读写)
error TEXT, -- 失败原因(检测中途失败/服务重启中断) error TEXT, -- 失败原因(检测中途失败/服务重启中断)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
finished_at TEXT 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 变更) - 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`),新代码不读写,存量库保留不迁移 - 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:数据全部入表后无保留价值)** - 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 直读) ## 全局训练配置(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(预标注接口报「标注服务未配置」) - 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」 - 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`),**禁止图片进库** - DB 只存文件名/来源/prompt 等元数据(`dataset` / `dataset_image`),**禁止图片进库**
- 生成/上传图片是**付费资产**:删除接口必须带前端确认文案(提示 AI 生成图有成本);删除 = 删文件 + 删记录,目录清理 - 生成/上传图片是**付费资产**:删除接口必须带前端确认文案(提示 AI 生成图有成本);删除 = 删文件 + 删记录,目录清理
- 训练机与 Go 服务器可能异机:训练前按 `training` 通道同步(subprocess 同机 cp、ssh 异机 scp);**数据集在训练机上的权威路径** `training.workdir`/`training.datasetDir`/`<name>/`DB `model_training.dataset` 只存数据集名 - 训练机与 Go 服务器可能异机:训练前按 `training` 通道同步(subprocess 同机 cp、ssh 异机 scp);**数据集在训练机上的权威路径** `training.workdir`/`training.datasetDir`/`<name>/`DB `model_training.dataset` 只存数据集名
- zip 导出(`GET /admin/datasets/export`):打包图片目录为 zip 下载——用于标注衔接(二期前的人工标注路径)与备份
### AI 生成图片(provider 抽象) ### AI 生成图片(provider 抽象)
@@ -359,12 +349,12 @@ training:
- **训练参数默认走配置(2026-08-26**imgsz/epochs/batch/device 不随管理端请求传(界面一键开始),由 `training` 节点统一配置——device 取决于训练机硬件、imgsz 必须与端侧推理对齐、epochs 取决于算力预期,均为部署级参数;任务记录仍存各值(`model_training.imgsz/epochs/batch/device`)供列表展示 - **训练参数默认走配置(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` 配置**(见「全局训练配置」节) - 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` 保留作训练机人工深度调试 - **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 - **任务生命周期**`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」的不一致 - **发起训练异步化(2026-08-27)**:发起请求仅做校验(数据集存在 / `prepareYoloSet` 有标注 / Serial 内并发检查)+ 落 running 记录即返回(毫秒级);训练机侧准备(写任务参数 → ssh tar 同步数据集 → 启动进程,耗时可达分钟级)在后台协程执行(`context.Background()`,与预标注 `runDetection` 同模式),任何一步失败经 `finishFailed` 置任务 failed 由列表/轮询呈现——此前同步执行超过管理端 axios 10s 超时,出现「任务已落库但前端报 timeout」的不一致
- **并发度 1**:发起训练时若已有 running 任务返回错误「训练进行中」;训练任务不排队(简化,管理端人工再点一次) - **并发度 1**:发起训练时若已有 running 任务返回错误「训练进行中」;训练任务不排队(简化,管理端人工再点一次)
- 产物拉取:成功后拉 `best.tflite` + `artifact.zip`服务器 `workspace/trainings/<taskId>/`,发布时引用 - 产物拉取2026-08-27 重构):成功后`best.tflite` 直写服务器 `workspace/trainings/<数据集名>.tflite`(原子覆盖,无 per-task 存档、不再打包 zip
- 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务 - 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
### 模型版本(每数据集一个模型,多模型体系) ### 模型版本(每数据集一个模型,多模型体系)
@@ -372,11 +362,11 @@ training:
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注(类别名不同则自然互补,同类名跨模型 NMS 去重)。 **核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注(类别名不同则自然互补,同类名跨模型 NMS 去重)。
- 版本号规则:`m<major>.<minor>.<patch>`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复 - 版本号规则:`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 合并推理依赖它** - 类别名:发布时从训练任务/数据集记录类别(训练脚本 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` 表保留——仅支撑客户端下发目录;版本只增不删不回滚(发布即最新) - **管理端无模型管理界面**(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 布局重构) ### 标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)
+14 -22
View File
@@ -1,7 +1,7 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Picture, Setting, VideoPlay, Download } from '@element-plus/icons-vue' import { Plus, Picture, Setting, VideoPlay } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import request from '../api/request' import request from '../api/request'
@@ -14,7 +14,7 @@ const size = ref(12)
const keyword = ref('') const keyword = ref('')
const createVisible = ref(false) const createVisible = ref(false)
const createForm = reactive({ name: '', source: 'manual' }) const createForm = reactive({ name: '' })
const creating = ref(false) const creating = ref(false)
const configVisible = ref(false) const configVisible = ref(false)
@@ -55,7 +55,6 @@ function search() {
function openCreate() { function openCreate() {
createForm.name = '' createForm.name = ''
createForm.source = 'manual'
createVisible.value = true createVisible.value = true
} }
@@ -66,7 +65,7 @@ function submitCreate() {
} }
creating.value = true creating.value = true
request request
.post('/datasets', { name: createForm.name.trim(), source: createForm.source }) .post('/datasets', { name: createForm.name.trim(), source: 'manual' })
.then(() => { .then(() => {
ElMessage.success('数据集已创建,请上传或生成图片') ElMessage.success('数据集已创建,请上传或生成图片')
createVisible.value = false createVisible.value = false
@@ -88,8 +87,10 @@ function openConfig(row) {
}) })
coverFile.value = null coverFile.value = null
coverDeleted.value = false coverDeleted.value = false
// 已有封面回显:el-upload picture-card 直接以 url 项展示 // 已有封面回显:el-upload picture-card 直接以 url 项展示(带版本参数防浏览器缓存旧封面)
coverFileList.value = row.cover ? [{ name: '当前封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}`) }] : [] coverFileList.value = row.cover
? [{ name: '当前封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}&v=${encodeURIComponent(row.updatedAt || '')}`) }]
: []
configVisible.value = true configVisible.value = true
} }
@@ -150,11 +151,7 @@ function startTrain(row) {
}) })
} }
// ---------- 导出 / 删除 ---------- // ---------- 删除 ----------
function exportDataset(row) {
window.open(`${location.origin}/api/v1/admin/datasets/export?datasetId=${row.id}`, '_blank')
}
function removeDataset(row) { function removeDataset(row) {
const warn = const warn =
@@ -181,7 +178,8 @@ function imgUrl(u) {
function coverUrl(row) { function coverUrl(row) {
if (!row.cover) return '' if (!row.cover) return ''
return imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}`) // v=updatedAt 版本参数:换封面后 URL 变化,强制浏览器重新拉取
return imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}&v=${encodeURIComponent(row.updatedAt || '')}`)
} }
// 训练进度(百分比):有总轮数才显示比例 // 训练进度(百分比):有总轮数才显示比例
@@ -265,7 +263,6 @@ onBeforeUnmount(() => {
{{ row.trainingStatus === 'running' ? '训练中' : '开始训练' }} {{ row.trainingStatus === 'running' ? '训练中' : '开始训练' }}
</el-button> </el-button>
<el-button size="small" :icon="Setting" @click="openConfig(row)">配置</el-button> <el-button size="small" :icon="Setting" @click="openConfig(row)">配置</el-button>
<el-button size="small" :icon="Download" @click="exportDataset(row)">导出</el-button>
<el-button size="small" type="danger" @click="removeDataset(row)">删除</el-button> <el-button size="small" type="danger" @click="removeDataset(row)">删除</el-button>
</div> </div>
@@ -283,7 +280,8 @@ onBeforeUnmount(() => {
{{ trainStatusMap[row.trainingStatus] || row.trainingStatus }} {{ trainStatusMap[row.trainingStatus] || row.trainingStatus }}
</el-tag> </el-tag>
</el-tooltip> </el-tooltip>
<el-tag v-else :type="trainStatusTag[row.trainingStatus] || 'info'" size="small"> <!-- 训练成功不显示已训练标签只保留发布按钮 -->
<el-tag v-else-if="row.trainingStatus !== 'success'" :type="trainStatusTag[row.trainingStatus] || 'info'" size="small">
{{ trainStatusMap[row.trainingStatus] || row.trainingStatus }} {{ trainStatusMap[row.trainingStatus] || row.trainingStatus }}
</el-tag> </el-tag>
<el-button v-if="row.trainingStatus === 'success' && row.trainingId" size="small" type="success" @click="publishModel(row)"> <el-button v-if="row.trainingStatus === 'success' && row.trainingId" size="small" type="success" @click="publishModel(row)">
@@ -316,12 +314,6 @@ onBeforeUnmount(() => {
placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名" placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名"
/> />
</el-form-item> </el-form-item>
<el-form-item label="图片来源">
<el-radio-group v-model="createForm.source">
<el-radio value="manual">手动上传</el-radio>
<el-radio value="ai">AI 生成</el-radio>
</el-radio-group>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createVisible = false">取消</el-button> <el-button @click="createVisible = false">取消</el-button>
@@ -404,13 +396,13 @@ onBeforeUnmount(() => {
} }
.ds-cover { .ds-cover {
position: relative; position: relative;
height: 150px; aspect-ratio: 16 / 9;
background: #f5f7fa; background: #f5f7fa;
} }
.ds-cover img { .ds-cover img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
display: block; display: block;
} }
.ds-cover-placeholder { .ds-cover-placeholder {